feat: add resizable workspace shell

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-02 18:15:52 +08:00
co-authored by Claude
parent 593d2aee5e
commit 0f634981b5
4 changed files with 253 additions and 25 deletions
@@ -0,0 +1,112 @@
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { PanelLeftClose, PanelLeftOpen, Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
import { StatusBar } from "@/components/workspace/StatusBar";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import {
findFileByPath,
type MockFileNode,
} from "@/pages/workspace/mock-data";
interface WorkspaceShellProps {
files: MockFileNode[];
}
export function WorkspaceShell({ files }: WorkspaceShellProps) {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
const toggleSidebar = useWorkspaceUiStore((s) => s.toggleSidebar);
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
const activeFile = findFileByPath(files, activeFilePath);
return (
<div className="flex h-full w-full flex-col bg-background text-foreground">
{/* Toolbar */}
<header className="flex h-9 shrink-0 items-center border-b border-border bg-sidebar px-3">
<h1 className="text-sm font-semibold">codespace</h1>
<Separator orientation="vertical" className="mx-3 h-4" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={toggleSidebar}
>
{sidebarCollapsed ? (
<PanelLeftOpen className="size-4" aria-hidden="true" />
) : (
<PanelLeftClose className="size-4" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={toggleTerminal}
>
<Terminal className="size-4" aria-hidden="true" />
</Button>
</div>
</header>
{/* Main layout */}
<div className="flex min-h-0 flex-1">
<PanelGroup direction="horizontal" className="flex-1">
{/* Sidebar */}
{!sidebarCollapsed && (
<>
<Panel defaultSize={18} minSize={12} maxSize={30}>
<FileExplorerPanel files={files} />
</Panel>
<PanelResizeHandle className="w-1 bg-border transition-colors hover:bg-accent" />
</>
)}
{/* Main content area */}
<Panel>
<PanelGroup direction="vertical">
{/* Editor placeholder */}
<Panel>
<div className="flex h-full w-full items-center justify-center border-r border-border text-sm text-muted-foreground">
<div className="text-center">
<p className="mb-2">Editor placeholder</p>
<div className="space-y-1 text-xs">
<p>
Active file:{" "}
<code className="text-foreground">{activeFilePath}</code>
</p>
<p className="text-muted-foreground">
{activeFile
? `${activeFile.name} | ${activeFile.language ?? "plain text"} | ${activeFile.kind}`
: "No matching mock file"}
</p>
</div>
</div>
</div>
</Panel>
{/* Terminal panel */}
{terminalVisible && (
<>
<PanelResizeHandle className="h-1 bg-border transition-colors hover:bg-accent" />
<Panel defaultSize={30} minSize={10} maxSize={70}>
<div className="flex h-full w-full items-center justify-center border-r border-border text-sm text-muted-foreground">
Terminal placeholder
</div>
</Panel>
</>
)}
</PanelGroup>
</Panel>
</PanelGroup>
</div>
{/* Status bar */}
<StatusBar activeFilePath={activeFilePath} />
</div>
);
}
@@ -0,0 +1,99 @@
import { ChevronRight, FileCode2, Folder } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { cn } from "@/lib/utils";
import type { MockFileNode } from "@/pages/workspace/mock-data";
interface FileExplorerPanelProps {
files: MockFileNode[];
}
interface FileTreeNodeProps {
node: MockFileNode;
depth: number;
activeFilePath: string;
onSelectFile: (path: string) => void;
}
function FileTreeNode({
node,
depth,
activeFilePath,
onSelectFile,
}: FileTreeNodeProps) {
const isFile = node.kind === "file";
const isActive = isFile && node.path === activeFilePath;
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
return (
<li>
<button
type="button"
disabled={!isFile}
onClick={() => isFile && onSelectFile(node.path)}
className={cn(
"flex h-7 w-full items-center gap-1.5 truncate px-2 text-left text-xs text-muted-foreground transition-colors",
isFile && "hover:bg-accent hover:text-accent-foreground",
!isFile && "cursor-default text-foreground",
isActive && "bg-accent text-accent-foreground",
)}
style={{ paddingLeft }}
>
{isFile ? (
<FileCode2 className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<>
<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>
{node.children?.length ? (
<ul>
{node.children.map((child) => (
<FileTreeNode
key={child.path}
node={child}
depth={depth + 1}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
/>
))}
</ul>
) : null}
</li>
);
}
export function FileExplorerPanel({ files }: FileExplorerPanelProps) {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const setActiveFilePath = useWorkspaceUiStore((s) => s.setActiveFilePath);
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>
<ScrollArea className="min-h-0 flex-1">
<nav className="py-2" aria-label="Workspace files">
<ul>
{files.map((node) => (
<FileTreeNode
key={node.path}
node={node}
depth={0}
activeFilePath={activeFilePath}
onSelectFile={setActiveFilePath}
/>
))}
</ul>
</nav>
</ScrollArea>
</aside>
);
}
@@ -0,0 +1,39 @@
import { Terminal, FileCode2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
interface StatusBarProps {
activeFilePath: string;
}
export function StatusBar({ activeFilePath }: StatusBarProps) {
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
return (
<footer className="flex h-6 shrink-0 items-center border-t border-border bg-sidebar px-3 text-[11px] text-muted-foreground">
<div className="flex items-center gap-2">
<FileCode2 className="size-3" aria-hidden="true" />
<span className="truncate max-w-48">{activeFilePath}</span>
</div>
<Separator orientation="vertical" className="mx-2 h-3" />
<span className="font-medium">codespace</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={toggleTerminal}
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-accent-foreground",
terminalVisible && "text-foreground",
)}
>
<Terminal className="size-3" aria-hidden="true" />
<span>Terminal</span>
<span>{terminalVisible ? "Visible" : "Hidden"}</span>
</button>
</div>
</footer>
);
}
+3 -25
View File
@@ -1,28 +1,6 @@
import { findFileByPath, mockWorkspaceFiles } from "@/pages/workspace/mock-data";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { mockWorkspaceFiles } from "@/pages/workspace/mock-data";
import { WorkspaceShell } from "@/components/layout/WorkspaceShell";
export function WorkspacePage() {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const activeFile = findFileByPath(mockWorkspaceFiles, activeFilePath);
return (
<div className="flex h-full flex-col bg-background text-foreground">
<header className="border-b border-border px-4 py-2">
<h1 className="text-sm font-semibold">Codespace Workspace</h1>
</header>
<div className="flex-1 overflow-auto p-4">
<p className="text-sm text-muted-foreground">
Active file: <code className="text-foreground">{activeFilePath}</code>
</p>
<p className="mt-2 text-sm text-muted-foreground">
Mock file match:{" "}
<code className="text-foreground">
{activeFile
? `${activeFile.name} (${activeFile.language ?? activeFile.kind})`
: "not found"}
</code>
</p>
</div>
</div>
);
return <WorkspaceShell files={mockWorkspaceFiles} />;
}