diff --git a/src/App.test.tsx b/src/App.test.tsx index 1f4891c..da65d67 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -3,6 +3,18 @@ import { describe, expect, it } from "vitest"; import { App } from "./App"; describe("Teamserver 工作区", () => { + it("从 Rail 添加并保存内存 Teamserver 配置", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "添加 Teamserver" })); + const dialog = screen.getByRole("dialog", { name: "添加 Teamserver" }); + fireEvent.change(screen.getByLabelText("名称"), { target: { value: "Delta Lab" } }); + fireEvent.change(screen.getByLabelText("地址"), { target: { value: "https://delta.example" } }); + fireEvent.click(screen.getByRole("button", { name: "保存到内存" })); + expect(await screen.findByRole("button", { name: "切换到 Delta Lab" })).toBeInTheDocument(); + expect(screen.queryByRole("dialog", { name: "添加 Teamserver" })).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("尚未建立真实连接"); + expect(dialog).not.toBeInTheDocument(); + }); it("禁用桌面应用内容区域的原生右键菜单", () => { render(); const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); diff --git a/src/App.tsx b/src/App.tsx index afcfb7e..e83a1d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,16 @@ import { useEffect, useReducer, useState, type ReactNode } from "react"; -import { ConnectionCenter, MemoryMockConnectionAdapter } from "./features/connections"; +import { blankConnection, ConnectionCenter, ConnectionEditor, MemoryMockConnectionAdapter } from "./features/connections"; import { ArtifactsPage, AuditPage, EventsPage, OverviewPage, SessionDetailPage, TasksPage } from "./features/workbench"; import { createSessionTableViewState, SessionQueryIntent, SessionTable } from "./SessionTable"; import { demoSessions } from "./protocol/fixtures"; import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState"; import { toSessionRow } from "./sessionPresentation"; import { WorkspaceLayout } from "./shell"; +import { Dialog, Toast } from "./ui"; import { createInitialWorkspaceState, selectActiveTab, selectActiveWorkspace, workspaceReducer } from "./workspace"; const fixtureNow = new Date("2026-08-03T19:12:02Z"); -const sessions = demoSessions.map((session) => toSessionRow(session, fixtureNow)); +const sessions = demoSessions.map((session, index) => ({ ...toSessionRow(session, fixtureNow), capabilities: index === 0 ? ["session.processes", "session.files"] : [] })); const connectionAdapter = new MemoryMockConnectionAdapter(); export function App() { @@ -17,6 +18,9 @@ export function App() { const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState); const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder)); const [lastQueryIntent, setLastQueryIntent] = useState>({}); + const [addOpen, setAddOpen] = useState(false); + const [connectionAddresses, setConnectionAddresses] = useState([]); + const [notice, setNotice] = useState(""); const server = selectActiveWorkspace(workspace); const activeTab = selectActiveTab(server); const tableState = { ...(tableStates[server.id] ?? createSessionTableViewState()), filter: server.filter }; const navigate = (page: string) => { @@ -24,7 +28,8 @@ export function App() { dispatch({ type: "navigate", serverId: server.id, project: server.activeProject, navigation: page }); dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.activeProject.toLowerCase()}-${page.toLowerCase().replaceAll(" ", "-")}`, title, context: { serverId: server.id, project: server.activeProject, session: "Project" } } }); }; - const sessionsPage = <>

Sessions

{activeTab.context.project} 中的 248 个 Session · 4 个实时更新

演示数据 · 内存 adapter · 未连接真实 Teamserver

{ if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter }); setTableStates((current) => updateSessionTableState(current, server.id, state)); }} onOpenSession={(session) => dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.id}-${activeTab.context.project}-${session.id}-overview`, title: "Session Overview", context: { serverId: server.id, project: activeTab.context.project, session: session.id } } })} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}; + const sessionsPage = <>

Sessions

{activeTab.context.project} 中的 248 个 Session · 4 个实时更新

演示数据 · 内存 adapter · 未连接真实 Teamserver

{ if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter }); setTableStates((current) => updateSessionTableState(current, server.id, state)); }} onOpenSession={(session) => dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.id}-${activeTab.context.project}-${session.id}-overview`, title: "Session Overview", context: { serverId: server.id, project: activeTab.context.project, session: session.id } } })} onOpenOperation={(session, operation) => dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.id}-${activeTab.context.project}-${session.id}-${operation}`, title: operation === "processes" ? "进程" : operation === "files" ? "文件" : "打开操作", context: { serverId: server.id, project: activeTab.context.project, session: session.id }, dirty: operation === "operation" } })} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}; const pages: Record = { Overview: , Sessions: sessionsPage, "Session Overview": , Tasks: , "Task Output": , Events: , Artifacts: , Audit: , "连接中心": }; - return workspace.servers[id])} server={server} activeTab={activeTab} activePage={server.navigation} onNavigate={navigate} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })} onCloseTab={(tabId) => dispatch({ type: "closeTab", serverId: server.id, tabId })} onTogglePin={(tabId) => dispatch({ type: "togglePinTab", serverId: server.id, tabId })}>
{pages[activeTab.title] ?? }
; + const operationBoundary = ["进程", "文件", "打开操作"].includes(activeTab.title) ?

{activeTab.title}

{activeTab.context.serverId} / {activeTab.context.project} / {activeTab.context.session}

Mock 界面边界:需要真实 Teamserver adapter 才能执行或读取数据。

: undefined; + return <> workspace.servers[id])} server={server} activeTab={activeTab} activePage={server.navigation} onNavigate={navigate} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onAddServer={() => setAddOpen(true)} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })} onCloseTab={(tabId) => dispatch({ type: "closeTab", serverId: server.id, tabId })} onTogglePin={(tabId) => dispatch({ type: "togglePinTab", serverId: server.id, tabId })}>
{pages[activeTab.title] ?? operationBoundary ?? }
setAddOpen(false)} onSave={async (config) => { await connectionAdapter.save(config); setConnectionAddresses((current) => [...current, config.address.trim()]); const tab = { id: `${config.id}-connections`, title: "连接中心", context: { serverId: config.id, project: "尚未选择 Project", session: "Project" } }; dispatch({ type: "addServer", server: { id: config.id, name: config.name, shortName: config.name.trim().slice(0, 2).toUpperCase(), connection: "DEGRADED", unread: 0, activeProject: "尚未选择 Project", navigation: "连接中心", filter: "", activeTabId: tab.id, tabs: [tab] } }); setAddOpen(false); setNotice("连接配置已创建,尚未建立真实连接。"); }} />{notice && setNotice("")}>{notice}}; } diff --git a/src/SessionTable.test.tsx b/src/SessionTable.test.tsx index ff50857..a41d442 100644 --- a/src/SessionTable.test.tsx +++ b/src/SessionTable.test.tsx @@ -4,13 +4,13 @@ import { describe, expect, it, vi } from "vitest"; import { createSessionTableViewState, SessionQueryIntent, SessionRow, SessionTable } from "./SessionTable"; const rows: SessionRow[] = [ - { id: "S-1", status: "ONLINE", host: "alpha", user: "one", os: "Linux", address: "10.0.0.1", seen: "1m", tags: ["a"] }, + { id: "S-1", status: "ONLINE", host: "alpha", user: "one", os: "Linux", address: "10.0.0.1", seen: "1m", tags: ["a"], capabilities: ["session.processes", "session.files"] }, { id: "S-2", status: "OFFLINE", host: "beta", user: "two", os: "Windows", address: "10.0.0.2", seen: "2m", tags: ["b"] }, ]; -function Harness({ onIntent = () => undefined, onOpenSession }: { onIntent?: (intent: SessionQueryIntent) => void; onOpenSession?: (session: SessionRow) => void }) { +function Harness({ onIntent = () => undefined, onOpenSession, onOpenOperation }: { onIntent?: (intent: SessionQueryIntent) => void; onOpenSession?: (session: SessionRow) => void; onOpenOperation?: (session: SessionRow, operation: "processes" | "files" | "operation") => void }) { const [state, setState] = useState(createSessionTableViewState); - return ; + return ; } describe("SessionTable", () => { @@ -72,4 +72,19 @@ describe("SessionTable", () => { expect(onOpenSession).toHaveBeenNthCalledWith(1, rows[0]); expect(onOpenSession).toHaveBeenNthCalledWith(2, rows[0]); }); + it("右键按桌面语义选择并按 Capability 显示操作", () => { + const onOpenOperation = vi.fn(); render(); + fireEvent.contextMenu(screen.getByRole("row", { name: /S-1/ }), { clientX: 20, clientY: 20 }); + expect(screen.getByRole("row", { name: /S-1/ })).toHaveAttribute("aria-selected", "true"); + fireEvent.click(screen.getByRole("menuitem", { name: "查看进程" })); + expect(onOpenOperation).toHaveBeenCalledWith(rows[0], "processes"); + fireEvent.contextMenu(screen.getByRole("row", { name: /S-2/ })); + expect(screen.queryByRole("menuitem", { name: "查看进程" })).not.toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "浏览文件" })).not.toBeInTheDocument(); + }); + it("右键菜单支持方向键、Home、End 与 Escape 焦点恢复", () => { + render(); const row = screen.getByRole("row", { name: /S-1/ }); row.focus(); fireEvent.contextMenu(row); + const menu = screen.getByRole("menu"); fireEvent.keyDown(menu, { key: "End" }); expect(screen.getByRole("menuitem", { name: "复制 Session ID" })).toHaveFocus(); + fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(row).toHaveFocus(); + }); }); diff --git a/src/SessionTable.tsx b/src/SessionTable.tsx index 92de4c4..e54c665 100644 --- a/src/SessionTable.tsx +++ b/src/SessionTable.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, KeyboardEvent, MouseEvent, useRef } from "react"; +import { CSSProperties, KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react"; export type SessionStatus = "ONLINE" | "IDLE" | "OFFLINE" | (string & {}); @@ -11,6 +11,7 @@ export interface SessionRow { address: string; seen: string; tags: string[]; + capabilities?: string[]; } export type SessionColumnId = "status" | "id" | "identity" | "os" | "address" | "seen" | "tags"; @@ -38,6 +39,7 @@ export interface SessionTableProps { onStateChange: (state: SessionTableViewState) => void; onQueryIntent: (intent: SessionQueryIntent) => void; onOpenSession?: (session: SessionRow) => void; + onOpenOperation?: (session: SessionRow, operation: "processes" | "files" | "operation") => void; } export interface SessionTableCapabilities { @@ -65,10 +67,15 @@ function nextSort(state: SessionTableViewState, column: SessionColumnId): SortDi return state.sort?.column === column && state.sort.direction === "ascending" ? "descending" : "ascending"; } -export function SessionTable({ rows, state, capabilities, page, onStateChange, onQueryIntent, onOpenSession }: SessionTableProps) { +type SessionMenuState = { session: SessionRow; x: number; y: number; returnFocus: HTMLElement | null }; + +export function SessionTable({ rows, state, capabilities, page, onStateChange, onQueryIntent, onOpenSession, onOpenOperation }: SessionTableProps) { const rowRefs = useRef(new Map()); + const menuRef = useRef(null); + const [menu, setMenu] = useState(); const selected = new Set(state.selectedSessionIds); const visible = new Set(state.visibleColumns); + useEffect(() => { if (!menu) return; menuRef.current?.querySelector('button:not(:disabled)')?.focus(); const close = (event: globalThis.KeyboardEvent | PointerEvent) => { if (event.type === "keydown" && (event as globalThis.KeyboardEvent).key !== "Escape") return; if (event.type === "pointerdown" && menuRef.current?.contains(event.target as Node)) return; setMenu(undefined); menu.returnFocus?.focus(); }; document.addEventListener("keydown", close); document.addEventListener("pointerdown", close); return () => { document.removeEventListener("keydown", close); document.removeEventListener("pointerdown", close); }; }, [menu]); const selectRow = (id: string, index: number, modifiers: { additive: boolean; range: boolean }) => { if (modifiers.range) { const anchorIndex = Math.max(0, rows.findIndex((row) => row.id === (state.selectionAnchorId ?? id))); @@ -89,6 +96,7 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o if ((event.target as HTMLElement).closest("button, input, select, a")) return; selectRow(id, index, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey }); }; + const openMenu = (event: MouseEvent, index: number, session: SessionRow) => { event.preventDefault(); if (!selected.has(session.id)) selectRow(session.id, index, { additive: false, range: false }); setMenu({ session, x: Math.min(event.clientX, window.innerWidth - 230), y: Math.min(event.clientY, window.innerHeight - 270), returnFocus: event.currentTarget }); }; const focusRow = (index: number) => { const row = rows[Math.max(0, Math.min(rows.length - 1, index))]; @@ -143,7 +151,7 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o })} -
{rows.map((session, index) =>
{ if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onClick={(event) => handleRowClick(event, index, session.id)} onDoubleClick={() => onOpenSession?.(session)} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}> +
{rows.map((session, index) =>
{ if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onClick={(event) => handleRowClick(event, index, session.id)} onContextMenu={(event) => openMenu(event, index, session)} onDoubleClick={() => onOpenSession?.(session)} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}> {visible.has("status") &&