refactor: split FileBrowserPane / Workspace / runAssistantTurn

- FileBrowserPane.tsx: deleted (unreferenced post-v1.4 PaneTab.tsx removal;
  the legacy file_browser pane kind isn't part of the active WorkspacePane
  taxonomy).
- Workspace.tsx (524 -> 172 lines): extracted useWorkspacePanes(sessionId)
  and useSessionChats(sessionId) hooks. Workspace is layout-only composition
  now. localStorage key + WS frame handling + drag semantics unchanged.
- inference.ts runAssistantTurn (~265 -> 48 lines): bundled args into
  TurnArgs interface, extracted executeStreamPhase / executeToolPhase /
  finalizeCompletion / handleAbortOrError. All WS publish ordering preserved
  byte-for-byte (mentally traced for tool / non-tool / abort / error /
  depth-exceeded paths). flushPromise chain + setImmediate + signal
  propagation unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 04:35:20 +00:00
parent 59fe6f0522
commit 9436a81b5f
5 changed files with 779 additions and 1421 deletions

View File

@@ -1,865 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { api, ApiError } from '@/api/client';
import type {
FileBrowserPaneState,
FileEntry,
Pane,
ViewFileResult,
} from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane & { kind: 'file_browser' };
projectId: string;
onStateChange: (state: FileBrowserPaneState) => void;
}
const SHIKI_THEME = 'github-dark';
function splitShikiLines(html: string): string[] {
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
if (!match) return [];
const inner = match[1]!;
const lines = inner.split(/(?=<span class="line">)/);
return lines.filter(l => l.trim().length > 0);
}
interface FileViewerProps {
code: string;
lang: string | null;
selectedLines: Set<number>;
onLineClick: (lineNo: number, shiftKey: boolean) => void;
}
function FileViewer({ code, lang, selectedLines, onLineClick }: FileViewerProps) {
const [copied, setCopied] = useState(false);
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
if (!lang) {
setLineHtmls(null);
return;
}
(async () => {
try {
const result = await codeToHtml(code, { lang, theme: SHIKI_THEME });
if (cancelled) return;
const lines = splitShikiLines(result);
setLineHtmls(lines.length > 0 ? lines : null);
} catch (err) {
console.warn('shiki failed', err);
if (!cancelled) setLineHtmls(null);
}
})();
return () => {
cancelled = true;
};
}, [code, lang]);
async function copy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* ignore */
}
}
const plainLines = code.split('\n');
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
return (
<div className="text-sm font-mono">
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span>
<button
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<div className="overflow-x-auto">
{Array.from({ length: totalLines }, (_, i) => {
const lineNo = i + 1;
const isSelected = selectedLines.has(lineNo);
return (
<div
key={lineNo}
className={cn(
'flex',
isSelected && 'bg-blue-500/10'
)}
>
<button
type="button"
className="shrink-0 w-[3ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
style={{ fontVariantNumeric: 'tabular-nums' }}
onClick={(e) => onLineClick(lineNo, e.shiftKey)}
>
{lineNo}
</button>
{lineHtmls ? (
<div
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
// eslint-disable-next-line react/no-danger -- Shiki generates sanitized HTML spans, not user content
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
/>
) : (
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
{plainLines[i] ?? ''}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function joinPath(parent: string, name: string): string {
if (!parent || parent === '.' || parent === '') return name;
return `${parent}/${name}`;
}
interface TreeNodeProps {
parentPath: string; // '' for root children
entries: FileEntry[];
cache: Map<string, FileEntry[]>;
expanded: Set<string>;
openFile: string | null;
highlightedPath: string | null;
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
setHighlightedPath: (p: string) => void;
}
function TreeNode({
parentPath,
entries,
cache,
expanded,
openFile,
highlightedPath,
depth,
onToggleDir,
onSelectFile,
setHighlightedPath,
}: TreeNodeProps) {
// Sort: dirs first, then files; alphabetical within each.
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return copy;
}, [entries]);
return (
<ul className="list-none">
{sorted.map((entry) => {
const fullPath = joinPath(parentPath, entry.name);
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
const isActive = entry.kind === 'file' && openFile === fullPath;
const isHighlight = highlightedPath === fullPath;
return (
<li key={fullPath}>
<div
data-path={fullPath}
data-kind={entry.kind}
className={cn(
'flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
style={{ paddingLeft: 4 + depth * 12 }}
onClick={() => {
setHighlightedPath(fullPath);
if (entry.kind === 'dir') {
onToggleDir(fullPath);
} else {
onSelectFile(fullPath);
}
}}
>
{entry.kind === 'dir' ? (
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
className="p-0.5 hover:bg-muted rounded shrink-0"
onClick={(e) => {
e.stopPropagation();
setHighlightedPath(fullPath);
onToggleDir(fullPath);
}}
>
{isExpanded ? (
<ChevronDown size={10} />
) : (
<ChevronRight size={10} />
)}
</button>
) : (
<span className="w-[16px] shrink-0" />
)}
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.name}</span>
</div>
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
<TreeNode
parentPath={fullPath}
entries={cache.get(fullPath) ?? []}
cache={cache}
expanded={expanded}
openFile={openFile}
highlightedPath={highlightedPath}
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</li>
);
})}
</ul>
);
}
export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
const openFile = pane.state.open_file ?? null;
const filter = pane.state.filter ?? '';
const expandedDirs = useMemo(
() => pane.state.expanded_dirs ?? [],
[pane.state.expanded_dirs]
);
// Local filter (debounced 100ms before pushing to onStateChange)
const [filterDraft, setFilterDraft] = useState(filter);
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track previous external filter so we can sync local draft when the
// canonical state changes from outside (e.g. server snapshot, other tab).
const lastExternalFilter = useRef(filter);
useEffect(() => {
if (filter !== lastExternalFilter.current) {
lastExternalFilter.current = filter;
setFilterDraft(filter);
}
}, [filter]);
function onFilterInput(value: string) {
setFilterDraft(value);
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
filterDebounceRef.current = setTimeout(() => {
filterDebounceRef.current = null;
lastExternalFilter.current = value;
onStateChange({
...pane.state,
filter: value,
open_file: openFile,
expanded_dirs: expandedDirs,
});
}, 100);
}
useEffect(() => {
return () => {
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
};
}, []);
// Full file list fetched once on mount for filter mode (covers unexpanded dirs)
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api.projects.files(projectId);
if (!cancelled) setFullFileList(result.files);
} catch {
// Silently ignore; filter will fall back to cache-based list
}
})();
return () => {
cancelled = true;
};
// Intentionally run once per mount (projectId is stable per pane)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Directory cache: dirPath -> entries
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const [dirErrors, setDirErrors] = useState<Map<string, string>>(new Map());
const loadDir = useCallback(
async (dirPath: string) => {
// dirPath '' is root; server expects '.'
const apiPath = dirPath === '' ? '.' : dirPath;
setLoadingDirs((prev) => {
if (prev.has(dirPath)) return prev;
const next = new Set(prev);
next.add(dirPath);
return next;
});
try {
const result = await api.projects.listDir(projectId, apiPath);
setCache((prev) => {
const next = new Map(prev);
next.set(dirPath, result.entries);
return next;
});
setDirErrors((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Map(prev);
next.delete(dirPath);
return next;
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'failed to list directory';
setDirErrors((prev) => {
const next = new Map(prev);
next.set(dirPath, msg);
return next;
});
} finally {
setLoadingDirs((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Set(prev);
next.delete(dirPath);
return next;
});
}
},
[projectId]
);
// Load root on mount + any expanded dirs from server state.
useEffect(() => {
if (!cache.has('')) {
void loadDir('');
}
for (const dir of expandedDirs) {
if (!cache.has(dir)) {
void loadDir(dir);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// When expandedDirs grows (e.g. user expands), ensure new dir is loaded.
useEffect(() => {
for (const dir of expandedDirs) {
if (!cache.has(dir) && !loadingDirs.has(dir)) {
void loadDir(dir);
}
}
}, [expandedDirs, cache, loadingDirs, loadDir]);
const expandedSet = useMemo(() => new Set(expandedDirs), [expandedDirs]);
function toggleDir(dirPath: string) {
let nextDirs: string[];
if (expandedSet.has(dirPath)) {
nextDirs = expandedDirs.filter((d) => d !== dirPath);
} else {
nextDirs = [...expandedDirs, dirPath];
}
onStateChange({
...pane.state,
open_file: openFile,
filter: filterDraft,
expanded_dirs: nextDirs,
});
}
function selectFile(path: string) {
onStateChange({
...pane.state,
open_file: path,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
function closeOpenFile() {
onStateChange({
...pane.state,
open_file: null,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
// Build a flat list of all entries reachable through the loaded cache,
// for filter results and keyboard navigation.
interface FlatEntry {
path: string;
name: string;
kind: 'file' | 'dir';
}
const flattenedVisible = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
const sorted = [...entries].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const e of sorted) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir' && expandedSet.has(full)) {
walk(full);
}
}
}
walk('');
return result;
}, [cache, expandedSet]);
const flattenedAll = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
for (const e of entries) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir') walk(full);
}
}
walk('');
return result;
}, [cache]);
const trimmedFilter = filterDraft.trim();
const filterActive = trimmedFilter.length > 0;
interface FilterResult {
path: string;
name: string;
}
const filterResults = useMemo<FilterResult[]>(() => {
if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase();
if (fullFileList !== null) {
// Use complete file list from API; rank filename matches above path-only matches
const filenameMatches: string[] = [];
const pathOnlyMatches: string[] = [];
for (const p of fullFileList) {
const lp = p.toLowerCase();
if (!lp.includes(needle)) continue;
const bn = basename(p).toLowerCase();
if (bn.includes(needle)) {
filenameMatches.push(p);
} else {
pathOnlyMatches.push(p);
}
}
filenameMatches.sort((a, b) => a.localeCompare(b));
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
return [...filenameMatches, ...pathOnlyMatches]
.slice(0, 50)
.map((p) => ({ path: p, name: basename(p) }));
}
// Fallback: use cache-based flat list (only loaded directories, files only)
return flattenedAll
.filter((e) => e.kind === 'file' && e.path.toLowerCase().includes(needle))
.slice(0, 50)
.map((e) => ({ path: e.path, name: e.name }));
}, [filterActive, trimmedFilter, fullFileList, flattenedAll]);
// Keyboard navigation
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
const treeRef = useRef<HTMLDivElement | null>(null);
// Reset highlight if it falls out of the current list (e.g. when filter
// changes or dirs collapse).
useEffect(() => {
if (!highlightedPath) return;
const list = filterActive ? filterResults : flattenedVisible;
if (!list.some((e) => e.path === highlightedPath)) {
setHighlightedPath(null);
}
}, [highlightedPath, filterActive, filterResults, flattenedVisible]);
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (filterActive) {
if (filterResults.length === 0) return;
const idx = highlightedPath
? filterResults.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(filterResults.length - 1, idx + 1);
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = filterResults[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = filterResults[idx];
if (!target) return;
e.preventDefault();
// Filter results are always files (API returns only files)
selectFile(target.path);
}
return;
}
// Tree mode: use flattenedVisible which has kind info
const list = flattenedVisible;
if (list.length === 0) return;
const idx = highlightedPath
? list.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1);
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = list[idx];
if (!target) return;
e.preventDefault();
if (target.kind === 'dir') {
toggleDir(target.path);
} else {
selectFile(target.path);
}
}
}
// Line selection state
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [selectionAnchor, setSelectionAnchor] = useState<number | null>(null);
function handleLineClick(lineNo: number, shiftKey: boolean) {
if (shiftKey && selectionAnchor !== null) {
const start = Math.min(selectionAnchor, lineNo);
const end = Math.max(selectionAnchor, lineNo);
const range = new Set<number>();
for (let i = start; i <= end; i++) range.add(i);
setSelectedLines(range);
} else {
setSelectedLines(prev => {
const next = new Set(prev);
if (next.has(lineNo)) {
next.delete(lineNo);
} else {
next.add(lineNo);
}
return next;
});
setSelectionAnchor(lineNo);
}
}
// Viewer state
const [viewer, setViewer] = useState<{
path: string;
state: 'loading' | 'ready' | 'error';
result?: ViewFileResult;
error?: string;
} | null>(null);
useEffect(() => {
if (!openFile) {
setViewer(null);
return;
}
let cancelled = false;
setViewer({ path: openFile, state: 'loading' });
(async () => {
try {
const result = await api.projects.viewFile(projectId, openFile);
if (cancelled) return;
setViewer({ path: openFile, state: 'ready', result });
} catch (err) {
if (cancelled) return;
let message: string;
if (err instanceof ApiError) {
const apiMsg =
typeof err.body === 'object' &&
err.body !== null &&
'error' in err.body
? String((err.body as { error: unknown }).error)
: err.message;
if (err.status === 404) {
message = 'File not found';
} else if (apiMsg.toLowerCase().includes('too large')) {
message = 'File too large to view';
} else if (
apiMsg.toLowerCase().includes('outside') ||
apiMsg.toLowerCase().includes('not a file') ||
apiMsg.toLowerCase().includes('path')
) {
message = 'Cannot view files outside project';
} else {
message = apiMsg;
}
} else if (err instanceof Error) {
message = err.message;
} else {
message = 'Failed to load file';
}
setViewer({ path: openFile, state: 'error', error: message });
}
})();
return () => {
cancelled = true;
};
}, [openFile, projectId]);
// Clear line selection when open file changes
useEffect(() => {
setSelectedLines(new Set());
setSelectionAnchor(null);
}, [openFile]);
// Compute selection range for the floating action bar (loop avoids call-stack limit on spread)
let selectionMin = 0;
let selectionMax = 0;
if (selectedLines.size > 0) {
for (const n of selectedLines) {
if (selectionMin === 0 || n < selectionMin) selectionMin = n;
if (n > selectionMax) selectionMax = n;
}
}
function handleAttachLines() {
if (!openFile || !viewer?.result || selectedLines.size === 0) return;
const min = selectionMin;
const max = selectionMax;
const selectedContent = viewer.result.content
.split('\n')
.slice(min - 1, max)
.join('\n');
sessionEvents.emit({
type: 'attach_chat_file',
attachment: {
kind: 'lines',
filename: openFile,
language: inferLanguage(openFile) ?? null,
content: selectedContent,
range: [min, max],
source: 'line-select',
},
});
setSelectedLines(new Set());
setSelectionAnchor(null);
}
// Root errors / loading
const rootEntries = cache.get('');
const rootLoading = loadingDirs.has('') && !rootEntries;
const rootError = dirErrors.get('');
return (
<div className="flex flex-col h-full min-h-0">
<div className="px-2 py-1.5 border-b border-border bg-muted/20">
<input
type="text"
value={filterDraft}
onChange={(e) => onFilterInput(e.target.value)}
placeholder="Filter files..."
className="w-full px-2 py-1 text-xs bg-background border border-border rounded outline-none focus:border-ring"
aria-label="Filter files"
/>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[minmax(0,260px)_1fr]">
<div
ref={treeRef}
tabIndex={0}
onKeyDown={onTreeKeyDown}
className="overflow-y-auto border-r border-border outline-none focus:ring-1 focus:ring-inset focus:ring-ring/40"
role="tree"
aria-label="Project files"
>
{rootLoading && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{rootError && (
<div className="text-xs text-destructive px-2 py-1.5">
{rootError}
</div>
)}
{!rootLoading && !rootError && filterActive && (
<ul className="list-none">
{filterResults.length === 0 ? (
<li className="text-xs text-muted-foreground px-2 py-1.5">
No matches
</li>
) : (
filterResults.map((entry) => {
const isActive = openFile === entry.path;
const isHighlight = highlightedPath === entry.path;
return (
<li key={entry.path}>
<div
className={cn(
'flex items-center gap-1 px-2 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
onClick={() => {
setHighlightedPath(entry.path);
selectFile(entry.path);
}}
>
<FileText size={12} className="text-muted-foreground shrink-0" />
<span className="truncate">
<span className="font-bold">{entry.name}</span>
<span className="text-muted-foreground ml-1">{entry.path}</span>
</span>
</div>
</li>
);
})
)}
</ul>
)}
{!rootLoading && !rootError && !filterActive && rootEntries && (
<TreeNode
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedSet}
openFile={openFile}
highlightedPath={highlightedPath}
depth={0}
onToggleDir={toggleDir}
onSelectFile={selectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</div>
<div className="flex flex-col min-h-0">
{!openFile && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Select a file to view
</div>
)}
{openFile && (
<>
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<span
className="text-xs font-mono truncate"
title={openFile}
>
{basename(openFile)}
</span>
<button
type="button"
onClick={closeOpenFile}
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Close file"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto relative">
{viewer?.state === 'loading' && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{viewer?.state === 'error' && (
<div className="text-xs text-destructive px-2 py-1.5">
{viewer.error}
</div>
)}
{viewer?.state === 'ready' && viewer.result && (
<div className="p-2">
{selectedLines.size > 0 && (
<div className="sticky top-0 z-10 bg-muted border-b border-border flex items-center justify-between px-2 py-1 mb-2 rounded-t">
<span className="text-xs text-muted-foreground">
{selectedLines.size === 1
? `Attach line ${selectionMin} to chat`
: `Attach lines ${selectionMin}${selectionMax} to chat`}
</span>
<button
type="button"
className="text-xs font-medium text-primary hover:underline"
onClick={handleAttachLines}
>
Attach
</button>
</div>
)}
{viewer.result.truncated && (
<div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border">
Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total.
</div>
)}
<FileViewer
code={viewer.result.content}
lang={inferLanguage(openFile)}
selectedLines={selectedLines}
onLineClick={handleLineClick}
/>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}