minecraft-dev-starter 0.0.0-stage → 0.1.1
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/AGENTS.md +166 -0
- package/README.md +124 -2
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1521 -0
- package/package.json +54 -4
- package/src/client/Capsule.js +158 -0
- package/src/client/DetailTab.js +228 -0
- package/src/client/Flyout.js +191 -0
- package/src/client/LogTab.js +180 -0
- package/src/client/Skeleton.js +18 -0
- package/src/client/api.js +33 -0
- package/src/client/debug.js +27 -0
- package/src/client/format.js +89 -0
- package/src/client/h.js +8 -0
- package/src/client/icons.js +147 -0
- package/src/client/index.js +205 -0
- package/src/client/runtime.js +41 -0
- package/src/client/selection.js +154 -0
- package/src/client/state.js +233 -0
- package/src/client/store.js +34 -0
- package/src/client/styles.js +329 -0
- package/src/index.js +193 -0
- package/src/lib/api.js +186 -0
- package/src/lib/argv.js +66 -0
- package/src/lib/detect.js +460 -0
- package/src/lib/gradle.js +295 -0
- package/src/lib/java.js +204 -0
- package/src/lib/launches.js +495 -0
- package/src/lib/metadata.js +175 -0
- package/src/lib/scanner.js +131 -0
- package/src/lib/state.js +91 -0
- package/src/lib/text.js +92 -0
- package/src/lib/toml.js +281 -0
- package/src/tools.js +291 -0
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minecraft-dev-starter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|