import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react' import { Highlight } from 'prism-react-renderer' import type { WorkspaceChangedFile, WorkspaceFileStatus, WorkspaceTreeEntry, WorkspaceTreeResult, } from '../../api/sessions' import { useTranslation } from '../../i18n' import { useShallow } from 'zustand/react/shallow' import { useWorkspacePanelStore, type WorkspacePreviewCloseScope, type WorkspacePreviewKind, type WorkspacePreviewTab, } from '../../stores/workspacePanelStore' import { useChatStore } from '../../stores/chatStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { getFileExtension, normalizePrismLanguage, WORKSPACE_PREVIEW_LINE_LIMIT, WorkspaceDiffSurface, workspacePrismTheme, } from './WorkspaceCodeSurface' type WorkspacePanelProps = { sessionId: string } type TreeNodeProps = { sessionId: string entry: WorkspaceTreeEntry depth: number expandedPaths: Set treeByPath: Record treeLoadingByPath: Record treeErrorsByPath: Record filterQuery: string onToggle: (path: string) => void onOpenFile: (path: string) => void onFileContextMenu: (event: MouseEvent, path: string) => void activePath: string | null } const FILE_STATUS_META: Record = { modified: { label: 'M', className: 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/12 text-[var(--color-warning)]', }, added: { label: 'A', className: 'border-[var(--color-success)]/35 bg-[var(--color-success)]/12 text-[var(--color-success)]', }, deleted: { label: 'D', className: 'border-[var(--color-error)]/35 bg-[var(--color-error)]/12 text-[var(--color-error)]', }, renamed: { label: 'R', className: 'border-[var(--color-brand)]/35 bg-[var(--color-brand)]/12 text-[var(--color-brand)]', }, untracked: { label: 'U', className: 'border-[var(--color-tertiary)]/35 bg-[var(--color-tertiary)]/12 text-[var(--color-tertiary)]', }, copied: { label: 'C', className: 'border-[var(--color-secondary)]/35 bg-[var(--color-secondary)]/12 text-[var(--color-secondary)]', }, type_changed: { label: 'T', className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', }, unknown: { label: '?', className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]', }, } const EMPTY_TREE_BY_PATH: Record = {} const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] const EMPTY_EXPANDED_PATHS: string[] = [] const SELECTION_MENU_OFFSET = 8 const FILE_BADGE_META: Record = { ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, js: { label: 'JS', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' }, jsx: { label: 'JSX', className: 'bg-[var(--color-warning)]/16 text-[var(--color-warning)]' }, json: { label: '{}', className: 'bg-[var(--color-tertiary)]/14 text-[var(--color-tertiary)]' }, md: { label: 'MD', className: 'bg-[var(--color-text-tertiary)]/14 text-[var(--color-text-secondary)]' }, css: { label: 'CSS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' }, html: { label: 'H', className: 'bg-[var(--color-brand)]/14 text-[var(--color-brand)]' }, png: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, jpg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, jpeg: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, gif: { label: 'IMG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, svg: { label: 'SVG', className: 'bg-[var(--color-success)]/14 text-[var(--color-success)]' }, } function makeTreeStateKey(sessionId: string, path: string) { return `${sessionId}::${path}` } function makePreviewStateKey(sessionId: string, tabId: string) { return `${sessionId}::${tabId}` } function getSessionScopedRecord( record: Record, sessionId: string, ) { const prefix = `${sessionId}::` return Object.fromEntries( Object.entries(record).filter(([key]) => key.startsWith(prefix)), ) as Record } function getPreviewKindLabel( t: ReturnType, kind: WorkspacePreviewKind, ) { return kind === 'diff' ? t('workspace.previewKind.diff') : t('workspace.previewKind.file') } function getFileBadgeMeta(name: string) { const extension = getFileExtension(name) return FILE_BADGE_META[extension] ?? { label: extension ? extension.slice(0, 3).toUpperCase() : 'TXT', className: 'bg-[var(--color-text-tertiary)]/12 text-[var(--color-text-secondary)]', } } function resolveWorkspaceAttachmentPath(workDir: string | undefined, filePath: string) { if (!workDir || filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) return filePath return `${workDir.replace(/[\\/]+$/, '')}/${filePath.replace(/^[/\\]+/, '')}` } function isMarkdownPreview(tab: WorkspacePreviewTab) { if (tab.kind !== 'file') return false const language = (tab.language ?? '').toLowerCase() const extension = getFileExtension(tab.path) return language === 'markdown' || language === 'md' || extension === 'md' || extension === 'markdown' } function FileTypeBadge({ name, subtle = false }: { name: string; subtle?: boolean }) { const meta = getFileBadgeMeta(name) return ( ) } function getInlineStateMessage( t: ReturnType, state: WorkspacePreviewTab['state'] | WorkspaceTreeResult['state'] | 'not_git_repo' | undefined, fallbackError?: string | null, ) { switch (state) { case 'loading': return t('workspace.previewState.loading') case 'binary': return t('workspace.previewState.binary') case 'too_large': return t('workspace.previewState.tooLarge') case 'missing': return t('workspace.previewState.missing') case 'not_git_repo': return t('workspace.notGitRepo') case 'error': return fallbackError || t('workspace.loadError') default: return fallbackError || t('workspace.loadError') } } function normalizeFilterQuery(query: string) { return query.trim().toLowerCase() } function changedFileMatchesFilter(file: WorkspaceChangedFile, query: string) { if (!query) return true return ( file.path.toLowerCase().includes(query) || file.oldPath?.toLowerCase().includes(query) || file.status.toLowerCase().includes(query) ) } function treeEntryMatchesFilter( entry: WorkspaceTreeEntry, query: string, treeByPath: Record, ): boolean { if (!query) return true if (entry.name.toLowerCase().includes(query) || entry.path.toLowerCase().includes(query)) { return true } if (!entry.isDirectory) return false const childTree = treeByPath[entry.path] if (childTree?.state !== 'ok') return false return childTree.entries.some((child) => treeEntryMatchesFilter(child, query, treeByPath)) } type WorkspaceTextSelection = { text: string startLine?: number endLine?: number } type FloatingSelectionMenuState = WorkspaceTextSelection & { x: number y: number } type SelectionPointer = { clientX: number clientY: number } function getElementForNode(node: Node | null): Element | null { if (!node) return null return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement } function getLineNumberFromNode(node: Node | null, root: HTMLElement) { const element = getElementForNode(node) const row = element?.closest('[data-workspace-line-number]') if (!row || !root.contains(row)) return undefined const line = Number(row.getAttribute('data-workspace-line-number')) return Number.isFinite(line) ? line : undefined } function clampValue(value: number, min: number, max: number) { return Math.max(min, Math.min(value, max)) } function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) { const rect = typeof range.getBoundingClientRect === 'function' ? range.getBoundingClientRect() : null const rootRect = root.getBoundingClientRect() const pointerInsideRoot = pointer && pointer.clientX >= rootRect.left && pointer.clientX <= rootRect.right && pointer.clientY >= rootRect.top && pointer.clientY <= rootRect.bottom const fallbackX = rect && rect.width > 0 ? rect.left + rect.width / 2 : rect?.left ?? rootRect.left + 24 const fallbackY = rect ? rect.bottom + SELECTION_MENU_OFFSET : rootRect.top + 24 const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX const unclampedY = pointerInsideRoot ? pointer.clientY + SELECTION_MENU_OFFSET : fallbackY const minX = Math.max(12, rootRect.left + 8) const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136)) const minY = Math.max(12, rootRect.top + 8) const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40)) return { x: clampValue(unclampedX, minX, maxX), y: clampValue(unclampedY, minY, maxY), } } function getTextSelectionFromContainer( root: HTMLElement | null, resolveLines?: (text: string, range: Range) => { startLine?: number; endLine?: number }, pointer?: SelectionPointer, ): FloatingSelectionMenuState | null { if (!root) return null const selection = window.getSelection() if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null const range = selection.getRangeAt(0) const startElement = getElementForNode(range.startContainer) const endElement = getElementForNode(range.endContainer) if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) { return null } const text = selection.toString().trim() if (!text) return null const nodeLines = { startLine: getLineNumberFromNode(range.startContainer, root), endLine: getLineNumberFromNode(range.endContainer, root), } const resolvedLines = resolveLines?.(text, range) ?? nodeLines const startLine = resolvedLines.startLine ?? nodeLines.startLine const endLine = resolvedLines.endLine ?? nodeLines.endLine ?? startLine const orderedStart = startLine && endLine ? Math.min(startLine, endLine) : startLine const orderedEnd = startLine && endLine ? Math.max(startLine, endLine) : endLine return { ...getSelectionPosition(range, root, pointer), text, ...(orderedStart ? { startLine: orderedStart } : {}), ...(orderedEnd ? { endLine: orderedEnd } : {}), } } function getLineRangeForText(value: string, text: string) { const index = value.indexOf(text) if (index < 0) return {} const startLine = value.slice(0, index).split('\n').length const endLine = startLine + text.split('\n').length - 1 return { startLine, endLine } } function FloatingSelectionMenu({ selection, onAdd, }: { selection: FloatingSelectionMenuState | null onAdd: () => void }) { const t = useTranslation() if (!selection) return null return ( ) } function PanelMessage({ icon, message, tone = 'muted', compact = false, }: { icon: string message: string tone?: 'muted' | 'error' compact?: boolean }) { const toneClass = tone === 'error' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]' return (
{icon} {message}
) } function ToolbarIconButton({ icon, label, onClick, }: { icon: string label: string onClick: () => void }) { return ( ) } function WorkspaceFilterInput({ value, onChange, }: { value: string onChange: (value: string) => void }) { const t = useTranslation() return (
) } function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) { const meta = FILE_STATUS_META[status] return ( {meta.label} ) } function CodeSurface({ value, language, onAddLineComment, onAddSelection, }: { value: string language: string onAddLineComment: (line: number, note: string, quote: string) => void onAddSelection: (selection: WorkspaceTextSelection) => void }) { const t = useTranslation() const surfaceRef = useRef(null) const [commentLine, setCommentLine] = useState(null) const [commentDraft, setCommentDraft] = useState('') const [showAllLines, setShowAllLines] = useState(false) const [selectionMenu, setSelectionMenu] = useState(null) const lines = value.split('\n') const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT) const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : '' const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PREVIEW_LINE_LIMIT const visibleCode = usePlainLargePreview ? '' : visibleLines.join('\n') useEffect(() => { setShowAllLines(false) setCommentLine(null) setCommentDraft('') setSelectionMenu(null) }, [language, value]) const submitLineComment = () => { if (!commentLine || !commentDraft.trim()) return onAddLineComment(commentLine, commentDraft.trim(), activeQuote) setCommentLine(null) setCommentDraft('') } const handleSelectionMouseUp = (event: MouseEvent) => { const selection = getTextSelectionFromContainer(surfaceRef.current, undefined, event) if (!selection?.startLine || !selection.endLine || selection.startLine === selection.endLine) { setSelectionMenu(selection) return } setSelectionMenu({ ...selection, text: visibleLines.slice(selection.startLine - 1, selection.endLine).join('\n').trim(), }) } const addCurrentSelectionToChat = () => { if (!selectionMenu) return onAddSelection({ text: selectionMenu.text, startLine: selectionMenu.startLine, endLine: selectionMenu.endLine, }) setSelectionMenu(null) window.getSelection()?.removeAllRanges() } const renderLineCommentEditor = (lineNumber: number) => { if (commentLine !== lineNumber) return null return (