合并 main 并解决工作区状态冲突
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
commit
9ad4182b2b
54
.forgejo/workflows/ci.yml
Normal file
54
.forgejo/workflows/ci.yml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
name: Node 质量门禁
|
||||||
|
runs-on: docker
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 配置 Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.18.0
|
||||||
|
cache: npm
|
||||||
|
- name: 安装锁定依赖
|
||||||
|
run: npm ci
|
||||||
|
- name: TypeScript 类型检查
|
||||||
|
run: npm run typecheck
|
||||||
|
- name: 单元与组件测试
|
||||||
|
run: npm test
|
||||||
|
- name: 生产构建
|
||||||
|
run: npm run build
|
||||||
|
- name: 高危依赖审计
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Docker 构建与健康检查
|
||||||
|
runs-on: docker
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 构建 Web 镜像
|
||||||
|
run: docker compose build web
|
||||||
|
- name: 启动 Web 服务
|
||||||
|
run: docker compose up --no-build --detach web
|
||||||
|
- name: 等待健康检查通过
|
||||||
|
run: ./scripts/check-web-health.sh
|
||||||
|
- name: 输出故障诊断
|
||||||
|
if: failure()
|
||||||
|
run: docker compose ps && docker compose logs --no-color web
|
||||||
|
- name: 清理容器
|
||||||
|
if: always()
|
||||||
|
run: docker compose down --volumes --remove-orphans
|
||||||
29
README.md
29
README.md
@ -21,8 +21,11 @@ npm run tauri dev # 桌面开发模式
|
|||||||
无需本机 Node.js 的前端验证:
|
无需本机 Node.js 的前端验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose build test build
|
||||||
docker compose run --rm test
|
docker compose run --rm test
|
||||||
docker compose run --rm build
|
docker compose run --rm build
|
||||||
|
docker compose run --rm test npm run typecheck
|
||||||
|
docker compose run --rm test npm audit --audit-level=high
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker 启动 Web 测试服务
|
## Docker 启动 Web 测试服务
|
||||||
@ -38,6 +41,7 @@ docker compose up --build -d
|
|||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs -f web
|
docker compose logs -f web
|
||||||
|
./scripts/check-web-health.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
停止服务:
|
停止服务:
|
||||||
@ -64,8 +68,33 @@ WEB_BIND_ADDRESS=0.0.0.0 WEB_PORT=1420 docker compose up --build -d
|
|||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm ci
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm test
|
npm test
|
||||||
npm run build
|
npm run build
|
||||||
|
npm audit --audit-level=high
|
||||||
npm run tauri build
|
npm run tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 持续集成与本地复现
|
||||||
|
|
||||||
|
Forgejo 工作流 [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) 在推送到 `main` 和拉取请求时运行两个独立作业:
|
||||||
|
|
||||||
|
- Node 质量门禁依次执行锁定依赖安装、TypeScript 类型检查、测试、生产构建和高危依赖审计。
|
||||||
|
- Docker 门禁构建 `web` 镜像,按 Compose 的最小权限配置启动服务,并等待镜像内置健康检查通过。
|
||||||
|
|
||||||
|
本机装有 Node.js 22.18.0 时,可使用“常用命令”中的前五条命令逐步复现 Node 作业。仅安装 Docker 时,可完整复现 CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build test build
|
||||||
|
docker compose run --rm test npm run typecheck
|
||||||
|
docker compose run --rm test
|
||||||
|
docker compose run --rm build
|
||||||
|
docker compose run --rm test npm audit --audit-level=high
|
||||||
|
docker compose build web
|
||||||
|
docker compose up --no-build -d web
|
||||||
|
./scripts/check-web-health.sh
|
||||||
|
docker compose down --volumes --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖审计会在发现高危或严重漏洞时失败;网络或软件源不可用同样视为审计失败,不静默跳过。Docker 服务仍以非 root 用户运行,并保留只读根文件系统与 `no-new-privileges` 限制。
|
||||||
|
|||||||
32
scripts/check-web-health.sh
Executable file
32
scripts/check-web-health.sh
Executable file
@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
max_attempts="${WEB_HEALTH_ATTEMPTS:-30}"
|
||||||
|
attempt=1
|
||||||
|
|
||||||
|
while [ "$attempt" -le "$max_attempts" ]; do
|
||||||
|
container_id="$(docker compose ps --quiet web)"
|
||||||
|
if [ -z "$container_id" ]; then
|
||||||
|
echo "Web 容器未运行" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$container_id")"
|
||||||
|
case "$status" in
|
||||||
|
healthy)
|
||||||
|
echo "Web 容器健康检查通过"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
unhealthy|missing)
|
||||||
|
echo "Web 容器健康状态异常:$status" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "等待 Web 容器健康检查($attempt/$max_attempts,当前:$status)"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Web 容器未在预期时间内进入 healthy 状态" >&2
|
||||||
|
exit 1
|
||||||
42
src/App.tsx
42
src/App.tsx
@ -1,7 +1,12 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useReducer, useState } from "react";
|
||||||
import { SessionQueryIntent, SessionTable } from "./SessionTable";
|
import { SessionQueryIntent, SessionTable } from "./SessionTable";
|
||||||
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
|
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
|
||||||
import { initialWorkspaces, selectWorkspace } from "./workspace";
|
import {
|
||||||
|
createInitialWorkspaceState,
|
||||||
|
selectActiveTab,
|
||||||
|
selectActiveWorkspace,
|
||||||
|
workspaceReducer,
|
||||||
|
} from "./workspace";
|
||||||
|
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{ id: "NW-042", status: "ONLINE", host: "ws-fin-042", user: "nora.chen", os: "Windows 11", address: "10.42.8.17", seen: "14s ago", tags: ["finance", "priority"] },
|
{ id: "NW-042", status: "ONLINE", host: "ws-fin-042", user: "nora.chen", os: "Windows 11", address: "10.42.8.17", seen: "14s ago", tags: ["finance", "priority"] },
|
||||||
@ -11,28 +16,22 @@ const sessions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeServerId, setActiveServerId] = useState(initialWorkspaces[0].id);
|
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
|
||||||
const [activeTabs, setActiveTabs] = useState<Record<string, string>>(
|
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
|
||||||
Object.fromEntries(initialWorkspaces.map((server) => [server.id, server.activeTabId])),
|
|
||||||
);
|
|
||||||
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(initialWorkspaces.map(({ id }) => id)));
|
|
||||||
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
|
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
|
||||||
const server = useMemo(
|
const server = selectActiveWorkspace(workspace);
|
||||||
() => selectWorkspace(initialWorkspaces, activeServerId) ?? initialWorkspaces[0],
|
const activeTab = selectActiveTab(server);
|
||||||
[activeServerId],
|
const tableState = { ...tableStates[server.id], filter: server.filter };
|
||||||
);
|
|
||||||
const activeTab = server.tabs.find((tab) => tab.id === activeTabs[server.id]) ?? server.tabs[0];
|
|
||||||
const tableState = tableStates[server.id];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<aside className="server-rail" aria-label="Teamserver 列表">
|
<aside className="server-rail" aria-label="Teamserver 列表">
|
||||||
<div className="brand">G</div>
|
<div className="brand">G</div>
|
||||||
{initialWorkspaces.map((item) => (
|
{workspace.serverOrder.map((serverId) => workspace.servers[serverId]).map((item) => (
|
||||||
<button
|
<button
|
||||||
className={`server-button ${item.id === server.id ? "active" : ""}`}
|
className={`server-button ${item.id === server.id ? "active" : ""}`}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setActiveServerId(item.id)}
|
onClick={() => dispatch({ type: "switchServer", serverId: item.id })}
|
||||||
aria-label={`切换到 ${item.name}`}
|
aria-label={`切换到 ${item.name}`}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
>
|
>
|
||||||
@ -73,22 +72,25 @@ export function App() {
|
|||||||
aria-selected={tab.id === activeTab.id}
|
aria-selected={tab.id === activeTab.id}
|
||||||
className={tab.id === activeTab.id ? "active" : ""}
|
className={tab.id === activeTab.id ? "active" : ""}
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTabs((tabs) => ({ ...tabs, [server.id]: tab.id }))}
|
onClick={() => dispatch({ type: "activateTab", serverId: server.id, tabId: tab.id })}
|
||||||
title={`${tab.project} / ${tab.session}`}
|
title={`${tab.context.project} / ${tab.context.session}`}
|
||||||
>
|
>
|
||||||
{tab.running && <span className="running" />}{tab.title}<span className="close">×</span>
|
{tab.running && <span className="running" />}{tab.title}<span className="close">×</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="contextbar" aria-label="锁定的操作上下文">
|
<div className="contextbar" aria-label="锁定的操作上下文">
|
||||||
<span className="context-lock">◆ LOCKED</span>{server.name} <i>/</i> {activeTab.project} <i>/</i> <b>{activeTab.session}</b> <i>/</i> {activeTab.title}
|
<span className="context-lock">◆ LOCKED</span>{server.name} <i>/</i> {activeTab.context.project} <i>/</i> <b>{activeTab.context.session}</b> <i>/</i> {activeTab.title}
|
||||||
</div>
|
</div>
|
||||||
<article className="content">
|
<article className="content">
|
||||||
<div className="content-heading">
|
<div className="content-heading">
|
||||||
<div><h2>{activeTab.title}</h2><p>{activeTab.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
||||||
<div className="heading-actions"><button className="secondary">导出视图</button><button className="primary">+ 新建操作</button></div>
|
<div className="heading-actions"><button className="secondary">导出视图</button><button className="primary">+ 新建操作</button></div>
|
||||||
</div>
|
</div>
|
||||||
<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) => setTableStates((current) => updateSessionTableState(current, server.id, state))} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />
|
<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));
|
||||||
|
}} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />
|
||||||
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
|
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
|
||||||
</article>
|
</article>
|
||||||
<footer className="statusbar"><span>● {server.connection}</span><span>身份 operator@example</span><span>延迟 38ms</span><span>协议 v1.0</span><span>事件流 ✓ 已同步</span><span className="status-spacer" />内存工作区</footer>
|
<footer className="statusbar"><span>● {server.connection}</span><span>身份 operator@example</span><span>延迟 38ms</span><span>协议 v1.0</span><span>事件流 ✓ 已同步</span><span className="status-spacer" />内存工作区</footer>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { createSessionTableViewState, SessionTableViewState } from "./SessionTab
|
|||||||
|
|
||||||
export type SessionTableStateByServer = Record<string, SessionTableViewState>;
|
export type SessionTableStateByServer = Record<string, SessionTableViewState>;
|
||||||
|
|
||||||
export function initializeSessionTableStates(serverIds: string[]): SessionTableStateByServer {
|
export function initializeSessionTableStates(serverIds: readonly string[]): SessionTableStateByServer {
|
||||||
return Object.fromEntries(serverIds.map((serverId) => [serverId, createSessionTableViewState()]));
|
return Object.fromEntries(serverIds.map((serverId) => [serverId, createSessionTableViewState()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
src/workspace.test.ts
Normal file
57
src/workspace.test.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createInitialWorkspaceState,
|
||||||
|
selectActiveTab,
|
||||||
|
selectActiveWorkspace,
|
||||||
|
type WorkspaceAction,
|
||||||
|
workspaceReducer,
|
||||||
|
} from "./workspace";
|
||||||
|
|
||||||
|
describe("workspaceReducer", () => {
|
||||||
|
it("隔离每个 Teamserver 的导航、筛选与未读状态", () => {
|
||||||
|
const initial = createInitialWorkspaceState();
|
||||||
|
const actions: WorkspaceAction[] = [
|
||||||
|
{ type: "navigate", serverId: "atlas", project: "Orchid", navigation: "Audit" },
|
||||||
|
{ type: "setFilter", serverId: "atlas", filter: "status:online" },
|
||||||
|
{ type: "setUnread", serverId: "atlas", unread: 0 },
|
||||||
|
];
|
||||||
|
const changed = actions.reduce(workspaceReducer, initial);
|
||||||
|
|
||||||
|
expect(changed.servers.atlas).toMatchObject({ activeProject: "Orchid", navigation: "Audit", filter: "status:online", unread: 0 });
|
||||||
|
expect(changed.servers.ember).toMatchObject({ activeProject: "Sandbox", navigation: "Events", filter: "", unread: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("切回 Teamserver 时恢复各自的激活标签", () => {
|
||||||
|
let state = createInitialWorkspaceState();
|
||||||
|
state = workspaceReducer(state, { type: "activateTab", serverId: "atlas", tabId: "nw-042" });
|
||||||
|
state = workspaceReducer(state, { type: "switchServer", serverId: "ember" });
|
||||||
|
state = workspaceReducer(state, { type: "activateTab", serverId: "ember", tabId: "sb-107" });
|
||||||
|
state = workspaceReducer(state, { type: "switchServer", serverId: "atlas" });
|
||||||
|
|
||||||
|
expect(selectActiveTab(selectActiveWorkspace(state)).id).toBe("nw-042");
|
||||||
|
expect(state.servers.ember.activeTabId).toBe("sb-107");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("导航与重复 tab ID 均不能重定向已创建标签的上下文", () => {
|
||||||
|
let state = createInitialWorkspaceState();
|
||||||
|
const originalContext = state.servers.atlas.tabs[1].context;
|
||||||
|
state = workspaceReducer(state, { type: "navigate", serverId: "atlas", project: "Orchid", navigation: "Sessions" });
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "openTab",
|
||||||
|
serverId: "atlas",
|
||||||
|
tab: { id: "nw-042", title: "被拒绝的替换", context: { serverId: "atlas", project: "Orchid", session: "OR-999" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.servers.atlas.tabs[1].context).toEqual(originalContext);
|
||||||
|
expect(state.servers.atlas.tabs[1].title).toBe("Session Overview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("退出等价于丢弃内存状态,新实例不恢复业务状态", () => {
|
||||||
|
const changed = workspaceReducer(createInitialWorkspaceState(), { type: "setFilter", serverId: "atlas", filter: "secret" });
|
||||||
|
const restarted = createInitialWorkspaceState();
|
||||||
|
|
||||||
|
expect(changed.servers.atlas.filter).toBe("secret");
|
||||||
|
expect(restarted.servers.atlas.filter).toBe("");
|
||||||
|
expect(restarted).not.toBe(changed);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
src/workspace.ts
133
src/workspace.ts
@ -1,25 +1,46 @@
|
|||||||
export type ConnectionState = "CONNECTED" | "DEGRADED" | "RECONNECTING";
|
export type ConnectionState = "CONNECTED" | "DEGRADED" | "RECONNECTING";
|
||||||
|
|
||||||
|
export interface TabContext {
|
||||||
|
readonly serverId: string;
|
||||||
|
readonly project: string;
|
||||||
|
readonly session: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationTab {
|
export interface OperationTab {
|
||||||
id: string;
|
readonly id: string;
|
||||||
project: string;
|
readonly context: TabContext;
|
||||||
session: string;
|
readonly title: string;
|
||||||
title: string;
|
readonly running?: boolean;
|
||||||
running?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamserverWorkspace {
|
export interface TeamserverWorkspace {
|
||||||
id: string;
|
readonly id: string;
|
||||||
name: string;
|
readonly name: string;
|
||||||
shortName: string;
|
readonly shortName: string;
|
||||||
connection: ConnectionState;
|
readonly connection: ConnectionState;
|
||||||
unread: number;
|
readonly tabs: readonly OperationTab[];
|
||||||
activeProject: string;
|
|
||||||
activeTabId: string;
|
activeTabId: string;
|
||||||
tabs: OperationTab[];
|
activeProject: string;
|
||||||
|
navigation: string;
|
||||||
|
filter: string;
|
||||||
|
unread: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialWorkspaces: TeamserverWorkspace[] = [
|
export interface WorkspaceState {
|
||||||
|
activeServerId: string;
|
||||||
|
servers: Record<string, TeamserverWorkspace>;
|
||||||
|
serverOrder: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceAction =
|
||||||
|
| { type: "switchServer"; serverId: string }
|
||||||
|
| { type: "activateTab"; serverId: string; tabId: string }
|
||||||
|
| { type: "navigate"; serverId: string; project: string; navigation: string }
|
||||||
|
| { type: "setFilter"; serverId: string; filter: string }
|
||||||
|
| { type: "setUnread"; serverId: string; unread: number }
|
||||||
|
| { type: "openTab"; serverId: string; tab: OperationTab };
|
||||||
|
|
||||||
|
const seedWorkspaces: readonly TeamserverWorkspace[] = [
|
||||||
{
|
{
|
||||||
id: "atlas",
|
id: "atlas",
|
||||||
name: "Atlas Teamserver",
|
name: "Atlas Teamserver",
|
||||||
@ -27,11 +48,13 @@ export const initialWorkspaces: TeamserverWorkspace[] = [
|
|||||||
connection: "CONNECTED",
|
connection: "CONNECTED",
|
||||||
unread: 2,
|
unread: 2,
|
||||||
activeProject: "Northwind",
|
activeProject: "Northwind",
|
||||||
|
navigation: "Sessions",
|
||||||
|
filter: "",
|
||||||
activeTabId: "nw-sessions",
|
activeTabId: "nw-sessions",
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: "nw-sessions", project: "Northwind", session: "All sessions", title: "Sessions" },
|
{ id: "nw-sessions", context: { serverId: "atlas", project: "Northwind", session: "All sessions" }, title: "Sessions" },
|
||||||
{ id: "nw-042", project: "Northwind", session: "NW-042", title: "Session Overview" },
|
{ id: "nw-042", context: { serverId: "atlas", project: "Northwind", session: "NW-042" }, title: "Session Overview" },
|
||||||
{ id: "or-audit", project: "Orchid", session: "Project", title: "Audit", running: true },
|
{ id: "or-audit", context: { serverId: "atlas", project: "Orchid", session: "Project" }, title: "Audit", running: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -41,17 +64,81 @@ export const initialWorkspaces: TeamserverWorkspace[] = [
|
|||||||
connection: "DEGRADED",
|
connection: "DEGRADED",
|
||||||
unread: 5,
|
unread: 5,
|
||||||
activeProject: "Sandbox",
|
activeProject: "Sandbox",
|
||||||
|
navigation: "Events",
|
||||||
|
filter: "",
|
||||||
activeTabId: "sb-events",
|
activeTabId: "sb-events",
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: "sb-events", project: "Sandbox", session: "Project", title: "Events" },
|
{ id: "sb-events", context: { serverId: "ember", project: "Sandbox", session: "Project" }, title: "Events" },
|
||||||
{ id: "sb-107", project: "Sandbox", session: "SB-107", title: "Task Output", running: true },
|
{ id: "sb-107", context: { serverId: "ember", project: "Sandbox", session: "SB-107" }, title: "Task Output", running: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function selectWorkspace(
|
function cloneServer(server: TeamserverWorkspace): TeamserverWorkspace {
|
||||||
workspaces: TeamserverWorkspace[],
|
return {
|
||||||
workspaceId: string,
|
...server,
|
||||||
): TeamserverWorkspace | undefined {
|
tabs: server.tabs.map((tab) => ({ ...tab, context: { ...tab.context } })),
|
||||||
return workspaces.find(({ id }) => id === workspaceId);
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每次进程启动都从种子数据创建全新状态,不读取或写入任何持久化存储。 */
|
||||||
|
export function createInitialWorkspaceState(): WorkspaceState {
|
||||||
|
return {
|
||||||
|
activeServerId: seedWorkspaces[0].id,
|
||||||
|
serverOrder: seedWorkspaces.map(({ id }) => id),
|
||||||
|
servers: Object.fromEntries(seedWorkspaces.map((server) => [server.id, cloneServer(server)])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateServer(
|
||||||
|
state: WorkspaceState,
|
||||||
|
serverId: string,
|
||||||
|
update: (server: TeamserverWorkspace) => TeamserverWorkspace,
|
||||||
|
): WorkspaceState {
|
||||||
|
const server = state.servers[serverId];
|
||||||
|
if (!server) return state;
|
||||||
|
const nextServer = update(server);
|
||||||
|
if (nextServer === server) return state;
|
||||||
|
return { ...state, servers: { ...state.servers, [serverId]: nextServer } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "switchServer":
|
||||||
|
return state.servers[action.serverId] && action.serverId !== state.activeServerId
|
||||||
|
? { ...state, activeServerId: action.serverId }
|
||||||
|
: state;
|
||||||
|
case "activateTab":
|
||||||
|
return updateServer(state, action.serverId, (server) =>
|
||||||
|
server.tabs.some(({ id }) => id === action.tabId) && server.activeTabId !== action.tabId
|
||||||
|
? { ...server, activeTabId: action.tabId }
|
||||||
|
: server,
|
||||||
|
);
|
||||||
|
case "navigate":
|
||||||
|
return updateServer(state, action.serverId, (server) => ({
|
||||||
|
...server,
|
||||||
|
activeProject: action.project,
|
||||||
|
navigation: action.navigation,
|
||||||
|
}));
|
||||||
|
case "setFilter":
|
||||||
|
return updateServer(state, action.serverId, (server) => ({ ...server, filter: action.filter }));
|
||||||
|
case "setUnread":
|
||||||
|
return updateServer(state, action.serverId, (server) => ({ ...server, unread: Math.max(0, action.unread) }));
|
||||||
|
case "openTab":
|
||||||
|
return updateServer(state, action.serverId, (server) => {
|
||||||
|
if (action.tab.context.serverId !== action.serverId) return server;
|
||||||
|
const existing = server.tabs.find(({ id }) => id === action.tab.id);
|
||||||
|
// 相同 ID 只能重新激活,不能借导航或重复创建改写其锁定上下文。
|
||||||
|
if (existing) return { ...server, activeTabId: existing.id };
|
||||||
|
return { ...server, tabs: [...server.tabs, action.tab], activeTabId: action.tab.id };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectActiveWorkspace(state: WorkspaceState): TeamserverWorkspace {
|
||||||
|
return state.servers[state.activeServerId] ?? state.servers[state.serverOrder[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectActiveTab(server: TeamserverWorkspace): OperationTab {
|
||||||
|
return server.tabs.find(({ id }) => id === server.activeTabId) ?? server.tabs[0];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user