Merge pull request 'GAZ-13: 修复桌面交互死角并统一 VS Code 风格视觉' (#15) from agent/docker/922af15f into main

Reviewed-on: #15
This commit is contained in:
dev 2026-08-03 20:22:11 +00:00
commit b23ee8e6d5
11 changed files with 94 additions and 27 deletions

View File

@ -3,6 +3,18 @@ import { describe, expect, it } from "vitest";
import { App } from "./App"; import { App } from "./App";
describe("Teamserver 工作区", () => { describe("Teamserver 工作区", () => {
it("从 Rail 添加并保存内存 Teamserver 配置", async () => {
render(<App />);
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("禁用桌面应用内容区域的原生右键菜单", () => { it("禁用桌面应用内容区域的原生右键菜单", () => {
render(<App />); render(<App />);
const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });

View File

@ -1,15 +1,16 @@
import { useEffect, useReducer, useState, type ReactNode } from "react"; 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 { ArtifactsPage, AuditPage, EventsPage, OverviewPage, SessionDetailPage, TasksPage } from "./features/workbench";
import { createSessionTableViewState, SessionQueryIntent, SessionTable } from "./SessionTable"; import { createSessionTableViewState, SessionQueryIntent, SessionTable } from "./SessionTable";
import { demoSessions } from "./protocol/fixtures"; import { demoSessions } from "./protocol/fixtures";
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState"; import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
import { toSessionRow } from "./sessionPresentation"; import { toSessionRow } from "./sessionPresentation";
import { WorkspaceLayout } from "./shell"; import { WorkspaceLayout } from "./shell";
import { Dialog, Toast } from "./ui";
import { createInitialWorkspaceState, selectActiveTab, selectActiveWorkspace, workspaceReducer } from "./workspace"; import { createInitialWorkspaceState, selectActiveTab, selectActiveWorkspace, workspaceReducer } from "./workspace";
const fixtureNow = new Date("2026-08-03T19:12:02Z"); 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(); const connectionAdapter = new MemoryMockConnectionAdapter();
export function App() { export function App() {
@ -17,6 +18,9 @@ export function App() {
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState); const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder)); const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({}); const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
const [addOpen, setAddOpen] = useState(false);
const [connectionAddresses, setConnectionAddresses] = useState<string[]>([]);
const [notice, setNotice] = useState("");
const server = selectActiveWorkspace(workspace); const activeTab = selectActiveTab(server); const server = selectActiveWorkspace(workspace); const activeTab = selectActiveTab(server);
const tableState = { ...(tableStates[server.id] ?? createSessionTableViewState()), filter: server.filter }; const tableState = { ...(tableStates[server.id] ?? createSessionTableViewState()), filter: server.filter };
const navigate = (page: string) => { const navigate = (page: string) => {
@ -24,7 +28,8 @@ export function App() {
dispatch({ type: "navigate", serverId: server.id, project: server.activeProject, navigation: page }); 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" } } }); 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 = <><div className="content-heading"><div><h2>Sessions</h2><p>{activeTab.context.project} 248 Session · 4 </p></div></div><p className="demo-boundary" role="status"> · adapter · Teamserver</p><SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => { 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 }))} /><output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output></>; const sessionsPage = <><div className="content-heading"><div><h2>Sessions</h2><p>{activeTab.context.project} 248 Session · 4 </p></div></div><p className="demo-boundary" role="status"> · adapter · Teamserver</p><SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => { 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 }))} /><output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output></>;
const pages: Record<string, ReactNode> = { Overview: <OverviewPage />, Sessions: sessionsPage, "Session Overview": <SessionDetailPage sessionId={activeTab.context.session} />, Tasks: <TasksPage />, "Task Output": <TasksPage />, Events: <EventsPage />, Artifacts: <ArtifactsPage />, Audit: <AuditPage />, "连接中心": <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> }; const pages: Record<string, ReactNode> = { Overview: <OverviewPage />, Sessions: sessionsPage, "Session Overview": <SessionDetailPage sessionId={activeTab.context.session} />, Tasks: <TasksPage />, "Task Output": <TasksPage />, Events: <EventsPage />, Artifacts: <ArtifactsPage />, Audit: <AuditPage />, "连接中心": <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> };
return <WorkspaceLayout servers={workspace.serverOrder.map((id) => 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 })}><article className="content" aria-label={`${activeTab.title} 页面`}>{pages[activeTab.title] ?? <OverviewPage />}</article></WorkspaceLayout>; const operationBoundary = ["进程", "文件", "打开操作"].includes(activeTab.title) ? <section className="operation-boundary"><h2>{activeTab.title}</h2><p>{activeTab.context.serverId} / {activeTab.context.project} / {activeTab.context.session}</p><p className="demo-boundary">Mock Teamserver adapter </p></section> : undefined;
return <><WorkspaceLayout servers={workspace.serverOrder.map((id) => 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 })}><article className="content" aria-label={`${activeTab.title} 页面`}>{pages[activeTab.title] ?? operationBoundary ?? <OverviewPage />}</article></WorkspaceLayout><Dialog open={addOpen} onOpenChange={setAddOpen} title="添加 Teamserver"><ConnectionEditor initial={blankConnection()} existingAddresses={connectionAddresses} developmentMode={import.meta.env.DEV} onCancel={() => 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("连接配置已创建,尚未建立真实连接。"); }} /></Dialog>{notice && <Toast tone="info" onDismiss={() => setNotice("")}>{notice}</Toast>}</>;
} }

View File

@ -4,13 +4,13 @@ import { describe, expect, it, vi } from "vitest";
import { createSessionTableViewState, SessionQueryIntent, SessionRow, SessionTable } from "./SessionTable"; import { createSessionTableViewState, SessionQueryIntent, SessionRow, SessionTable } from "./SessionTable";
const rows: SessionRow[] = [ 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"] }, { 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); const [state, setState] = useState(createSessionTableViewState);
return <SessionTable rows={rows} state={state} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 2, total: 200, hasPrevious: false, hasNext: true }} onStateChange={setState} onQueryIntent={onIntent} onOpenSession={onOpenSession} />; return <SessionTable rows={rows} state={state} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 2, total: 200, hasPrevious: false, hasNext: true }} onStateChange={setState} onQueryIntent={onIntent} onOpenSession={onOpenSession} onOpenOperation={onOpenOperation} />;
} }
describe("SessionTable", () => { describe("SessionTable", () => {
@ -72,4 +72,19 @@ describe("SessionTable", () => {
expect(onOpenSession).toHaveBeenNthCalledWith(1, rows[0]); expect(onOpenSession).toHaveBeenNthCalledWith(1, rows[0]);
expect(onOpenSession).toHaveBeenNthCalledWith(2, rows[0]); expect(onOpenSession).toHaveBeenNthCalledWith(2, rows[0]);
}); });
it("右键按桌面语义选择并按 Capability 显示操作", () => {
const onOpenOperation = vi.fn(); render(<Harness onOpenOperation={onOpenOperation} />);
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(<Harness />); 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();
});
}); });

View File

@ -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 & {}); export type SessionStatus = "ONLINE" | "IDLE" | "OFFLINE" | (string & {});
@ -11,6 +11,7 @@ export interface SessionRow {
address: string; address: string;
seen: string; seen: string;
tags: string[]; tags: string[];
capabilities?: string[];
} }
export type SessionColumnId = "status" | "id" | "identity" | "os" | "address" | "seen" | "tags"; export type SessionColumnId = "status" | "id" | "identity" | "os" | "address" | "seen" | "tags";
@ -38,6 +39,7 @@ export interface SessionTableProps {
onStateChange: (state: SessionTableViewState) => void; onStateChange: (state: SessionTableViewState) => void;
onQueryIntent: (intent: SessionQueryIntent) => void; onQueryIntent: (intent: SessionQueryIntent) => void;
onOpenSession?: (session: SessionRow) => void; onOpenSession?: (session: SessionRow) => void;
onOpenOperation?: (session: SessionRow, operation: "processes" | "files" | "operation") => void;
} }
export interface SessionTableCapabilities { 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"; 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<string, HTMLDivElement>()); const rowRefs = useRef(new Map<string, HTMLDivElement>());
const menuRef = useRef<HTMLDivElement>(null);
const [menu, setMenu] = useState<SessionMenuState>();
const selected = new Set(state.selectedSessionIds); const selected = new Set(state.selectedSessionIds);
const visible = new Set(state.visibleColumns); const visible = new Set(state.visibleColumns);
useEffect(() => { if (!menu) return; menuRef.current?.querySelector<HTMLButtonElement>('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 }) => { const selectRow = (id: string, index: number, modifiers: { additive: boolean; range: boolean }) => {
if (modifiers.range) { if (modifiers.range) {
const anchorIndex = Math.max(0, rows.findIndex((row) => row.id === (state.selectionAnchorId ?? id))); 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; if ((event.target as HTMLElement).closest("button, input, select, a")) return;
selectRow(id, index, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey }); selectRow(id, index, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey });
}; };
const openMenu = (event: MouseEvent<HTMLDivElement>, 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 focusRow = (index: number) => {
const row = rows[Math.max(0, Math.min(rows.length - 1, index))]; 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
})} })}
<span role="columnheader" /> <span role="columnheader" />
</div> </div>
</div><div role="rowgroup">{rows.map((session, index) => <div className={`table-row session-data-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} aria-label={`${session.id},双击或按 Enter 打开详情`} key={session.id} tabIndex={session.id === state.focusedSessionId || (!state.focusedSessionId && index === 0) ? 0 : -1} ref={(element) => { 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)}> </div><div role="rowgroup">{rows.map((session, index) => <div className={`table-row session-data-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} aria-label={`${session.id},双击或按 Enter 打开详情`} key={session.id} tabIndex={session.id === state.focusedSessionId || (!state.focusedSessionId && index === 0) ? 0 : -1} ref={(element) => { 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") && <span role="cell"><em className={`status-mark ${session.status.toLowerCase()}`} aria-hidden="true" /><span className="sr-only"></span>{session.status}</span>} {visible.has("status") && <span role="cell"><em className={`status-mark ${session.status.toLowerCase()}`} aria-hidden="true" /><span className="sr-only"></span>{session.status}</span>}
{visible.has("id") && <strong role="cell" className="mono">{session.id}</strong>} {visible.has("id") && <strong role="cell" className="mono">{session.id}</strong>}
{visible.has("identity") && <span role="cell"><b>{session.host}</b><small>{session.user}</small></span>} {visible.has("identity") && <span role="cell"><b>{session.host}</b><small>{session.user}</small></span>}
@ -154,7 +162,14 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
<span role="cell"><button className="row-action" aria-label={`${session.id} 快速操作`}></button></span> <span role="cell"><button className="row-action" aria-label={`${session.id} 快速操作`}></button></span>
</div>)} </div>)}
</div></div><footer><span aria-live="polite"> {state.selectedSessionIds.length} </span><span>{page.start}{page.end} / {page.total}</span><button aria-label="上一页" disabled={!capabilities.pagination || !page.hasPrevious} onClick={() => onQueryIntent({ type: "page", direction: "previous" })}></button><button aria-label="下一页" disabled={!capabilities.pagination || !page.hasNext} onClick={() => onQueryIntent({ type: "page", direction: "next" })}></button></footer> </div></div><footer><span aria-live="polite"> {state.selectedSessionIds.length} </span><span>{page.start}{page.end} / {page.total}</span><button aria-label="上一页" disabled={!capabilities.pagination || !page.hasPrevious} onClick={() => onQueryIntent({ type: "page", direction: "previous" })}></button><button aria-label="下一页" disabled={!capabilities.pagination || !page.hasNext} onClick={() => onQueryIntent({ type: "page", direction: "next" })}></button></footer>
</section> </section>{menu && <div ref={menuRef} className="session-context-menu" role="menu" aria-label={`${menu.session.id} 操作`} style={{ left: menu.x, top: menu.y }} onKeyDown={(event) => { const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')]; const current = items.indexOf(document.activeElement as HTMLButtonElement); const index = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : event.key === "ArrowDown" ? (current + 1) % items.length : event.key === "ArrowUp" ? (current - 1 + items.length) % items.length : -1; if (index >= 0) { event.preventDefault(); items[index]?.focus(); } }}>
<button role="menuitem" onClick={() => { onOpenSession?.(menu.session); setMenu(undefined); }}> Overview</button>
{menu.session.capabilities?.includes("session.processes") ? <button role="menuitem" onClick={() => { onOpenOperation?.(menu.session, "processes"); setMenu(undefined); }}></button> : null}
{menu.session.capabilities?.includes("session.files") ? <button role="menuitem" onClick={() => { onOpenOperation?.(menu.session, "files"); setMenu(undefined); }}></button> : null}
<button role="menuitem" onClick={() => { onOpenOperation?.(menu.session, "operation"); setMenu(undefined); }}></button>
<button role="menuitem" title="需要 Teamserver 分组 adapter" disabled></button>
<button role="menuitem" onClick={() => { void navigator.clipboard?.writeText(menu.session.id); setMenu(undefined); }}> Session ID</button>
</div>}
</> </>
); );
} }

View File

@ -11,10 +11,10 @@
.activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#0f1115 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 } .activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#0f1115 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 }
.activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#0f1115; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace } .activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#0f1115; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace }
.activity-danger { color:#a12323; border-color:#cf7777 } .activity-danger { color:#a12323; border-color:#cf7777 }
.activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#1118 }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:white;border-radius:10px;padding:20px;box-shadow:0 18px 60px #0004 }.activity-dialog footer { display:flex;justify-content:flex-end;gap:8px;margin-top:20px } .activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#080a0dcc }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:#171b21;border:1px solid #454e5a;border-radius:6px;padding:20px;box-shadow:0 18px 60px #0008 }.activity-dialog footer { display:flex;justify-content:flex-end;gap:8px;margin-top:20px }
.activity-filters { display:flex;align-items:end;gap:12px;flex-wrap:wrap }.activity-filters label { display:grid;gap:4px;font-size:12px }.activity-filters select { min-height:58px } .activity-filters { display:flex;align-items:end;gap:12px;flex-wrap:wrap }.activity-filters label { display:grid;gap:4px;font-size:12px }.activity-filters select { min-height:58px }
.activity-events { min-height:200px;border:1px solid var(--border);border-radius:5px;overflow:hidden }.activity-events__feed{max-height:420px;overflow:auto}.activity-event{width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#15191f;border:0;border-bottom:1px solid var(--border);color:inherit}.activity-event:hover,.activity-event--new{background:var(--selected)}.activity-event span{color:var(--muted)} .activity-events { min-height:200px;border:1px solid var(--border);border-radius:5px;overflow:hidden }.activity-events__feed{max-height:420px;overflow:auto}.activity-event{width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#15191f;border:0;border-bottom:1px solid var(--border);color:inherit}.activity-event:hover,.activity-event--new{background:var(--selected)}.activity-event span{color:var(--muted)}
.activity-banner { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border:1px solid #e2b75e;border-radius:7px;background:#fff8e5;color:#654b14 } .activity-banner { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border:1px solid #5d4b29;border-radius:5px;background:#292316;color:#e5c777 }
button,input,select { font:inherit }button { cursor:pointer;border:1px solid #bbc2ce;border-radius:5px;padding:5px 9px;background:#fff }button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:3px solid #477ee8;outline-offset:2px }button:disabled { opacity:.5;cursor:not-allowed } .activity-detail button,.activity-dialog button,.activity-output button,.activity-events button,.activity-banner button{font:inherit;cursor:pointer;border:1px solid var(--border);border-radius:4px;padding:5px 9px;background:#1b2027;color:var(--text)}.activity-detail button:disabled,.activity-dialog button:disabled,.activity-output button:disabled,.activity-events button:disabled{opacity:.5;cursor:not-allowed}
@media (max-width:700px) { .activity-list,.activity-events { overflow-x:auto }.activity-list__row,.activity-event { min-width:620px } } @media (max-width:700px) { .activity-list,.activity-events { overflow-x:auto }.activity-list__row,.activity-event { min-width:620px } }
@media (prefers-reduced-motion:reduce) { .activity-event--new { animation:none } } @media (prefers-reduced-motion:reduce) { .activity-event--new { animation:none } }

View File

@ -1,4 +1,5 @@
import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react"; import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
import { Button } from "../../ui";
import { ConnectionAdapter } from "./mockAdapter"; import { ConnectionAdapter } from "./mockAdapter";
import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model"; import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
@ -50,12 +51,14 @@ export function ReconnectPolicy({ config, onChange }: { config: ConnectionConfig
</Section>; </Section>;
} }
export function ConnectionEditor({ initial, developmentMode = false, onSave, onCancel }: { initial?: ConnectionConfig; developmentMode?: boolean; onSave: (config: ConnectionConfig) => void; onCancel: () => void }) { export function ConnectionEditor({ initial, developmentMode = false, existingAddresses = [], onSave, onCancel }: { initial?: ConnectionConfig; developmentMode?: boolean; existingAddresses?: string[]; onSave: (config: ConnectionConfig) => void | Promise<void>; onCancel: () => void }) {
const [config, setConfig] = useState(() => structuredClone(initial ?? blankConnection())); const [config, setConfig] = useState(() => structuredClone(initial ?? blankConnection()));
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const errors = useMemo(() => validateConnection(config, developmentMode), [config, developmentMode]); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const errors = useMemo(() => { const next = validateConnection(config, developmentMode); if (existingAddresses.some((address) => address === config.address.trim() && config.address.trim())) next.address = "该 Teamserver 地址已存在"; return next; }, [config, developmentMode, existingAddresses]);
const set = field(config, setConfig); const set = field(config, setConfig);
return <form className="connection-editor" onSubmit={(e) => { e.preventDefault(); setSubmitted(true); if (!Object.keys(errors).length) onSave(config); }}> return <form className="connection-editor" onSubmit={async (e) => { e.preventDefault(); setSubmitted(true); setSaveError(""); if (Object.keys(errors).length) return; setSaving(true); try { await onSave(config); } catch (error) { setSaveError(error instanceof Error ? error.message : "保存失败,请重试"); } finally { setSaving(false); } }}>
<div className="editor-grid"><Section title="基本设置"> <div className="editor-grid"><Section title="基本设置">
<label><input value={config.name} onChange={(e) => set("name", e.target.value)} />{submitted && errors.name && <small className="field-error">{errors.name}</small>}</label> <label><input value={config.name} onChange={(e) => set("name", e.target.value)} />{submitted && errors.name && <small className="field-error">{errors.name}</small>}</label>
<label><select value={config.transport} onChange={(e) => set("transport", e.target.value as ConnectionConfig["transport"])}><option value="grpc">gRPC</option><option value="websocket">WebSocket</option></select></label> <label><select value={config.transport} onChange={(e) => set("transport", e.target.value as ConnectionConfig["transport"])}><option value="grpc">gRPC</option><option value="websocket">WebSocket</option></select></label>
@ -63,8 +66,8 @@ export function ConnectionEditor({ initial, developmentMode = false, onSave, onC
<label><input type="number" value={config.timeoutSeconds} onChange={(e) => set("timeoutSeconds", Number(e.target.value))} /></label> <label><input type="number" value={config.timeoutSeconds} onChange={(e) => set("timeoutSeconds", Number(e.target.value))} /></label>
<label><input type="number" value={config.heartbeatSeconds} onChange={(e) => set("heartbeatSeconds", Number(e.target.value))} /></label> <label><input type="number" value={config.heartbeatSeconds} onChange={(e) => set("heartbeatSeconds", Number(e.target.value))} /></label>
</Section><TlsSettings config={config} onChange={setConfig} developmentMode={developmentMode} /><AuthSettings config={config} onChange={setConfig} /><ProxySettings config={config} onChange={setConfig} /><ReconnectPolicy config={config} onChange={setConfig} /></div> </Section><TlsSettings config={config} onChange={setConfig} developmentMode={developmentMode} /><AuthSettings config={config} onChange={setConfig} /><ProxySettings config={config} onChange={setConfig} /><ReconnectPolicy config={config} onChange={setConfig} /></div>
{submitted && Object.keys(errors).length > 0 && <p role="alert" className="form-error"> {Object.keys(errors).length} </p>} {submitted && Object.keys(errors).length > 0 && <p role="alert" className="form-error"> {Object.keys(errors).length} </p>}{saveError && <p role="alert" className="form-error">{saveError}</p>}
<footer><button type="button" className="secondary" onClick={onCancel}></button><button className="primary" type="submit"></button></footer> <footer><Button type="button" onClick={onCancel} disabled={saving}></Button><Button intent="primary" loading={saving} type="submit"></Button></footer>
</form>; </form>;
} }

View File

@ -1,8 +1,17 @@
import { Icon } from "../ui"; import { Icon, IconButton } from "../ui";
import type { OperationTab } from "../workspace"; import type { OperationTab } from "../workspace";
export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; onTogglePin?: (tabId: string) => void; } export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; onTogglePin?: (tabId: string) => void; }
export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose, onTogglePin }: OperationTabsProps) { export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose, onTogglePin }: OperationTabsProps) {
const active = tabs.find(({ id }) => id === activeTabId) ?? tabs[0]; return <div className="tabbar-wrap"><div className="tabbar" role="tablist" aria-label={`${serverName} 标签`} onKeyDown={(event) => {
return <div className="tabbar-wrap"><div className="tabbar" role="tablist" aria-label={`${serverName} 标签`} onKeyDown={(event) => { const current = tabs.findIndex(({ id }) => id === activeTabId); if (event.key === "ArrowRight") onActivate(tabs[(current + 1) % tabs.length].id); if (event.key === "ArrowLeft") onActivate(tabs[(current - 1 + tabs.length) % tabs.length].id); }}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeTabId} tabIndex={tab.id === activeTabId ? 0 : -1} className={tab.id === activeTabId ? "active" : ""} key={tab.id} onClick={() => onActivate(tab.id)} title={`${tab.context.project} / ${tab.context.session}`}><span className="tab-label">{tab.running && <span className="running" aria-label="任务运行中" />}{tab.pinned && <span aria-label="已固定"> </span>}{tab.title}</span></button>)}</div><div className="tab-controls" aria-label="当前标签操作">{onTogglePin && <button aria-label={`${active.pinned ? "取消固定" : "固定"} ${active.title}`} onClick={() => onTogglePin(active.id)}></button>}{onClose && !active.pinned && <button aria-label={`关闭 ${active.title}`} onClick={() => onClose(active.id)}><Icon name="close" size={12} /></button>}</div></div>; const current = tabs.findIndex(({ id }) => id === activeTabId);
if (event.key === "ArrowRight") onActivate(tabs[(current + 1) % tabs.length].id);
if (event.key === "ArrowLeft") onActivate(tabs[(current - 1 + tabs.length) % tabs.length].id);
}}>{tabs.map((tab) => <button role="tab" aria-label={tab.title} aria-selected={tab.id === activeTabId} tabIndex={tab.id === activeTabId ? 0 : -1} className={`operation-tab ${tab.id === activeTabId ? "active" : ""}`} key={tab.id} onClick={() => onActivate(tab.id)} title={`${tab.context.project} / ${tab.context.session}`}>
<span className="tab-label">{tab.running && <span className="running" aria-label="任务运行中" />}{tab.dirty && <span className="dirty" aria-label="有未提交更改"></span>}{tab.pinned && <span aria-label="已固定"></span>}{tab.title}</span>
</button>)}</div><div className="tab-actions-strip" aria-label="标签内操作">{tabs.map((tab) => <div className={`tab-actions ${tab.id === activeTabId ? "active" : ""}`} key={tab.id}>
{onTogglePin && <IconButton className="tab-action pin" label={`${tab.pinned ? "取消固定" : "固定"} ${tab.title}`} onClick={() => onTogglePin(tab.id)}></IconButton>}
{onClose && !tab.pinned && <IconButton className="tab-action close" label={`关闭 ${tab.title}`} onClick={() => onClose(tab.id)}><Icon name="close" size={12} /></IconButton>}
</div>)}</div></div>;
} }

View File

@ -6,5 +6,5 @@ import { ProjectSidebar } from "./ProjectSidebar";
import { StatusBar } from "./StatusBar"; import { StatusBar } from "./StatusBar";
import { TeamserverRail } from "./TeamserverRail"; import { TeamserverRail } from "./TeamserverRail";
export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; onCloseTab?: (id: string) => void; onTogglePin?: (id: string) => void; } export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onAddServer?: () => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; onCloseTab?: (id: string) => void; onTogglePin?: (id: string) => void; }
export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onActivateTab, onNavigate, onCloseTab, onTogglePin }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} /><ProjectSidebar server={server} activePage={activePage} onNavigate={onNavigate} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} onClose={onCloseTab} onTogglePin={onTogglePin} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; } export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onAddServer, onActivateTab, onNavigate, onCloseTab, onTogglePin }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} onAdd={onAddServer} /><ProjectSidebar server={server} activePage={activePage} onNavigate={onNavigate} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} onClose={onCloseTab} onTogglePin={onTogglePin} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; }

View File

@ -40,9 +40,11 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; } .nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; }
.memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; } .memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; }
.workspace { display: grid; grid-template-rows: 38px 42px 1fr 24px; min-width: 0; height: 100vh; background: var(--content); } .workspace { display: grid; grid-template-rows: 38px 42px 1fr 24px; min-width: 0; height: 100vh; background: var(--content); }
.tabbar-wrap { display:flex; min-width:0; background:#111419; border-bottom:1px solid var(--border); }.tabbar { display:flex; flex:1; overflow:hidden; } .tabbar-wrap { display:flex; min-width:0; background:#111419; border-bottom:1px solid var(--border); }.tabbar { display:flex; flex:1; overflow-x:auto; overflow-y:hidden; scrollbar-width:thin; }
.tabbar > button { min-width:132px;max-width:190px;border:0;border-right:1px solid var(--border);background:transparent;padding:0 11px;font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden }.tabbar > button:hover{background:var(--hover)}.tabbar > button.active{background:var(--content);color:var(--text);box-shadow:inset 0 2px var(--info)}.tab-controls{display:flex;border-left:1px solid var(--border)}.tab-controls button{width:30px;border:0;background:transparent;color:#8b94a2;padding:0}.tab-controls button:hover{background:var(--hover)}.tab-label{overflow:hidden;text-overflow:ellipsis} .operation-tab{position:relative;display:flex;flex:0 0 min(190px,22vw);min-width:132px;height:38px;border-right:1px solid var(--border);background:#111419}.operation-tab.active{background:var(--content);box-shadow:inset 0 2px var(--info)}.operation-tab>button[role=tab]{min-width:0;flex:1;border:0;border-radius:0;background:transparent;padding:0 5px 0 11px;color:var(--muted);white-space:nowrap;overflow:hidden}.operation-tab:hover{background:var(--hover)}.operation-tab.active>button[role=tab]{color:var(--text)}.operation-tab .tab-action{visibility:hidden;opacity:.55;flex:0 0 25px;width:25px;min-height:0;border:0;border-radius:0;background:transparent;padding:0}.operation-tab:hover .tab-action,.operation-tab.active .tab-action,.operation-tab .tab-action:focus-visible{visibility:visible}.operation-tab .tab-action:hover{opacity:1;background:#ffffff0b}.tab-label{display:flex;align-items:center;gap:6px;overflow:hidden;text-overflow:ellipsis}.dirty{font-size:8px;color:var(--text)}
.running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; } .running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; }
.operation-tab[role=tab]{align-items:center;padding-left:11px;color:var(--muted)}.operation-tab[role=tab].active{color:var(--text)}.operation-tab[role=tab]>.tab-label{flex:1;min-width:0}
.tabbar-wrap{position:relative}.tabbar>button.operation-tab{position:relative;display:flex;align-items:center;flex:0 0 min(190px,22vw);min-width:132px;height:38px;border:0;border-right:1px solid var(--border);border-radius:0;background:#111419;padding:0 55px 0 11px;color:var(--muted);white-space:nowrap;overflow:hidden}.tabbar>button.operation-tab.active{background:var(--content);color:var(--text);box-shadow:inset 0 2px var(--info)}.tab-actions-strip{position:absolute;inset:0 auto 0 0;display:flex;pointer-events:none}.tab-actions{display:flex;justify-content:flex-end;align-items:center;flex:0 0 min(190px,22vw);min-width:132px;padding-right:3px}.tab-actions .tab-action{pointer-events:auto;visibility:hidden;opacity:.6}.tab-actions.active .tab-action,.tab-actions:hover .tab-action,.tab-actions .tab-action:focus-visible{visibility:visible}.tab-actions .tab-action:hover{opacity:1}
.contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; } .contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; }
.contextbar i { color: #515967; font-style: normal; } .contextbar i { color: #515967; font-style: normal; }
.contextbar b { color: #cdd2da; font: 500 11px ui-monospace, "JetBrains Mono", monospace; } .contextbar b { color: #cdd2da; font: 500 11px ui-monospace, "JetBrains Mono", monospace; }
@ -73,6 +75,7 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.table-row.focused { background: #202a35; box-shadow: inset 2px 0 var(--info); } .table-row.focused { background: #202a35; box-shadow: inset 2px 0 var(--info); }
.table-row[aria-selected="true"] { background: #243346; box-shadow: inset 2px 0 var(--info); } .table-row[aria-selected="true"] { background: #243346; box-shadow: inset 2px 0 var(--info); }
.session-data-row { user-select: none; -webkit-user-select: none; } .session-data-row { user-select: none; -webkit-user-select: none; }
.session-context-menu{position:fixed;z-index:40;width:220px;padding:5px;border:1px solid #414956;background:#171b21;box-shadow:0 12px 34px #000a}.session-context-menu button{display:block;width:100%;height:29px;border:0;border-radius:2px;padding:0 9px;background:transparent;color:var(--text);text-align:left}.session-context-menu button:hover:not(:disabled),.session-context-menu button:focus-visible{background:var(--selected)}.session-context-menu button:disabled{color:#68717e;cursor:not-allowed}
.table-head { position: sticky; top: -22px; z-index: 2; min-height: 30px; background: #111419; color: #78818e; font-size: 9px; font-weight: 650; letter-spacing: .04em; } .table-head { position: sticky; top: -22px; z-index: 2; min-height: 30px; background: #111419; color: #78818e; font-size: 9px; font-weight: 650; letter-spacing: .04em; }
.sort-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; text-transform: uppercase; } .sort-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; text-transform: uppercase; }
.table-row b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; color: #dde1e7; } .table-row b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; color: #dde1e7; }

View File

@ -1,5 +1,5 @@
.ui-button { min-height: 28px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:var(--border-subtle); border-radius:var(--radius-sm); padding:5px 9px; color:var(--color-text); background:#1b2027; font-size:var(--font-sm); } .ui-button { min-height: 28px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:var(--border-subtle); border-radius:var(--radius-sm); padding:5px 9px; color:var(--color-text); background:#1b2027; font-size:var(--font-sm); }
.ui-button:hover:not(:disabled) { background:var(--color-hover); }.ui-button:active:not(:disabled){transform:translateY(1px)}.ui-button:disabled{cursor:not-allowed;opacity:.45}.ui-button.primary{background:#326ba9;border-color:#457dbd}.ui-button.primary:hover:not(:disabled){background:#3d78b8}.ui-button.danger{background:#8e3037;border-color:#b84a53}.ui-icon-button{width:28px;padding:0}.ui-icon{flex:none}.ui-spinner{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:ui-spin .7s linear infinite} .ui-button:hover:not(:disabled) { background:var(--color-hover); }.ui-button:active:not(:disabled){background:#252c35}.ui-button:disabled{cursor:not-allowed;opacity:.45}.ui-button.primary{background:#326ba9;border-color:#457dbd}.ui-button.primary:hover:not(:disabled){background:#3d78b8}.ui-button.danger{color:#ffdadd;background:#6c292f;border-color:#a2434b}.ui-icon-button{width:28px;padding:0}.ui-icon{flex:none}.ui-spinner{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:ui-spin .7s linear infinite}
.ui-badge{display:inline-flex;min-width:16px;height:16px;align-items:center;justify-content:center;padding:0 4px;border-radius:8px;background:#343b45;font:600 var(--font-xs) ui-monospace}.ui-badge.danger{background:var(--color-danger);color:white} .ui-badge{display:inline-flex;min-width:16px;height:16px;align-items:center;justify-content:center;padding:0 4px;border-radius:8px;background:#343b45;font:600 var(--font-xs) ui-monospace}.ui-badge.danger{background:var(--color-danger);color:white}
.ui-status{display:inline-flex;align-items:center;gap:5px;color:var(--color-muted);font:600 var(--font-xs) ui-monospace}.ui-status-mark{width:15px;height:15px;display:grid;place-items:center;border:1px solid currentColor;border-radius:3px}.ui-status.online{color:#82d5aa}.ui-status.warning{color:#e5bb67}.ui-status.offline{color:#a4abb5}.ui-status.compact .ui-status-mark{width:10px;height:10px;border-radius:50%}.ui-status.compact svg{display:none} .ui-status{display:inline-flex;align-items:center;gap:5px;color:var(--color-muted);font:600 var(--font-xs) ui-monospace}.ui-status-mark{width:15px;height:15px;display:grid;place-items:center;border:1px solid currentColor;border-radius:3px}.ui-status.online{color:#82d5aa}.ui-status.warning{color:#e5bb67}.ui-status.offline{color:#a4abb5}.ui-status.compact .ui-status-mark{width:10px;height:10px;border-radius:50%}.ui-status.compact svg{display:none}
.ui-field{display:grid;gap:4px}.ui-field-label{color:var(--color-muted);font-size:var(--font-sm)}.ui-input-wrap,.ui-field select{display:flex;align-items:center;gap:7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#111419;padding:6px 8px}.ui-field input{width:100%;min-width:0;border:0;outline:0;background:transparent}.ui-field select{color:var(--color-text)}.ui-checkbox{display:flex;align-items:center;gap:7px}.ui-tabs{display:flex;overflow:auto}.ui-tabs>button{flex:0 0 auto}.ui-tooltip{position:relative;display:inline-flex}.ui-tooltip [role=tooltip]{position:absolute;left:50%;bottom:calc(100% + 7px);z-index:var(--z-popover);transform:translateX(-50%);visibility:hidden;opacity:0;white-space:nowrap;padding:5px 7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#080a0d;color:var(--color-text);transition:opacity var(--duration-fast)}.ui-tooltip:hover [role=tooltip],.ui-tooltip:focus-within [role=tooltip]{visibility:visible;opacity:1} .ui-field{display:grid;gap:4px}.ui-field-label{color:var(--color-muted);font-size:var(--font-sm)}.ui-input-wrap,.ui-field select{display:flex;align-items:center;gap:7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#111419;padding:6px 8px}.ui-field input{width:100%;min-width:0;border:0;outline:0;background:transparent}.ui-field select{color:var(--color-text)}.ui-checkbox{display:flex;align-items:center;gap:7px}.ui-tabs{display:flex;overflow:auto}.ui-tabs>button{flex:0 0 auto}.ui-tooltip{position:relative;display:inline-flex}.ui-tooltip [role=tooltip]{position:absolute;left:50%;bottom:calc(100% + 7px);z-index:var(--z-popover);transform:translateX(-50%);visibility:hidden;opacity:0;white-space:nowrap;padding:5px 7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#080a0d;color:var(--color-text);transition:opacity var(--duration-fast)}.ui-tooltip:hover [role=tooltip],.ui-tooltip:focus-within [role=tooltip]{visibility:visible;opacity:1}

View File

@ -12,6 +12,7 @@ export interface OperationTab {
readonly title: string; readonly title: string;
readonly running?: boolean; readonly running?: boolean;
readonly pinned?: boolean; readonly pinned?: boolean;
readonly dirty?: boolean;
} }
export interface TeamserverWorkspace { export interface TeamserverWorkspace {
@ -40,6 +41,7 @@ export type WorkspaceAction =
| { type: "setFilter"; serverId: string; filter: string } | { type: "setFilter"; serverId: string; filter: string }
| { type: "setUnread"; serverId: string; unread: number } | { type: "setUnread"; serverId: string; unread: number }
| { type: "openTab"; serverId: string; tab: OperationTab } | { type: "openTab"; serverId: string; tab: OperationTab }
| { type: "addServer"; server: TeamserverWorkspace }
| { type: "closeTab"; serverId: string; tabId: string } | { type: "closeTab"; serverId: string; tabId: string }
| { type: "togglePinTab"; serverId: string; tabId: string }; | { type: "togglePinTab"; serverId: string; tabId: string };
@ -107,6 +109,9 @@ function updateServer(
export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState { export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState {
switch (action.type) { switch (action.type) {
case "addServer":
if (state.servers[action.server.id]) return state;
return { ...state, activeServerId: action.server.id, serverOrder: [...state.serverOrder, action.server.id], servers: { ...state.servers, [action.server.id]: action.server } };
case "switchServer": case "switchServer":
return state.servers[action.serverId] && action.serverId !== state.activeServerId return state.servers[action.serverId] && action.serverId !== state.activeServerId
? { ...state, activeServerId: action.serverId } ? { ...state, activeServerId: action.serverId }