feat: wire file explorer to real backend API

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 11:19:30 +08:00
co-authored by Claude
parent 867f1d204e
commit d587c94f50
7 changed files with 282 additions and 139 deletions
+18 -8
View File
@@ -8,23 +8,33 @@ import { TerminalPanel } from "@/components/terminal/TerminalPanel";
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel"; import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
import { StatusBar } from "@/components/workspace/StatusBar"; import { StatusBar } from "@/components/workspace/StatusBar";
import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector"; import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector";
import { inferLanguage } from "@/lib/api/files";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store"; import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { import type { MockFileNode } from "@/pages/workspace/mock-data";
findFileByPath,
type MockFileNode,
} from "@/pages/workspace/mock-data";
interface WorkspaceShellProps { interface WorkspaceShellProps {
files: MockFileNode[]; workspaceId: string;
} }
export function WorkspaceShell({ files }: WorkspaceShellProps) { function fileNodeFromPath(path: string): MockFileNode {
const name = path.split("/").pop() ?? path;
return {
name,
path,
kind: "file",
language: inferLanguage(name),
};
}
export function WorkspaceShell({ workspaceId }: WorkspaceShellProps) {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath); const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed); const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible); const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
const toggleSidebar = useWorkspaceUiStore((s) => s.toggleSidebar); const toggleSidebar = useWorkspaceUiStore((s) => s.toggleSidebar);
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal); const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
const activeFile = findFileByPath(files, activeFilePath); const activeFile = activeFilePath
? fileNodeFromPath(activeFilePath)
: undefined;
return ( return (
<div className="flex h-full w-full flex-col bg-background text-foreground"> <div className="flex h-full w-full flex-col bg-background text-foreground">
@@ -65,7 +75,7 @@ export function WorkspaceShell({ files }: WorkspaceShellProps) {
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<> <>
<Panel defaultSize={18} minSize={12} maxSize={30}> <Panel defaultSize={18} minSize={12} maxSize={30}>
<FileExplorerPanel files={files} /> <FileExplorerPanel workspaceId={workspaceId} />
</Panel> </Panel>
<PanelResizeHandle className="w-1 bg-border transition-colors hover:bg-accent" /> <PanelResizeHandle className="w-1 bg-border transition-colors hover:bg-accent" />
</> </>
@@ -1,78 +1,155 @@
import { ChevronRight, FileCode2, Folder } from "lucide-react"; import { ChevronRight, FileCode2, Folder } from "lucide-react";
import type { ReactNode } from "react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useFileList, type FileEntry } from "@/lib/api/files";
import { useFileTreeStore } from "@/lib/store/file-tree-store";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store"; import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MockFileNode } from "@/pages/workspace/mock-data";
interface FileExplorerPanelProps { interface FileExplorerPanelProps {
files: MockFileNode[]; workspaceId: string | null;
} }
interface FileTreeNodeProps { interface FileTreeDirectoryProps {
node: MockFileNode; workspaceId: string;
entry: FileEntry;
depth: number; depth: number;
activeFilePath: string;
onSelectFile: (path: string) => void; onSelectFile: (path: string) => void;
} }
function FileTreeNode({ interface FileTreeFileProps {
node, entry: FileEntry;
depth, depth: number;
activeFilePath, onSelectFile: (path: string) => void;
onSelectFile, }
}: FileTreeNodeProps) {
const isFile = node.kind === "file"; function FileTreeFile({ entry, depth, onSelectFile }: FileTreeFileProps) {
const isActive = isFile && node.path === activeFilePath; const selectedPath = useFileTreeStore((s) => s.selectedPath);
const isSelected = selectedPath === entry.path;
const paddingLeft = `${depth * 0.875 + 0.75}rem`; const paddingLeft = `${depth * 0.875 + 0.75}rem`;
return ( return (
<li> <li>
<button <button
type="button" type="button"
disabled={!isFile} onClick={() => onSelectFile(entry.path)}
onClick={() => isFile && onSelectFile(node.path)}
className={cn( className={cn(
"flex h-7 w-full items-center gap-1.5 truncate px-2 text-left text-xs text-muted-foreground transition-colors", "flex h-7 w-full items-center gap-1.5 truncate px-2 text-left text-xs transition-colors",
isFile && "hover:bg-accent hover:text-accent-foreground", "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
!isFile && "cursor-default text-foreground", isSelected && "bg-accent text-accent-foreground",
isActive && "bg-accent text-accent-foreground",
)} )}
style={{ paddingLeft }} style={{ paddingLeft }}
> >
{isFile ? ( <FileCode2 className="size-3.5 shrink-0" aria-hidden="true" />
<FileCode2 className="size-3.5 shrink-0" aria-hidden="true" /> <span className="truncate">{entry.name}</span>
) : (
<>
<ChevronRight
className="size-3 shrink-0 rotate-90"
aria-hidden="true"
/>
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
</>
)}
<span className="truncate">{node.name}</span>
</button> </button>
{node.children?.length ? (
<ul>
{node.children.map((child) => (
<FileTreeNode
key={child.path}
node={child}
depth={depth + 1}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
/>
))}
</ul>
) : null}
</li> </li>
); );
} }
export function FileExplorerPanel({ files }: FileExplorerPanelProps) { function FileTreeDirectory({
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath); workspaceId,
entry,
depth,
onSelectFile,
}: FileTreeDirectoryProps) {
const expanded = useFileTreeStore((s) => s.expandedPaths.has(entry.path));
const toggleExpanded = useFileTreeStore((s) => s.toggleExpanded);
const { data, isLoading, error } = useFileList(
workspaceId,
entry.path,
expanded,
);
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
return (
<li>
<button
type="button"
onClick={() => toggleExpanded(entry.path)}
className={cn(
"flex h-7 w-full items-center gap-1.5 truncate px-2 text-left text-xs transition-colors",
"text-foreground hover:bg-accent hover:text-accent-foreground",
)}
style={{ paddingLeft }}
>
<ChevronRight
className={cn(
"size-3 shrink-0 transition-transform",
expanded && "rotate-90",
)}
aria-hidden="true"
/>
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{entry.name}</span>
</button>
{expanded && (
<ul>
{isLoading && (
<li className="px-3 py-1 text-xs text-muted-foreground">
Loading
</li>
)}
{error && (
<li className="px-3 py-1 text-xs text-destructive">
{error.message}
</li>
)}
{data?.map((child) =>
child.isDir ? (
<FileTreeDirectory
key={child.path}
workspaceId={workspaceId}
entry={child}
depth={depth + 1}
onSelectFile={onSelectFile}
/>
) : (
<FileTreeFile
key={child.path}
entry={child}
depth={depth + 1}
onSelectFile={onSelectFile}
/>
),
)}
</ul>
)}
</li>
);
}
function EmptyState({ children }: { children: ReactNode }) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4 text-center text-xs text-muted-foreground">
{children}
</div>
);
}
export function FileExplorerPanel({ workspaceId }: FileExplorerPanelProps) {
const setActiveFilePath = useWorkspaceUiStore((s) => s.setActiveFilePath); const setActiveFilePath = useWorkspaceUiStore((s) => s.setActiveFilePath);
const setSelectedPath = useFileTreeStore((s) => s.setSelectedPath);
const { data, isLoading, error } = useFileList(workspaceId, ".");
const onSelectFile = (path: string) => {
setSelectedPath(path);
setActiveFilePath(path);
};
if (!workspaceId) {
return (
<aside className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
<div className="flex h-9 shrink-0 items-center border-b border-sidebar-border px-3 text-[11px] font-semibold uppercase text-muted-foreground">
Explorer
</div>
<EmptyState>
<p>No workspace selected</p>
</EmptyState>
</aside>
);
}
return ( return (
<aside className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground"> <aside className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
@@ -82,15 +159,34 @@ export function FileExplorerPanel({ files }: FileExplorerPanelProps) {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<nav className="py-2" aria-label="Workspace files"> <nav className="py-2" aria-label="Workspace files">
<ul> <ul>
{files.map((node) => ( {isLoading && (
<FileTreeNode <li className="px-3 py-2 text-xs text-muted-foreground">
key={node.path} Loading files
node={node} </li>
depth={0} )}
activeFilePath={activeFilePath} {error && (
onSelectFile={setActiveFilePath} <li className="px-3 py-2 text-xs text-destructive">
/> {error.message}
))} </li>
)}
{data?.map((entry) =>
entry.isDir ? (
<FileTreeDirectory
key={entry.path}
workspaceId={workspaceId}
entry={entry}
depth={0}
onSelectFile={onSelectFile}
/>
) : (
<FileTreeFile
key={entry.path}
entry={entry}
depth={0}
onSelectFile={onSelectFile}
/>
),
)}
</ul> </ul>
</nav> </nav>
</ScrollArea> </ScrollArea>
+64
View File
@@ -0,0 +1,64 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
export interface FileEntry {
name: string;
path: string;
isDir: boolean;
size?: number;
modTime?: string;
language?: string;
}
const LANGUAGE_BY_EXTENSION: Record<string, string> = {
css: "css",
go: "go",
html: "html",
js: "javascript",
jsx: "javascript",
json: "json",
md: "markdown",
py: "python",
ts: "typescript",
tsx: "typescript",
yaml: "yaml",
yml: "yaml",
};
export function inferLanguage(name: string): string | undefined {
const ext = name.split(".").pop()?.toLowerCase();
return ext ? LANGUAGE_BY_EXTENSION[ext] : undefined;
}
function fileListQueryKey(workspaceId: string, path: string) {
return ["workspaces", workspaceId, "files", path || "."] as const;
}
async function fetchFileList(
workspaceId: string,
path: string,
): Promise<FileEntry[]> {
const queryPath = path || ".";
const url =
`/api/workspaces/${encodeURIComponent(workspaceId)}/files?` +
new URLSearchParams({ path: queryPath }).toString();
const res = await apiClient(url);
if (!res.ok) {
throw new Error(`Failed to list files: ${res.status}`);
}
return (await res.json()) as FileEntry[];
}
export function useFileList(
workspaceId: string | null,
path: string,
enabled = true,
): UseQueryResult<FileEntry[], Error> {
const queryPath = path || ".";
return useQuery({
queryKey: fileListQueryKey(workspaceId ?? "", queryPath),
queryFn: () => fetchFileList(workspaceId!, queryPath),
enabled: !!workspaceId && enabled,
});
}
+24
View File
@@ -0,0 +1,24 @@
import { create } from "zustand";
interface FileTreeState {
expandedPaths: Set<string>;
toggleExpanded: (path: string) => void;
selectedPath: string | null;
setSelectedPath: (path: string | null) => void;
}
export const useFileTreeStore = create<FileTreeState>((set) => ({
expandedPaths: new Set<string>(),
toggleExpanded: (path) =>
set((state) => {
const next = new Set(state.expandedPaths);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return { expandedPaths: next };
}),
selectedPath: null,
setSelectedPath: (path) => set({ selectedPath: path }),
}));
+1 -1
View File
@@ -12,7 +12,7 @@ interface WorkspaceUiState {
} }
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({ export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
activeFilePath: "src/main.tsx", activeFilePath: "",
terminalVisible: true, terminalVisible: true,
sidebarCollapsed: false, sidebarCollapsed: false,
currentWorkspaceId: null, currentWorkspaceId: null,
+23 -2
View File
@@ -1,6 +1,27 @@
import { mockWorkspaceFiles } from "@/pages/workspace/mock-data"; import { useFileList } from "@/lib/api/files";
import { useWorkspaces } from "@/lib/api/workspaces";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { WorkspaceShell } from "@/components/layout/WorkspaceShell"; import { WorkspaceShell } from "@/components/layout/WorkspaceShell";
export function WorkspacePage() { export function WorkspacePage() {
return <WorkspaceShell files={mockWorkspaceFiles} />; const currentWorkspaceId = useWorkspaceUiStore(
(s) => s.currentWorkspaceId,
);
// Keep workspace list fresh for the selector and prime the root file cache.
useWorkspaces();
useFileList(currentWorkspaceId ?? null, ".");
if (!currentWorkspaceId) {
return (
<main className="flex h-full w-full items-center justify-center bg-background text-foreground">
<div className="text-center text-sm text-muted-foreground">
<p className="font-medium">No workspace selected</p>
<p className="mt-1">Select or create a workspace to get started.</p>
</div>
</main>
);
}
return <WorkspaceShell workspaceId={currentWorkspaceId} />;
} }
+1 -73
View File
@@ -1,80 +1,8 @@
export type MockFileNode = { export interface MockFileNode {
name: string; name: string;
path: string; path: string;
kind: "file" | "dir"; kind: "file" | "dir";
language?: string; language?: string;
content?: string; content?: string;
children?: MockFileNode[]; children?: MockFileNode[];
};
const file = (
name: string,
path: string,
language: string,
content: string,
): MockFileNode => ({ name, path, kind: "file", language, content });
const dir = (
name: string,
path: string,
children: MockFileNode[],
): MockFileNode => ({ name, path, kind: "dir", children });
export const mockWorkspaceFiles: MockFileNode[] = [
file("package.json", "package.json", "json", `{
"name": "codespace",
"version": "0.0.0"
}
`),
file("README.md", "README.md", "markdown", `# Codespace
A self-hosted development environment.
`),
dir("src", "src", [
file(
"main.tsx",
"src/main.tsx",
"typescript",
`import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "@/app/App";
import "@/styles/index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
`,
),
]),
dir("internal", "internal", [
dir("api", "internal/api", [
file(
"router.go",
"internal/api/router.go",
"go",
`package api
// Router is reserved for future backend routes.
type Router struct{}
`,
),
]),
]),
];
/** Depth-first search for a node with the given slash-delimited path. */
export function findFileByPath(
files: MockFileNode[],
path: string,
): MockFileNode | undefined {
for (const node of files) {
if (node.path === path) return node;
if (node.children) {
const found = findFileByPath(node.children, path);
if (found) return found;
}
}
return undefined;
} }