batch3 T7: pane components — PaneShell, ChatPane, FileBrowserPane, PaneTab, Workspace
- PaneShell: per-pane chrome (kind label + close) - ChatPane: extracts message+input rendering, subscribes to useSessionStream - FileBrowserPane: tree + filter (debounced 100ms) + inline viewer via Shiki - PaneTab: tab with kind icon + context menu (Split, Close, Close others, Close to right, Close all) via shadcn ContextMenu - Workspace: tab strip + pane grid (CSS grid repeat(N,1fr)), native HTML5 drag-to-reorder, "+" button (disabled at 5), subscribes to open_file_in_browser (focus existing file-browser pane or spawn one) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
39
apps/web/src/components/panes/ChatPane.tsx
Normal file
39
apps/web/src/components/panes/ChatPane.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||
import { MessageList } from '@/components/MessageList';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function ChatPane({ sessionId }: Props) {
|
||||
const stream = useSessionStream(sessionId);
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
|
||||
// Surface stream errors via toast — matches Session.tsx behavior.
|
||||
useEffect(() => {
|
||||
if (stream.error && stream.error !== lastErrorRef.current) {
|
||||
lastErrorRef.current = stream.error;
|
||||
toast.error(stream.error);
|
||||
}
|
||||
if (!stream.error) {
|
||||
lastErrorRef.current = null;
|
||||
}
|
||||
}, [stream.error]);
|
||||
|
||||
async function handleSend(content: string) {
|
||||
await api.messages.send(sessionId, content);
|
||||
}
|
||||
|
||||
const streaming = stream.messages.some((m) => m.status === 'streaming');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<MessageList messages={stream.messages} sessionId={sessionId} />
|
||||
<ChatInput disabled={streaming} onSend={handleSend} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
637
apps/web/src/components/panes/FileBrowserPane.tsx
Normal file
637
apps/web/src/components/panes/FileBrowserPane.tsx
Normal file
@@ -0,0 +1,637 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { ChevronRight, ChevronDown, FileText, Folder, X } from 'lucide-react';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import type {
|
||||
FileBrowserPaneState,
|
||||
FileEntry,
|
||||
Pane,
|
||||
ViewFileResult,
|
||||
} from '@/api/types';
|
||||
import { CodeBlock } from '@/components/CodeBlock';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
pane: Pane & { kind: 'file_browser' };
|
||||
projectId: string;
|
||||
onStateChange: (state: FileBrowserPaneState) => void;
|
||||
}
|
||||
|
||||
const LANG_BY_EXT: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
py: 'python',
|
||||
go: 'go',
|
||||
rs: 'rust',
|
||||
rb: 'ruby',
|
||||
java: 'java',
|
||||
c: 'c',
|
||||
h: 'c',
|
||||
cpp: 'cpp',
|
||||
hpp: 'cpp',
|
||||
cs: 'csharp',
|
||||
php: 'php',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
json: 'json',
|
||||
toml: 'toml',
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
sql: 'sql',
|
||||
dockerfile: 'dockerfile',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
};
|
||||
|
||||
function deriveLang(filePath: string): string | undefined {
|
||||
// basename
|
||||
const base = filePath.split('/').pop() ?? filePath;
|
||||
if (base.toLowerCase() === 'dockerfile') return 'dockerfile';
|
||||
const dot = base.lastIndexOf('.');
|
||||
if (dot < 0 || dot === base.length - 1) return undefined;
|
||||
const ext = base.slice(dot + 1).toLowerCase();
|
||||
return LANG_BY_EXT[ext];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
const filterResults = useMemo<FlatEntry[]>(() => {
|
||||
if (!filterActive) return [];
|
||||
const needle = trimmedFilter.toLowerCase();
|
||||
return flattenedAll.filter((e) => e.path.toLowerCase().includes(needle));
|
||||
}, [filterActive, trimmedFilter, 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>) {
|
||||
const list = filterActive ? filterResults : 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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 =
|
||||
entry.kind === 'file' && 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);
|
||||
if (entry.kind === 'dir') {
|
||||
toggleDir(entry.path);
|
||||
} else {
|
||||
selectFile(entry.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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.path}</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">
|
||||
{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">
|
||||
{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>
|
||||
)}
|
||||
<CodeBlock code={viewer.result.content} lang={deriveLang(openFile)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/components/panes/PaneShell.tsx
Normal file
31
apps/web/src/components/panes/PaneShell.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Pane } from '@/api/types';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
pane: Pane;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PaneShell({ pane, onClose, className, children }: Props) {
|
||||
const label = pane.kind === 'chat' ? 'Chat' : 'Files';
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full min-h-0 border-r border-border last:border-r-0', className)}>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-0.5 hover:bg-muted rounded"
|
||||
aria-label="Close pane"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user