minecraft-dev-starter 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "minecraft-dev-starter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "DSH plugin that detects Minecraft mod projects and launches the client from the session header, with live logs in the sidebar",
6
+ "type": "module",
7
+ "main": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "lib",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "AGENTS.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "keywords": [
24
+ "dsh",
25
+ "dsh-plugin",
26
+ "minecraft",
27
+ "modding",
28
+ "gradle",
29
+ "fabric",
30
+ "neoforge",
31
+ "forge"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "platform": "web"
39
+ }
40
+ },
41
+ "scripts": {
42
+ "build": "node scripts/build-client.mjs",
43
+ "build:client": "node scripts/build-client.mjs",
44
+ "test": "node --test \"test/*.test.js\""
45
+ },
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.3"
49
+ },
50
+ "peerDependencies": {
51
+ "@deepseek-ai/cordis": "^4.0.1"
52
+ },
53
+ "devDependencies": {
54
+ "esbuild": "^0.28.2"
55
+ }
56
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * 标题栏胶囊:状态点 + 选中目标名 + 一个启停按钮。点名字展开浮层选目标。
3
+ *
4
+ * 工作区跟着**当前会话**走:会话头 slot 会把 `sessionId` 与 `useSessions` 传给组件,
5
+ * 我们从中取 cwd 再让宿主按这个目录扫描。早期版本只在挂载时扫一次、且宿主用的是
6
+ * "最近创建的会话",于是切会话后胶囊不会同步。
7
+ * @module minecraft-dev-starter/client/Capsule
8
+ */
9
+
10
+ import { useEffect, useRef } from 'react'
11
+ import { h } from './h.js'
12
+ import { IconPlay, IconStop } from './icons.js'
13
+ import { report } from './debug.js'
14
+ import { capsuleParts, selectedTarget } from './selection.js'
15
+ import {
16
+ activeLaunchFor,
17
+ loadScan,
18
+ loadStateInfo,
19
+ selectedProject,
20
+ startTarget,
21
+ stopLaunch,
22
+ store,
23
+ useLaunches,
24
+ useStoreState,
25
+ } from './state.js'
26
+ import { Flyout } from './Flyout.js'
27
+
28
+ /**
29
+ * 入口:有会话标准工具(sessionId + useSessions)就按会话取 cwd,否则退回宿主默认。
30
+ * @param {{ sessionId?: string, useSessions?: any }} [props]
31
+ */
32
+ export function Capsule(props) {
33
+ if (props?.useSessions === undefined) return h(CapsuleBody, { cwd: undefined })
34
+ return h(SessionCapsule, props)
35
+ }
36
+
37
+ /**
38
+ * 订阅当前会话的 cwd;会话切换时这里会重渲染,把新 root 交给下面的 body。
39
+ * @param {{ sessionId?: string, useSessions: any }} props
40
+ */
41
+ function SessionCapsule(props) {
42
+ const cwd = props.useSessions((state) => state.byId?.[props.sessionId]?.cwd)
43
+ return h(CapsuleBody, { cwd: typeof cwd === 'string' && cwd !== '' ? cwd : undefined })
44
+ }
45
+
46
+ /**
47
+ * @param {{ cwd?: string }} props
48
+ */
49
+ function CapsuleBody({ cwd }) {
50
+ const state = useStoreState()
51
+ const launches = useLaunches(5000)
52
+ const anchor = useRef(/** @type {HTMLDivElement | null} */ (null))
53
+ const lastRoot = useRef(/** @type {string | undefined} */ (undefined))
54
+
55
+ useEffect(() => {
56
+ report('capsule.mounted')
57
+ void loadStateInfo()
58
+ }, [])
59
+
60
+ // 会话切换 = 工作区切换:清掉上一个工作区的扫描结果再重新扫
61
+ useEffect(() => {
62
+ if (cwd === undefined) {
63
+ if (lastRoot.current === undefined) void loadScan()
64
+ return
65
+ }
66
+ if (lastRoot.current === cwd) return
67
+ lastRoot.current = cwd
68
+ store.set({ workspaceRoot: cwd, scan: undefined, selectedRoot: undefined, error: undefined })
69
+ report('workspace.changed', { root: cwd })
70
+ void loadScan({ root: cwd, refresh: true })
71
+ }, [cwd])
72
+
73
+ useEffect(() => {
74
+ if (!state.flyoutOpen) return undefined
75
+ const onPointerDown = (event) => {
76
+ if (anchor.current !== null && anchor.current.contains(/** @type {Node} */ (event.target))) return
77
+ store.set({ flyoutOpen: false })
78
+ }
79
+ document.addEventListener('pointerdown', onPointerDown)
80
+ return () => document.removeEventListener('pointerdown', onPointerDown)
81
+ }, [state.flyoutOpen])
82
+
83
+ const project = selectedProject()
84
+ const target = selectedTarget(project, state.selection)
85
+ const running = project === undefined ? undefined : activeLaunchFor(project.root)
86
+ const busy = launches.busy !== undefined
87
+ const multipleProjects = (state.scan?.projects?.length ?? 0) > 1
88
+
89
+ // 四种状态:错误(红)、启动中(黄,呼吸)、运行中(绿,呼吸)、空闲(灰)
90
+ const tone =
91
+ state.error !== undefined || launches.error !== undefined
92
+ ? 'error'
93
+ : running === undefined
94
+ ? (project === undefined ? 'idle' : 'ok')
95
+ : running.status === 'starting'
96
+ ? 'warn'
97
+ : 'ok'
98
+ const parts = capsuleParts(project, target, { multipleProjects })
99
+ const runningLabel = running === undefined ? undefined : running.label
100
+ const title =
101
+ launches.error ??
102
+ state.error ??
103
+ (project === undefined
104
+ ? `没有识别到 MC 工程\n${state.workspaceRoot ?? ''}`
105
+ : `${project.modName ?? project.name}\n${project.root}\n${target === undefined ? '没有可运行目标' : target.taskPath}`)
106
+
107
+ return h(
108
+ 'div',
109
+ { className: 'mcdev-anchor', ref: anchor },
110
+ h(
111
+ 'div',
112
+ { className: 'mcdev-capsule' },
113
+ h(
114
+ 'button',
115
+ { type: 'button', className: 'mcdev-pill', title, onClick: () => store.set({ flyoutOpen: !state.flyoutOpen }) },
116
+ h('span', { className: 'mcdev-dot', 'data-state': tone, 'data-pulse': running === undefined ? 'false' : 'true' }),
117
+ h(
118
+ 'span',
119
+ { className: 'mcdev-pill-text' },
120
+ runningLabel !== undefined || parts.project === undefined
121
+ ? undefined
122
+ : [
123
+ h('span', { key: 'project', className: 'mcdev-pill-project', title: parts.project }, parts.project),
124
+ h('span', { key: 'sep', className: 'mcdev-pill-sep' }, '·'),
125
+ ],
126
+ h('span', { key: 'target', className: 'mcdev-pill-target' }, runningLabel ?? parts.target),
127
+ ),
128
+ ),
129
+ running === undefined
130
+ ? h(
131
+ 'button',
132
+ {
133
+ type: 'button',
134
+ className: 'mcdev-run mcdev-run-icon',
135
+ disabled: target === undefined || busy,
136
+ title: target === undefined ? '没有可运行目标' : `启动 ${target.taskPath}`,
137
+ onClick: () => {
138
+ if (project !== undefined && target !== undefined) void startTarget(project, target)
139
+ },
140
+ },
141
+ h('span', { className: 'mcdev-icon' }, h(IconPlay, undefined)),
142
+ )
143
+ : h(
144
+ 'button',
145
+ {
146
+ type: 'button',
147
+ className: 'mcdev-run mcdev-run-icon',
148
+ 'data-tone': 'stop',
149
+ disabled: busy,
150
+ title: `停止 ${running.taskPath}`,
151
+ onClick: () => void stopLaunch(running.id),
152
+ },
153
+ h('span', { className: 'mcdev-icon' }, h(IconStop, undefined)),
154
+ ),
155
+ ),
156
+ state.flyoutOpen ? h(Flyout) : undefined,
157
+ )
158
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 右侧栏「MC 工程」标签:单个工程的完整画像——版本、环境、模块与目标、证据、警告。
3
+ *
4
+ * 这是给人核对"你凭什么说它是 Forge 1.20.1"的地方,所以证据永远展开显示。
5
+ * @module minecraft-dev-starter/client/DetailTab
6
+ */
7
+
8
+ import { useEffect } from 'react'
9
+ import { h } from './h.js'
10
+ import { kindLabel, loaderLabel, PANEL_TITLE, projectSubtitle, projectTitle, text } from './format.js'
11
+ import { IconRefresh, IconWarning } from './icons.js'
12
+ import { capsuleText, selectedTarget } from './selection.js'
13
+ import { Skeleton } from './Skeleton.js'
14
+ import { openLogTab } from './runtime.js'
15
+ import {
16
+ loadScan,
17
+ selectedProject,
18
+ startTarget,
19
+ stopLaunch,
20
+ useLaunches,
21
+ useStoreState,
22
+ } from './state.js'
23
+
24
+ export function DetailTab() {
25
+ const state = useStoreState()
26
+ const launches = useLaunches(1500)
27
+
28
+ useEffect(() => {
29
+ if (state.scan === undefined) loadScan()
30
+ }, [state.scan])
31
+
32
+ const project = selectedProject()
33
+ const chosen = selectedTarget(project, state.selection)
34
+
35
+ const running = project === undefined
36
+ ? undefined
37
+ : launches.list.find(
38
+ (entry) =>
39
+ String(entry.root).toLowerCase() === String(project.root).toLowerCase() &&
40
+ (entry.status === 'starting' || entry.status === 'running'),
41
+ )
42
+
43
+ if (project === undefined) {
44
+ return h(
45
+ 'div',
46
+ { className: 'mcdev-detail' },
47
+ h('div', { className: 'mcdev-title' }, PANEL_TITLE),
48
+ state.loading
49
+ ? h(Skeleton, { rows: 4, hint: '正在扫描工作区…' })
50
+ : h('div', { className: 'mcdev-muted', style: { marginTop: 8 } }, '当前工作区没有识别到模组工程'),
51
+ state.error === undefined ? undefined : h('div', { className: 'mcdev-error', style: { marginTop: 8 } }, state.error),
52
+ )
53
+ }
54
+
55
+ const java = /** @type {Record<string, any>} */ (project.java ?? {})
56
+ const modules = /** @type {Array<Record<string, any>>} */ (project.modules ?? [])
57
+ const evidence = /** @type {Array<Record<string, any>>} */ (project.evidence ?? [])
58
+ const warnings = /** @type {string[]} */ (project.warnings ?? [])
59
+ const conflicts = /** @type {string[]} */ (project.conflicts ?? [])
60
+
61
+ return h(
62
+ 'div',
63
+ { className: 'mcdev-detail' },
64
+ h('div', { className: 'mcdev-title' }, projectTitle(project)),
65
+ h('div', { className: 'mcdev-sub' }, projectSubtitle(project)),
66
+ h('div', { className: 'mcdev-muted mcdev-code' }, project.root),
67
+ h(
68
+ 'div',
69
+ { className: 'mcdev-muted', style: { marginTop: 2 } },
70
+ `${kindLabel(project.kind)} · 置信度 ${Math.round(Number(project.confidence ?? 0) * 100)}%`,
71
+ ),
72
+
73
+ section('基本信息', [
74
+ row('loader', loaderLabel(project.loader)),
75
+ row('MC 版本', text(project.minecraftVersion)),
76
+ row('loader 版本', text(project.loaderVersion)),
77
+ row('mod id', text(project.modId)),
78
+ row('mod 版本', text(project.modVersion)),
79
+ ]),
80
+
81
+ section('构建环境', [
82
+ row('Gradle', `${text(project.gradleVersion)}${project.wrapper === true ? '(wrapper)' : '(无 wrapper)'}`),
83
+ row('JDK 需求', text(java.required)),
84
+ row('JDK 选中', `${text(java.selected)}${java.exact === true ? '' : '(不完全匹配)'}`),
85
+ row('本机 JDK', java.available?.length > 0 ? java.available.map((jdk) => `${jdk.major}`).join(' / ') : '未发现'),
86
+ ]),
87
+
88
+ section('运行', [
89
+ ...(running === undefined
90
+ ? [
91
+ row('目标', capsuleText(project, chosen)),
92
+ h(
93
+ 'div',
94
+ { className: 'mcdev-actions', style: { marginTop: 6 } },
95
+ h(
96
+ 'button',
97
+ {
98
+ type: 'button',
99
+ className: 'mcdev-run',
100
+ disabled: chosen === undefined || launches.busy !== undefined,
101
+ title: chosen?.taskPath ?? '',
102
+ onClick: () => {
103
+ if (chosen !== undefined) void startTarget(project, chosen)
104
+ },
105
+ },
106
+ '▶ 启动',
107
+ ),
108
+ h('span', { className: 'mcdev-hint' }, '在标题栏胶囊的浮层里切换目标'),
109
+ ),
110
+ ]
111
+ : [
112
+ row('实例', `${running.id} · ${running.taskPath}`),
113
+ row('状态', `${running.status === 'starting' ? '启动中' : '运行中'}${running.readyTimedOut === true ? '(还没看到启动信号,可能仍在构建)' : ''}`),
114
+ row('PID', text(running.pid)),
115
+ h(
116
+ 'div',
117
+ { className: 'mcdev-actions', style: { marginTop: 6 } },
118
+ h(
119
+ 'button',
120
+ { type: 'button', className: 'mcdev-run', 'data-tone': 'stop', disabled: launches.busy === running.id, onClick: () => stopLaunch(running.id) },
121
+ '⏹ 停止',
122
+ ),
123
+ h(
124
+ 'button',
125
+ { type: 'button', className: 'mcdev-run', onClick: () => openLogTab() },
126
+ '打开日志',
127
+ ),
128
+ ),
129
+ ]),
130
+ ]),
131
+
132
+ section('模块与运行目标', [
133
+ ...(modules.length === 0
134
+ ? [h('div', { className: 'mcdev-muted' }, '没有模块信息')]
135
+ : [
136
+ h(
137
+ 'table',
138
+ { className: 'mcdev-table' },
139
+ h('thead', undefined, h('tr', undefined, h('th', undefined, '模块'), h('th', undefined, '平台'), h('th', undefined, '任务'))),
140
+ h(
141
+ 'tbody',
142
+ undefined,
143
+ modules.map((module) =>
144
+ h(
145
+ 'tr',
146
+ { key: module.path },
147
+ h('td', undefined, module.path),
148
+ h('td', undefined, module.platform),
149
+ h(
150
+ 'td',
151
+ { className: 'mcdev-code' },
152
+ module.targets?.length > 0
153
+ ? module.targets.map((target) => `${target.taskPath}${target.guessed === true ? ' ?' : ''}`).join(' ')
154
+ : '—',
155
+ ),
156
+ ),
157
+ ),
158
+ ),
159
+ ),
160
+ ]),
161
+ h('div', { className: 'mcdev-hint', style: { marginTop: 6 } }, '带 ? 的任务是按 loader 惯例推测的,未经验证;启动能力在 M2 接入。'),
162
+ ]),
163
+
164
+ section(
165
+ '证据',
166
+ evidence.length === 0
167
+ ? [h('div', { className: 'mcdev-muted' }, '没有证据')]
168
+ : [
169
+ h(
170
+ 'div',
171
+ { className: 'mcdev-stack' },
172
+ evidence.map((entry, index) =>
173
+ h(
174
+ 'div',
175
+ { key: `${entry.file}:${index}` },
176
+ h('span', { className: 'mcdev-code mcdev-sub' }, entry.file),
177
+ h('span', { className: 'mcdev-muted' }, ` — ${entry.detail}`),
178
+ ),
179
+ ),
180
+ ),
181
+ ],
182
+ ),
183
+
184
+ warnings.length === 0 && conflicts.length === 0
185
+ ? undefined
186
+ : section('提示', [
187
+ ...warnings.map((warning, index) =>
188
+ h(
189
+ 'div',
190
+ { key: `w${index}`, className: 'mcdev-warn mcdev-icon-text' },
191
+ h('span', { className: 'mcdev-icon' }, h(IconWarning, undefined)),
192
+ warning,
193
+ ),
194
+ ),
195
+ ...conflicts.map((conflict, index) => h('div', { key: `c${index}`, className: 'mcdev-error' }, `· ${conflict}`)),
196
+ ]),
197
+
198
+ h(
199
+ 'div',
200
+ { style: { marginTop: 14 } },
201
+ h(
202
+ 'button',
203
+ { type: 'button', className: 'mcdev-mini', onClick: () => loadScan({ refresh: true }) },
204
+ h('span', { className: 'mcdev-icon' }, h(IconRefresh, { size: 12 })),
205
+ state.loading ? '重新扫描中…' : '重新扫描',
206
+ ),
207
+ ),
208
+ state.scan?.scannedAt === undefined
209
+ ? undefined
210
+ : h('div', { className: 'mcdev-muted mcdev-hint', style: { marginTop: 4 } }, `扫描于 ${state.scan.scannedAt}`),
211
+ )
212
+ }
213
+
214
+ /**
215
+ * @param {string} title
216
+ * @param {Array<any>} children
217
+ */
218
+ function section(title, children) {
219
+ return h('div', { className: 'mcdev-section' }, h('h4', undefined, title), ...children)
220
+ }
221
+
222
+ /**
223
+ * @param {string} key
224
+ * @param {string} value
225
+ */
226
+ function row(key, value) {
227
+ return h('div', { className: 'mcdev-row', key }, h('span', { className: 'mcdev-key' }, key), h('span', { className: 'mcdev-code' }, value))
228
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 工程浮层。两种形态:
3
+ *
4
+ * - **多工程**:三层结构——表头固定、工程列表滚动、选中工程的目标固定在底部。
5
+ * 列表每行高度恒定,选工程只改底部,不会"点一下整块跳一下"。
6
+ * - **单工程**(更常见):跳过列表,表头直接显示工程名与 loader/版本,底下就是目标;
7
+ * 少一层选择,也少一次点击。
8
+ *
9
+ * @module minecraft-dev-starter/client/Flyout
10
+ */
11
+
12
+ import { h } from './h.js'
13
+ import { kindLabel, PANEL_TITLE, projectSubtitle, projectTitle } from './format.js'
14
+ import { IconChevronRight, IconRefresh } from './icons.js'
15
+ import { groupByModule, selectedTarget, targetKey } from './selection.js'
16
+ import { Skeleton } from './Skeleton.js'
17
+ import { loadScan, selectTarget, store, useLaunches, useStoreState } from './state.js'
18
+ import { openDetailTab } from './runtime.js'
19
+
20
+ export function Flyout() {
21
+ const state = useStoreState()
22
+ const launches = useLaunches(1500)
23
+ const projects = state.scan?.projects ?? []
24
+ const current = projects.find((project) => project.root === state.selectedRoot) ?? projects[0]
25
+ const single = projects.length === 1
26
+ const running = launches.list.find(
27
+ (entry) =>
28
+ (entry.status === 'starting' || entry.status === 'running') &&
29
+ String(entry.root).toLowerCase() === String(current?.root ?? '').toLowerCase(),
30
+ )
31
+
32
+ return h(
33
+ 'div',
34
+ { className: 'mcdev-flyout', role: 'dialog', 'aria-label': PANEL_TITLE },
35
+
36
+ // ① 表头:与列表之间有一条通栏分割线,滚动时不动
37
+ h(
38
+ 'div',
39
+ { className: 'mcdev-flyout-head' },
40
+ h(
41
+ 'div',
42
+ { className: 'mcdev-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
43
+ h('span', { className: 'mcdev-title' }, PANEL_TITLE),
44
+ h(
45
+ 'button',
46
+ {
47
+ type: 'button',
48
+ className: 'mcdev-mini mcdev-mini-icon',
49
+ title: state.loading ? '扫描中…' : '重新扫描工作区',
50
+ disabled: state.loading,
51
+ onClick: () => loadScan({ refresh: true }),
52
+ },
53
+ h('span', { className: 'mcdev-icon' }, h(IconRefresh, { size: 12 })),
54
+ ),
55
+ ),
56
+ // 单工程:表头直接承担原来列表行的工作,省掉一层选择
57
+ single && current !== undefined
58
+ ? h(
59
+ 'div',
60
+ { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
61
+ h('div', { className: 'mcdev-title', title: projectTitle(current) }, projectTitle(current)),
62
+ h('div', { className: 'mcdev-sub' }, projectSubtitle(current)),
63
+ h('div', { className: 'mcdev-muted mcdev-code mcdev-ellipsis', title: current.root }, current.root),
64
+ )
65
+ : h('div', { className: 'mcdev-muted mcdev-code mcdev-ellipsis', title: state.scan?.root ?? '' }, state.scan?.root ?? '—'),
66
+ state.error === undefined ? undefined : h('div', { className: 'mcdev-error' }, state.error),
67
+ launches.error === undefined ? undefined : h('div', { className: 'mcdev-error' }, launches.error),
68
+ ),
69
+
70
+ // ② 列表:只在多工程时出现;每行只有名字与 loader/版本,高度恒定
71
+ single
72
+ ? undefined
73
+ : h(
74
+ 'div',
75
+ { className: 'mcdev-flyout-list' },
76
+ projects.length === 0
77
+ ? state.loading
78
+ ? h(Skeleton, { rows: 3, hint: '正在扫描工作区…' })
79
+ : h('div', { className: 'mcdev-hint', style: { padding: '10px 4px' } }, '没有找到 Gradle 工程')
80
+ : projects.map((project) =>
81
+ h(
82
+ 'button',
83
+ {
84
+ key: project.root,
85
+ type: 'button',
86
+ className: 'mcdev-list-row',
87
+ 'data-current': project.root === current?.root ? 'true' : 'false',
88
+ title: `${project.root}\n${kindLabel(project.kind)}`,
89
+ onClick: () => store.set({ selectedRoot: project.root }),
90
+ },
91
+ h('span', { className: 'mcdev-list-name' }, project.modName ?? project.name ?? project.root),
92
+ h('span', { className: 'mcdev-list-meta' }, projectSubtitle(project)),
93
+ ),
94
+ ),
95
+ ),
96
+
97
+ // ③ 选择区:选中工程的目标,位置固定
98
+ h(
99
+ 'div',
100
+ { className: 'mcdev-flyout-pane' },
101
+ current === undefined
102
+ ? undefined
103
+ : [
104
+ h(
105
+ 'div',
106
+ { className: 'mcdev-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
107
+ h(
108
+ 'span',
109
+ { className: 'mcdev-ellipsis', style: { minWidth: 0 } },
110
+ h('span', { className: 'mcdev-muted' }, '运行目标'),
111
+ running === undefined
112
+ ? undefined
113
+ : h(
114
+ 'span',
115
+ { className: 'mcdev-inline-status' },
116
+ h('span', {
117
+ className: 'mcdev-dot',
118
+ 'data-state': running.status === 'starting' ? 'warn' : 'ok',
119
+ 'data-pulse': 'true',
120
+ }),
121
+ `${running.label} ${running.status === 'starting' ? '启动中' : '运行中'} · ${elapsed(running.startedAt)}`,
122
+ ),
123
+ ),
124
+ h(
125
+ 'button',
126
+ { type: 'button', className: 'mcdev-mini', onClick: () => openDetailTab() },
127
+ '详情',
128
+ h('span', { className: 'mcdev-icon' }, h(IconChevronRight, { size: 12 })),
129
+ ),
130
+ ),
131
+ renderTargets(current, state.selection, running !== undefined),
132
+ ],
133
+ ),
134
+ )
135
+ }
136
+
137
+ /**
138
+ * @param {Record<string, any>} project
139
+ * @param {Record<string, string>} selection
140
+ * @param {boolean} locked 有实例在跑时不允许换目标
141
+ */
142
+ function renderTargets(project, selection, locked) {
143
+ const targets = /** @type {Array<Record<string, any>>} */ (project.runTargets ?? [])
144
+ if (targets.length === 0) return h('div', { className: 'mcdev-hint', style: { marginTop: 6 } }, '没有找到可运行目标')
145
+
146
+ const chosen = selectedTarget(project, selection)
147
+ const groups = groupByModule(targets)
148
+
149
+ return h(
150
+ 'div',
151
+ { className: 'mcdev-options' },
152
+ groups.map((group) =>
153
+ h(
154
+ 'div',
155
+ { key: group.module, className: 'mcdev-option-group' },
156
+ groups.length > 1 ? h('div', { className: 'mcdev-option-module' }, group.module) : undefined,
157
+ group.targets.map((target) =>
158
+ h(
159
+ 'label',
160
+ { key: targetKey(target), className: `mcdev-option${locked ? ' is-disabled' : ''}`, title: target.taskPath },
161
+ h('input', {
162
+ type: 'radio',
163
+ className: 'mcdev-radio',
164
+ name: `mcdev-${project.root}`,
165
+ checked: chosen !== undefined && targetKey(chosen) === targetKey(target),
166
+ disabled: locked,
167
+ onChange: () => selectTarget(project.root, targetKey(target)),
168
+ }),
169
+ h('span', { className: 'mcdev-option-label' }, target.label),
170
+ target.guessed === true
171
+ ? h('span', { className: 'mcdev-badge', 'data-guessed': 'true', title: '按 loader 惯例推测,尚未验证' }, '未验证')
172
+ : undefined,
173
+ ),
174
+ ),
175
+ ),
176
+ ),
177
+ )
178
+ }
179
+
180
+ /**
181
+ * @param {string} startedAt
182
+ * @returns {string}
183
+ */
184
+ function elapsed(startedAt) {
185
+ const started = Date.parse(startedAt)
186
+ if (Number.isNaN(started)) return '—'
187
+ const seconds = Math.max(0, Math.round((Date.now() - started) / 1000))
188
+ if (seconds < 60) return `${seconds}s`
189
+ const minutes = Math.floor(seconds / 60)
190
+ return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
191
+ }