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}}
- - - 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'