frontend: build three-pane workspace shell with collapsible panels and synced path state

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-10 17:45:38 +08:00
co-authored by Cursor
parent 097cc510a7
commit 81e71837bf
14 changed files with 525 additions and 53 deletions
+113
View File
@@ -0,0 +1,113 @@
import { PanelBottom, PanelLeft, PanelRight } from "lucide-react"
import { Button } from "~/components/ui/button"
import { Separator } from "~/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"
import type { WorkspaceSession } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
// 三个可折叠面板的显隐状态,由 WorkspaceShell 持有。
export interface PanelVisibility {
tree: boolean
changes: boolean
console: boolean
}
/**
* 顶部上下文栏:展示固定的 Server / Collection / Workspace 上下文,
* 以及面板折叠开关与重新连接入口,不承载对象级操作。
*/
export function TopBar({
session,
panels,
onTogglePanel,
onReconnect,
}: {
session: WorkspaceSession
panels: PanelVisibility
onTogglePanel: (panel: keyof PanelVisibility) => void
onReconnect: () => void
}) {
return (
<header className="flex h-12 shrink-0 items-center justify-between gap-3 border-b bg-background px-3">
<div className="flex min-w-0 items-center gap-2 text-sm">
<span className="shrink-0 font-semibold">macTFS</span>
<Separator orientation="vertical" className="h-4!" />
<span className="hidden shrink-0 text-xs text-muted-foreground lg:inline">
服务器
</span>
<span className="hidden max-w-56 truncate font-mono text-xs lg:inline">
{session.serverUri}
</span>
<Separator orientation="vertical" className="hidden h-4! lg:block" />
<span className="shrink-0 text-xs text-muted-foreground">Collection</span>
<span className="max-w-40 truncate text-xs font-medium">{session.collection}</span>
<Separator orientation="vertical" className="h-4!" />
<span className="shrink-0 text-xs text-muted-foreground">Workspace</span>
<span className="max-w-44 truncate font-mono text-xs">{session.workspace}</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<PanelToggle
label="源码目录面板"
active={panels.tree}
onClick={() => onTogglePanel("tree")}
>
<PanelLeft />
</PanelToggle>
<PanelToggle
label="挂起更改面板"
active={panels.changes}
onClick={() => onTogglePanel("changes")}
>
<PanelRight />
</PanelToggle>
<PanelToggle
label="操作日志面板"
active={panels.console}
onClick={() => onTogglePanel("console")}
>
<PanelBottom />
</PanelToggle>
<Separator orientation="vertical" className="mx-1 h-4!" />
<Button size="sm" variant="outline" onClick={onReconnect}>
重新连接
</Button>
</div>
</header>
)
}
/**
* 面板折叠开关按钮:图标 + tooltip,激活态用底色区分。
*/
function PanelToggle({
label,
active,
onClick,
children,
}: {
label: string
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
aria-pressed={active}
onClick={onClick}
className={cn(!active && "text-muted-foreground")}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{active ? `收起${label}` : `展开${label}`}
</TooltipContent>
</Tooltip>
)
}
+50 -37
View File
@@ -1,52 +1,65 @@
import { Button } from "~/components/ui/button"
import { useCallback, useState } from "react"
import { TopBar, type PanelVisibility } from "~/components/app/top-bar"
import { FolderItemsPanel } from "~/components/explorer/folder-items-panel"
import { SourceTreePanel } from "~/components/explorer/source-tree-panel"
import { ChangesPanel } from "~/components/inspector/changes-panel"
import { ConsolePanel } from "~/components/logs/console-panel"
import { TooltipProvider } from "~/components/ui/tooltip"
import type { WorkspaceSession } from "~/lib/tfs/session"
/**
* 工作台占位外壳:展示已固定的 Collection / Workspace 上下文,三栏布局在 FE-004 实现。
* 三栏工作台外壳:顶部上下文栏 + 左侧目录树 + 中间主工作区 + 右侧 Changes + 底部 Console,
* 左、右、底面板支持收起;当前路径由上层 home.tsx 统一维护并下发。
*/
export function WorkspaceShell({
session,
selectedServerPath,
onNavigate,
onReconnect,
}: {
session: WorkspaceSession
selectedServerPath: string
onNavigate: (serverPath: string) => void
onReconnect: () => void
}) {
return (
<div className="flex min-h-svh flex-col bg-muted/40">
<header className="flex h-14 shrink-0 items-center justify-between border-b bg-background px-4">
<div className="flex min-w-0 items-center gap-2 text-sm">
<span className="font-semibold">macTFS</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">服务器</span>
<span className="truncate font-mono text-xs">{session.serverUri}</span>
</div>
<Button size="sm" variant="outline" onClick={onReconnect}>
重新连接
</Button>
</header>
const [panels, setPanels] = useState<PanelVisibility>({
tree: true,
changes: true,
console: true,
})
<main className="flex flex-1 items-center justify-center p-6">
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-sm">
<p className="text-sm font-medium">工作台上下文已固定</p>
<dl className="mt-4 space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="text-muted-foreground">Collection</dt>
<dd className="truncate font-medium">{session.collection}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-muted-foreground">当前 Workspace</dt>
<dd className="truncate font-mono text-xs">{session.workspace}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-muted-foreground">映射数</dt>
<dd className="font-medium">{session.mappings.length}</dd>
</div>
</dl>
<p className="mt-4 text-sm text-muted-foreground">
三栏工作台布局、目录树与文件列表将在后续任务实现。
</p>
/**
* 切换指定面板的展开 / 收起状态。
*/
const togglePanel = useCallback((panel: keyof PanelVisibility) => {
setPanels((prev) => ({ ...prev, [panel]: !prev[panel] }))
}, [])
return (
<TooltipProvider>
<div className="flex h-svh flex-col overflow-hidden bg-background">
<TopBar
session={session}
panels={panels}
onTogglePanel={togglePanel}
onReconnect={onReconnect}
/>
<div className="flex min-h-0 flex-1">
{panels.tree && (
<SourceTreePanel
collection={session.collection}
selectedServerPath={selectedServerPath}
onNavigate={onNavigate}
/>
)}
<FolderItemsPanel session={session} selectedServerPath={selectedServerPath} />
{panels.changes && <ChangesPanel />}
</div>
</main>
</div>
{panels.console && <ConsolePanel />}
</div>
</TooltipProvider>
)
}
@@ -0,0 +1,66 @@
import { FolderOpen } from "lucide-react"
import type { WorkspaceSession } from "~/lib/tfs/session"
/**
* 按最长前缀匹配查找服务端路径所属的 Mapping,未命中返回 null。
*/
function findMapping(session: WorkspaceSession, serverPath: string) {
let matched: WorkspaceSession["mappings"][number] | null = null
for (const mapping of session.mappings) {
if (
serverPath === mapping.serverPath ||
serverPath.startsWith(`${mapping.serverPath}/`)
) {
if (!matched || mapping.serverPath.length > matched.serverPath.length) {
matched = mapping
}
}
}
return matched
}
/**
* 中间主工作区骨架:顶部展示当前路径与 Mapping 摘要,主体为文件列表占位,
* 真实目录数据加载与表格在 FE-005 实现。
*/
export function FolderItemsPanel({
session,
selectedServerPath,
}: {
session: WorkspaceSession
selectedServerPath: string
}) {
const mapping = findMapping(session, selectedServerPath)
return (
<section className="flex h-full min-w-0 flex-1 flex-col bg-background">
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<span className="shrink-0 text-xs text-muted-foreground">当前路径</span>
<span className="min-w-0 truncate font-mono text-xs">{selectedServerPath}</span>
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
{mapping ? (
<span className="font-mono">{mapping.localPath}</span>
) : (
"未映射到本地"
)}
</span>
</div>
<div className="flex flex-1 items-center justify-center p-6">
<div className="flex flex-col items-center gap-2 text-center">
<FolderOpen className="size-8 text-muted-foreground/60" />
<p className="text-sm text-muted-foreground">
当前目录文件列表将在后续任务加载
</p>
<p className="font-mono text-xs text-muted-foreground/80">
{selectedServerPath}
</p>
</div>
</div>
<div className="flex h-8 shrink-0 items-center border-t px-3 text-xs text-muted-foreground">
未选中对象
</div>
</section>
)
}
@@ -0,0 +1,49 @@
import { FolderClosed } from "lucide-react"
import { ScrollArea } from "~/components/ui/scroll-area"
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
import { cn } from "~/lib/utils"
/**
* 左侧 Source Tree 面板骨架:展示固定 Collection 的根节点并与中间列表共享路径状态,
* 目录树懒加载与子节点展开在 FE-005 实现。
*/
export function SourceTreePanel({
collection,
selectedServerPath,
onNavigate,
}: {
collection: string
selectedServerPath: string
onNavigate: (serverPath: string) => void
}) {
const rootActive = selectedServerPath === SERVER_ROOT_PATH
return (
<aside className="flex h-full w-[280px] shrink-0 flex-col border-r bg-sidebar">
<div className="flex h-9 shrink-0 items-center justify-between border-b px-3">
<span className="text-xs font-medium text-muted-foreground">源码目录</span>
<span className="max-w-32 truncate text-xs text-muted-foreground">{collection}</span>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
<button
type="button"
onClick={() => onNavigate(SERVER_ROOT_PATH)}
className={cn(
"flex h-7 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm",
rootActive
? "bg-primary/10 font-medium text-foreground"
: "text-foreground hover:bg-muted",
)}
>
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-xs">{SERVER_ROOT_PATH}</span>
</button>
<p className="px-2 py-3 text-xs leading-5 text-muted-foreground">
服务端目录树将在后续任务加载,当前仅提供根节点导航。
</p>
</div>
</ScrollArea>
</aside>
)
}
@@ -0,0 +1,22 @@
import { ListChecks } from "lucide-react"
/**
* 右侧 Changes 面板骨架:Pending Changes 列表与 Checkin 入口在 FE-010 实现。
*/
export function ChangesPanel() {
return (
<aside className="flex h-full w-[340px] shrink-0 flex-col border-l bg-sidebar">
<div className="flex h-9 shrink-0 items-center border-b px-3">
<span className="text-xs font-medium text-muted-foreground">挂起更改</span>
</div>
<div className="flex flex-1 items-center justify-center p-4">
<div className="flex flex-col items-center gap-2 text-center">
<ListChecks className="size-6 text-muted-foreground/60" />
<p className="text-xs leading-5 text-muted-foreground">
挂起更改与签入将在后续任务实现
</p>
</div>
</div>
</aside>
)
}
@@ -0,0 +1,15 @@
/**
* 底部 Operation Console 骨架:操作日志列表与执行反馈在 FE-012 实现。
*/
export function ConsolePanel() {
return (
<footer className="flex h-[180px] shrink-0 flex-col border-t bg-background">
<div className="flex h-8 shrink-0 items-center border-b px-3">
<span className="text-xs font-medium text-muted-foreground">操作日志</span>
</div>
<div className="flex flex-1 items-center justify-center">
<p className="text-xs text-muted-foreground">操作日志将在后续任务展示</p>
</div>
</footer>
)
}
@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "~/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+3
View File
@@ -1,5 +1,8 @@
import type { MappingInfo } from "~/lib/api"
// 服务端目录树根路径,工作台导航的默认起点。
export const SERVER_ROOT_PATH = "$/"
// 工作台进入后固定的共享上下文,serverUri / collection / workspace 不再在台内切换。
export interface WorkspaceSession {
serverUri: string
+20 -3
View File
@@ -5,6 +5,7 @@ import { WorkspaceShell } from "~/components/app/workspace-shell"
import { Button } from "~/components/ui/button"
import { getServiceStatus, isElectron, startService } from "~/lib/electron"
import type { ServiceStatus } from "~/lib/electron"
import { SERVER_ROOT_PATH } from "~/lib/tfs/session"
import type { WorkspaceSession } from "~/lib/tfs/session"
// 顶层视图状态:检测服务、服务未就绪、连接入口、已进入工作台。
@@ -12,12 +13,13 @@ type AppPhase = "checking" | "blocked" | "connect" | "workspace"
/**
* 第一版前端入口:先确保本地服务就绪,再走连接 / Collection / Workspace 流程,
* 进入工作台后持有固定上下文(FE-003)。
* 进入工作台后持有固定上下文,并统一维护左树与中间列表共享的当前路径(FE-004)。
*/
export default function Home() {
const [phase, setPhase] = useState<AppPhase>("checking")
const [status, setStatus] = useState<ServiceStatus | null>(null)
const [session, setSession] = useState<WorkspaceSession | null>(null)
const [selectedServerPath, setSelectedServerPath] = useState(SERVER_ROOT_PATH)
/**
* 检测本地服务,未就绪时在 Electron 环境下尝试自动拉起。
@@ -37,10 +39,11 @@ export default function Home() {
}, [bootstrap])
/**
* 连接流程完成后固定上下文并进入工作台。
* 连接流程完成后固定上下文并进入工作台,当前路径重置到根目录。
*/
const handleReady = useCallback((next: WorkspaceSession) => {
setSession(next)
setSelectedServerPath(SERVER_ROOT_PATH)
setPhase("workspace")
}, [])
@@ -52,8 +55,22 @@ export default function Home() {
setPhase("connect")
}, [])
/**
* 同步导航:左侧树与中间列表共用的当前服务端路径变更入口。
*/
const handleNavigate = useCallback((serverPath: string) => {
setSelectedServerPath(serverPath)
}, [])
if (phase === "workspace" && session) {
return <WorkspaceShell session={session} onReconnect={handleReconnect} />
return (
<WorkspaceShell
session={session}
selectedServerPath={selectedServerPath}
onNavigate={handleNavigate}
onReconnect={handleReconnect}
/>
)
}
if (phase === "connect") {