Download desktop/src/components/workspace/WorkspacePanel.tsx from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 58 kB
-
https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/workspace/WorkspacePanel.tsx
- Command line
-
hf download hf://chenbhao/codev@e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/workspace/WorkspacePanel.tsx
-
curl -L -o WorkspacePanel.tsx https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/workspace/WorkspacePanel.tsx
58 kB
| import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react' | |
| import { MessageCircle } from 'lucide-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 { useUIStore } from '../../stores/uiStore' | |
| import { copyTextToClipboard } from '../chat/clipboard' | |
| import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss' | |
| 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<string> | |
| treeByPath: Record<string, WorkspaceTreeResult | undefined> | |
| treeLoadingByPath: Record<string, boolean | undefined> | |
| treeErrorsByPath: Record<string, string | null | undefined> | |
| filterQuery: string | |
| onToggle: (path: string) => void | |
| onOpenFile: (path: string) => void | |
| onFileContextMenu: (event: MouseEvent, path: string) => void | |
| activePath: string | null | |
| } | |
| const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className: string }> = { | |
| 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<string, WorkspaceTreeResult | undefined> = {} | |
| const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] | |
| const EMPTY_EXPANDED_PATHS: string[] = [] | |
| const SELECTION_MENU_OFFSET = 10 | |
| const SELECTION_MENU_WIDTH = 158 | |
| const SELECTION_MENU_HEIGHT = 44 | |
| const FILE_BADGE_META: Record<string, { label: string; className: string }> = { | |
| 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<T>( | |
| record: Record<string, T>, | |
| sessionId: string, | |
| ) { | |
| const prefix = `${sessionId}::` | |
| return Object.fromEntries( | |
| Object.entries(record).filter(([key]) => key.startsWith(prefix)), | |
| ) as Record<string, T> | |
| } | |
| function getPreviewKindLabel( | |
| t: ReturnType<typeof useTranslation>, | |
| 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 ( | |
| <span | |
| className={`inline-flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-[5px] px-1 font-[var(--font-label)] text-[9px] font-semibold leading-none ${meta.className} ${subtle ? 'opacity-55 grayscale' : ''}`} | |
| aria-hidden="true" | |
| > | |
| {meta.label} | |
| </span> | |
| ) | |
| } | |
| function getInlineStateMessage( | |
| t: ReturnType<typeof useTranslation>, | |
| 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<string, WorkspaceTreeResult | undefined>, | |
| ): 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 getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) { | |
| return getSelectionPopoverPosition(range, root, { | |
| menuWidth: SELECTION_MENU_WIDTH, | |
| menuHeight: SELECTION_MENU_HEIGHT, | |
| offset: SELECTION_MENU_OFFSET, | |
| fallbackPointer: pointer, | |
| }) | |
| } | |
| 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, | |
| popoverRef, | |
| }: { | |
| selection: FloatingSelectionMenuState | null | |
| onAdd: () => void | |
| popoverRef: { current: HTMLButtonElement | null } | |
| }) { | |
| const t = useTranslation() | |
| if (!selection) return null | |
| return ( | |
| <button | |
| ref={popoverRef} | |
| type="button" | |
| onMouseDown={(event) => event.preventDefault()} | |
| onClick={onAdd} | |
| className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35" | |
| style={{ left: selection.x, top: selection.y }} | |
| > | |
| <MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" /> | |
| <span>{t('workspace.addSelectionToChat')}</span> | |
| </button> | |
| ) | |
| } | |
| 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 ( | |
| <div | |
| className={`flex items-center gap-2 px-4 ${compact ? 'py-2 text-[11px]' : 'py-8 text-xs'} ${toneClass}`} | |
| role={tone === 'error' ? 'alert' : 'status'} | |
| > | |
| <span className={`material-symbols-outlined shrink-0 text-[16px] ${icon === 'progress_activity' ? 'animate-spin' : ''}`}> | |
| {icon} | |
| </span> | |
| <span className="min-w-0 leading-relaxed">{message}</span> | |
| </div> | |
| ) | |
| } | |
| function ToolbarIconButton({ | |
| icon, | |
| label, | |
| onClick, | |
| }: { | |
| icon: string | |
| label: string | |
| onClick: () => void | |
| }) { | |
| return ( | |
| <button | |
| type="button" | |
| aria-label={label} | |
| onClick={onClick} | |
| className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35" | |
| > | |
| <span className="material-symbols-outlined text-[16px]">{icon}</span> | |
| </button> | |
| ) | |
| } | |
| function WorkspaceFilterInput({ | |
| value, | |
| onChange, | |
| }: { | |
| value: string | |
| onChange: (value: string) => void | |
| }) { | |
| const t = useTranslation() | |
| return ( | |
| <div className="shrink-0 border-b border-[var(--color-border)] px-3 py-2"> | |
| <label className="flex h-8 items-center gap-2 rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[var(--color-text-tertiary)] transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/10"> | |
| <span className="material-symbols-outlined shrink-0 text-[17px]">search</span> | |
| <input | |
| value={value} | |
| onChange={(event) => onChange(event.target.value)} | |
| aria-label={t('workspace.filterPlaceholder')} | |
| placeholder={t('workspace.filterPlaceholder')} | |
| className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" | |
| /> | |
| {value.length > 0 && ( | |
| <button | |
| type="button" | |
| aria-label={t('workspace.clearFilter')} | |
| onClick={() => onChange('')} | |
| className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]" | |
| > | |
| <span className="material-symbols-outlined text-[13px]">close</span> | |
| </button> | |
| )} | |
| </label> | |
| </div> | |
| ) | |
| } | |
| function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) { | |
| const meta = FILE_STATUS_META[status] | |
| return ( | |
| <span | |
| className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[10px] font-semibold ${meta.className}`} | |
| aria-label={status} | |
| > | |
| {meta.label} | |
| </span> | |
| ) | |
| } | |
| 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<HTMLDivElement>(null) | |
| const selectionMenuRef = useRef<HTMLButtonElement>(null) | |
| const [commentLine, setCommentLine] = useState<number | null>(null) | |
| const [commentDraft, setCommentDraft] = useState('') | |
| const [showAllLines, setShowAllLines] = useState(false) | |
| const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(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 dismissSelectionMenu = useCallback(() => { | |
| setSelectionMenu(null) | |
| }, []) | |
| useSelectionPopoverDismiss({ | |
| active: Boolean(selectionMenu), | |
| popoverRef: selectionMenuRef, | |
| onDismiss: dismissSelectionMenu, | |
| }) | |
| const submitLineComment = () => { | |
| if (!commentLine || !commentDraft.trim()) return | |
| onAddLineComment(commentLine, commentDraft.trim(), activeQuote) | |
| setCommentLine(null) | |
| setCommentDraft('') | |
| } | |
| const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => { | |
| 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) | |
| clearWindowSelection() | |
| } | |
| const renderLineCommentEditor = (lineNumber: number) => { | |
| if (commentLine !== lineNumber) return null | |
| return ( | |
| <div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2"> | |
| <span aria-hidden="true" /> | |
| <div className="rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-sm"> | |
| <div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2"> | |
| <span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span> | |
| <span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span> | |
| <span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]"> | |
| {t('workspace.commentLineTarget', { line: lineNumber })} | |
| </span> | |
| </div> | |
| <textarea | |
| value={commentDraft} | |
| onChange={(event) => setCommentDraft(event.target.value)} | |
| autoFocus | |
| rows={3} | |
| placeholder={t('workspace.commentPlaceholder')} | |
| className="block w-full resize-none bg-transparent px-3 py-3 text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" | |
| /> | |
| <div className="flex justify-end gap-2 px-3 pb-3"> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setCommentLine(null) | |
| setCommentDraft('') | |
| }} | |
| className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| {t('common.cancel')} | |
| </button> | |
| <button | |
| type="button" | |
| onClick={submitLineComment} | |
| disabled={!commentDraft.trim()} | |
| className="rounded-[7px] bg-[var(--color-text-primary)] px-2.5 py-1 text-[12px] font-medium text-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-45" | |
| > | |
| {t('workspace.addCommentToChat')} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const renderLineNumberButton = (lineNumber: number) => ( | |
| <button | |
| type="button" | |
| aria-label={t('workspace.commentLine', { line: lineNumber })} | |
| onClick={() => { | |
| setCommentLine(lineNumber) | |
| setCommentDraft('') | |
| }} | |
| className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]" | |
| > | |
| {lineNumber} | |
| </button> | |
| ) | |
| return ( | |
| <div | |
| ref={surfaceRef} | |
| className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]" | |
| onMouseUp={handleSelectionMouseUp} | |
| onKeyDown={(event) => { | |
| if (event.key === 'Escape') setSelectionMenu(null) | |
| }} | |
| > | |
| <div className="relative min-w-max py-2"> | |
| {usePlainLargePreview ? ( | |
| <pre | |
| data-workspace-code="" | |
| data-testid="workspace-code" | |
| className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]" | |
| style={{ color: 'var(--color-code-fg)', background: 'transparent' }} | |
| > | |
| {visibleLines.map((line, index) => { | |
| const lineNumber = index + 1 | |
| return ( | |
| <div key={lineNumber}> | |
| <div | |
| className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]" | |
| data-workspace-line-number={lineNumber} | |
| > | |
| {renderLineNumberButton(lineNumber)} | |
| <span className="whitespace-pre pr-6">{line || ' '}</span> | |
| </div> | |
| {renderLineCommentEditor(lineNumber)} | |
| </div> | |
| ) | |
| })} | |
| </pre> | |
| ) : ( | |
| <Highlight | |
| theme={workspacePrismTheme} | |
| code={visibleCode} | |
| language={normalizePrismLanguage(language)} | |
| > | |
| {({ tokens, getLineProps, getTokenProps }) => ( | |
| <pre | |
| data-workspace-code="" | |
| data-testid="workspace-code" | |
| className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55]" | |
| style={{ color: 'var(--color-code-fg)', background: 'transparent' }} | |
| > | |
| {tokens.map((line, index) => { | |
| const { key: lineKey, ...lineProps } = getLineProps({ line, key: index }) | |
| const lineNumber = index + 1 | |
| return ( | |
| <div key={String(lineKey)}> | |
| <div | |
| {...lineProps} | |
| data-workspace-line-number={lineNumber} | |
| className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]" | |
| > | |
| {renderLineNumberButton(lineNumber)} | |
| <span className="whitespace-pre pr-6"> | |
| {line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => { | |
| const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) | |
| return <span key={String(tokenKey)} {...tokenProps} /> | |
| })} | |
| </span> | |
| </div> | |
| {renderLineCommentEditor(lineNumber)} | |
| </div> | |
| ) | |
| })} | |
| </pre> | |
| )} | |
| </Highlight> | |
| )} | |
| {lines.length > WORKSPACE_PREVIEW_LINE_LIMIT && ( | |
| <div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur"> | |
| <span> | |
| {showAllLines | |
| ? t('workspace.previewAllLines', { total: lines.length }) | |
| : t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => setShowAllLines((current) => !current)} | |
| className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]" | |
| > | |
| {showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} /> | |
| </div> | |
| ) | |
| } | |
| function MarkdownSurface({ | |
| value, | |
| onAddSelection, | |
| }: { | |
| value: string | |
| onAddSelection: (selection: WorkspaceTextSelection) => void | |
| }) { | |
| const surfaceRef = useRef<HTMLDivElement>(null) | |
| const selectionMenuRef = useRef<HTMLButtonElement>(null) | |
| const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null) | |
| useEffect(() => { | |
| setSelectionMenu(null) | |
| }, [value]) | |
| const dismissSelectionMenu = useCallback(() => { | |
| setSelectionMenu(null) | |
| }, []) | |
| useSelectionPopoverDismiss({ | |
| active: Boolean(selectionMenu), | |
| popoverRef: selectionMenuRef, | |
| onDismiss: dismissSelectionMenu, | |
| }) | |
| const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => { | |
| setSelectionMenu(getTextSelectionFromContainer( | |
| surfaceRef.current, | |
| (text) => getLineRangeForText(value, text), | |
| event, | |
| )) | |
| } | |
| const addCurrentSelectionToChat = () => { | |
| if (!selectionMenu) return | |
| onAddSelection({ | |
| text: selectionMenu.text, | |
| startLine: selectionMenu.startLine, | |
| endLine: selectionMenu.endLine, | |
| }) | |
| setSelectionMenu(null) | |
| clearWindowSelection() | |
| } | |
| return ( | |
| <div | |
| ref={surfaceRef} | |
| className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)]" | |
| onMouseUp={handleSelectionMouseUp} | |
| onKeyDown={(event) => { | |
| if (event.key === 'Escape') setSelectionMenu(null) | |
| }} | |
| > | |
| <div className="mx-auto w-full max-w-[860px] px-6 py-5"> | |
| <MarkdownRenderer | |
| content={value} | |
| variant="document" | |
| className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4" | |
| /> | |
| </div> | |
| <FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} /> | |
| </div> | |
| ) | |
| } | |
| function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) { | |
| const t = useTranslation() | |
| if (!tab.dataUrl) { | |
| return ( | |
| <PanelMessage | |
| icon="image_not_supported" | |
| message={tab.error || t('workspace.imagePreviewUnavailable')} | |
| /> | |
| ) | |
| } | |
| return ( | |
| <div className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)] p-4"> | |
| <div className="flex min-h-full items-center justify-center"> | |
| <img | |
| src={tab.dataUrl} | |
| alt={tab.path} | |
| className="max-h-full max-w-full rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] object-contain shadow-sm" | |
| /> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function ChangedFileRow({ | |
| file, | |
| onClick, | |
| onContextMenu, | |
| }: { | |
| file: WorkspaceChangedFile | |
| onClick: () => void | |
| onContextMenu: (event: MouseEvent, path: string) => void | |
| }) { | |
| return ( | |
| <button | |
| type="button" | |
| onClick={onClick} | |
| onContextMenu={(event) => onContextMenu(event, file.path)} | |
| className="mx-2 flex w-[calc(100%-16px)] items-center gap-3 rounded-[7px] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]" | |
| > | |
| <FileStatusBadge status={file.status} /> | |
| <div className="min-w-0 flex-1"> | |
| <div className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{file.path}</div> | |
| {file.oldPath && ( | |
| <div className="truncate text-[11px] text-[var(--color-text-tertiary)]"> | |
| {file.oldPath} | |
| </div> | |
| )} | |
| </div> | |
| <div className="shrink-0 text-right font-[var(--font-mono)] text-[11px] leading-4"> | |
| <div className="text-[var(--color-success)]">+{file.additions}</div> | |
| <div className="text-[var(--color-error)]">-{file.deletions}</div> | |
| </div> | |
| </button> | |
| ) | |
| } | |
| function TreeNode({ | |
| sessionId, | |
| entry, | |
| depth, | |
| expandedPaths, | |
| treeByPath, | |
| treeLoadingByPath, | |
| treeErrorsByPath, | |
| filterQuery, | |
| onToggle, | |
| onOpenFile, | |
| onFileContextMenu, | |
| activePath, | |
| }: TreeNodeProps) { | |
| const t = useTranslation() | |
| const childTree = treeByPath[entry.path] | |
| const childLoading = treeLoadingByPath[makeTreeStateKey(sessionId, entry.path)] ?? false | |
| const childError = treeErrorsByPath[makeTreeStateKey(sessionId, entry.path)] ?? null | |
| const isExpanded = expandedPaths.has(entry.path) | |
| const isVisuallyExpanded = isExpanded || filterQuery.length > 0 | |
| const indent = 14 + depth * 20 | |
| if (!entry.isDirectory) { | |
| const isActive = entry.path === activePath | |
| return ( | |
| <button | |
| type="button" | |
| onClick={() => onOpenFile(entry.path)} | |
| onContextMenu={(event) => onFileContextMenu(event, entry.path)} | |
| className={`group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors ${ | |
| isActive | |
| ? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1.5px_var(--color-border-focus)]' | |
| : 'hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| style={{ paddingLeft: indent }} | |
| > | |
| <FileTypeBadge name={entry.name} subtle={!isActive} /> | |
| <span className="min-w-0 truncate text-[14px] font-medium text-[var(--color-text-primary)]">{entry.name}</span> | |
| </button> | |
| ) | |
| } | |
| return ( | |
| <div> | |
| <button | |
| type="button" | |
| onClick={() => onToggle(entry.path)} | |
| aria-expanded={isVisuallyExpanded} | |
| className="group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]" | |
| style={{ paddingLeft: indent }} | |
| > | |
| <span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-primary)]"> | |
| {isVisuallyExpanded ? 'expand_more' : 'chevron_right'} | |
| </span> | |
| <span className="min-w-0 truncate text-[15px] font-medium text-[var(--color-text-primary)]">{entry.name}</span> | |
| </button> | |
| {isVisuallyExpanded && ( | |
| <div className="relative"> | |
| {depth < 4 && ( | |
| <span | |
| aria-hidden="true" | |
| className="pointer-events-none absolute bottom-1 top-1 w-px bg-[var(--color-border)]" | |
| style={{ left: 28 + depth * 20 }} | |
| /> | |
| )} | |
| {childLoading && !childTree && ( | |
| <PanelMessage | |
| compact | |
| icon="progress_activity" | |
| message={t('common.loading')} | |
| /> | |
| )} | |
| {!childLoading && childError && ( | |
| <PanelMessage compact icon="error" tone="error" message={childError} /> | |
| )} | |
| {!childLoading && !childError && childTree?.state === 'missing' && ( | |
| <PanelMessage compact icon="folder_off" message={t('workspace.previewState.missing')} /> | |
| )} | |
| {!childLoading && !childError && childTree?.state === 'error' && ( | |
| <PanelMessage | |
| compact | |
| icon="error" | |
| tone="error" | |
| message={childTree.error || t('workspace.loadError')} | |
| /> | |
| )} | |
| {!childLoading && !childError && childTree?.state === 'ok' && childTree.entries.length === 0 && ( | |
| <PanelMessage compact icon="folder_open" message={t('workspace.noFiles')} /> | |
| )} | |
| {!childLoading && !childError && childTree?.state === 'ok' && childTree.entries | |
| .filter((childEntry) => treeEntryMatchesFilter(childEntry, filterQuery, treeByPath)) | |
| .map((childEntry) => ( | |
| <TreeNode | |
| key={childEntry.path} | |
| sessionId={sessionId} | |
| entry={childEntry} | |
| depth={depth + 1} | |
| expandedPaths={expandedPaths} | |
| treeByPath={treeByPath} | |
| treeLoadingByPath={treeLoadingByPath} | |
| treeErrorsByPath={treeErrorsByPath} | |
| filterQuery={filterQuery} | |
| onToggle={onToggle} | |
| onOpenFile={onOpenFile} | |
| onFileContextMenu={onFileContextMenu} | |
| activePath={activePath} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { | |
| const t = useTranslation() | |
| const addToast = useUIStore((state) => state.addToast) | |
| const [filterQuery, setFilterQuery] = useState('') | |
| const [isViewMenuOpen, setIsViewMenuOpen] = useState(false) | |
| const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null) | |
| const [fileContextMenu, setFileContextMenu] = useState<{ path: string; x: number; y: number } | null>(null) | |
| const width = useWorkspacePanelStore((state) => state.width) | |
| const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId)) | |
| const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId)) | |
| const status = useWorkspacePanelStore((state) => state.statusBySession[sessionId]) | |
| const treeByPath = useWorkspacePanelStore((state) => state.treeBySessionPath[sessionId] ?? EMPTY_TREE_BY_PATH) | |
| const previewTabs = useWorkspacePanelStore((state) => state.previewTabsBySession[sessionId] ?? EMPTY_PREVIEW_TABS) | |
| const activePreviewTabId = useWorkspacePanelStore((state) => state.activePreviewTabIdBySession[sessionId] ?? null) | |
| const expandedPaths = useWorkspacePanelStore((state) => state.expandedPathsBySession[sessionId] ?? EMPTY_EXPANDED_PATHS) | |
| const statusLoading = useWorkspacePanelStore((state) => state.loading.statusBySession[sessionId] ?? false) | |
| const treeLoadingByPath = useWorkspacePanelStore( | |
| useShallow((state) => getSessionScopedRecord(state.loading.treeBySessionPath, sessionId)), | |
| ) | |
| const statusError = useWorkspacePanelStore((state) => state.errors.statusBySession[sessionId] ?? null) | |
| const treeErrorsByPath = useWorkspacePanelStore( | |
| useShallow((state) => getSessionScopedRecord(state.errors.treeBySessionPath, sessionId)), | |
| ) | |
| const setActiveView = useWorkspacePanelStore((state) => state.setActiveView) | |
| const loadStatus = useWorkspacePanelStore((state) => state.loadStatus) | |
| const loadTree = useWorkspacePanelStore((state) => state.loadTree) | |
| const toggleTreeNode = useWorkspacePanelStore((state) => state.toggleTreeNode) | |
| const openPreview = useWorkspacePanelStore((state) => state.openPreview) | |
| const closePreview = useWorkspacePanelStore((state) => state.closePreview) | |
| const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs) | |
| const closePanel = useWorkspacePanelStore((state) => state.closePanel) | |
| const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference) | |
| const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle') | |
| const refreshLifecycleRef = useRef({ | |
| sessionId, | |
| isOpen: false, | |
| chatState: 'idle', | |
| }) | |
| const rootTree = treeByPath[''] | |
| const rootTreeKey = makeTreeStateKey(sessionId, '') | |
| const rootTreeLoading = treeLoadingByPath[rootTreeKey] ?? false | |
| const rootTreeError = treeErrorsByPath[rootTreeKey] ?? null | |
| const normalizedFilterQuery = normalizeFilterQuery(filterQuery) | |
| const expandedPathSet = new Set(expandedPaths) | |
| const activePreviewTab = | |
| previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null | |
| const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null | |
| const filteredChangedFiles = useMemo( | |
| () => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)), | |
| [normalizedFilterQuery, status?.changedFiles], | |
| ) | |
| const filteredRootEntries = useMemo( | |
| () => rootTree?.state === 'ok' | |
| ? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath)) | |
| : [], | |
| [normalizedFilterQuery, rootTree, treeByPath], | |
| ) | |
| const activePreviewRequestKey = activePreviewTab | |
| ? makePreviewStateKey(sessionId, activePreviewTab.id) | |
| : null | |
| const activePreviewLoading = useWorkspacePanelStore((state) => | |
| activePreviewRequestKey ? state.loading.previewByTabId[activePreviewRequestKey] ?? false : false, | |
| ) | |
| const activePreviewError = useWorkspacePanelStore((state) => | |
| activePreviewRequestKey ? state.errors.previewByTabId[activePreviewRequestKey] ?? null : null, | |
| ) | |
| useEffect(() => { | |
| const previous = refreshLifecycleRef.current | |
| const sessionChanged = previous.sessionId !== sessionId | |
| const opened = isOpen && (sessionChanged || !previous.isOpen) | |
| const completedTurn = | |
| isOpen && | |
| !sessionChanged && | |
| previous.chatState !== 'idle' && | |
| chatState === 'idle' | |
| refreshLifecycleRef.current = { sessionId, isOpen, chatState } | |
| const shouldRefreshOnOpen = opened | |
| const shouldRefreshAfterCompletedTurn = completedTurn && chatState === 'idle' | |
| if ((!shouldRefreshOnOpen && !shouldRefreshAfterCompletedTurn) || statusLoading) return | |
| void loadStatus(sessionId) | |
| }, [chatState, isOpen, loadStatus, sessionId, statusLoading]) | |
| useEffect(() => { | |
| if (!isOpen || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return | |
| void loadTree(sessionId, '') | |
| }, [activeView, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId]) | |
| useEffect(() => { | |
| if (!previewTabContextMenu && !fileContextMenu) return | |
| const close = () => { | |
| setPreviewTabContextMenu(null) | |
| setFileContextMenu(null) | |
| } | |
| document.addEventListener('click', close) | |
| return () => document.removeEventListener('click', close) | |
| }, [fileContextMenu, previewTabContextMenu]) | |
| if (!isOpen) return null | |
| const hasPreviewTabs = previewTabs.length > 0 | |
| const panelWidth = hasPreviewTabs ? width : Math.min(width, 520) | |
| const panelMaxWidth = hasPreviewTabs ? 'min(62%, calc(100% - 328px))' : '36%' | |
| const panelMinWidth = hasPreviewTabs ? 'min(420px, 54%)' : 'min(340px, 40%)' | |
| const handleRefresh = () => { | |
| void loadStatus(sessionId) | |
| if (activeView === 'all') { | |
| void loadTree(sessionId, '') | |
| } | |
| } | |
| const handleOpenDiff = (path: string) => { | |
| void openPreview(sessionId, path, 'diff') | |
| } | |
| const handleOpenFile = (path: string) => { | |
| void openPreview(sessionId, path, 'file') | |
| } | |
| const addFileToChat = (path: string) => { | |
| addWorkspaceReference(sessionId, { | |
| kind: 'file', | |
| path, | |
| absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path), | |
| name: path.split('/').pop() || path, | |
| }) | |
| } | |
| const addLineCommentToChat = (path: string, line: number, note: string, quote: string) => { | |
| addWorkspaceReference(sessionId, { | |
| kind: 'code-comment', | |
| path, | |
| absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path), | |
| name: path.split('/').pop() || path, | |
| lineStart: line, | |
| lineEnd: line, | |
| note, | |
| quote, | |
| }) | |
| } | |
| const addSelectionToChat = (path: string, selection: WorkspaceTextSelection) => { | |
| addWorkspaceReference(sessionId, { | |
| kind: 'code-selection', | |
| path, | |
| absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path), | |
| name: path.split('/').pop() || path, | |
| lineStart: selection.startLine, | |
| lineEnd: selection.endLine, | |
| quote: selection.text, | |
| }) | |
| } | |
| const handleSetActiveView = (view: 'changed' | 'all') => { | |
| setActiveView(sessionId, view) | |
| setIsViewMenuOpen(false) | |
| } | |
| const handlePreviewTabContextMenu = (event: MouseEvent, tabId: string) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| setFileContextMenu(null) | |
| setPreviewTabContextMenu({ tabId, x: event.clientX, y: event.clientY }) | |
| } | |
| const handleFileContextMenu = (event: MouseEvent, path: string) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| setPreviewTabContextMenu(null) | |
| setFileContextMenu({ path, x: event.clientX, y: event.clientY }) | |
| } | |
| const handleClosePreviewTabs = (scope: WorkspacePreviewCloseScope) => { | |
| if (!previewTabContextMenu) return | |
| closePreviewTabs(sessionId, previewTabContextMenu.tabId, scope) | |
| setPreviewTabContextMenu(null) | |
| } | |
| const copyWorkspacePath = async (path: string) => { | |
| const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path) | |
| const copied = await copyTextToClipboard(resolvedPath) | |
| setFileContextMenu(null) | |
| addToast({ | |
| type: copied ? 'success' : 'error', | |
| message: copied ? t('workspace.pathCopied') : t('common.copyFailed'), | |
| }) | |
| } | |
| const renderChangedView = () => { | |
| if (statusLoading && !status) { | |
| return <PanelMessage icon="progress_activity" message={t('common.loading')} /> | |
| } | |
| if (status?.state === 'missing_workdir') { | |
| return <PanelMessage icon="folder_off" message={t('workspace.missingWorkdir')} /> | |
| } | |
| if (status?.state === 'not_git_repo') { | |
| return <PanelMessage icon="account_tree" message={t('workspace.notGitRepo')} /> | |
| } | |
| if (statusError || status?.state === 'error') { | |
| return ( | |
| <PanelMessage | |
| icon="error" | |
| tone="error" | |
| message={statusError || status?.error || t('workspace.loadError')} | |
| /> | |
| ) | |
| } | |
| if (!status) { | |
| return <PanelMessage icon="progress_activity" message={t('common.loading')} /> | |
| } | |
| if (status.changedFiles.length === 0) { | |
| return <PanelMessage icon="check_circle" message={t('workspace.noChanges')} /> | |
| } | |
| if (filteredChangedFiles.length === 0) { | |
| return <PanelMessage icon="search_off" message={t('workspace.noMatchingFiles')} /> | |
| } | |
| return ( | |
| <div> | |
| {filteredChangedFiles.map((file) => ( | |
| <ChangedFileRow | |
| key={`${file.path}:${file.status}:${file.oldPath ?? ''}`} | |
| file={file} | |
| onClick={() => handleOpenDiff(file.path)} | |
| onContextMenu={handleFileContextMenu} | |
| /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| const renderAllFilesView = () => { | |
| if (rootTreeLoading && !rootTree) { | |
| return <PanelMessage icon="progress_activity" message={t('common.loading')} /> | |
| } | |
| if (rootTreeError) { | |
| return <PanelMessage icon="error" tone="error" message={rootTreeError} /> | |
| } | |
| if (rootTree?.state === 'missing') { | |
| return <PanelMessage icon="folder_off" message={t('workspace.missingWorkdir')} /> | |
| } | |
| if (rootTree?.state === 'error') { | |
| return <PanelMessage icon="error" tone="error" message={rootTree.error || t('workspace.loadError')} /> | |
| } | |
| if (!rootTree) { | |
| return <PanelMessage icon="progress_activity" message={t('common.loading')} /> | |
| } | |
| if (rootTree.entries.length === 0) { | |
| return <PanelMessage icon="folder_open" message={t('workspace.noFiles')} /> | |
| } | |
| if (filteredRootEntries.length === 0) { | |
| return <PanelMessage icon="search_off" message={t('workspace.noMatchingFiles')} /> | |
| } | |
| return ( | |
| <div className="py-1"> | |
| {filteredRootEntries.map((entry) => ( | |
| <TreeNode | |
| key={entry.path} | |
| sessionId={sessionId} | |
| entry={entry} | |
| depth={0} | |
| expandedPaths={expandedPathSet} | |
| treeByPath={treeByPath} | |
| treeLoadingByPath={treeLoadingByPath} | |
| treeErrorsByPath={treeErrorsByPath} | |
| filterQuery={normalizedFilterQuery} | |
| onToggle={(path) => { | |
| void toggleTreeNode(sessionId, path) | |
| }} | |
| onOpenFile={handleOpenFile} | |
| onFileContextMenu={handleFileContextMenu} | |
| activePath={activeTreePath} | |
| /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| const renderPreviewContent = () => { | |
| if (!activePreviewTab) { | |
| return ( | |
| <div className="flex min-h-0 flex-1 items-center justify-center px-4 text-xs text-[var(--color-text-tertiary)]"> | |
| {t('workspace.previewEmpty')} | |
| </div> | |
| ) | |
| } | |
| const kindLabel = getPreviewKindLabel(t, activePreviewTab.kind) | |
| const state = activePreviewTab.state ?? 'loading' | |
| return ( | |
| <> | |
| <div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-[12px]"> | |
| <span className="truncate text-[var(--color-text-tertiary)]">{status?.repoName || 'workspace'}</span> | |
| {activePreviewTab.path.split('/').map((segment, index, segments) => ( | |
| <span key={`${segment}:${index}`} className="flex min-w-0 items-center gap-1.5"> | |
| <span className="text-[var(--color-text-tertiary)]">›</span> | |
| <span className={`truncate ${index === segments.length - 1 ? 'font-semibold text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}> | |
| {segment} | |
| </span> | |
| </span> | |
| ))} | |
| <button | |
| type="button" | |
| onClick={() => addFileToChat(activePreviewTab.path)} | |
| className="ml-auto inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] px-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]" | |
| > | |
| <span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span> | |
| <span>{t('workspace.addToChat')}</span> | |
| </button> | |
| <span className="shrink-0 rounded-[5px] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"> | |
| {kindLabel} | |
| </span> | |
| </div> | |
| {activePreviewLoading || state === 'loading' ? ( | |
| <PanelMessage icon="progress_activity" message={t('workspace.previewState.loading')} /> | |
| ) : state === 'ok' && activePreviewTab.previewType === 'image' ? ( | |
| <ImagePreview tab={activePreviewTab} /> | |
| ) : state === 'ok' && activePreviewTab.kind === 'diff' ? ( | |
| <WorkspaceDiffSurface | |
| value={activePreviewTab.diff ?? ''} | |
| path={activePreviewTab.path} | |
| /> | |
| ) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? ( | |
| <MarkdownSurface | |
| value={activePreviewTab.content ?? ''} | |
| onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)} | |
| /> | |
| ) : state === 'ok' ? ( | |
| <CodeSurface | |
| value={activePreviewTab.content ?? ''} | |
| language={activePreviewTab.language ?? 'text'} | |
| onAddLineComment={(line, note, quote) => addLineCommentToChat(activePreviewTab.path, line, note, quote)} | |
| onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)} | |
| /> | |
| ) : ( | |
| <PanelMessage | |
| icon="error" | |
| tone={state === 'error' ? 'error' : 'muted'} | |
| message={getInlineStateMessage(t, state, activePreviewError || activePreviewTab.error || null)} | |
| /> | |
| )} | |
| </> | |
| ) | |
| } | |
| const renderPreviewTabs = () => ( | |
| <> | |
| <div | |
| role="tablist" | |
| aria-label={t('workspace.previewTabs')} | |
| className="flex h-11 shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3" | |
| > | |
| {previewTabs.length === 0 ? ( | |
| <div className="flex items-center gap-2 px-1.5 text-[12px] text-[var(--color-text-tertiary)]"> | |
| <span className="material-symbols-outlined text-[15px]">docs</span> | |
| <span>{t('workspace.preview')}</span> | |
| </div> | |
| ) : ( | |
| previewTabs.map((tab) => { | |
| const kindLabel = getPreviewKindLabel(t, tab.kind) | |
| const isActive = tab.id === activePreviewTab?.id | |
| return ( | |
| <div | |
| key={tab.id} | |
| onContextMenu={(event) => handlePreviewTabContextMenu(event, tab.id)} | |
| className={`group flex h-8 min-w-[118px] max-w-[210px] shrink-0 items-center gap-2 rounded-[8px] px-2 text-left text-[13px] transition-colors ${ | |
| isActive | |
| ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' | |
| : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]' | |
| }`} | |
| > | |
| <button | |
| type="button" | |
| role="tab" | |
| aria-selected={isActive} | |
| onClick={() => { | |
| void openPreview(sessionId, tab.path, tab.kind) | |
| }} | |
| className="min-w-0 flex flex-1 items-center gap-2 text-left" | |
| > | |
| {tab.kind === 'diff' ? ( | |
| <span className="material-symbols-outlined shrink-0 text-[15px] text-[var(--color-text-tertiary)]">difference</span> | |
| ) : ( | |
| <FileTypeBadge name={tab.title} subtle={!isActive} /> | |
| )} | |
| <span className="min-w-0 flex-1 truncate">{tab.title}</span> | |
| </button> | |
| <button | |
| type="button" | |
| aria-label={`${t('workspace.closeTab')} ${tab.title} ${kindLabel}`} | |
| onClick={() => { | |
| closePreview(sessionId, tab.id) | |
| }} | |
| className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] opacity-0 transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] group-hover:opacity-100 focus-visible:opacity-100" | |
| > | |
| <span className="material-symbols-outlined text-[13px] leading-none">close</span> | |
| </button> | |
| </div> | |
| ) | |
| }) | |
| )} | |
| </div> | |
| {previewTabContextMenu && ( | |
| <div | |
| role="menu" | |
| className="fixed z-50 min-w-[156px] rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 text-[12px] shadow-[var(--shadow-dropdown)]" | |
| style={{ left: previewTabContextMenu.x, top: previewTabContextMenu.y }} | |
| onClick={(event) => event.stopPropagation()} | |
| > | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => handleClosePreviewTabs('current')} | |
| className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| {t('tabs.close')} | |
| </button> | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => handleClosePreviewTabs('others')} | |
| className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| {t('tabs.closeOthers')} | |
| </button> | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => handleClosePreviewTabs('left')} | |
| className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| {t('tabs.closeLeft')} | |
| </button> | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => handleClosePreviewTabs('right')} | |
| className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| {t('tabs.closeRight')} | |
| </button> | |
| <div className="my-1 border-t border-[var(--color-border)]" /> | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => handleClosePreviewTabs('all')} | |
| className="block w-full px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| {t('tabs.closeAll')} | |
| </button> | |
| </div> | |
| )} | |
| </> | |
| ) | |
| return ( | |
| <aside | |
| data-testid="workspace-panel" | |
| className="flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]" | |
| style={{ width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }} | |
| > | |
| {hasPreviewTabs && ( | |
| <div className="flex min-w-0 flex-1 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]"> | |
| {renderPreviewTabs()} | |
| {renderPreviewContent()} | |
| </div> | |
| )} | |
| <div | |
| className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-[var(--color-surface)]`} | |
| > | |
| <div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] px-2.5"> | |
| <div className="relative min-w-0"> | |
| <button | |
| type="button" | |
| aria-label={activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')} | |
| aria-haspopup="menu" | |
| aria-expanded={isViewMenuOpen} | |
| onClick={() => setIsViewMenuOpen((open) => !open)} | |
| className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[7px] px-2 py-1 text-[14px] font-semibold leading-5 text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35" | |
| > | |
| <span className="truncate"> | |
| {activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')} | |
| </span> | |
| <span className="material-symbols-outlined shrink-0 text-[15px] font-normal text-[var(--color-text-tertiary)]">expand_more</span> | |
| </button> | |
| {isViewMenuOpen && ( | |
| <div | |
| role="menu" | |
| className="absolute left-0 top-[calc(100%+4px)] z-30 min-w-[124px] overflow-hidden rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]" | |
| > | |
| {(['changed', 'all'] as const).map((view) => { | |
| const selected = activeView === view | |
| return ( | |
| <button | |
| key={view} | |
| type="button" | |
| role="menuitem" | |
| onClick={() => handleSetActiveView(view)} | |
| className={`flex h-7 w-full items-center gap-2 px-2.5 text-left text-[12px] transition-colors ${ | |
| selected ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| > | |
| <span className="min-w-0 flex-1 truncate"> | |
| {view === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')} | |
| </span> | |
| {selected && ( | |
| <span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">check</span> | |
| )} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| <div className="ml-auto flex shrink-0 items-center gap-1"> | |
| <ToolbarIconButton | |
| icon="refresh" | |
| label={t('workspace.refresh')} | |
| onClick={handleRefresh} | |
| /> | |
| <ToolbarIconButton | |
| icon="close" | |
| label={t('workspace.closePanel')} | |
| onClick={() => closePanel(sessionId)} | |
| /> | |
| </div> | |
| </div> | |
| <WorkspaceFilterInput value={filterQuery} onChange={setFilterQuery} /> | |
| <div className="min-h-0 flex-1 overflow-auto py-2"> | |
| {activeView === 'changed' ? renderChangedView() : renderAllFilesView()} | |
| </div> | |
| </div> | |
| {fileContextMenu && ( | |
| <div | |
| role="menu" | |
| className="fixed z-50 min-w-[156px] rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 text-[12px] shadow-[var(--shadow-dropdown)]" | |
| style={{ left: fileContextMenu.x, top: fileContextMenu.y }} | |
| onClick={(event) => event.stopPropagation()} | |
| > | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => { | |
| addFileToChat(fileContextMenu.path) | |
| setFileContextMenu(null) | |
| }} | |
| className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| <span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">person_add</span> | |
| <span>{t('workspace.addToChat')}</span> | |
| </button> | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => void copyWorkspacePath(fileContextMenu.path)} | |
| className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]" | |
| > | |
| <span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">content_copy</span> | |
| <span>{t('workspace.copyPath')}</span> | |
| </button> | |
| </div> | |
| )} | |
| </aside> | |
| ) | |
| } | |