From 362b576e7bf7a4c0160958da10ad451e03f6a67c Mon Sep 17 00:00:00 2001 From: delta <528491526@qq.com> Date: Fri, 9 Oct 2026 22:06:41 +0800 Subject: [PATCH] feat(groups): show which batch run is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only a dot on the 批量 tab hinted that some group was executing, and only while it was starting: a parallel group ends as soon as its scripts start, so the servers it brought up looked unrelated to any group. A group now counts as active while its execution runs or while any run it started or reused is still alive (activeGroups in shared, derived from the existing group states and runs, so no API change). Active groups appear in a bar above the sidebar tabs (jump to / stop), on the tab dot's tooltip, in the title bar, and as a 批量: badge on their script rows. Closes #46 --- apps/desktop/e2e/groups.spec.ts | 18 ++- apps/desktop/src/renderer/src/App.tsx | 4 + .../features/groups/running-groups-bar.tsx | 76 ++++++++++++ .../src/features/groups/use-active-groups.ts | 37 ++++++ .../src/features/scripts/script-list.tsx | 116 +++++++++++------- .../renderer/src/features/sidebar/sidebar.tsx | 83 ++++++++----- .../src/features/title-bar/title-bar.tsx | 48 +++++++- docs/ARCHITECTURE.md | 1 + docs/PROGRESS.md | 1 + packages/shared/src/active-groups.test.ts | 79 ++++++++++++ packages/shared/src/active-groups.ts | 43 +++++++ packages/shared/src/index.ts | 1 + 12 files changed, 427 insertions(+), 80 deletions(-) create mode 100644 apps/desktop/src/renderer/src/features/groups/running-groups-bar.tsx create mode 100644 apps/desktop/src/renderer/src/features/groups/use-active-groups.ts create mode 100644 packages/shared/src/active-groups.test.ts create mode 100644 packages/shared/src/active-groups.ts diff --git a/apps/desktop/e2e/groups.spec.ts b/apps/desktop/e2e/groups.spec.ts index 6a021d2..cca43dd 100644 --- a/apps/desktop/e2e/groups.spec.ts +++ b/apps/desktop/e2e/groups.spec.ts @@ -63,11 +63,18 @@ test('a serial group across projects waits for each step, then stops everything' const detail = page.getByRole('region', { name: '任务运行 front1' }) await expect(detail).toContainText('输出出现文字「API ready」') await item.getByRole('button', { name: '执行 front1' }).click() - // The tab signals a running group even while the projects tab is shown. + // The tab, a bar above the tabs and the title bar name the group while it starts... const running = page.getByRole('tab', { name: /批量/ }).getByRole('status') - await expect(running).toBeVisible() + const bar = page.getByRole('region', { name: '正在运行的批量任务' }) + const titleBar = page.getByRole('banner').getByRole('button', { name: /^正在运行:/ }) + await expect(running).toHaveAttribute('aria-label', /front1(启动中 \d\/2)/) + await expect(bar).toContainText(/front1启动中 \d\/2/) + await expect(titleBar).toContainText('front1') await expect(item).toContainText('已完成', { timeout: 30_000 }) - await expect(running).toBeHidden() + // ...and while the servers it started keep running after the execution ended. + await expect(running).toHaveAttribute('aria-label', '正在运行:front1(运行中 2/2)') + await expect(bar).toContainText('front1运行中 2/2') + await expect(titleBar).toContainText('front1运行中 2/2') await expect(detail).toContainText('2/2 个步骤完成') await expect(detail).toContainText('服务进程可能仍在运行') await expect(detail.getByRole('button', { name: '停止全部' })).toBeEnabled() @@ -80,6 +87,8 @@ test('a serial group across projects waits for each step, then stops everything' await expect( page.locator('main').getByRole('button', { name: 'serve', exact: true }) ).toHaveAttribute('aria-current', 'true') + // The script row tells which batch run started it. + await expect(page.locator('main').getByText('批量:front1')).toBeVisible() const runs = await listRuns(page) expect(runs.filter((run) => run.status === 'running')).toHaveLength(2) @@ -100,6 +109,9 @@ test('a serial group across projects waits for each step, then stops everything' timeout: 20_000 }) .toBe(0) + await expect(bar).toBeHidden() + await expect(running).toBeHidden() + await expect(titleBar).toBeHidden() }) test('a failing serial step stops the sequence and explains why', async ({ diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index c366f55..0b09335 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -79,6 +79,10 @@ function App(): React.JSX.Element { setDrawerOpen(false) }} onOpenNavigation={narrow ? () => setDrawerOpen(true) : undefined} + onSelectGroup={(groupId) => { + setSelectedGroupId(groupId) + setShowSettings(false) + }} />
{narrow ? ( diff --git a/apps/desktop/src/renderer/src/features/groups/running-groups-bar.tsx b/apps/desktop/src/renderer/src/features/groups/running-groups-bar.tsx new file mode 100644 index 0000000..d62709a --- /dev/null +++ b/apps/desktop/src/renderer/src/features/groups/running-groups-bar.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { Loader2, Square, X as XIcon } from 'lucide-react' +import { Button } from '@renderer/components/ui/button' +import { cn } from '@renderer/lib/utils' +import { describeActiveGroup, type ActiveGroupView } from './use-active-groups' +import { useStopGroup } from './use-groups' + +interface RunningGroupsBarProps { + groups: ActiveGroupView[] + onSelectGroup: (groupId: string) => void +} + +/** Lists active batch runs above the sidebar tabs, so they stay visible on either tab. */ +export function RunningGroupsBar({ + groups, + onSelectGroup +}: RunningGroupsBarProps): React.JSX.Element | null { + const stop = useStopGroup() + const [error, setError] = useState(null) + if (groups.length === 0) return null + return ( +
+ {error && ( +

+ {error} + +

+ )} +
    + {groups.map((group) => { + const stopping = stop.isPending && stop.variables === group.groupId + return ( +
  • + + +
  • + ) + })} +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/features/groups/use-active-groups.ts b/apps/desktop/src/renderer/src/features/groups/use-active-groups.ts new file mode 100644 index 0000000..07ced28 --- /dev/null +++ b/apps/desktop/src/renderer/src/features/groups/use-active-groups.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import { activeGroups, type ActiveGroup } from '@devhub/shared' +import { useRuns } from '@renderer/features/runs/use-runs' +import { useGroupStates, useGroups } from './use-groups' + +export interface ActiveGroupView extends ActiveGroup { + name: string +} + +/** Batch runs still starting or with scripts alive, named after the current group. */ +export function useActiveGroups(): ActiveGroupView[] { + const groups = useGroups().data + const states = useGroupStates().data + const runs = useRuns().data + return useMemo( + () => + activeGroups(states ?? [], runs ?? []).map((active) => { + const state = states?.find((candidate) => candidate.groupId === active.groupId) + const name = + groups?.find((group) => group.id === active.groupId)?.name ?? + state?.group?.name ?? + '(已删除的批量任务)' + return { ...active, name } + }), + [groups, states, runs] + ) +} + +/** "启动中 1/3" or "运行中 2/3". */ +export function describeActiveGroup(group: ActiveGroup): string { + return `${group.phase === 'starting' ? '启动中' : '运行中'} ${group.current}/${group.total}` +} + +/** "前后端联调(运行中 2/3)、全量构建(启动中 0/2)" for tooltips and screen readers. */ +export function describeActiveGroups(groups: ActiveGroupView[]): string { + return groups.map((group) => `${group.name}(${describeActiveGroup(group)})`).join('、') +} diff --git a/apps/desktop/src/renderer/src/features/scripts/script-list.tsx b/apps/desktop/src/renderer/src/features/scripts/script-list.tsx index 0d7e3fa..0770924 100644 --- a/apps/desktop/src/renderer/src/features/scripts/script-list.tsx +++ b/apps/desktop/src/renderer/src/features/scripts/script-list.tsx @@ -5,6 +5,7 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from '@renderer/comp import { Badge } from '@renderer/components/ui/badge' import { Button } from '@renderer/components/ui/button' import { Skeleton } from '@renderer/components/ui/skeleton' +import { useActiveGroups, type ActiveGroupView } from '@renderer/features/groups/use-active-groups' import { RunControls } from '@renderer/features/runs/run-controls' import { RunHistoryButton } from '@renderer/features/runs/run-history-button' import { useRuns } from '@renderer/features/runs/use-runs' @@ -20,6 +21,7 @@ interface ScriptListProps { export function ScriptList({ project, onRunStarted }: ScriptListProps): React.JSX.Element { const scripts = useProjectScripts(project.id) const runs = useRuns() + const activeGroups = useActiveGroups() // `retry` is set for a taken port: the user may start the script anyway. const [runError, setRunError] = useState<{ error: Error; retry?: () => void } | null>(null) @@ -122,6 +124,7 @@ export function ScriptList({ project, onRunStarted }: ScriptListProps): React.JS source={source} scripts={group} runs={runs.data ?? []} + activeGroups={activeGroups} onRunError={(error, retry) => setRunError({ error, retry })} onRunStarted={onRunStarted} /> @@ -137,6 +140,8 @@ interface ScriptGroupProps { source: ScriptSource scripts: Script[] runs: Run[] + /** Labels scripts that a batch run started or reused. */ + activeGroups: ActiveGroupView[] onRunError: (error: Error, retry?: () => void) => void onRunStarted: (run: Run) => void } @@ -146,6 +151,7 @@ function ScriptGroup({ source, scripts, runs, + activeGroups, onRunError, onRunStarted }: ScriptGroupProps): React.JSX.Element { @@ -159,53 +165,79 @@ function ScriptGroup({ {executable && {executable}}
    - {scripts.map((script) => ( -
  • - {/* On phones the name goes above the command, leaving the row's width to the controls. */} -
    - - {script.name} - -
    - - - {script.command} - - {script.ports?.map((port) => ( + {scripts.map((script) => { + const run = runs.find( + (candidate) => + candidate.kind === 'script' && + candidate.projectId === projectId && + candidate.scriptId === script.id + ) + const groups = run ? activeGroups.filter((group) => group.runIds.includes(run.id)) : [] + return ( +
  • + {/* On phones the name goes above the command, leaving the row's width to the controls. */} +
    + + + {script.name} + + {groups.length > 0 && ( group.name).join('、')}`} > - 端口 {port} + + 批量:{groups.map((group) => group.name).join('、')} + - ))} + )} - {script.description && ( -

    - {script.description} -

    - )} +
    + + + {script.command} + + {script.ports?.map((port) => ( + + 端口 {port} + + ))} + + {script.description && ( +

    + {script.description} +

    + )} +
    -
- - - run.kind === 'script' && run.projectId === projectId && run.scriptId === script.id - )} - onError={onRunError} - onStarted={onRunStarted} - /> - - ))} + + + + ) + })} ) diff --git a/apps/desktop/src/renderer/src/features/sidebar/sidebar.tsx b/apps/desktop/src/renderer/src/features/sidebar/sidebar.tsx index 8fe27f2..e268096 100644 --- a/apps/desktop/src/renderer/src/features/sidebar/sidebar.tsx +++ b/apps/desktop/src/renderer/src/features/sidebar/sidebar.tsx @@ -2,8 +2,10 @@ import { useState } from 'react' import type { Run } from '@devhub/shared' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' import { GroupList } from '@renderer/features/groups/group-list' -import { useGroupStates } from '@renderer/features/groups/use-groups' +import { RunningGroupsBar } from '@renderer/features/groups/running-groups-bar' +import { describeActiveGroups, useActiveGroups } from '@renderer/features/groups/use-active-groups' import { ProjectList } from '@renderer/features/projects/project-list' +import { cn } from '@renderer/lib/utils' type SidebarTab = 'projects' | 'groups' @@ -33,8 +35,7 @@ export function Sidebar({ const [tab, setTab] = useState(() => localStorage.getItem(tabKey) === 'groups' ? 'groups' : 'projects' ) - // Hidden behind the projects tab, a running group would otherwise go unnoticed. - const groupRunning = (useGroupStates().data ?? []).some((state) => state.status === 'running') + const activeGroups = useActiveGroups() const changeTab = (value: string): void => { const next: SidebarTab = value === 'groups' ? 'groups' : 'projects' @@ -43,35 +44,51 @@ export function Sidebar({ } return ( - - - 项目 - - 批量 - {groupRunning && ( - - )} - - - - - - - - - +
+ {/* Above the tabs, so active groups stay visible while browsing projects. */} + { + changeTab('groups') + onSelectGroup(groupId) + }} + /> + + + 项目 + + 批量 + {activeGroups.length > 0 && ( + group.phase === 'starting') + ? 'animate-pulse bg-info' + : 'bg-success' + )} + /> + )} + + + + + + + + + +
) } diff --git a/apps/desktop/src/renderer/src/features/title-bar/title-bar.tsx b/apps/desktop/src/renderer/src/features/title-bar/title-bar.tsx index 3ec303a..444016f 100644 --- a/apps/desktop/src/renderer/src/features/title-bar/title-bar.tsx +++ b/apps/desktop/src/renderer/src/features/title-bar/title-bar.tsx @@ -1,7 +1,12 @@ -import { Copy, Menu, Minus, Settings as SettingsIcon, Square, X } from 'lucide-react' +import { Copy, Layers, Menu, Minus, Settings as SettingsIcon, Square, X } from 'lucide-react' import { shell } from '@renderer/api' import { Button } from '@renderer/components/ui/button' import { useAppInfo } from '@renderer/features/app-info/use-app-info' +import { + describeActiveGroup, + describeActiveGroups, + useActiveGroups +} from '@renderer/features/groups/use-active-groups' import { UpdateBadge } from '@renderer/features/updates/update-badge' import { cn } from '@renderer/lib/utils' import { useWindowState } from './use-window-state' @@ -11,6 +16,8 @@ interface TitleBarProps { onToggleSettings: () => void /** Opens the sidebar drawer; only given on narrow screens, where the sidebar is hidden. */ onOpenNavigation?: () => void + /** Shows a batch run named in the title bar. */ + onSelectGroup: (groupId: string) => void } /** @@ -20,7 +27,8 @@ interface TitleBarProps { export function TitleBar({ showSettings, onToggleSettings, - onOpenNavigation + onOpenNavigation, + onSelectGroup }: TitleBarProps): React.JSX.Element { const { data: appInfo } = useAppInfo() const windowState = useWindowState() @@ -51,6 +59,8 @@ export function TitleBar({

DevHub

{appInfo && v{appInfo.version}} + {/* Phones show the drawer's list instead; there is no room here. */} + {!onOpenNavigation && }
+ ) +} + function WindowButtons({ maximized }: { maximized: boolean }): React.JSX.Element { const button = 'app-no-drag flex h-10 w-[46px] items-center justify-center text-foreground/80 transition-colors' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index acdc7d6..9a22e91 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -176,6 +176,7 @@ ShellApi 的通道在 `shellChannel` 中手动定义(数量少,不走自动 - `features/groups/group-detail.tsx` 展示任务运行详情,App 管理项目与任务选择;日志入口携带具体 `runId` 切换到项目终端。 - `GroupRunState` 可选保存执行配置快照与每步 `reused` 标志,随最近结果持久化。流程状态与实时 Run / RunHealth 独立,停止使用最近执行配置,兼容旧历史(ADR 0006 补充)。 +- 「正在运行的批量任务」由 shared 的 `activeGroups(states, runs)` 从最近执行状态与实时 Run 推导(执行中,或执行结束但步骤的 `runId` 仍未退出),不新增 API;renderer 经 `features/groups/use-active-groups.ts` 用于侧栏常驻条、「批量」标签、标题栏与脚本行徽标。 - Windows 使用 NSIS 安装向导,`build/installer.nsh` 的 nsDialogs 目录页在浏览选择时更新输入框、手动输入时预览最终路径,统一规范新选目录为 `devhub` 尾目录,并保留原安装位置及静默升级路径(ADR 0014)。 ## 安全 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 05ffc56..0a601c6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -52,6 +52,7 @@ M4-3 Android: - #22 已完成:启动的子进程不再直接继承 DevHub 的环境:Windows 从注册表修复并补全 PATH(更新后经 Explorer 重启时 `%NVM_HOME%` 未展开、找不到 pnpm),Linux 捕获交互登录 shell 的环境(从桌面启动时缺少 `.bashrc` 中的 nvm)(ADR 0024)。 - #39 已完成,待合并:Linux 上收到 SIGTERM 不退出(#8 中发现)。没有运行中的脚本时,`before-quit` 里的 `app.quit()` 会在 Electron 处理信号的 `Quit()` 内部重入,退出状态被外层覆盖,窗口关闭后进程挂住;改为推迟到下一轮事件循环。E2E 覆盖 SIGTERM 退出与停止进程树(Windows 跳过)。 - #42 已完成,待合并:远程服务启动时端口被占用(更新后旧进程尚未退出)会重试约 15 秒,期间状态显示「正在重试」;修改设置时的冲突仍立即报告。 +- #46 已完成,待合并:显示哪个批量任务正在运行:执行结束后只要它启动或复用的脚本还在运行,就算「运行中」(`activeGroups`,shared);侧栏顶部常驻条(可跳转、停止)、「批量」标签圆点提示、标题栏(窄屏隐藏)、项目详情的脚本行徽标「批量:任务名」。 桌面 MVP 发布: diff --git a/packages/shared/src/active-groups.test.ts b/packages/shared/src/active-groups.test.ts new file mode 100644 index 0000000..bb44bc3 --- /dev/null +++ b/packages/shared/src/active-groups.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { activeGroups } from './active-groups' +import type { Run } from './domain' +import type { GroupRunState, GroupStepState } from './groups' + +const run = (id: string, status: Run['status'] = 'running'): Run => ({ + id, + kind: 'script', + projectId: 'p', + scriptId: `npm:${id}`, + title: id, + command: `npm run ${id}`, + status, + pid: 1, + exitCode: null, + stopped: false, + startedAt: '2026-10-09T00:00:00.000Z' +}) + +const state = ( + status: GroupRunState['status'], + steps: { state: GroupStepState; runId?: string }[] +): GroupRunState => ({ + groupId: 'g', + status, + steps: steps.map((step, index) => ({ stepId: `s${index}`, ...step })) +}) + +describe('activeGroups', () => { + it('reports an execution in progress with the steps done so far', () => { + const states = [ + state('running', [ + { state: 'done', runId: 'a' }, + { state: 'running', runId: 'b' }, + { state: 'pending' } + ]) + ] + expect(activeGroups(states, [run('a'), run('b')])).toEqual([ + { groupId: 'g', phase: 'starting', current: 1, total: 3, runIds: ['a', 'b'] } + ]) + }) + + it('keeps an ended execution active while its scripts run', () => { + const states = [ + state('done', [ + { state: 'done', runId: 'a' }, + { state: 'done', runId: 'b' } + ]) + ] + expect(activeGroups(states, [run('a'), run('b', 'exited')])).toEqual([ + { groupId: 'g', phase: 'running', current: 1, total: 2, runIds: ['a'] } + ]) + }) + + it('counts a stopping script as still alive', () => { + const states = [state('stopped', [{ state: 'done', runId: 'a' }])] + expect(activeGroups(states, [run('a', 'stopping')])).toHaveLength(1) + }) + + it('drops a group once all its scripts exited', () => { + const states = [state('failed', [{ state: 'done', runId: 'a' }, { state: 'failed' }])] + expect(activeGroups(states, [run('a', 'exited')])).toEqual([]) + expect(activeGroups(states, [])).toEqual([]) + }) + + it('includes reused runs, counting a run shared by two steps once', () => { + const states = [ + state('done', [ + { state: 'done', runId: 'a' }, + { state: 'done', runId: 'a' } + ]) + ] + expect(activeGroups(states, [run('a')])[0]).toMatchObject({ current: 1, runIds: ['a'] }) + }) + + it('ignores older results without run ids', () => { + expect(activeGroups([state('done', [{ state: 'done' }])], [run('a')])).toEqual([]) + }) +}) diff --git a/packages/shared/src/active-groups.ts b/packages/shared/src/active-groups.ts new file mode 100644 index 0000000..62ddacf --- /dev/null +++ b/packages/shared/src/active-groups.ts @@ -0,0 +1,43 @@ +import type { Run } from './domain' +import type { GroupRunState } from './groups' + +/** + * A batch run that still has something going on. An execution ends as soon as its scripts + * are started (parallel) or the last step's condition is met (serial), while the servers it + * started keep running; both count as active. + */ +export interface ActiveGroup { + groupId: string + /** `starting`: the execution is in progress; `running`: it ended, its scripts still run. */ + phase: 'starting' | 'running' + /** Starting: steps done so far; running: scripts still alive. */ + current: number + total: number + /** Runs of the latest execution that have not exited, started or reused by it. */ + runIds: string[] +} + +/** Active groups in the order of `states`; groups whose scripts all exited are left out. */ +export function activeGroups(states: GroupRunState[], runs: Run[]): ActiveGroup[] { + const alive = new Set(runs.filter((run) => run.status !== 'exited').map((run) => run.id)) + const result: ActiveGroup[] = [] + for (const state of states) { + const runIds = [ + ...new Set(state.steps.flatMap((step) => (step.runId ? [step.runId] : []))) + ].filter((runId) => alive.has(runId)) + const total = state.steps.length + if (state.status === 'running') { + const done = state.steps.filter((step) => step.state === 'done').length + result.push({ groupId: state.groupId, phase: 'starting', current: done, total, runIds }) + } else if (runIds.length > 0) { + result.push({ + groupId: state.groupId, + phase: 'running', + current: runIds.length, + total, + runIds + }) + } + } + return result +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 784d254..b012402 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './active-groups' export * from './api' export * from './args' export * from './connect-input'