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
@@ -204,11 +204,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
|
||||
const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
|
||||
const resolvedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined;
|
||||
|
||||
|
||||
if (!resolvedAgent && !resolvedProvider && !resolvedModel && !resolvedVariant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
agentName: resolvedAgent,
|
||||
providerId: resolvedProvider,
|
||||
@@ -344,7 +344,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const variants = model?.variants;
|
||||
return Boolean(variants && Object.keys(variants).length > 0);
|
||||
}, [isUser, modelID, providerID, providers]);
|
||||
|
||||
|
||||
const displayAgentName = useStickyDisplayValue<string>(agentName);
|
||||
const displayProviderIDValue = useStickyDisplayValue<string>(providerID ?? undefined);
|
||||
const displayModelName = useStickyDisplayValue<string>(modelName);
|
||||
@@ -532,36 +532,36 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const shouldShowHeader = React.useMemo(() => {
|
||||
if (isUser) return true;
|
||||
|
||||
|
||||
// Use turn grouping context if available for more precise control
|
||||
const headerMessageId = turnGroupingContext?.headerMessageId;
|
||||
if (headerMessageId) {
|
||||
// For turn grouping: only show header for the first assistant message in the turn
|
||||
const isFirstAssistantInTurn = message.info.id === headerMessageId;
|
||||
|
||||
|
||||
if (isFirstAssistantInTurn) {
|
||||
// For completed messages, always show header (historical messages)
|
||||
if (streamPhase === 'completed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// For streaming messages: show header when streaming starts and keep it visible
|
||||
const isCurrentlyStreaming = streamPhase === 'streaming' || streamPhase === 'cooldown';
|
||||
const hasStartedStreaming = shouldShowHeaderRef.current;
|
||||
|
||||
|
||||
// Update the ref when streaming starts
|
||||
if (isCurrentlyStreaming && !hasStartedStreaming) {
|
||||
shouldShowHeaderRef.current = true;
|
||||
}
|
||||
|
||||
|
||||
// Show header if streaming has started or is currently active
|
||||
return hasStartedStreaming || isCurrentlyStreaming;
|
||||
}
|
||||
|
||||
|
||||
// For non-first assistant messages, don't show header
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Fallback to original logic when turn grouping is not available
|
||||
if (!previousRole) return true;
|
||||
return previousRole.isUser;
|
||||
@@ -600,7 +600,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined;
|
||||
|
||||
const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined;
|
||||
|
||||
|
||||
const assistantSummaryCandidate =
|
||||
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
|
||||
? turnGroupingContext.summaryBody
|
||||
@@ -634,6 +634,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
if (!detail) {
|
||||
return undefined;
|
||||
}
|
||||
if (errorName === 'SessionRetry') {
|
||||
return `Opencode failed to send a message. Retry attempt info: \n\`${detail}\``;
|
||||
}
|
||||
return `Opencode failed to send message with error:\n\`${detail}\``;
|
||||
}, [isUser, message.info]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
@@ -10,6 +11,7 @@ import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScro
|
||||
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
|
||||
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
interface ChatMessageEntry {
|
||||
info: Message;
|
||||
@@ -211,7 +213,7 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
onMessageContentChange('permission');
|
||||
}, [permissions, questions, onMessageContentChange]);
|
||||
|
||||
const displayMessages = React.useMemo(() => {
|
||||
const baseDisplayMessages = React.useMemo(() => {
|
||||
const seenIds = new Set<string>();
|
||||
return messages
|
||||
.filter((message) => {
|
||||
@@ -238,6 +240,101 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const activeRetryStatus = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const sessionId = state.currentSessionId;
|
||||
if (!sessionId) return null;
|
||||
const status = state.sessionStatus?.get(sessionId);
|
||||
if (!status || status.type !== 'retry') return null;
|
||||
const rawMessage = typeof status.message === 'string' ? status.message.trim() : '';
|
||||
return {
|
||||
sessionId,
|
||||
message: rawMessage || 'Quota limit reached. Retrying automatically.',
|
||||
confirmedAt: status.confirmedAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const displayMessages = React.useMemo(() => {
|
||||
if (!activeRetryStatus) {
|
||||
return baseDisplayMessages;
|
||||
}
|
||||
|
||||
const retryError = {
|
||||
name: 'SessionRetry',
|
||||
message: activeRetryStatus.message,
|
||||
data: { message: activeRetryStatus.message },
|
||||
};
|
||||
|
||||
const resolveRole = (message: ChatMessageEntry): string | null => {
|
||||
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
|
||||
return (typeof info.clientRole === 'string' ? info.clientRole : null)
|
||||
?? (typeof info.role === 'string' ? info.role : null)
|
||||
?? null;
|
||||
};
|
||||
|
||||
let lastUserIndex = -1;
|
||||
for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) {
|
||||
if (resolveRole(baseDisplayMessages[index]) === 'user') {
|
||||
lastUserIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserIndex < 0) {
|
||||
return baseDisplayMessages;
|
||||
}
|
||||
|
||||
// Prefer attaching retry error to the assistant message in the current turn (if one exists)
|
||||
// to avoid rendering a separate header-only placeholder + error block.
|
||||
let targetAssistantIndex = -1;
|
||||
for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) {
|
||||
if (resolveRole(baseDisplayMessages[index]) === 'assistant') {
|
||||
targetAssistantIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetAssistantIndex >= 0) {
|
||||
const existing = baseDisplayMessages[targetAssistantIndex];
|
||||
const existingInfo = existing.info as unknown as { error?: unknown };
|
||||
if (existingInfo.error) {
|
||||
return baseDisplayMessages;
|
||||
}
|
||||
|
||||
return baseDisplayMessages.map((message, index) => {
|
||||
if (index !== targetAssistantIndex) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
info: {
|
||||
...(message.info as unknown as Record<string, unknown>),
|
||||
error: retryError,
|
||||
} as unknown as Message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const eventTime = typeof activeRetryStatus.confirmedAt === 'number' ? activeRetryStatus.confirmedAt : Date.now();
|
||||
const syntheticId = `synthetic_retry_notice_${activeRetryStatus.sessionId}`;
|
||||
const synthetic: ChatMessageEntry = {
|
||||
info: {
|
||||
id: syntheticId,
|
||||
sessionID: activeRetryStatus.sessionId,
|
||||
role: 'assistant',
|
||||
time: { created: eventTime, completed: eventTime },
|
||||
finish: 'stop',
|
||||
error: retryError,
|
||||
} as unknown as Message,
|
||||
parts: [],
|
||||
};
|
||||
|
||||
const next = baseDisplayMessages.slice();
|
||||
next.splice(lastUserIndex + 1, 0, synthetic);
|
||||
return next;
|
||||
}, [activeRetryStatus, baseDisplayMessages]);
|
||||
|
||||
const { turns, ungroupedMessages } = React.useMemo(() => {
|
||||
const groupedTurns = detectTurns(displayMessages);
|
||||
const groupedMessageIds = new Set<string>();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { InlineCommentCard } from './InlineCommentCard';
|
||||
import { InlineCommentInput } from './InlineCommentInput';
|
||||
|
||||
type SelectedLineRange = { start: number; end: number };
|
||||
|
||||
type CommentPos = {
|
||||
top: number;
|
||||
flipUp: boolean;
|
||||
};
|
||||
|
||||
const COMMENT_POPOVER_HEIGHT = 200;
|
||||
|
||||
function getLineTop(view: EditorView, wrapper: HTMLElement, lineNumber: number, position: 'top' | 'bottom'): number | undefined {
|
||||
const lineCount = view.state.doc.lines;
|
||||
if (lineNumber < 1 || lineNumber > lineCount) return undefined;
|
||||
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const coords = view.coordsAtPos(line.from);
|
||||
if (!coords) return undefined;
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
if (position === 'bottom') {
|
||||
return coords.bottom - wrapperRect.top;
|
||||
}
|
||||
return coords.top - wrapperRect.top;
|
||||
}
|
||||
|
||||
function shouldFlipUp(view: EditorView, endLine: number, scrollContainer: HTMLElement | null): boolean {
|
||||
const lineCount = view.state.doc.lines;
|
||||
if (endLine < 1 || endLine > lineCount) return false;
|
||||
|
||||
const line = view.state.doc.line(endLine);
|
||||
const coords = view.coordsAtPos(line.from);
|
||||
if (!coords) return false;
|
||||
|
||||
const viewportBottom = scrollContainer
|
||||
? scrollContainer.getBoundingClientRect().bottom
|
||||
: window.innerHeight;
|
||||
|
||||
return (coords.bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom;
|
||||
}
|
||||
|
||||
function computePosition(
|
||||
view: EditorView,
|
||||
wrapper: HTMLElement,
|
||||
scrollContainer: HTMLElement | null,
|
||||
range: { start: number; end: number },
|
||||
): CommentPos | undefined {
|
||||
const flipUp = shouldFlipUp(view, range.end, scrollContainer);
|
||||
|
||||
const top = flipUp
|
||||
? getLineTop(view, wrapper, range.start, 'top')
|
||||
: getLineTop(view, wrapper, range.end, 'bottom');
|
||||
|
||||
if (top === undefined) return undefined;
|
||||
return { top, flipUp };
|
||||
}
|
||||
|
||||
type FloatingCommentsProps = {
|
||||
editorView: EditorView | null;
|
||||
wrapperRef: React.RefObject<HTMLElement | null>;
|
||||
fileDrafts: InlineCommentDraft[];
|
||||
editingDraftId: string | null;
|
||||
commentText: string;
|
||||
lineSelection: SelectedLineRange | null;
|
||||
isDragging: boolean;
|
||||
fileLabel: string;
|
||||
onSaveComment: (text: string, range?: SelectedLineRange) => void;
|
||||
onCancelComment: () => void;
|
||||
onEditDraft: (draft: InlineCommentDraft) => void;
|
||||
onDeleteDraft: (draft: InlineCommentDraft) => void;
|
||||
};
|
||||
|
||||
export function useFloatingComments({
|
||||
editorView,
|
||||
wrapperRef,
|
||||
fileDrafts,
|
||||
editingDraftId,
|
||||
commentText,
|
||||
lineSelection,
|
||||
isDragging,
|
||||
fileLabel,
|
||||
onSaveComment,
|
||||
onCancelComment,
|
||||
onEditDraft,
|
||||
onDeleteDraft,
|
||||
}: FloatingCommentsProps): React.ReactNode {
|
||||
const [positions, setPositions] = React.useState<Record<string, CommentPos | undefined>>({});
|
||||
|
||||
const updatePositions = React.useCallback(() => {
|
||||
const view = editorView;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!view || !wrapper) return;
|
||||
|
||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
||||
const next: Record<string, CommentPos | undefined> = {};
|
||||
|
||||
for (const d of fileDrafts) {
|
||||
next[d.id] = computePosition(view, wrapper, scrollContainer, {
|
||||
start: d.startLine,
|
||||
end: d.endLine,
|
||||
});
|
||||
}
|
||||
|
||||
if (lineSelection && !editingDraftId && !isDragging) {
|
||||
next['__new__'] = computePosition(view, wrapper, scrollContainer, {
|
||||
start: lineSelection.start,
|
||||
end: lineSelection.end,
|
||||
});
|
||||
}
|
||||
|
||||
setPositions(next);
|
||||
}, [editorView, wrapperRef, fileDrafts, editingDraftId, lineSelection, isDragging]);
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(updatePositions);
|
||||
}, [updatePositions]);
|
||||
|
||||
// Also update on scroll
|
||||
React.useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const onScroll = () => requestAnimationFrame(updatePositions);
|
||||
scrollContainer.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => scrollContainer.removeEventListener('scroll', onScroll);
|
||||
}, [wrapperRef, updatePositions]);
|
||||
|
||||
const popoverStyle = (flipUp: boolean): React.CSSProperties => 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 };
|
||||
|
||||
return (
|
||||
<>
|
||||
{fileDrafts.map((d) => {
|
||||
const pos = positions[d.id];
|
||||
if (!pos) return null;
|
||||
|
||||
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(pos.flipUp)}>
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={{ start: d.startLine, end: d.endLine }}
|
||||
isEditing={true}
|
||||
onSave={onSaveComment}
|
||||
onCancel={onCancelComment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`saved-${d.id}`}
|
||||
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
|
||||
>
|
||||
<InlineCommentCard
|
||||
draft={d}
|
||||
onEdit={() => onEditDraft(d)}
|
||||
onDelete={() => onDeleteDraft(d)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{lineSelection && !editingDraftId && !isDragging && positions['__new__'] && (
|
||||
<div
|
||||
key="new-comment"
|
||||
style={{ position: 'absolute', right: 24, top: positions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
|
||||
>
|
||||
<div style={popoverStyle(positions['__new__'].flipUp)}>
|
||||
<InlineCommentInput
|
||||
initialText={commentText}
|
||||
fileLabel={fileLabel}
|
||||
lineRange={lineSelection}
|
||||
isEditing={false}
|
||||
onSave={onSaveComment}
|
||||
onCancel={onCancelComment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,10 @@ interface BottomTerminalDockProps {
|
||||
|
||||
export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen, isMobile, children }) => {
|
||||
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
|
||||
const isFullscreen = useUIStore((state) => state.isBottomTerminalExpanded);
|
||||
const setBottomTerminalHeight = useUIStore((state) => state.setBottomTerminalHeight);
|
||||
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
const setBottomTerminalExpanded = useUIStore((state) => state.setBottomTerminalExpanded);
|
||||
const [fullscreenHeight, setFullscreenHeight] = React.useState<number | null>(null);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const dockRef = React.useRef<HTMLElement | null>(null);
|
||||
@@ -32,7 +33,6 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsFullscreen(false);
|
||||
setFullscreenHeight(null);
|
||||
setIsResizing(false);
|
||||
}
|
||||
@@ -118,14 +118,14 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
if (!isOpen) return;
|
||||
|
||||
if (isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
setBottomTerminalExpanded(false);
|
||||
const restoreHeight = Math.min(BOTTOM_DOCK_MAX_HEIGHT, Math.max(BOTTOM_DOCK_MIN_HEIGHT, previousHeightRef.current));
|
||||
setBottomTerminalHeight(restoreHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
previousHeightRef.current = standardHeight;
|
||||
setIsFullscreen(true);
|
||||
setBottomTerminalExpanded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ import { Header } from './Header';
|
||||
import { BottomTerminalDock } from './BottomTerminalDock';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { RightSidebar } from './RightSidebar';
|
||||
import { RightSidebarTabs } from './RightSidebarTabs';
|
||||
import { ContextPanel } from './ContextPanel';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { CommandPalette } from '../ui/CommandPalette';
|
||||
import { HelpDialog } from '../ui/HelpDialog';
|
||||
@@ -15,11 +17,31 @@ import { MultiRunLauncher } from '@/components/multirun';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
|
||||
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
|
||||
@@ -42,8 +64,19 @@ export const MainLayout: React.FC = () => {
|
||||
} = useUIStore();
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
|
||||
const isContextPanelOpen = useUIStore((state) => {
|
||||
if (!directoryKey) {
|
||||
return false;
|
||||
}
|
||||
const panelState = state.contextPanelByDirectory[directoryKey];
|
||||
return Boolean(panelState?.isOpen && panelState?.mode);
|
||||
});
|
||||
const setSidebarOpen = useUIStore((state) => state.setSidebarOpen);
|
||||
const rightSidebarAutoClosedRef = React.useRef(false);
|
||||
const bottomTerminalAutoClosedRef = React.useRef(false);
|
||||
const leftSidebarAutoClosedByContextRef = React.useRef(false);
|
||||
|
||||
useEdgeSwipe({ enabled: true });
|
||||
|
||||
@@ -90,6 +123,22 @@ export const MainLayout: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isContextPanelOpen) {
|
||||
const currentlyOpen = useUIStore.getState().isSidebarOpen;
|
||||
if (currentlyOpen) {
|
||||
setSidebarOpen(false);
|
||||
leftSidebarAutoClosedByContextRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (leftSidebarAutoClosedByContextRef.current) {
|
||||
setSidebarOpen(true);
|
||||
leftSidebarAutoClosedByContextRef.current = false;
|
||||
}
|
||||
}, [isContextPanelOpen, setSidebarOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -508,22 +557,25 @@ export const MainLayout: React.FC = () => {
|
||||
<Header />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
||||
<SessionSidebar />
|
||||
<SessionSidebar hideProjectSelector />
|
||||
</Sidebar>
|
||||
<div className="flex flex-1 min-w-0 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<ContextPanel />
|
||||
</div>
|
||||
<RightSidebar isOpen={isRightSidebarOpen} isMobile={isMobile}>
|
||||
<ErrorBoundary><GitView mode="sidebar" /></ErrorBoundary>
|
||||
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
|
||||
</RightSidebar>
|
||||
</div>
|
||||
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
|
||||
|
||||
interface ProjectEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
initialIcon?: string | null;
|
||||
initialColor?: string | null;
|
||||
onSave: (data: { label: string; icon: string | null; color: string | null }) => void;
|
||||
}
|
||||
|
||||
export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectName,
|
||||
projectPath,
|
||||
initialIcon = null,
|
||||
initialColor = null,
|
||||
onSave,
|
||||
}) => {
|
||||
const [name, setName] = React.useState(projectName);
|
||||
const [icon, setIcon] = React.useState<string | null>(initialIcon);
|
||||
const [color, setColor] = React.useState<string | null>(initialColor);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setName(projectName);
|
||||
setIcon(initialIcon);
|
||||
setColor(initialColor);
|
||||
}
|
||||
}, [open, projectName, initialIcon, initialColor]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
onSave({ label: trimmed, icon, color });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit project</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-1">
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground truncate">
|
||||
{projectPath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* No color option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColor(null)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
|
||||
color === null
|
||||
? 'border-foreground scale-110'
|
||||
: 'border-border hover:border-border/80'
|
||||
)}
|
||||
title="None"
|
||||
>
|
||||
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
|
||||
</button>
|
||||
{PROJECT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => setColor(c.key)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg border-2 transition-all',
|
||||
color === c.key
|
||||
? 'border-foreground scale-110'
|
||||
: 'border-transparent hover:border-border'
|
||||
)}
|
||||
style={{ backgroundColor: c.cssVar }}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Icon
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* No icon option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIcon(null)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
|
||||
icon === null
|
||||
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
|
||||
: 'border-border hover:border-border/80'
|
||||
)}
|
||||
title="None"
|
||||
>
|
||||
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
|
||||
</button>
|
||||
{PROJECT_ICONS.map((i) => {
|
||||
const IconComponent = i.Icon;
|
||||
return (
|
||||
<button
|
||||
key={i.key}
|
||||
type="button"
|
||||
onClick={() => setIcon(i.key)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
|
||||
icon === i.key
|
||||
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
|
||||
: 'border-border hover:border-border/80'
|
||||
)}
|
||||
title={i.label}
|
||||
>
|
||||
<IconComponent
|
||||
className="w-4 h-4"
|
||||
style={currentColorVar ? { color: currentColorVar } : undefined}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { RiFolder3Line, RiGitBranchLine } from '@remixicon/react';
|
||||
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { GitView } from '@/components/views';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||
|
||||
type RightTab = 'git' | 'files';
|
||||
|
||||
export const RightSidebarTabs: React.FC = () => {
|
||||
const rightSidebarTab = useUIStore((state) => state.rightSidebarTab);
|
||||
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
|
||||
<div className="border-b border-border/40 bg-background px-3 py-1.5">
|
||||
<AnimatedTabs<RightTab>
|
||||
value={rightSidebarTab}
|
||||
onValueChange={setRightSidebarTab}
|
||||
size="sm"
|
||||
collapseLabelsOnSmall
|
||||
collapseLabelsOnNarrow
|
||||
tabs={[
|
||||
{ value: 'git', label: 'Git', icon: RiGitBranchLine },
|
||||
{ value: 'files', label: 'Files', icon: RiFolder3Line },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{rightSidebarTab === 'git' ? <GitView mode="sidebar" /> : <SidebarFilesTree />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { UpdateDialog } from '../ui/UpdateDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export const SIDEBAR_CONTENT_WIDTH = 264;
|
||||
const SIDEBAR_MIN_WIDTH = 200;
|
||||
const SIDEBAR_MIN_WIDTH = 300;
|
||||
const SIDEBAR_MAX_WIDTH = 500;
|
||||
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
||||
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiCodeLine,
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiFileAddLine,
|
||||
RiFileCopyLine,
|
||||
RiFileImageLine,
|
||||
RiFileTextLine,
|
||||
RiFolder3Fill,
|
||||
RiFolderAddLine,
|
||||
RiFolderOpenFill,
|
||||
RiLoader4Line,
|
||||
RiMore2Fill,
|
||||
RiRefreshLine,
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
type FileNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
extension?: string;
|
||||
relativePath?: string;
|
||||
};
|
||||
|
||||
const sortNodes = (items: FileNode[]) =>
|
||||
items.slice().sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const normalizePath = (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 isAbsolutePath = (value: string): boolean => {
|
||||
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
||||
};
|
||||
|
||||
const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']);
|
||||
|
||||
const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name);
|
||||
|
||||
const shouldIgnorePath = (path: string): boolean => {
|
||||
const normalized = normalizePath(path);
|
||||
return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/');
|
||||
};
|
||||
|
||||
// --- File icons (matching FilesView) ---
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts',
|
||||
'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl', 'stylus',
|
||||
'vue', 'svelte', 'astro',
|
||||
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd',
|
||||
'py', 'pyw', 'pyx', 'pxd', 'pxi',
|
||||
'rb', 'erb', 'rake', 'gemspec',
|
||||
'php', 'phtml', 'php3', 'php4', 'php5', 'phps',
|
||||
'java', 'kt', 'kts', 'scala', 'sc', 'groovy', 'gradle',
|
||||
'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hxx', 'hh', 'm', 'mm',
|
||||
'cs', 'fs', 'fsx', 'fsi',
|
||||
'go', 'rs', 'swift', 'dart', 'lua',
|
||||
'pl', 'pm', 'pod', 'r', 'R', 'rmd', 'jl',
|
||||
'hs', 'lhs', 'ex', 'exs', 'erl', 'hrl',
|
||||
'clj', 'cljs', 'cljc', 'edn',
|
||||
'lisp', 'cl', 'el', 'scm', 'ss', 'rkt',
|
||||
'ml', 'mli', 're', 'rei', 'nim', 'zig', 'v', 'cr',
|
||||
'sql', 'psql', 'plsql', 'graphql', 'gql', 'sol',
|
||||
'asm', 's', 'S', 'mk', 'nix', 'tf', 'tfvars', 'pp', 'ansible',
|
||||
]);
|
||||
|
||||
const DATA_EXTENSIONS = new Set([
|
||||
'json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'geojson',
|
||||
'yaml', 'yml', 'toml',
|
||||
'xml', 'xsl', 'xslt', 'xsd', 'dtd', 'plist',
|
||||
'ini', 'cfg', 'conf', 'config', 'env', 'properties',
|
||||
'csv', 'tsv', 'lock',
|
||||
]);
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns',
|
||||
'bmp', 'tiff', 'tif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
|
||||
'heic', 'heif', 'avif', 'jxl',
|
||||
]);
|
||||
|
||||
const DOCUMENT_EXTENSIONS = new Set([
|
||||
'md', 'mdx', 'markdown', 'mdown', 'mkd',
|
||||
'txt', 'text', 'rtf', 'doc', 'docx', 'odt', 'pdf',
|
||||
'rst', 'adoc', 'asciidoc', 'org', 'tex', 'latex', 'bib',
|
||||
]);
|
||||
|
||||
const getFileIcon = (extension?: string): React.ReactNode => {
|
||||
const ext = extension?.toLowerCase();
|
||||
if (ext && CODE_EXTENSIONS.has(ext)) {
|
||||
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-info)]" />;
|
||||
}
|
||||
if (ext && DATA_EXTENSIONS.has(ext)) {
|
||||
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-warning)]" />;
|
||||
}
|
||||
if (ext && IMAGE_EXTENSIONS.has(ext)) {
|
||||
return <RiFileImageLine className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]" />;
|
||||
}
|
||||
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
|
||||
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
// --- Git status indicators (matching FilesView) ---
|
||||
|
||||
type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted';
|
||||
|
||||
const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
|
||||
const color = {
|
||||
open: 'var(--status-info)',
|
||||
modified: 'var(--status-warning)',
|
||||
'git-modified': 'var(--status-warning)',
|
||||
'git-added': 'var(--status-success)',
|
||||
'git-deleted': 'var(--status-error)',
|
||||
}[status];
|
||||
|
||||
return <span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />;
|
||||
};
|
||||
|
||||
// --- FileRow with context menu (matching FilesView) ---
|
||||
|
||||
interface FileRowProps {
|
||||
node: FileNode;
|
||||
isExpanded: boolean;
|
||||
isActive: boolean;
|
||||
status?: FileStatus | null;
|
||||
badge?: { modified: number; added: number } | null;
|
||||
permissions: {
|
||||
canRename: boolean;
|
||||
canCreateFile: boolean;
|
||||
canCreateFolder: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
contextMenuPath: string | null;
|
||||
setContextMenuPath: (path: string | null) => void;
|
||||
onSelect: (node: FileNode) => void;
|
||||
onToggle: (path: string) => void;
|
||||
onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void;
|
||||
}
|
||||
|
||||
const FileRow: React.FC<FileRowProps> = ({
|
||||
node,
|
||||
isExpanded,
|
||||
isActive,
|
||||
status,
|
||||
badge,
|
||||
permissions,
|
||||
contextMenuPath,
|
||||
setContextMenuPath,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onOpenDialog,
|
||||
}) => {
|
||||
const isDir = node.type === 'directory';
|
||||
const { canRename, canCreateFile, canCreateFolder, canDelete } = permissions;
|
||||
|
||||
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
|
||||
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) return;
|
||||
event?.preventDefault();
|
||||
setContextMenuPath(node.path);
|
||||
}, [canRename, canCreateFile, canCreateFolder, canDelete, node.path, setContextMenuPath]);
|
||||
|
||||
const handleInteraction = React.useCallback(() => {
|
||||
if (isDir) {
|
||||
onToggle(node.path);
|
||||
} else {
|
||||
onSelect(node);
|
||||
}
|
||||
}, [isDir, node, onSelect, onToggle]);
|
||||
|
||||
const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuPath(node.path);
|
||||
}, [node.path, setContextMenuPath]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center"
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInteraction}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
|
||||
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
|
||||
)}
|
||||
>
|
||||
{isDir ? (
|
||||
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" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(node.extension)
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate typography-meta" title={node.path}>
|
||||
{node.name}
|
||||
</span>
|
||||
{!isDir && status && <FileStatusDot status={status} />}
|
||||
{isDir && badge && (
|
||||
<span className="text-xs flex items-center gap-1 ml-auto mr-1">
|
||||
{badge.modified > 0 && <span className="text-[var(--status-warning)]">M{badge.modified}</span>}
|
||||
{badge.added > 0 && <span className="text-[var(--status-success)]">+{badge.added}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100 group-hover:opacity-100">
|
||||
<DropdownMenu
|
||||
open={contextMenuPath === node.path}
|
||||
onOpenChange={(open) => setContextMenuPath(open ? node.path : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleMenuButtonClick}
|
||||
>
|
||||
<RiMore2Fill className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" onCloseAutoFocus={() => setContextMenuPath(null)}>
|
||||
{canRename && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
|
||||
<RiEditLine className="mr-2 h-4 w-4" /> Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard.writeText(node.path);
|
||||
toast.success('Path copied');
|
||||
}}>
|
||||
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
||||
</DropdownMenuItem>
|
||||
{isDir && (canCreateFile || canCreateFolder) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{canCreateFile && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
|
||||
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canCreateFolder && (
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
|
||||
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => { e.stopPropagation(); onOpenDialog('delete', node); }}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export const SidebarFilesTree: React.FC = () => {
|
||||
const { files, runtime } = useRuntimeAPIs();
|
||||
const currentDirectory = useEffectiveDirectory() ?? '';
|
||||
const root = normalizePath(currentDirectory.trim());
|
||||
const showHidden = useDirectoryShowHidden();
|
||||
const showGitignored = useFilesViewShowGitignored();
|
||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||
const openContextFile = useUIStore((state) => state.openContextFile);
|
||||
const gitStatus = useGitStatus(currentDirectory);
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
|
||||
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
|
||||
const loadedDirsRef = React.useRef<Set<string>>(new Set());
|
||||
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const EMPTY_PATHS: string[] = React.useMemo(() => [], []);
|
||||
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
|
||||
const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
|
||||
const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
|
||||
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
|
||||
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
|
||||
const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
|
||||
|
||||
// Dialog state for CRUD operations
|
||||
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);
|
||||
const [dialogInputValue, setDialogInputValue] = React.useState('');
|
||||
const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false);
|
||||
|
||||
const canCreateFile = Boolean(files.writeFile);
|
||||
const canCreateFolder = Boolean(files.createDirectory);
|
||||
const canRename = Boolean(files.rename);
|
||||
const canDelete = Boolean(files.delete);
|
||||
|
||||
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
|
||||
setActiveDialog(type);
|
||||
setDialogData(data);
|
||||
setDialogInputValue(type === 'rename' ? data.name || '' : '');
|
||||
setIsDialogSubmitting(false);
|
||||
}, []);
|
||||
|
||||
const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => {
|
||||
const nodes = entries
|
||||
.filter((entry) => entry && typeof entry.name === 'string' && entry.name.length > 0)
|
||||
.filter((entry) => showHidden || !entry.name.startsWith('.'))
|
||||
.filter((entry) => showGitignored || !shouldIgnoreEntryName(entry.name))
|
||||
.map<FileNode>((entry) => {
|
||||
const name = entry.name;
|
||||
const normalizedEntryPath = normalizePath(entry.path || '');
|
||||
const path = normalizedEntryPath
|
||||
? (isAbsolutePath(normalizedEntryPath)
|
||||
? normalizedEntryPath
|
||||
: normalizePath(`${dirPath}/${normalizedEntryPath}`))
|
||||
: normalizePath(`${dirPath}/${name}`);
|
||||
const type = entry.isDirectory ? 'directory' : 'file';
|
||||
const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined;
|
||||
return { name, path, type, extension };
|
||||
});
|
||||
|
||||
return sortNodes(nodes);
|
||||
}, [showGitignored, showHidden]);
|
||||
|
||||
const loadDirectory = React.useCallback(async (dirPath: string) => {
|
||||
const normalizedDir = normalizePath(dirPath.trim());
|
||||
if (!normalizedDir) return;
|
||||
|
||||
if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) return;
|
||||
|
||||
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||
inFlightDirsRef.current.add(normalizedDir);
|
||||
|
||||
try {
|
||||
const respectGitignore = !showGitignored;
|
||||
let entries: Array<{ name: string; path: string; isDirectory: boolean }>;
|
||||
if (runtime.isDesktop) {
|
||||
const result = await files.listDirectory(normalizedDir, { respectGitignore });
|
||||
entries = result.entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
}));
|
||||
} else {
|
||||
const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore });
|
||||
entries = result.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
}));
|
||||
}
|
||||
|
||||
const mapped = mapDirectoryEntries(normalizedDir, entries);
|
||||
|
||||
loadedDirsRef.current = new Set(loadedDirsRef.current);
|
||||
loadedDirsRef.current.add(normalizedDir);
|
||||
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
|
||||
} catch {
|
||||
setChildrenByDir((prev) => ({
|
||||
...prev,
|
||||
[normalizedDir]: prev[normalizedDir] ?? [],
|
||||
}));
|
||||
} finally {
|
||||
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
|
||||
inFlightDirsRef.current.delete(normalizedDir);
|
||||
}
|
||||
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
|
||||
|
||||
const refreshRoot = React.useCallback(async () => {
|
||||
if (!root) return;
|
||||
|
||||
loadedDirsRef.current = new Set();
|
||||
inFlightDirsRef.current = new Set();
|
||||
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||||
|
||||
await loadDirectory(root);
|
||||
}, [loadDirectory, root]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!root) return;
|
||||
|
||||
loadedDirsRef.current = new Set();
|
||||
inFlightDirsRef.current = new Set();
|
||||
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
||||
void loadDirectory(root);
|
||||
}, [loadDirectory, root, showHidden, showGitignored]);
|
||||
|
||||
// --- Fuzzy search scoring (matching FilesView) ---
|
||||
|
||||
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return 0;
|
||||
|
||||
const c = candidate.toLowerCase();
|
||||
let score = 0;
|
||||
let lastIndex = -1;
|
||||
let consecutive = 0;
|
||||
|
||||
for (let i = 0; i < q.length; i += 1) {
|
||||
const ch = q[i];
|
||||
if (!ch || ch === ' ') continue;
|
||||
|
||||
const idx = c.indexOf(ch, lastIndex + 1);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const gap = idx - lastIndex - 1;
|
||||
if (gap === 0) {
|
||||
consecutive += 1;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
|
||||
score += 10;
|
||||
score += Math.max(0, 18 - idx);
|
||||
score -= Math.max(0, gap);
|
||||
|
||||
if (idx === 0) {
|
||||
score += 12;
|
||||
} else {
|
||||
const prev = c[idx - 1];
|
||||
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
score += consecutive > 0 ? 12 : 0;
|
||||
lastIndex = idx;
|
||||
}
|
||||
|
||||
score += Math.max(0, 24 - Math.round(c.length / 3));
|
||||
return score;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) {
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedQuery = debouncedSearchQuery.trim();
|
||||
if (!trimmedQuery) {
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQueryLower = trimmedQuery.toLowerCase();
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
|
||||
searchFiles(currentDirectory, trimmedQuery, 150, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
})
|
||||
.then((hits) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
|
||||
|
||||
const ranked = filtered
|
||||
.map((hit) => {
|
||||
const label = hit.relativePath || hit.name || hit.path;
|
||||
const score = fuzzyScore(normalizedQueryLower, label);
|
||||
return score === null ? null : { hit, score, labelLength: label.length };
|
||||
})
|
||||
.filter(Boolean) as Array<{ hit: typeof hits[0]; score: number; labelLength: number }>;
|
||||
|
||||
ranked.sort((a, b) => (
|
||||
b.score - a.score
|
||||
|| a.labelLength - b.labelLength
|
||||
|| a.hit.path.localeCompare(b.hit.path)
|
||||
));
|
||||
|
||||
const mapped: FileNode[] = ranked.map(({ hit }) => ({
|
||||
name: hit.name,
|
||||
path: normalizePath(hit.path),
|
||||
type: 'file',
|
||||
extension: hit.extension,
|
||||
relativePath: hit.relativePath,
|
||||
}));
|
||||
|
||||
setSearchResults(mapped);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSearchResults([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setSearching(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
// --- Git status helpers (matching FilesView) ---
|
||||
|
||||
const getFileStatus = React.useCallback((path: string): FileStatus | null => {
|
||||
if (openPaths.includes(path)) return 'open';
|
||||
|
||||
if (gitStatus?.files) {
|
||||
const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
|
||||
const file = gitStatus.files.find((f) => f.path === relative);
|
||||
if (file) {
|
||||
if (file.index === 'A' || file.working_dir === '?') return 'git-added';
|
||||
if (file.index === 'D') return 'git-deleted';
|
||||
if (file.index === 'M' || file.working_dir === 'M') return 'git-modified';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [openPaths, gitStatus, root]);
|
||||
|
||||
const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => {
|
||||
if (!gitStatus?.files) return null;
|
||||
const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath;
|
||||
const prefix = relativeDir ? `${relativeDir}/` : '';
|
||||
|
||||
let modified = 0, added = 0;
|
||||
for (const f of gitStatus.files) {
|
||||
if (f.path.startsWith(prefix)) {
|
||||
if (f.index === 'M' || f.working_dir === 'M') modified++;
|
||||
if (f.index === 'A' || f.working_dir === '?') added++;
|
||||
}
|
||||
}
|
||||
return modified + added > 0 ? { modified, added } : null;
|
||||
}, [gitStatus, root]);
|
||||
|
||||
// --- File operations ---
|
||||
|
||||
const handleOpenFile = React.useCallback((node: FileNode) => {
|
||||
if (!root) return;
|
||||
|
||||
setSelectedPath(root, node.path);
|
||||
addOpenPath(root, node.path);
|
||||
openContextFile(root, node.path);
|
||||
}, [addOpenPath, openContextFile, root, setSelectedPath]);
|
||||
|
||||
const toggleDirectory = React.useCallback(async (dirPath: string) => {
|
||||
const normalized = normalizePath(dirPath);
|
||||
if (!root) return;
|
||||
|
||||
toggleExpandedPath(root, normalized);
|
||||
if (!loadedDirsRef.current.has(normalized)) {
|
||||
await loadDirectory(normalized);
|
||||
}
|
||||
}, [loadDirectory, root, toggleExpandedPath]);
|
||||
|
||||
// --- Dialog submit (matching FilesView) ---
|
||||
|
||||
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!dialogData || !activeDialog) return;
|
||||
|
||||
setIsDialogSubmitting(true);
|
||||
try {
|
||||
if (activeDialog === 'createFile') {
|
||||
if (!dialogInputValue.trim()) throw new Error('Filename is required');
|
||||
const parentPath = dialogData.path;
|
||||
const prefix = parentPath ? `${parentPath}/` : '';
|
||||
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
|
||||
|
||||
if (!files.writeFile) throw new Error('Write not supported');
|
||||
const result = await files.writeFile(newPath, '');
|
||||
if (result.success) {
|
||||
toast.success('File created');
|
||||
await refreshRoot();
|
||||
}
|
||||
} else if (activeDialog === 'createFolder') {
|
||||
if (!dialogInputValue.trim()) throw new Error('Folder name is required');
|
||||
const parentPath = dialogData.path;
|
||||
const prefix = parentPath ? `${parentPath}/` : '';
|
||||
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
|
||||
|
||||
const result = await files.createDirectory(newPath);
|
||||
if (result.success) {
|
||||
toast.success('Folder created');
|
||||
await refreshRoot();
|
||||
}
|
||||
} else if (activeDialog === 'rename') {
|
||||
if (!dialogInputValue.trim()) throw new Error('Name is required');
|
||||
const oldPath = dialogData.path;
|
||||
const parentDir = oldPath.split('/').slice(0, -1).join('/');
|
||||
const prefix = parentDir ? `${parentDir}/` : '';
|
||||
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
|
||||
|
||||
if (files.rename) {
|
||||
const result = await files.rename(oldPath, newPath);
|
||||
if (result.success) {
|
||||
toast.success('Renamed successfully');
|
||||
await refreshRoot();
|
||||
if (root) {
|
||||
removeOpenPathsByPrefix(root, oldPath);
|
||||
}
|
||||
if (selectedPath === oldPath || (selectedPath && selectedPath.startsWith(`${oldPath}/`))) {
|
||||
setSelectedPath(root, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error('Rename not supported');
|
||||
}
|
||||
} else if (activeDialog === 'delete') {
|
||||
if (files.delete) {
|
||||
const result = await files.delete(dialogData.path);
|
||||
if (result.success) {
|
||||
toast.success('Deleted successfully');
|
||||
await refreshRoot();
|
||||
if (root) {
|
||||
removeOpenPathsByPrefix(root, dialogData.path);
|
||||
}
|
||||
if (selectedPath === dialogData.path || (selectedPath && selectedPath.startsWith(dialogData.path + '/'))) {
|
||||
setSelectedPath(root, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error('Delete not supported');
|
||||
}
|
||||
}
|
||||
setActiveDialog(null);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Operation failed');
|
||||
} finally {
|
||||
setIsDialogSubmitting(false);
|
||||
}
|
||||
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath]);
|
||||
|
||||
// --- Tree rendering (matching FilesView with indent guides) ---
|
||||
|
||||
const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => {
|
||||
const nodes = childrenByDir[dirPath] ?? [];
|
||||
|
||||
return nodes.map((node, index) => {
|
||||
const isDir = node.type === 'directory';
|
||||
const isExpanded = isDir && expandedPaths.includes(node.path);
|
||||
const isActive = selectedPath === node.path;
|
||||
const isLast = index === nodes.length - 1;
|
||||
|
||||
return (
|
||||
<li key={node.path} className="relative">
|
||||
{depth > 0 && (
|
||||
<>
|
||||
<span className="absolute top-3.5 left-[-12px] w-3 h-px bg-border/40" />
|
||||
{isLast && (
|
||||
<span className="absolute top-3.5 bottom-0 left-[-13px] w-[2px] bg-background" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FileRow
|
||||
node={node}
|
||||
isExpanded={isExpanded}
|
||||
isActive={isActive}
|
||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete }}
|
||||
contextMenuPath={contextMenuPath}
|
||||
setContextMenuPath={setContextMenuPath}
|
||||
onSelect={handleOpenFile}
|
||||
onToggle={toggleDirectory}
|
||||
onOpenDialog={handleOpenDialog}
|
||||
/>
|
||||
{isDir && isExpanded && (
|
||||
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
|
||||
{renderTree(node.path, depth + 1)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}, [childrenByDir, expandedPaths, handleOpenFile, selectedPath, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, getFileStatus, getFolderBadge]);
|
||||
|
||||
const hasTree = Boolean(root && childrenByDir[root]);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="h-8 pl-8 pr-8 typography-meta"
|
||||
/>
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{canCreateFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
title="New File"
|
||||
>
|
||||
<RiFileAddLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canCreateFolder && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
title="New Folder"
|
||||
>
|
||||
<RiFolderAddLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0" title="Refresh">
|
||||
<RiRefreshLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-2">
|
||||
<ul className="flex flex-col">
|
||||
{searching ? (
|
||||
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Searching...
|
||||
</li>
|
||||
) : searchResults.length > 0 ? (
|
||||
searchResults.map((node) => {
|
||||
const isActive = selectedPath === node.path;
|
||||
return (
|
||||
<li key={node.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenFile(node)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
|
||||
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
|
||||
)}
|
||||
title={node.path}
|
||||
>
|
||||
{getFileIcon(node.extension)}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate typography-meta"
|
||||
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||
>
|
||||
{node.relativePath ?? node.path}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : hasTree && root ? (
|
||||
renderTree(root, 0)
|
||||
) : (
|
||||
<li className="px-2 py-1 typography-meta text-muted-foreground">Loading...</li>
|
||||
)}
|
||||
</ul>
|
||||
</ScrollableOverlay>
|
||||
|
||||
{/* CRUD dialogs (matching FilesView) */}
|
||||
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{activeDialog === 'createFile' && 'Create File'}
|
||||
{activeDialog === 'createFolder' && 'Create Folder'}
|
||||
{activeDialog === 'rename' && 'Rename'}
|
||||
{activeDialog === 'delete' && 'Delete'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
|
||||
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
|
||||
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
|
||||
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{activeDialog !== 'delete' && (
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={dialogInputValue}
|
||||
onChange={(e) => setDialogInputValue(e.target.value)}
|
||||
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleDialogSubmit();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
|
||||
onClick={() => void handleDialogSubmit()}
|
||||
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
|
||||
>
|
||||
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
|
||||
activeDialog === 'delete' ? 'Delete' : 'Confirm'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -17,10 +17,17 @@ import {
|
||||
RiLoader4Line,
|
||||
RiPencilLine,
|
||||
RiSearchLine,
|
||||
RiSplitCellsHorizontal,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
@@ -38,13 +45,35 @@ interface BranchPickerDialogProps {
|
||||
const displayProjectName = (project: BranchPickerProject): string =>
|
||||
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
|
||||
|
||||
const normalizeBranchName = (value: string | null | undefined): string => {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/^remotes\//, '');
|
||||
};
|
||||
|
||||
const normalizePath = (value: string | null | undefined): string => {
|
||||
const raw = String(value || '').trim().replace(/\\/g, '/');
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (raw === '/') {
|
||||
return '/';
|
||||
}
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
|
||||
};
|
||||
|
||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||
const [rootBranchName, setRootBranchName] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState<string | null>(null);
|
||||
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
|
||||
@@ -57,16 +86,19 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [b, w] = await Promise.all([
|
||||
const [b, w, rootBranch] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
git.worktree.list(project.path),
|
||||
getRootBranch(project.path).catch(() => null),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
setRootBranchName(rootBranch);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setRootBranchName(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,6 +112,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
setCreatingWorktreeBranch(null);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
@@ -161,7 +194,105 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
}
|
||||
}, [project, refresh, forceDeleteBranch]);
|
||||
|
||||
const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean));
|
||||
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingWorktreeBranch(branchName);
|
||||
try {
|
||||
const setupCommands = await getWorktreeSetupCommands({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
});
|
||||
await createWorktreeWithDefaults(
|
||||
{
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
},
|
||||
{
|
||||
preferredName: branchName,
|
||||
mode: 'existing',
|
||||
existingBranch: branchName,
|
||||
branchName,
|
||||
worktreeName: branchName,
|
||||
setupCommands,
|
||||
}
|
||||
);
|
||||
await refresh();
|
||||
toast.success('Worktree created', { description: branchName });
|
||||
} catch (err) {
|
||||
toast.error('Failed to create worktree', {
|
||||
description: err instanceof Error ? err.message : 'Create worktree failed',
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktreeBranch(null);
|
||||
}
|
||||
}, [project, refresh]);
|
||||
|
||||
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
|
||||
if (!project || !worktree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||
const directSessions = sessions.filter((session) => {
|
||||
const sessionPath = normalizePath(session.directory ?? null);
|
||||
return Boolean(sessionPath) && sessionPath === normalizedWorktreePath;
|
||||
});
|
||||
const directSessionIds = new Set(directSessions.map((session) => session.id));
|
||||
|
||||
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
|
||||
const subsessions = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string | null }).parentID;
|
||||
if (!parentID) {
|
||||
return false;
|
||||
}
|
||||
return parentIds.has(parentID);
|
||||
});
|
||||
if (subsessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const subsessionIds = new Set(subsessions.map((session) => session.id));
|
||||
return [...subsessions, ...findSubsessions(subsessionIds)];
|
||||
};
|
||||
|
||||
const allSubsessions = findSubsessions(directSessionIds);
|
||||
const seenIds = new Set<string>();
|
||||
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(session.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const normalizedBranch = normalizeBranchName(worktree.branch);
|
||||
const worktreeMetadata: WorktreeMetadata = {
|
||||
source: 'sdk',
|
||||
name: worktree.name,
|
||||
path: worktree.path,
|
||||
projectDirectory: project.path,
|
||||
branch: normalizedBranch,
|
||||
label: normalizedBranch || worktree.name,
|
||||
};
|
||||
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allSessions,
|
||||
mode: 'worktree',
|
||||
worktree: worktreeMetadata,
|
||||
});
|
||||
}, [project, sessions]);
|
||||
|
||||
const worktreeByBranch = new Map<string, GitWorktreeInfo>();
|
||||
for (const worktree of worktrees) {
|
||||
const branchName = normalizeBranchName(worktree.branch);
|
||||
if (branchName && !worktreeByBranch.has(branchName)) {
|
||||
worktreeByBranch.set(branchName, worktree);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRootBranch = normalizeBranchName(rootBranchName);
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
|
||||
@@ -204,16 +335,34 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
) : (
|
||||
localBranches.map((branchName) => {
|
||||
const details = branches?.branches[branchName];
|
||||
const normalizedBranchName = normalizeBranchName(branchName);
|
||||
const isCurrent = Boolean(details?.current);
|
||||
const isDeleting = deletingBranch === branchName;
|
||||
const isRenaming = renamingBranchKey === branchName;
|
||||
const hasAttachedWorktree = worktreeBranches.has(branchName);
|
||||
const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null;
|
||||
const hasAttachedWorktree = Boolean(attachedWorktree);
|
||||
const isProjectRootBranch = Boolean(
|
||||
normalizedBranchName &&
|
||||
normalizedRootBranch &&
|
||||
normalizedBranchName === normalizedRootBranch
|
||||
);
|
||||
const isEditing = editingBranch === branchName;
|
||||
const isConfirming = confirmingDelete === branchName;
|
||||
const isForceDelete = forceDeleteBranch === branchName;
|
||||
const isCreatingWorktree = creatingWorktreeBranch === branchName;
|
||||
|
||||
const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableCreateWorktree = Boolean(
|
||||
hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing
|
||||
);
|
||||
const disableDelete = Boolean(
|
||||
isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableRename = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableWorktreeDelete = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -258,7 +407,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
current
|
||||
HEAD
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -284,6 +433,27 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
|
||||
{!isEditing && !isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateWorktreeForBranch(branchName)}
|
||||
disabled={disableCreateWorktree}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Create worktree"
|
||||
>
|
||||
{isCreatingWorktree ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiSplitCellsHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Worktree already exists' : 'Create worktree'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -297,7 +467,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'}
|
||||
{isProjectRootBranch ? 'Rename disabled for root branch' : 'Rename'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -305,10 +475,16 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(branchName)}
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
if (hasAttachedWorktree) {
|
||||
handleRemoveWorktree(attachedWorktree);
|
||||
return;
|
||||
}
|
||||
setConfirmingDelete(branchName);
|
||||
}}
|
||||
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label="Delete"
|
||||
aria-label={hasAttachedWorktree ? 'Delete worktree' : 'Delete'}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
@@ -318,11 +494,15 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: hasAttachedWorktree
|
||||
? 'Delete (remove worktree first)'
|
||||
: 'Delete'}
|
||||
{hasAttachedWorktree
|
||||
? isProjectRootBranch
|
||||
? 'Delete worktree (root branch protected)'
|
||||
: 'Delete worktree'
|
||||
: isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: isProjectRootBranch
|
||||
? 'Delete disabled for root branch'
|
||||
: 'Delete'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -354,7 +534,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isConfirming ? (
|
||||
{!isEditing && isConfirming && !hasAttachedWorktree ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'text-xs mr-1',
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
RiFolderAddLine,
|
||||
RiGitBranchLine,
|
||||
RiGitPullRequestLine,
|
||||
RiGitRepositoryLine,
|
||||
RiStickyNoteLine,
|
||||
RiLinkUnlinkM,
|
||||
|
||||
@@ -50,8 +51,10 @@ import {
|
||||
|
||||
RiMore2Line,
|
||||
RiPencilAiLine,
|
||||
RiPushpinLine,
|
||||
RiShare2Line,
|
||||
RiShieldLine,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
@@ -74,6 +77,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
||||
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
|
||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||
|
||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||
|
||||
@@ -86,6 +90,7 @@ const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
|
||||
|
||||
const formatDateLabel = (value: string | number) => {
|
||||
const targetDate = new Date(value);
|
||||
@@ -146,6 +151,28 @@ const toFiniteNumber = (value: unknown): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSessionCreatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.created) ?? 0;
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.updated) ?? 0;
|
||||
};
|
||||
|
||||
const compareSessionsByPinnedAndTime = (a: Session, b: Session, pinnedSessionIds: Set<string>): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aPinned && bPinned) {
|
||||
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||
}
|
||||
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
|
||||
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
|
||||
return transform;
|
||||
@@ -525,6 +552,7 @@ interface SessionSidebarProps {
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
allowReselect?: boolean;
|
||||
hideDirectoryControls?: boolean;
|
||||
hideProjectSelector?: boolean;
|
||||
showOnlyMainWorkspace?: boolean;
|
||||
}
|
||||
|
||||
@@ -533,6 +561,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onSessionSelected,
|
||||
allowReselect = false,
|
||||
hideDirectoryControls = false,
|
||||
hideProjectSelector = false,
|
||||
showOnlyMainWorkspace = false,
|
||||
}) => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
@@ -554,9 +583,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
||||
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
|
||||
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return new Set();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as string[];
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
|
||||
@@ -722,10 +764,46 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
} catch { /* ignored */ }
|
||||
}, [safeStorage]);
|
||||
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
||||
React.useEffect(() => {
|
||||
const existingSessionIds = new Set(sessions.map((session) => session.id));
|
||||
setPinnedSessionIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (existingSessionIds.has(id)) {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(SESSION_PINNED_STORAGE_KEY, JSON.stringify(Array.from(pinnedSessionIds)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [pinnedSessionIds, safeStorage]);
|
||||
|
||||
const togglePinnedSession = React.useCallback((sessionId: string) => {
|
||||
setPinnedSessionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
}, [sessions, pinnedSessionIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const normalizedProjects = projects
|
||||
@@ -778,9 +856,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
collection.push(session);
|
||||
map.set(parentID, collection);
|
||||
});
|
||||
map.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
|
||||
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
|
||||
return map;
|
||||
}, [sortedSessions]);
|
||||
}, [sortedSessions, pinnedSessionIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const directories = new Set<string>();
|
||||
@@ -1110,7 +1188,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
projectIsRepo: boolean,
|
||||
) => {
|
||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||
const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
||||
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
|
||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
@@ -1123,7 +1201,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
childrenMap.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
|
||||
|
||||
// Build worktree lookup map
|
||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||
@@ -1245,7 +1323,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
return groups;
|
||||
},
|
||||
[homeDirectory, worktreeMetadata]
|
||||
[homeDirectory, worktreeMetadata, pinnedSessionIds]
|
||||
);
|
||||
|
||||
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
|
||||
@@ -1390,16 +1468,25 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
[availableWorktreesByProject, getSessionsByDirectory, sessionsByDirectory, isVSCode],
|
||||
);
|
||||
|
||||
// Keep last-known repo status to avoid UI jiggling during project switch
|
||||
const lastRepoStatusRef = React.useRef(false);
|
||||
if (activeProjectId && projectRepoStatus.has(activeProjectId)) {
|
||||
lastRepoStatusRef.current = Boolean(projectRepoStatus.get(activeProjectId));
|
||||
}
|
||||
|
||||
const projectSections = React.useMemo(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = getSessionsForProject(project);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatusRef.current;
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
projectRootBranches.get(project.id) ?? null,
|
||||
Boolean(projectRepoStatus.get(project.id)),
|
||||
isRepo,
|
||||
);
|
||||
return {
|
||||
project,
|
||||
@@ -1429,11 +1516,26 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
: null),
|
||||
[activeProjectForHeader],
|
||||
);
|
||||
const branchPickerProject = React.useMemo(() => {
|
||||
if (!activeProjectForHeader) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: activeProjectForHeader.id,
|
||||
path: activeProjectForHeader.path,
|
||||
normalizedPath: activeProjectForHeader.normalizedPath,
|
||||
label: activeProjectForHeader.label,
|
||||
};
|
||||
}, [activeProjectForHeader]);
|
||||
|
||||
const activeProjectIsRepo = React.useMemo(
|
||||
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
|
||||
[activeProjectForHeader, projectRepoStatus],
|
||||
);
|
||||
// Only flip to false once the new project's status is actually resolved (present in map)
|
||||
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id)
|
||||
? activeProjectIsRepo
|
||||
: lastRepoStatusRef.current;
|
||||
const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
|
||||
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
|
||||
|
||||
@@ -1690,6 +1792,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = session.title || 'Untitled Session';
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||
const isExpanded = expandedParents.has(session.id);
|
||||
const needsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
|
||||
const sessionSummary = session.summary as
|
||||
@@ -1834,8 +1937,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
>
|
||||
{}
|
||||
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
{showStatusMarker ? (
|
||||
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
{showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{isStreaming ? (
|
||||
<GridLoader size="xs" className="text-primary" />
|
||||
@@ -1856,6 +1959,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinnedSession ? (
|
||||
<RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" />
|
||||
) : null}
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||
{sessionTitle}
|
||||
</div>
|
||||
@@ -1955,6 +2061,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<RiPencilAiLine className="mr-1 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||||
{isPinnedSession ? (
|
||||
<RiUnpinLine className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<RiPushpinLine className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
||||
</DropdownMenuItem>
|
||||
{!session.share ? (
|
||||
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
|
||||
<RiShare2Line className="mr-1 h-4 w-4" />
|
||||
@@ -2023,6 +2137,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
toggleParent,
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
pinnedSessionIds,
|
||||
togglePinnedSession,
|
||||
handleShareSession,
|
||||
handleCopyShareUrl,
|
||||
handleUnshareSession,
|
||||
@@ -2056,7 +2172,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
};
|
||||
const allGroupSessions = collectGroupSessions(group.sessions);
|
||||
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
|
||||
const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId));
|
||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
||||
? Boolean(projectRepoStatus.get(projectId))
|
||||
: lastRepoStatusRef.current;
|
||||
const showBranchSubtitle = !group.isMain && isBranchDifferentFromLabel(group.branch, group.label);
|
||||
const isActiveGroup = Boolean(
|
||||
normalizedGroupDirectory
|
||||
@@ -2136,12 +2254,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined}
|
||||
>
|
||||
{!hideGroupLabel ? (
|
||||
<div className="min-w-0 flex items-center gap-1.5 px-0">
|
||||
{isCollapsed ? (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex items-center gap-1.5 pl-1.5">
|
||||
{!group.isMain || isGitProject ? (
|
||||
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
@@ -2155,6 +2268,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
) : <div />}
|
||||
{group.directory ? (
|
||||
@@ -2282,7 +2400,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
>
|
||||
{!hideDirectoryControls && (
|
||||
<div className="select-none pl-3.5 pr-2 py-1.5 flex-shrink-0 border-b border-border/60">
|
||||
<div className={cn('select-none pl-3.5 pr-2 flex-shrink-0 border-b border-border/60', hideProjectSelector ? 'py-1' : 'py-1.5')}>
|
||||
{!hideProjectSelector && (
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
@@ -2403,11 +2522,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<RiFolderAddLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{reserveHeaderActionsSpace ? (
|
||||
<div className="mt-1 h-8 pl-1">
|
||||
<div className="mt-1 -ml-1 flex h-8 items-center">
|
||||
{activeProjectForHeader ? (
|
||||
<div className="inline-flex h-8 items-center gap-1.5 rounded-md pl-0 pr-1">
|
||||
{activeProjectIsRepo ? (
|
||||
<div className="flex h-full items-center gap-1.5 rounded-md pl-0 pr-1">
|
||||
{stableActiveProjectIsRepo ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2479,6 +2599,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{stableActiveProjectIsRepo && branchPickerProject ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBranchPickerOpen(true)}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Manage branches"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Manage branches</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{useMobileNotesPanel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2512,7 +2647,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<DropdownMenuContent align="start" className="w-[340px] p-0">
|
||||
<ProjectNotesTodoPanel
|
||||
projectRef={activeProjectRefForHeader}
|
||||
canCreateWorktree={activeProjectIsRepo}
|
||||
canCreateWorktree={stableActiveProjectIsRepo}
|
||||
onActionComplete={() => setProjectNotesPanelOpen(false)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
@@ -2741,6 +2876,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<BranchPickerDialog
|
||||
open={isBranchPickerOpen}
|
||||
onOpenChange={setIsBranchPickerOpen}
|
||||
project={branchPickerProject}
|
||||
/>
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<MobileOverlayPanel
|
||||
open={projectNotesPanelOpen}
|
||||
@@ -2749,7 +2890,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
>
|
||||
<ProjectNotesTodoPanel
|
||||
projectRef={activeProjectRefForHeader}
|
||||
canCreateWorktree={activeProjectIsRepo}
|
||||
canCreateWorktree={stableActiveProjectIsRepo}
|
||||
onActionComplete={() => setProjectNotesPanelOpen(false)}
|
||||
className="p-0"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiDonutChartLine } from '@remixicon/react';
|
||||
import { RiDonutChartFill, RiDonutChartLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
@@ -12,6 +12,10 @@ interface ContextUsageDisplayProps {
|
||||
size?: 'default' | 'compact';
|
||||
isMobile?: boolean;
|
||||
hideIcon?: boolean;
|
||||
showPercentIcon?: boolean;
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
percentIconClassName?: string;
|
||||
}
|
||||
|
||||
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
@@ -22,6 +26,10 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
size = 'default',
|
||||
isMobile = false,
|
||||
hideIcon = false,
|
||||
showPercentIcon = false,
|
||||
className,
|
||||
valueClassName,
|
||||
percentIconClassName,
|
||||
}) => {
|
||||
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
|
||||
|
||||
@@ -53,13 +61,26 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
className={cn(
|
||||
'app-region-no-drag flex items-center gap-1.5 text-muted-foreground/60 select-none',
|
||||
size === 'compact' ? 'typography-micro' : 'typography-meta',
|
||||
className,
|
||||
)}
|
||||
aria-label="Context usage"
|
||||
onClick={isMobile ? () => setMobileTooltipOpen(true) : undefined}
|
||||
>
|
||||
{!isMobile && !hideIcon && <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />}
|
||||
<span className={cn(getPercentageColor(percentage), 'font-medium')}>
|
||||
{Math.min(percentage, 999).toFixed(1)}%
|
||||
<span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}>
|
||||
{showPercentIcon ? (
|
||||
<>
|
||||
<RiDonutChartFill
|
||||
className={cn('h-3.5 w-3.5', percentIconClassName, getPercentageColor(percentage))}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-foreground">{Math.min(percentage, 999).toFixed(1)}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={getPercentageColor(percentage)}>{Math.min(percentage, 999).toFixed(1)}</span>%
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
RiArrowUpSLine,
|
||||
RiBrainAi3Line,
|
||||
RiCloseCircleLine,
|
||||
RiCodeLine,
|
||||
RiCommandLine,
|
||||
RiGitBranchLine,
|
||||
RiLayoutLeftLine,
|
||||
@@ -160,9 +159,9 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: RiPaletteLine,
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + 2`],
|
||||
description: "Open Diff Panel",
|
||||
icon: RiCodeLine,
|
||||
keys: [`${mod} + 1...9`],
|
||||
description: "Switch Project or Main Tab",
|
||||
icon: RiLayoutLeftLine,
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + T`],
|
||||
|
||||
@@ -7,6 +7,7 @@ type OverlayScrollbarProps = {
|
||||
hideDelayMs?: number;
|
||||
className?: string;
|
||||
disableHorizontal?: boolean;
|
||||
observeMutations?: boolean;
|
||||
};
|
||||
|
||||
type ThumbMetrics = {
|
||||
@@ -20,6 +21,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
hideDelayMs = 1000,
|
||||
className,
|
||||
disableHorizontal = false,
|
||||
observeMutations = true,
|
||||
}) => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [vertical, setVertical] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
|
||||
@@ -102,7 +104,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
resizeObserver?.observe(container);
|
||||
|
||||
const mutationObserver =
|
||||
typeof MutationObserver !== "undefined"
|
||||
observeMutations && typeof MutationObserver !== "undefined"
|
||||
? new MutationObserver(() => updateMetrics())
|
||||
: null;
|
||||
mutationObserver?.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
@@ -114,7 +116,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
};
|
||||
}, [containerRef, handleScroll, scheduleHide, updateMetrics]);
|
||||
}, [containerRef, handleScroll, observeMutations, scheduleHide, updateMetrics]);
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>, axis: "vertical" | "horizontal") => {
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
||||
(
|
||||
{
|
||||
orientation = "vertical",
|
||||
offset = 72,
|
||||
offset = 0,
|
||||
size = 48,
|
||||
isEnabled = true,
|
||||
hideBottomShadow = false,
|
||||
|
||||
@@ -9,6 +9,7 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
|
||||
outerClassName?: string;
|
||||
scrollbarClassName?: string;
|
||||
disableHorizontal?: boolean;
|
||||
observeMutations?: boolean;
|
||||
fillContainer?: boolean;
|
||||
keyboardAvoid?: boolean;
|
||||
/** Prevent scroll from propagating to parent when at boundaries */
|
||||
@@ -26,6 +27,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
as: Component = "div",
|
||||
scrollbarClassName,
|
||||
disableHorizontal = false,
|
||||
observeMutations = true,
|
||||
fillContainer = true,
|
||||
keyboardAvoid = false,
|
||||
preventOverscroll = false,
|
||||
@@ -64,6 +66,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
hideDelayMs={hideDelayMs}
|
||||
className={scrollbarClassName}
|
||||
disableHorizontal={disableHorizontal}
|
||||
observeMutations={observeMutations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,86 +31,73 @@ export function AnimatedTabs<T extends string>({
|
||||
size = 'default',
|
||||
}: AnimatedTabsProps<T>) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
||||
const indicatorRef = React.useRef<HTMLDivElement>(null);
|
||||
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const [isReadyToAnimate, setIsReadyToAnimate] = React.useState(false);
|
||||
|
||||
const updateClipPath = React.useCallback(() => {
|
||||
const updateIndicator = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const activeTab = activeTabRef.current;
|
||||
const indicator = indicatorRef.current;
|
||||
const activeTab = tabRefs.current.get(value);
|
||||
|
||||
if (!container || !activeTab) return;
|
||||
if (!container || !indicator || !activeTab) return;
|
||||
|
||||
const containerWidth = container.offsetWidth;
|
||||
if (!containerWidth) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
|
||||
const { offsetLeft, offsetWidth } = activeTab;
|
||||
const leftPercent = Math.max(0, Math.min(100, (offsetLeft / containerWidth) * 100));
|
||||
const rightPercent = Math.max(0, Math.min(100, ((offsetLeft + offsetWidth) / containerWidth) * 100));
|
||||
const left = tabRect.left - containerRect.left;
|
||||
const width = tabRect.width;
|
||||
|
||||
container.style.clipPath = `inset(0 ${Number(100 - rightPercent).toFixed(2)}% 0 ${Number(leftPercent).toFixed(2)}% round 8px)`;
|
||||
}, []);
|
||||
indicator.style.transform = `translateX(${left}px)`;
|
||||
indicator.style.width = `${width}px`;
|
||||
}, [value]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateClipPath();
|
||||
updateIndicator();
|
||||
if (!isReadyToAnimate) {
|
||||
setIsReadyToAnimate(true);
|
||||
}
|
||||
}, [isReadyToAnimate, updateClipPath, value, tabs.length]);
|
||||
}, [isReadyToAnimate, updateIndicator, value, tabs.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver(() => updateClipPath());
|
||||
const observer = new ResizeObserver(() => updateIndicator());
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [updateClipPath]);
|
||||
}, [updateIndicator]);
|
||||
|
||||
const setTabRef = React.useCallback((el: HTMLButtonElement | null, tabValue: string) => {
|
||||
if (el) {
|
||||
tabRefs.current.set(tabValue, el);
|
||||
} else {
|
||||
tabRefs.current.delete(tabValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('relative isolate w-full', collapseLabelsOnNarrow && '@container/animated-tabs', className)}>
|
||||
<div className={cn('relative w-full', collapseLabelsOnNarrow && '@container/animated-tabs', className)}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)]',
|
||||
animate && isReadyToAnimate ? '[transition:clip-path_200ms_ease]' : null
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 bg-interactive-selection text-interactive-selection-foreground',
|
||||
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<div
|
||||
key={tab.value}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center font-semibold',
|
||||
size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm',
|
||||
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25'
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className={cn(size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4')} /> : null}
|
||||
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||
{tab.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-20 flex items-center gap-1 bg-muted/20',
|
||||
size === 'sm' ? 'h-7 rounded-md px-1' : 'h-9 rounded-lg px-1.5'
|
||||
'relative flex items-center overflow-hidden bg-[var(--surface-muted)]/50',
|
||||
size === 'sm'
|
||||
? 'h-8 rounded-lg py-0.5 px-px gap-0.5'
|
||||
: 'h-10 rounded-lg py-0.5 px-px gap-0.5'
|
||||
)}
|
||||
>
|
||||
{/* Sliding indicator */}
|
||||
<div
|
||||
ref={indicatorRef}
|
||||
className={cn(
|
||||
'absolute top-0.5 bottom-0.5 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-sm',
|
||||
animate && isReadyToAnimate ? 'transition-[transform,width] duration-200 ease-out' : null
|
||||
)}
|
||||
style={{ width: 0, transform: 'translateX(0)' }}
|
||||
/>
|
||||
|
||||
{tabs.map((tab) => {
|
||||
const isActive = value === tab.value;
|
||||
const Icon = tab.icon;
|
||||
@@ -118,17 +105,17 @@ export function AnimatedTabs<T extends string>({
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
ref={isActive ? activeTabRef : null}
|
||||
ref={(el) => setTabRef(el, tab.value)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isInteractive) return;
|
||||
onValueChange(tab.value);
|
||||
}}
|
||||
className={cn(
|
||||
'animated-tabs__button flex flex-1 items-center justify-center font-semibold transition-colors duration-150',
|
||||
size === 'sm' ? 'h-5 rounded-md px-2 text-xs' : 'h-7 rounded-lg px-2.5 text-sm',
|
||||
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.25' : 'gap-1.25',
|
||||
isActive ? 'text-accent-foreground' : 'text-muted-foreground',
|
||||
className={cn(
|
||||
'animated-tabs__button relative z-10 flex flex-1 items-center justify-center font-medium transition-colors duration-150',
|
||||
size === 'sm' ? 'h-6 rounded-lg px-2.5 text-sm' : 'h-7 rounded-lg px-3 text-sm',
|
||||
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.5' : 'gap-1.5',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
|
||||
)}
|
||||
aria-pressed={isActive}
|
||||
@@ -136,18 +123,17 @@ export function AnimatedTabs<T extends string>({
|
||||
aria-disabled={!isInteractive}
|
||||
tabIndex={isInteractive ? 0 : -1}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={cn(
|
||||
size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4',
|
||||
isActive ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||
{tab.label}
|
||||
</span>
|
||||
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={cn(
|
||||
size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -21,18 +21,19 @@ const GridLoader: React.FC<GridLoaderProps> = ({ className, size = 'md' }) => {
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('grid grid-cols-3', config.container, className)}
|
||||
<span
|
||||
className={cn('grid grid-cols-3 place-items-center', config.container, className)}
|
||||
style={{ width: '11px', height: '11px' }}
|
||||
aria-label="Loading"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<div
|
||||
<span
|
||||
key={i}
|
||||
className={cn('rounded-full bg-current animate-grid-pulse', config.dot)}
|
||||
className={cn('shrink-0 rounded-full bg-current animate-grid-pulse', config.dot)}
|
||||
style={{ animationDelay: `${getPulseDelayMs(i)}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { WorkerPoolContextProvider, useWorkerPool } from '@pierre/diffs/react';
|
||||
import type { SupportedLanguages } from '@pierre/diffs';
|
||||
import { WorkerPoolManager } from '@pierre/diffs/worker';
|
||||
|
||||
import { useOptionalThemeSystem } from './useThemeSystem';
|
||||
import { workerFactory } from '@/lib/diff/workerFactory';
|
||||
@@ -23,30 +23,87 @@ interface DiffWorkerProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Component that warms up the worker pool and precomputes diff ASTs
|
||||
type WorkerPoolStyle = 'unified' | 'split';
|
||||
|
||||
const WORKER_POOL_CONFIG: Record<WorkerPoolStyle, { poolSize: number; totalASTLRUCacheSize: number; lineDiffType: 'none' | 'word-alt' }> = {
|
||||
unified: {
|
||||
poolSize: 1,
|
||||
totalASTLRUCacheSize: 24,
|
||||
lineDiffType: 'none',
|
||||
},
|
||||
split: {
|
||||
poolSize: 2,
|
||||
totalASTLRUCacheSize: 56,
|
||||
lineDiffType: 'word-alt',
|
||||
},
|
||||
};
|
||||
|
||||
let unifiedWorkerPool: WorkerPoolManager | undefined;
|
||||
let splitWorkerPool: WorkerPoolManager | undefined;
|
||||
|
||||
const createWorkerPool = (style: WorkerPoolStyle) => {
|
||||
const config = WORKER_POOL_CONFIG[style];
|
||||
const pool = new WorkerPoolManager(
|
||||
{
|
||||
workerFactory,
|
||||
poolSize: config.poolSize,
|
||||
totalASTLRUCacheSize: config.totalASTLRUCacheSize,
|
||||
},
|
||||
{
|
||||
theme: {
|
||||
light: 'pierre-light',
|
||||
dark: 'pierre-dark',
|
||||
},
|
||||
langs: PRELOAD_LANGS,
|
||||
lineDiffType: config.lineDiffType,
|
||||
preferredHighlighter: 'shiki-wasm',
|
||||
}
|
||||
);
|
||||
void pool.initialize();
|
||||
return pool;
|
||||
};
|
||||
|
||||
const getWorkerPool = (style: WorkerPoolStyle): WorkerPoolManager | undefined => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (style === 'split') {
|
||||
splitWorkerPool ??= createWorkerPool('split');
|
||||
return splitWorkerPool;
|
||||
}
|
||||
|
||||
unifiedWorkerPool ??= createWorkerPool('unified');
|
||||
return unifiedWorkerPool;
|
||||
};
|
||||
|
||||
const WorkerPoolWarmup: React.FC<{
|
||||
children: React.ReactNode;
|
||||
renderTheme: { light: string; dark: string };
|
||||
}> = ({ children, renderTheme }) => {
|
||||
const workerPool = useWorkerPool();
|
||||
const unifiedPool = useWorkerPool('unified');
|
||||
const splitPool = useWorkerPool('split');
|
||||
|
||||
useEffect(() => {
|
||||
if (!workerPool) {
|
||||
return;
|
||||
if (unifiedPool) {
|
||||
void unifiedPool.setRenderOptions({
|
||||
theme: renderTheme,
|
||||
lineDiffType: WORKER_POOL_CONFIG.unified.lineDiffType,
|
||||
});
|
||||
}
|
||||
|
||||
// Important: WorkerPoolContextProvider uses a singleton and does not react to
|
||||
// prop changes. Update the worker pool render options explicitly.
|
||||
// Force-disable intra-line diff globally (word-level/char-level).
|
||||
void workerPool.setRenderOptions({ theme: renderTheme, lineDiffType: 'none' });
|
||||
}, [renderTheme, workerPool]);
|
||||
if (splitPool) {
|
||||
void splitPool.setRenderOptions({
|
||||
theme: renderTheme,
|
||||
lineDiffType: WORKER_POOL_CONFIG.split.lineDiffType,
|
||||
});
|
||||
}
|
||||
}, [renderTheme, splitPool, unifiedPool]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
|
||||
|
||||
const fallbackLight = getDefaultTheme(false);
|
||||
const fallbackDark = getDefaultTheme(true);
|
||||
@@ -64,15 +121,6 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
|
||||
ensurePierreThemeRegistered(lightTheme);
|
||||
ensurePierreThemeRegistered(darkTheme);
|
||||
|
||||
const highlighterOptions = useMemo(() => ({
|
||||
theme: {
|
||||
dark: darkTheme.metadata.id,
|
||||
light: lightTheme.metadata.id,
|
||||
},
|
||||
themeType: isDark ? ('dark' as const) : ('light' as const),
|
||||
langs: PRELOAD_LANGS,
|
||||
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id]);
|
||||
|
||||
const renderTheme = useMemo(
|
||||
() => ({
|
||||
light: lightTheme.metadata.id,
|
||||
@@ -82,22 +130,13 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkerPoolContextProvider
|
||||
poolOptions={{
|
||||
workerFactory,
|
||||
poolSize: 2,
|
||||
totalASTLRUCacheSize: 50,
|
||||
}}
|
||||
highlighterOptions={highlighterOptions}
|
||||
>
|
||||
<WorkerPoolWarmup
|
||||
renderTheme={renderTheme}
|
||||
>
|
||||
{children}
|
||||
</WorkerPoolWarmup>
|
||||
</WorkerPoolContextProvider>
|
||||
<WorkerPoolWarmup renderTheme={renderTheme}>
|
||||
{children}
|
||||
</WorkerPoolWarmup>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useWorkerPool };
|
||||
export const useWorkerPool = (style: WorkerPoolStyle = 'unified'): WorkerPoolManager | undefined => {
|
||||
return useMemo(() => getWorkerPool(style), [style]);
|
||||
};
|
||||
|
||||
@@ -640,19 +640,56 @@ export const useEventStream = () => {
|
||||
case 'session.status':
|
||||
{
|
||||
const sessionId = readStringProp(props, ['sessionID', 'sessionId']);
|
||||
const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record<string, unknown> : null;
|
||||
const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null;
|
||||
const statusInfo = statusObj ?? {};
|
||||
const statusRaw = (props as { status?: unknown }).status;
|
||||
const statusObj = (typeof statusRaw === 'object' && statusRaw !== null) ? statusRaw as Record<string, unknown> : null;
|
||||
const statusType =
|
||||
typeof statusRaw === 'string'
|
||||
? statusRaw
|
||||
: typeof statusObj?.type === 'string'
|
||||
? statusObj.type
|
||||
: typeof statusObj?.status === 'string'
|
||||
? statusObj.status
|
||||
: typeof (props as { type?: unknown }).type === 'string'
|
||||
? ((props as { type: string }).type)
|
||||
: typeof (props as { phase?: unknown }).phase === 'string'
|
||||
? ((props as { phase: string }).phase)
|
||||
: typeof (props as { state?: unknown }).state === 'string'
|
||||
? ((props as { state: string }).state)
|
||||
: null;
|
||||
const statusInfo = statusObj ?? ({} as Record<string, unknown>);
|
||||
const metadata = (props as { metadata?: unknown }).metadata;
|
||||
const metadataObj = (typeof metadata === 'object' && metadata !== null) ? metadata as Record<string, unknown> : null;
|
||||
|
||||
if (sessionId && statusType) {
|
||||
if (statusType === 'busy') {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||
} else if (statusType === 'retry') {
|
||||
updateSessionStatus(sessionId, {
|
||||
type: 'retry',
|
||||
attempt: typeof statusInfo.attempt === 'number' ? statusInfo.attempt : undefined,
|
||||
message: typeof statusInfo.message === 'string' ? statusInfo.message : undefined,
|
||||
next: typeof statusInfo.next === 'number' ? statusInfo.next : undefined,
|
||||
attempt:
|
||||
typeof statusInfo.attempt === 'number'
|
||||
? statusInfo.attempt
|
||||
: typeof (props as { attempt?: unknown }).attempt === 'number'
|
||||
? (props as { attempt: number }).attempt
|
||||
: typeof metadataObj?.attempt === 'number'
|
||||
? metadataObj.attempt
|
||||
: undefined,
|
||||
message:
|
||||
typeof statusInfo.message === 'string'
|
||||
? statusInfo.message
|
||||
: typeof (props as { message?: unknown }).message === 'string'
|
||||
? (props as { message: string }).message
|
||||
: typeof metadataObj?.message === 'string'
|
||||
? metadataObj.message
|
||||
: undefined,
|
||||
next:
|
||||
typeof statusInfo.next === 'number'
|
||||
? statusInfo.next
|
||||
: typeof (props as { next?: unknown }).next === 'number'
|
||||
? (props as { next: number }).next
|
||||
: typeof metadataObj?.next === 'number'
|
||||
? metadataObj.next
|
||||
: undefined,
|
||||
}, 'sse:session.status');
|
||||
} else {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
interface SessionState {
|
||||
status: 'idle' | 'busy' | 'retry';
|
||||
@@ -70,20 +71,27 @@ export function useServerSessionStatus() {
|
||||
lastSyncAtRef.current = now;
|
||||
|
||||
try {
|
||||
const snapshotResponse = await fetch('/api/sessions/snapshot', {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const [snapshotResult, upstreamStatusResult] = await Promise.allSettled([
|
||||
fetch('/api/sessions/snapshot', {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(async (r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(String(r.status));
|
||||
}
|
||||
return (await r.json()) as ServerSnapshotResponse;
|
||||
}),
|
||||
opencodeClient.getGlobalSessionStatus(),
|
||||
]);
|
||||
|
||||
if (!snapshotResponse.ok) {
|
||||
console.warn('[useServerSessionStatus] Failed to fetch session snapshot:', snapshotResponse.status);
|
||||
return;
|
||||
}
|
||||
const snapshotData: ServerSnapshotResponse | null =
|
||||
snapshotResult.status === 'fulfilled' ? snapshotResult.value : null;
|
||||
const statusSessions = snapshotData?.statusSessions ?? {};
|
||||
const attentionSessions = snapshotData?.attentionSessions ?? {};
|
||||
|
||||
const snapshotData: ServerSnapshotResponse = await snapshotResponse.json();
|
||||
const statusSessions = snapshotData.statusSessions ?? {};
|
||||
const attentionSessions = snapshotData.attentionSessions ?? {};
|
||||
const upstreamStatuses =
|
||||
upstreamStatusResult.status === 'fulfilled' ? (upstreamStatusResult.value ?? {}) : {};
|
||||
|
||||
// Update the session store with server state
|
||||
const currentStatuses = useSessionStore.getState().sessionStatus || new Map();
|
||||
@@ -117,10 +125,37 @@ export function useServerSessionStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay OpenCode's own session status endpoint.
|
||||
// This is the source-of-truth for retry message payload and works even when
|
||||
// OpenChamber server-side tracking misses transient updates.
|
||||
for (const [sessionId, upstream] of Object.entries(upstreamStatuses)) {
|
||||
const existing = (newStatuses ?? currentStatuses).get(sessionId);
|
||||
const hasChanged =
|
||||
!existing ||
|
||||
existing.type !== upstream.type ||
|
||||
existing.attempt !== upstream.attempt ||
|
||||
existing.message !== upstream.message ||
|
||||
existing.next !== upstream.next;
|
||||
|
||||
if (hasChanged) {
|
||||
ensureStatusesMap().set(sessionId, {
|
||||
type: upstream.type,
|
||||
confirmedAt: Date.now(),
|
||||
attempt: upstream.attempt,
|
||||
message: upstream.message,
|
||||
next: upstream.next,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for sessions that are no longer in server state (treat as idle)
|
||||
const activeServerStatusIds = new Set(Object.keys(statusSessions));
|
||||
const activeUpstreamIds = new Set(Object.keys(upstreamStatuses));
|
||||
|
||||
for (const [sessionId, currentStatus] of (newStatuses ?? currentStatuses)) {
|
||||
if ((currentStatus.type === 'busy' || currentStatus.type === 'retry') &&
|
||||
!statusSessions[sessionId]) {
|
||||
!activeServerStatusIds.has(sessionId) &&
|
||||
!activeUpstreamIds.has(sessionId)) {
|
||||
// Session was busy but not in server state anymore -> mark as idle
|
||||
ensureStatusesMap().set(sessionId, {
|
||||
type: 'idle',
|
||||
@@ -179,8 +214,9 @@ export function useServerSessionStatus() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug('[useServerSessionStatus] Updated session statuses from server:', {
|
||||
statusCount: Object.keys(statusSessions).length,
|
||||
upstreamCount: Object.keys(upstreamStatuses).length,
|
||||
attentionCount: Object.keys(attentionSessions).length,
|
||||
serverTime: snapshotData.serverTime,
|
||||
serverTime: snapshotData?.serverTime,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+22
-21
@@ -564,33 +564,25 @@ html:not(.dark) .chat-scroll {
|
||||
/* Pierre diff viewer styling */
|
||||
.pierre-diff-wrapper {
|
||||
--diffs-font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
||||
--diffs-font-size: var(--text-code, 13px);
|
||||
--diffs-line-height: 1.5;
|
||||
--diffs-font-size: var(--text-code);
|
||||
--diffs-line-height: 24px;
|
||||
--diffs-tab-size: 2;
|
||||
--diffs-header-font-family: var(--font-sans, 'IBM Plex Sans', sans-serif);
|
||||
--diffs-min-number-column-width: 4ch;
|
||||
--diffs-gap-inline: 0;
|
||||
--diffs-gap-block: 0;
|
||||
|
||||
/* Prevent browser scroll anchoring jumps during async highlight/virtualization */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* Prevent scroll anchoring in stacked diff scroll container */
|
||||
[data-diff-virtual-root] {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* Desktop: slightly smaller diff code text */
|
||||
:root:not(.mobile-pointer) .pierre-diff-wrapper {
|
||||
--diffs-font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* WebKit scroll rendering optimizations */
|
||||
/* Note: avoid will-change and contain as they break resize behavior */
|
||||
.pierre-diff-wrapper,
|
||||
.pierre-diff-wrapper diffs-container {
|
||||
/* Force GPU compositing layer */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
|
||||
/* WebKit touch scroll optimization */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
|
||||
.pierre-diff-wrapper diffs-container {
|
||||
display: block;
|
||||
@@ -686,6 +678,17 @@ html:not(.dark) .chat-scroll {
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility: hide scrollbars without affecting layout */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Git right-sidebar header/actions collapse to icon-only when narrow. */
|
||||
@container git-header (max-width: 26rem) {
|
||||
.git-header-label {
|
||||
@@ -952,11 +955,9 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
|
||||
@keyframes grid-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface GitFileDiffResponse {
|
||||
original: string;
|
||||
modified: string;
|
||||
path: string;
|
||||
isBinary?: boolean;
|
||||
}
|
||||
|
||||
export interface GetGitFileDiffOptions {
|
||||
@@ -486,6 +487,8 @@ export interface ProjectEntry {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
addedAt?: number;
|
||||
lastOpenedAt?: number;
|
||||
sidebarCollapsed?: boolean;
|
||||
|
||||
@@ -464,6 +464,7 @@ class OpencodeService {
|
||||
'application/toml',
|
||||
'application/x-sh',
|
||||
'application/x-shellscript',
|
||||
'application/octet-stream',
|
||||
];
|
||||
|
||||
return textBasedTypes.includes(lowerMime);
|
||||
@@ -683,21 +684,43 @@ class OpencodeService {
|
||||
throw new Error('Message must have at least one part (text or file)');
|
||||
}
|
||||
|
||||
// Use SDK session.prompt() method
|
||||
// DON'T send messageID - let server generate it (fixes Claude empty response issue)
|
||||
await this.client.session.prompt({
|
||||
sessionID: params.id,
|
||||
...(this.currentDirectory ? { directory: this.currentDirectory } : {}),
|
||||
// messageID intentionally omitted - server will generate
|
||||
model: {
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID
|
||||
// Use async prompt endpoint so the client doesn't block waiting
|
||||
// for model work (SSE will deliver output/status).
|
||||
// This avoids 504s from proxy timeouts on long-running turns.
|
||||
const base = this.baseUrl.replace(/\/+$/, '');
|
||||
const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/prompt_async`);
|
||||
if (this.currentDirectory) {
|
||||
url.searchParams.set('directory', this.currentDirectory);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
agent: params.agent,
|
||||
variant: params.variant,
|
||||
parts
|
||||
body: JSON.stringify({
|
||||
model: {
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
},
|
||||
agent: params.agent,
|
||||
variant: params.variant,
|
||||
parts,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = await response.text();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
|
||||
throw new Error(`Failed to send message (${response.status})${suffix}`);
|
||||
}
|
||||
|
||||
// Return temporary ID for optimistic UI
|
||||
// Real messageID will come from server via SSE events
|
||||
return tempMessageId;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
RiCodeBoxLine,
|
||||
RiTerminalBoxLine,
|
||||
RiRocketLine,
|
||||
RiFlaskLine,
|
||||
RiGamepadLine,
|
||||
RiBriefcaseLine,
|
||||
RiHomeLine,
|
||||
RiGlobalLine,
|
||||
RiLeafLine,
|
||||
RiShieldLine,
|
||||
RiPaletteLine,
|
||||
RiServerLine,
|
||||
RiSmartphoneLine,
|
||||
RiDatabase2Line,
|
||||
RiLightbulbLine,
|
||||
RiMusicLine,
|
||||
RiCameraLine,
|
||||
RiBookOpenLine,
|
||||
RiHeartLine,
|
||||
type RemixiconComponentType,
|
||||
} from '@remixicon/react';
|
||||
|
||||
export const PROJECT_ICONS: Array<{ key: string; Icon: RemixiconComponentType; label: string }> = [
|
||||
{ key: 'code', Icon: RiCodeBoxLine, label: 'Code' },
|
||||
{ key: 'terminal', Icon: RiTerminalBoxLine, label: 'Terminal' },
|
||||
{ key: 'rocket', Icon: RiRocketLine, label: 'Rocket' },
|
||||
{ key: 'flask', Icon: RiFlaskLine, label: 'Lab' },
|
||||
{ key: 'gamepad', Icon: RiGamepadLine, label: 'Game' },
|
||||
{ key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
|
||||
{ key: 'home', Icon: RiHomeLine, label: 'Home' },
|
||||
{ key: 'globe', Icon: RiGlobalLine, label: 'Web' },
|
||||
{ key: 'leaf', Icon: RiLeafLine, label: 'Nature' },
|
||||
{ key: 'shield', Icon: RiShieldLine, label: 'Security' },
|
||||
{ key: 'palette', Icon: RiPaletteLine, label: 'Design' },
|
||||
{ key: 'server', Icon: RiServerLine, label: 'Server' },
|
||||
{ key: 'phone', Icon: RiSmartphoneLine, label: 'Mobile' },
|
||||
{ key: 'database', Icon: RiDatabase2Line, label: 'Data' },
|
||||
{ key: 'lightbulb', Icon: RiLightbulbLine, label: 'Idea' },
|
||||
{ key: 'music', Icon: RiMusicLine, label: 'Music' },
|
||||
{ key: 'camera', Icon: RiCameraLine, label: 'Media' },
|
||||
{ key: 'book', Icon: RiBookOpenLine, label: 'Docs' },
|
||||
{ key: 'heart', Icon: RiHeartLine, label: 'Favorite' },
|
||||
];
|
||||
|
||||
export const PROJECT_ICON_MAP: Record<string, RemixiconComponentType> = Object.fromEntries(
|
||||
PROJECT_ICONS.map((i) => [i.key, i.Icon])
|
||||
);
|
||||
|
||||
export const PROJECT_COLORS: Array<{ key: string; label: string; cssVar: string }> = [
|
||||
{ key: 'keyword', label: 'Purple', cssVar: 'var(--syntax-keyword)' },
|
||||
{ key: 'string', label: 'Green', cssVar: 'var(--syntax-string)' },
|
||||
{ key: 'number', label: 'Pink', cssVar: 'var(--syntax-number)' },
|
||||
{ key: 'type', label: 'Gold', cssVar: 'var(--syntax-type)' },
|
||||
{ key: 'constant', label: 'Cyan', cssVar: 'var(--syntax-constant)' },
|
||||
{ key: 'comment', label: 'Muted', cssVar: 'var(--syntax-comment)' },
|
||||
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
|
||||
{ key: 'primary', label: 'Blue', cssVar: 'var(--primary)' },
|
||||
{ key: 'success', label: 'Green', cssVar: 'var(--status-success)' },
|
||||
];
|
||||
|
||||
export const PROJECT_COLOR_MAP: Record<string, string> = Object.fromEntries(
|
||||
PROJECT_COLORS.map((c) => [c.key, c.cssVar])
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
export const SEMANTIC_TYPOGRAPHY = {
|
||||
markdown: '0.9375rem',
|
||||
code: '0.9063rem',
|
||||
code: '0.8125rem',
|
||||
uiHeader: '0.9375rem',
|
||||
uiLabel: '0.8750rem',
|
||||
meta: '0.875rem',
|
||||
|
||||
@@ -25,7 +25,7 @@ interface DirectoryGitState {
|
||||
branches: GitBranch | null;
|
||||
log: GitLogResponse | null;
|
||||
identity: GitIdentitySummary | null;
|
||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>;
|
||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
|
||||
lastStatusFetch: number;
|
||||
lastStatusChange: number;
|
||||
lastLogFetch: number;
|
||||
@@ -55,8 +55,8 @@ interface GitStore {
|
||||
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
|
||||
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
||||
|
||||
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number } | null;
|
||||
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string }) => void;
|
||||
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
|
||||
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
|
||||
clearDiffCache: (directory: string) => void;
|
||||
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
|
||||
|
||||
@@ -72,6 +72,7 @@ interface GitFileDiffResponse {
|
||||
original: string;
|
||||
modified: string;
|
||||
path: string;
|
||||
isBinary?: boolean;
|
||||
}
|
||||
|
||||
interface GitAPI {
|
||||
@@ -98,10 +99,10 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
|
||||
|
||||
// LRU eviction helper for diff cache
|
||||
const evictDiffCacheIfNeeded = (
|
||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number }>,
|
||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>,
|
||||
maxEntries: number = DIFF_CACHE_MAX_ENTRIES,
|
||||
maxTotalSize: number = DIFF_CACHE_MAX_TOTAL_SIZE_BYTES
|
||||
): Map<string, { original: string; modified: string; fetchedAt: number }> => {
|
||||
): Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }> => {
|
||||
// Calculate total size
|
||||
let totalSize = 0;
|
||||
for (const entry of diffCache.values()) {
|
||||
@@ -117,7 +118,7 @@ const evictDiffCacheIfNeeded = (
|
||||
const entries = Array.from(diffCache.entries())
|
||||
.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
||||
|
||||
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number }>();
|
||||
const newCache = new Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>();
|
||||
let newTotalSize = 0;
|
||||
|
||||
// Keep entries from newest to oldest until limits are reached
|
||||
@@ -441,6 +442,7 @@ export const useGitStore = create<GitStore>()(
|
||||
|
||||
// Pre-fetch all diffs so they're ready when user opens Diff tab
|
||||
void get().fetchAllDiffs(directory, git);
|
||||
|
||||
},
|
||||
|
||||
getDiff: (directory, filePath) => {
|
||||
@@ -481,7 +483,7 @@ export const useGitStore = create<GitStore>()(
|
||||
if (limitedFilesToFetch.length === 0) return;
|
||||
|
||||
let nextIndex = 0;
|
||||
const results: Array<{ path: string; diff: { original: string; modified: string } }> = [];
|
||||
const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = [];
|
||||
|
||||
const takeNext = () => {
|
||||
const current = nextIndex;
|
||||
@@ -497,7 +499,7 @@ export const useGitStore = create<GitStore>()(
|
||||
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
||||
return {
|
||||
path: filePath,
|
||||
diff: { original: response.original ?? '', modified: response.modified ?? '' },
|
||||
diff: { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ProjectsStore {
|
||||
setActiveProject: (id: string) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
renameProject: (id: string, label: string) => void;
|
||||
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null }) => void;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
||||
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
||||
@@ -72,7 +73,8 @@ const deriveProjectLabel = (path: string): string => {
|
||||
return 'Root';
|
||||
}
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] || normalized;
|
||||
const raw = segments[segments.length - 1] || normalized;
|
||||
return raw.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
};
|
||||
|
||||
const createProjectId = (): string => {
|
||||
@@ -377,6 +379,26 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
persistProjects(nextProjects, activeProjectId);
|
||||
},
|
||||
|
||||
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null }) => {
|
||||
if (vscodeWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { projects, activeProjectId } = get();
|
||||
const nextProjects = projects.map((project) => {
|
||||
if (project.id !== id) return project;
|
||||
const updated = { ...project };
|
||||
if (meta.label !== undefined) {
|
||||
const trimmed = meta.label.trim();
|
||||
if (trimmed) updated.label = trimmed;
|
||||
}
|
||||
if (meta.icon !== undefined) updated.icon = meta.icon;
|
||||
if (meta.color !== undefined) updated.color = meta.color;
|
||||
return updated;
|
||||
});
|
||||
set({ projects: nextProjects });
|
||||
persistProjects(nextProjects, activeProjectId);
|
||||
},
|
||||
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => {
|
||||
if (vscodeWorkspace) {
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,17 @@ import { getSafeStorage } from './utils/safeStorage';
|
||||
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
|
||||
export type RightSidebarTab = 'git' | 'files';
|
||||
export type ContextPanelMode = 'diff' | 'file';
|
||||
|
||||
type ContextPanelDirectoryState = {
|
||||
isOpen: boolean;
|
||||
expanded: boolean;
|
||||
mode: ContextPanelMode | null;
|
||||
targetPath: string | null;
|
||||
width: number;
|
||||
touchedAt: number;
|
||||
};
|
||||
|
||||
export type MainTabGuard = (nextTab: MainTab) => boolean;
|
||||
export type EventStreamStatus =
|
||||
@@ -51,6 +62,71 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 360;
|
||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||
const LEFT_SIDEBAR_MIN_WIDTH = 300;
|
||||
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||
|
||||
const normalizeDirectoryPath = (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 clampContextPanelWidth = (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 touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanelDirectoryState => {
|
||||
if (prev) {
|
||||
return { ...prev, touchedAt: Date.now() };
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen: false,
|
||||
expanded: false,
|
||||
mode: null,
|
||||
targetPath: null,
|
||||
width: CONTEXT_PANEL_DEFAULT_WIDTH,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const clampContextPanelRoots = (
|
||||
byDirectory: Record<string, ContextPanelDirectoryState>,
|
||||
maxRoots: number
|
||||
): Record<string, ContextPanelDirectoryState> => {
|
||||
const entries = Object.entries(byDirectory);
|
||||
if (entries.length <= maxRoots) {
|
||||
return byDirectory;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => (b[1]?.touchedAt ?? 0) - (a[1]?.touchedAt ?? 0));
|
||||
const next: Record<string, ContextPanelDirectoryState> = {};
|
||||
for (const [directory, state] of entries.slice(0, maxRoots)) {
|
||||
next[directory] = state;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
interface UIStore {
|
||||
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
@@ -62,7 +138,10 @@ interface UIStore {
|
||||
isRightSidebarOpen: boolean;
|
||||
rightSidebarWidth: number;
|
||||
hasManuallyResizedRightSidebar: boolean;
|
||||
rightSidebarTab: RightSidebarTab;
|
||||
contextPanelByDirectory: Record<string, ContextPanelDirectoryState>;
|
||||
isBottomTerminalOpen: boolean;
|
||||
isBottomTerminalExpanded: boolean;
|
||||
bottomTerminalHeight: number;
|
||||
hasManuallyResizedBottomTerminal: boolean;
|
||||
isSessionSwitcherOpen: boolean;
|
||||
@@ -142,8 +221,15 @@ interface UIStore {
|
||||
toggleRightSidebar: () => void;
|
||||
setRightSidebarOpen: (open: boolean) => void;
|
||||
setRightSidebarWidth: (width: number) => void;
|
||||
setRightSidebarTab: (tab: RightSidebarTab) => void;
|
||||
openContextDiff: (directory: string, filePath: string) => void;
|
||||
openContextFile: (directory: string, filePath: string) => void;
|
||||
closeContextPanel: (directory: string) => void;
|
||||
toggleContextPanelExpanded: (directory: string) => void;
|
||||
setContextPanelWidth: (directory: string, width: number) => void;
|
||||
toggleBottomTerminal: () => void;
|
||||
setBottomTerminalOpen: (open: boolean) => void;
|
||||
setBottomTerminalExpanded: (expanded: boolean) => void;
|
||||
setBottomTerminalHeight: (height: number) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
@@ -221,12 +307,15 @@ export const useUIStore = create<UIStore>()(
|
||||
isMultiRunLauncherOpen: false,
|
||||
multiRunLauncherPrefillPrompt: '',
|
||||
isSidebarOpen: true,
|
||||
sidebarWidth: 264,
|
||||
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
|
||||
hasManuallyResizedLeftSidebar: false,
|
||||
isRightSidebarOpen: false,
|
||||
rightSidebarWidth: 420,
|
||||
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
hasManuallyResizedRightSidebar: false,
|
||||
rightSidebarTab: 'git',
|
||||
contextPanelByDirectory: {},
|
||||
isBottomTerminalOpen: false,
|
||||
isBottomTerminalExpanded: false,
|
||||
bottomTerminalHeight: 300,
|
||||
hasManuallyResizedBottomTerminal: false,
|
||||
isSessionSwitcherOpen: false,
|
||||
@@ -303,12 +392,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set((state) => {
|
||||
const newOpen = !state.isSidebarOpen;
|
||||
|
||||
if (newOpen && typeof window !== 'undefined') {
|
||||
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
|
||||
if (newOpen && !state.hasManuallyResizedLeftSidebar) {
|
||||
return {
|
||||
isSidebarOpen: newOpen,
|
||||
sidebarWidth: proportionalWidth,
|
||||
hasManuallyResizedLeftSidebar: false
|
||||
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
return { isSidebarOpen: newOpen };
|
||||
@@ -316,14 +403,11 @@ export const useUIStore = create<UIStore>()(
|
||||
},
|
||||
|
||||
setSidebarOpen: (open) => {
|
||||
set(() => {
|
||||
|
||||
if (open && typeof window !== 'undefined') {
|
||||
const proportionalWidth = Math.floor(window.innerWidth * 0.2);
|
||||
set((state) => {
|
||||
if (open && !state.hasManuallyResizedLeftSidebar) {
|
||||
return {
|
||||
isSidebarOpen: open,
|
||||
sidebarWidth: proportionalWidth,
|
||||
hasManuallyResizedLeftSidebar: false
|
||||
sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
return { isSidebarOpen: open };
|
||||
@@ -338,12 +422,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set((state) => {
|
||||
const newOpen = !state.isRightSidebarOpen;
|
||||
|
||||
if (newOpen && typeof window !== 'undefined') {
|
||||
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
|
||||
if (newOpen && !state.hasManuallyResizedRightSidebar) {
|
||||
return {
|
||||
isRightSidebarOpen: newOpen,
|
||||
rightSidebarWidth: proportionalWidth,
|
||||
hasManuallyResizedRightSidebar: false,
|
||||
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
return { isRightSidebarOpen: newOpen };
|
||||
@@ -351,13 +433,11 @@ export const useUIStore = create<UIStore>()(
|
||||
},
|
||||
|
||||
setRightSidebarOpen: (open) => {
|
||||
set(() => {
|
||||
if (open && typeof window !== 'undefined') {
|
||||
const proportionalWidth = Math.floor(window.innerWidth * 0.28);
|
||||
set((state) => {
|
||||
if (open && !state.hasManuallyResizedRightSidebar) {
|
||||
return {
|
||||
isRightSidebarOpen: open,
|
||||
rightSidebarWidth: proportionalWidth,
|
||||
hasManuallyResizedRightSidebar: false,
|
||||
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
return { isRightSidebarOpen: open };
|
||||
@@ -368,6 +448,125 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ rightSidebarWidth: width, hasManuallyResizedRightSidebar: true });
|
||||
},
|
||||
|
||||
setRightSidebarTab: (tab) => {
|
||||
set({ rightSidebarTab: tab });
|
||||
},
|
||||
|
||||
openContextDiff: (directory, filePath) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = (filePath || '').trim();
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'diff' as const,
|
||||
targetPath: normalizedFilePath,
|
||||
},
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
get().setPendingDiffFile(normalizedFilePath);
|
||||
},
|
||||
|
||||
openContextFile: (directory, filePath) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = (filePath || '').trim();
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'file' as const,
|
||||
targetPath: normalizedFilePath,
|
||||
},
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
closeContextPanel: (directory) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
if (!normalizedDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
if (!prev || !prev.isOpen) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...touchContextPanelState(prev),
|
||||
isOpen: false,
|
||||
},
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
toggleContextPanelExpanded: (directory) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
if (!normalizedDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
expanded: !current.expanded,
|
||||
},
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
setContextPanelWidth: (directory, width) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
if (!normalizedDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
width: clampContextPanelWidth(width),
|
||||
},
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
toggleBottomTerminal: () => {
|
||||
set((state) => {
|
||||
const newOpen = !state.isBottomTerminalOpen;
|
||||
@@ -400,6 +599,10 @@ export const useUIStore = create<UIStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setBottomTerminalExpanded: (expanded) => {
|
||||
set({ isBottomTerminalExpanded: expanded });
|
||||
},
|
||||
|
||||
setBottomTerminalHeight: (height) => {
|
||||
set({ bottomTerminalHeight: height, hasManuallyResizedBottomTerminal: true });
|
||||
},
|
||||
@@ -420,36 +623,7 @@ export const useUIStore = create<UIStore>()(
|
||||
if (guard && !guard(tab)) {
|
||||
return;
|
||||
}
|
||||
const state = get();
|
||||
const currentTab = state.activeMainTab;
|
||||
const fullscreenTabs: MainTab[] = ['files', 'diff'];
|
||||
const isEnteringFullscreen = fullscreenTabs.includes(tab) && !fullscreenTabs.includes(currentTab);
|
||||
const isLeavingFullscreen = !fullscreenTabs.includes(tab) && fullscreenTabs.includes(currentTab);
|
||||
|
||||
if (isEnteringFullscreen) {
|
||||
// Save current sidebar state and close it
|
||||
set({
|
||||
activeMainTab: tab,
|
||||
sidebarOpenBeforeFullscreenTab: state.isSidebarOpen,
|
||||
isSidebarOpen: false,
|
||||
});
|
||||
} else if (isLeavingFullscreen) {
|
||||
// Restore sidebar state if it was open before
|
||||
const shouldRestore = state.sidebarOpenBeforeFullscreenTab === true;
|
||||
set({
|
||||
activeMainTab: tab,
|
||||
sidebarOpenBeforeFullscreenTab: null,
|
||||
...(shouldRestore && typeof window !== 'undefined'
|
||||
? {
|
||||
isSidebarOpen: true,
|
||||
sidebarWidth: Math.floor(window.innerWidth * 0.2),
|
||||
hasManuallyResizedLeftSidebar: false,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
set({ activeMainTab: tab });
|
||||
}
|
||||
set({ activeMainTab: tab });
|
||||
},
|
||||
|
||||
setPendingDiffFile: (filePath) => {
|
||||
@@ -461,21 +635,7 @@ export const useUIStore = create<UIStore>()(
|
||||
if (guard && !guard('diff')) {
|
||||
return;
|
||||
}
|
||||
const state = get();
|
||||
const currentTab = state.activeMainTab;
|
||||
const fullscreenTabs: MainTab[] = ['files', 'diff'];
|
||||
const isEnteringFullscreen = !fullscreenTabs.includes(currentTab);
|
||||
|
||||
if (isEnteringFullscreen) {
|
||||
set({
|
||||
pendingDiffFile: filePath,
|
||||
activeMainTab: 'diff',
|
||||
sidebarOpenBeforeFullscreenTab: state.isSidebarOpen,
|
||||
isSidebarOpen: false,
|
||||
});
|
||||
} else {
|
||||
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
|
||||
}
|
||||
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
|
||||
},
|
||||
|
||||
consumePendingDiffFile: () => {
|
||||
@@ -770,14 +930,6 @@ export const useUIStore = create<UIStore>()(
|
||||
set((state) => {
|
||||
const updates: Partial<UIStore> = {};
|
||||
|
||||
if (state.isSidebarOpen && !state.hasManuallyResizedLeftSidebar) {
|
||||
updates.sidebarWidth = Math.floor(window.innerWidth * 0.2);
|
||||
}
|
||||
|
||||
if (state.isRightSidebarOpen && !state.hasManuallyResizedRightSidebar) {
|
||||
updates.rightSidebarWidth = Math.floor(window.innerWidth * 0.28);
|
||||
}
|
||||
|
||||
if (state.isBottomTerminalOpen && !state.hasManuallyResizedBottomTerminal) {
|
||||
updates.bottomTerminalHeight = Math.floor(window.innerHeight * 0.32);
|
||||
}
|
||||
@@ -865,7 +1017,7 @@ export const useUIStore = create<UIStore>()(
|
||||
{
|
||||
name: 'ui-store',
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
version: 3,
|
||||
version: 4,
|
||||
migrate: (persistedState, version) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState;
|
||||
@@ -905,6 +1057,14 @@ export const useUIStore = create<UIStore>()(
|
||||
delete state.memoryLimitActiveSession;
|
||||
}
|
||||
|
||||
if (typeof state.rightSidebarTab !== 'string' || (state.rightSidebarTab !== 'git' && state.rightSidebarTab !== 'files')) {
|
||||
state.rightSidebarTab = 'git';
|
||||
}
|
||||
|
||||
if (!state.contextPanelByDirectory || typeof state.contextPanelByDirectory !== 'object') {
|
||||
state.contextPanelByDirectory = {};
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
@@ -913,7 +1073,10 @@ export const useUIStore = create<UIStore>()(
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
isRightSidebarOpen: state.isRightSidebarOpen,
|
||||
rightSidebarWidth: state.rightSidebarWidth,
|
||||
rightSidebarTab: state.rightSidebarTab,
|
||||
contextPanelByDirectory: state.contextPanelByDirectory,
|
||||
isBottomTerminalOpen: state.isBottomTerminalOpen,
|
||||
isBottomTerminalExpanded: state.isBottomTerminalExpanded,
|
||||
bottomTerminalHeight: state.bottomTerminalHeight,
|
||||
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
|
||||
activeMainTab: state.activeMainTab,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
/* Semantic typography defaults (must match SEMANTIC_TYPOGRAPHY) */
|
||||
--text-markdown: 0.9375rem;
|
||||
--text-code: 0.9063rem;
|
||||
--text-code: 0.8125rem;
|
||||
--text-ui-header: 0.9375rem;
|
||||
--text-ui-label: 0.8750rem;
|
||||
--text-meta: 0.875rem;
|
||||
|
||||
Reference in New Issue
Block a user