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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端入口(cordis 插件形状:inject + apply)。
|
|
3
|
+
*
|
|
4
|
+
* 挂四处:
|
|
5
|
+
* - 会话标题栏的胶囊(`conversation.session.header.utilities`)
|
|
6
|
+
* - 右侧栏「Mod Dev」工程标签
|
|
7
|
+
* - 右侧栏「运行日志」标签
|
|
8
|
+
* - 装了 `dsh-better-sidebar` 时,再向它的侧栏注册同样两个页面(带图标)
|
|
9
|
+
*
|
|
10
|
+
* @module minecraft-dev-starter/client
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Capsule } from './Capsule.js'
|
|
14
|
+
import { DetailTab } from './DetailTab.js'
|
|
15
|
+
import { PANEL_TITLE } from './format.js'
|
|
16
|
+
import { LogTab } from './LogTab.js'
|
|
17
|
+
import { report } from './debug.js'
|
|
18
|
+
import { h } from './h.js'
|
|
19
|
+
import { IconCube, IconTerminal } from './icons.js'
|
|
20
|
+
import { DETAIL_KIND, LOG_KIND, runtime } from './runtime.js'
|
|
21
|
+
import { ensureStyles } from './styles.js'
|
|
22
|
+
|
|
23
|
+
export const name = 'mcdev-client'
|
|
24
|
+
|
|
25
|
+
/** cordis 只把 inject 里声明过的服务交给插件;三个服务都在 web 组合里。 */
|
|
26
|
+
export const inject = ['slots', 'sidebarRight', 'sidebarRightTabs']
|
|
27
|
+
|
|
28
|
+
const DETAIL_ID = 'minecraft-dev-starter/detail'
|
|
29
|
+
const LOG_ID = 'minecraft-dev-starter/logs'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {any} ctx
|
|
33
|
+
*/
|
|
34
|
+
export function apply(ctx) {
|
|
35
|
+
runtime.ctx = ctx
|
|
36
|
+
ensureStyles()
|
|
37
|
+
report('apply')
|
|
38
|
+
|
|
39
|
+
// 1) 标题栏胶囊
|
|
40
|
+
try {
|
|
41
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
42
|
+
ctx.slots.register(
|
|
43
|
+
{
|
|
44
|
+
name: 'conversation.session.header.utilities',
|
|
45
|
+
id: 'mc-dev-starter:run-capsule',
|
|
46
|
+
order: 15,
|
|
47
|
+
registrant: 'minecraft-dev-starter',
|
|
48
|
+
},
|
|
49
|
+
Capsule,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
report('capsule.registered')
|
|
53
|
+
} catch (error) {
|
|
54
|
+
report('capsule.failed', { message: String(error) })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2) 右侧栏两个标签类型:工程面板 + 运行日志
|
|
58
|
+
try {
|
|
59
|
+
ctx.effect(
|
|
60
|
+
() => ctx.sidebarRightTabs.register({ id: DETAIL_ID, kind: DETAIL_KIND, title: () => PANEL_TITLE }),
|
|
61
|
+
'mcdev: detail tab type',
|
|
62
|
+
)
|
|
63
|
+
ctx.effect(
|
|
64
|
+
() => ctx.sidebarRightTabs.register({ id: LOG_ID, kind: LOG_KIND, title: () => '运行日志' }),
|
|
65
|
+
'mcdev: log tab type',
|
|
66
|
+
)
|
|
67
|
+
report('tab.type.registered')
|
|
68
|
+
} catch (error) {
|
|
69
|
+
report('tab.type.failed', { message: String(error) })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3) 标签正文
|
|
73
|
+
registerBody(DETAIL_ID, DetailTab, 'detail')
|
|
74
|
+
registerBody(LOG_ID, LogTab, 'logs')
|
|
75
|
+
|
|
76
|
+
// 4) 装了 dsh-better-sidebar 时,把同样的两个页面挂到它的侧栏(它支持标签图标)
|
|
77
|
+
ctx.effect(() => registerBetterSidebarTabs(ctx), 'mcdev: better-sidebar tabs')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 向 `dsh-better-sidebar` 注册两个带图标的标签。
|
|
82
|
+
*
|
|
83
|
+
* 它是可选的第三方插件:可能没装、也可能比我们晚加载,所以轮询取服务,
|
|
84
|
+
* 拿到就注册,拿不到就安静退出(不报错、不影响别的功能)。
|
|
85
|
+
* @param {any} ctx
|
|
86
|
+
* @returns {() => void} disposer
|
|
87
|
+
*/
|
|
88
|
+
function registerBetterSidebarTabs(ctx) {
|
|
89
|
+
/** @type {Array<() => void>} */
|
|
90
|
+
const disposers = []
|
|
91
|
+
/** @type {(() => void) | undefined} */
|
|
92
|
+
let injectHandle
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {any} sidebar
|
|
96
|
+
*/
|
|
97
|
+
const register = (sidebar) => {
|
|
98
|
+
if (sidebar === undefined || typeof sidebar.registerTab !== 'function') return
|
|
99
|
+
try {
|
|
100
|
+
disposers.push(
|
|
101
|
+
sidebar.registerTab({
|
|
102
|
+
id: 'minecraft-dev-starter:project',
|
|
103
|
+
title: PANEL_TITLE,
|
|
104
|
+
icon: (size) => h(IconCube, { size }),
|
|
105
|
+
component: () => h(DetailTab),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
disposers.push(
|
|
109
|
+
sidebar.registerTab({
|
|
110
|
+
id: 'minecraft-dev-starter:logs',
|
|
111
|
+
title: '运行日志',
|
|
112
|
+
icon: (size) => h(IconTerminal, { size }),
|
|
113
|
+
component: () => h(LogTab),
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
report('betterSidebar.registered')
|
|
117
|
+
} catch (error) {
|
|
118
|
+
report('betterSidebar.failed', { message: String(error) })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 首选 cordis 的作用域注入:等服务出现再注册,插件不在也不会报错。
|
|
123
|
+
if (typeof ctx.inject === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
injectHandle = ctx.inject(['betterSidebar'], (scoped) => {
|
|
126
|
+
register(scoped.get('betterSidebar', false))
|
|
127
|
+
})
|
|
128
|
+
report('betterSidebar.waiting')
|
|
129
|
+
return () => {
|
|
130
|
+
try {
|
|
131
|
+
injectHandle?.()
|
|
132
|
+
} catch {
|
|
133
|
+
/* ignore */
|
|
134
|
+
}
|
|
135
|
+
for (const dispose of disposers.splice(0)) {
|
|
136
|
+
try {
|
|
137
|
+
dispose()
|
|
138
|
+
} catch {
|
|
139
|
+
/* ignore */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
report('betterSidebar.inject.failed', { message: String(error) })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 兜底:轮询 get(strict=false 不抛),30 秒找不到就放弃
|
|
149
|
+
let stopped = false
|
|
150
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
151
|
+
let timer
|
|
152
|
+
const startedAt = Date.now()
|
|
153
|
+
const attempt = () => {
|
|
154
|
+
if (stopped) return
|
|
155
|
+
let sidebar
|
|
156
|
+
try {
|
|
157
|
+
sidebar = typeof ctx.get === 'function' ? ctx.get('betterSidebar', false) : undefined
|
|
158
|
+
} catch {
|
|
159
|
+
sidebar = undefined
|
|
160
|
+
}
|
|
161
|
+
if (sidebar === undefined || typeof sidebar.registerTab !== 'function') {
|
|
162
|
+
if (Date.now() - startedAt > 30_000) {
|
|
163
|
+
report('betterSidebar.absent')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
timer = setTimeout(attempt, 700)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
register(sidebar)
|
|
170
|
+
}
|
|
171
|
+
attempt()
|
|
172
|
+
return () => {
|
|
173
|
+
stopped = true
|
|
174
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
175
|
+
for (const dispose of disposers.splice(0)) {
|
|
176
|
+
try {
|
|
177
|
+
dispose()
|
|
178
|
+
} catch {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 注册标签正文;某些版本要求先等 slot 声明上账,所以失败后走 inject 再试一次。
|
|
187
|
+
* @param {string} id
|
|
188
|
+
* @param {any} component
|
|
189
|
+
* @param {string} tag 诊断上报里的名字
|
|
190
|
+
*/
|
|
191
|
+
function registerBody(id, component, tag) {
|
|
192
|
+
const ctx = runtime.ctx
|
|
193
|
+
const register = () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: id }, component)
|
|
194
|
+
try {
|
|
195
|
+
register()
|
|
196
|
+
report(`${tag}.body.registered`)
|
|
197
|
+
} catch {
|
|
198
|
+
try {
|
|
199
|
+
ctx.slots.inject('sidebar.right.pane.tab', register)
|
|
200
|
+
report(`${tag}.body.registered.via-inject`)
|
|
201
|
+
} catch (error) {
|
|
202
|
+
report(`${tag}.body.failed`, { message: String(error) })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端运行时句柄。
|
|
3
|
+
*
|
|
4
|
+
* 用模块级对象而不是 props:slot 组件的注入约定会随版本变,
|
|
5
|
+
* 而 `ctx` 在 apply 时就确定了,组件里读它最稳。
|
|
6
|
+
* @module minecraft-dev-starter/client/runtime
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 右侧栏「MC 工程」详情标签的 kind。 */
|
|
10
|
+
export const DETAIL_KIND = 'mcdev.detail'
|
|
11
|
+
|
|
12
|
+
/** 右侧栏「运行日志」标签的 kind。 */
|
|
13
|
+
export const LOG_KIND = 'mcdev.logs'
|
|
14
|
+
|
|
15
|
+
/** @type {{ ctx?: any }} */
|
|
16
|
+
export const runtime = {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} kind
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
function open(kind) {
|
|
23
|
+
const sidebar = runtime.ctx?.sidebarRight
|
|
24
|
+
if (sidebar === undefined) return false
|
|
25
|
+
try {
|
|
26
|
+
sidebar.openTab(kind)
|
|
27
|
+
return true
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 打开工程详情标签(openTab 会顺带展开面板)。 */
|
|
34
|
+
export function openDetailTab() {
|
|
35
|
+
return open(DETAIL_KIND)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 打开运行日志标签。 */
|
|
39
|
+
export function openLogTab() {
|
|
40
|
+
return open(LOG_KIND)
|
|
41
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 目标选择的纯逻辑(不碰 React、不碰 DOM),方便单测。
|
|
3
|
+
*
|
|
4
|
+
* 选择结果按工程根目录记在 localStorage:同一工程下次打开还是上次那个目标。
|
|
5
|
+
* @module minecraft-dev-starter/client/selection
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @type {Record<string, string>} */
|
|
9
|
+
const STORAGE_KEY = 'mcdev.selection'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 一个目标的稳定 key(模块 + 任务路径)。
|
|
13
|
+
* @param {Record<string, any>} target
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function targetKey(target) {
|
|
17
|
+
return `${target.module ?? '.'}:${target.taskPath}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 按模块分组,保持 discovery 顺序;`common` 这类不可运行模块本来就无目标,不出现。
|
|
22
|
+
* @param {Array<Record<string, any>>} targets
|
|
23
|
+
* @returns {Array<{ module: string, targets: Array<Record<string, any>> }>}
|
|
24
|
+
*/
|
|
25
|
+
export function groupByModule(targets) {
|
|
26
|
+
/** @type {Array<{ module: string, targets: Array<Record<string, any>> }>} */
|
|
27
|
+
const groups = []
|
|
28
|
+
for (const target of targets) {
|
|
29
|
+
const module = String(target.module ?? '.')
|
|
30
|
+
const group = groups.find((entry) => entry.module === module)
|
|
31
|
+
if (group === undefined) groups.push({ module, targets: [target] })
|
|
32
|
+
else group.targets.push(target)
|
|
33
|
+
}
|
|
34
|
+
return groups
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 当前选中的目标:记住的还在就用它,否则退回第一个。
|
|
39
|
+
* @param {Record<string, any> | undefined} project
|
|
40
|
+
* @param {Record<string, string>} selection
|
|
41
|
+
* @returns {Record<string, any> | undefined}
|
|
42
|
+
*/
|
|
43
|
+
export function selectedTarget(project, selection) {
|
|
44
|
+
const targets = /** @type {Array<Record<string, any>>} */ (project?.runTargets ?? [])
|
|
45
|
+
if (targets.length === 0) return undefined
|
|
46
|
+
const remembered = selection[String(project?.root ?? '')]
|
|
47
|
+
return targets.find((target) => targetKey(target) === remembered) ?? targets[0]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 胶囊上那行字:单模块只显示目标名,多模块带模块前缀。
|
|
52
|
+
* @param {Record<string, any> | undefined} project
|
|
53
|
+
* @param {Record<string, any> | undefined} target
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function capsuleText(project, target) {
|
|
57
|
+
if (project === undefined) return '未检测到 MC 工程'
|
|
58
|
+
if (target === undefined) return '无可运行目标'
|
|
59
|
+
const module = String(target.module ?? '.')
|
|
60
|
+
return module === '.' ? String(target.label) : `${module} · ${target.label}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 读一次记住的选择(localStorage 不可用时返回空表)。
|
|
65
|
+
* @returns {Record<string, string>}
|
|
66
|
+
*/
|
|
67
|
+
export function readSelection() {
|
|
68
|
+
try {
|
|
69
|
+
const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
|
|
70
|
+
if (typeof raw !== 'string') return {}
|
|
71
|
+
const parsed = JSON.parse(raw)
|
|
72
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
73
|
+
} catch {
|
|
74
|
+
return {}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 写回选择。
|
|
80
|
+
* @param {Record<string, string>} selection
|
|
81
|
+
*/
|
|
82
|
+
export function writeSelection(selection) {
|
|
83
|
+
try {
|
|
84
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(selection))
|
|
85
|
+
} catch {
|
|
86
|
+
/* 隐身模式等场景写不进去,忽略 */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 日志标签应该看哪一条实例。
|
|
92
|
+
*
|
|
93
|
+
* 规则(按优先级):
|
|
94
|
+
* 1. 有正在跑/启动中的实例 → 永远跟它(点 ▶ 之后最想看的就是这次);
|
|
95
|
+
* 2. 用户手动选过(pinned)→ 保持他的选择;
|
|
96
|
+
* 3. 否则跟当前、再否则跟最新一条。
|
|
97
|
+
*
|
|
98
|
+
* 早期版本只在"没选过"时挑一次,于是新启动的实例不会顶上来——
|
|
99
|
+
* 面板会一直停在旧实例(哪怕它是 0 字节的失败记录)。
|
|
100
|
+
* @param {Array<Record<string, any>>} history
|
|
101
|
+
* @param {string | undefined} currentId
|
|
102
|
+
* @param {boolean} pinned
|
|
103
|
+
* @returns {string | undefined}
|
|
104
|
+
*/
|
|
105
|
+
export function pickLogTarget(history, currentId, pinned) {
|
|
106
|
+
const active = history.find((entry) => entry.status === 'starting' || entry.status === 'running')
|
|
107
|
+
if (active !== undefined) return active.id
|
|
108
|
+
if (pinned && currentId !== undefined) return currentId
|
|
109
|
+
if (currentId !== undefined && history.some((entry) => entry.id === currentId)) return currentId
|
|
110
|
+
return history[0]?.id
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 实例状态的中文标签。
|
|
115
|
+
* @param {Record<string, any>} entry
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
export function launchStatusText(entry) {
|
|
119
|
+
if (entry.status === 'starting') return '启动中'
|
|
120
|
+
if (entry.status === 'running') return '运行中'
|
|
121
|
+
if (entry.status === 'unknown') return '状态未知'
|
|
122
|
+
if (entry.outcome === 'killed') return '已停止'
|
|
123
|
+
if (entry.outcome === 'normal') return '已退出'
|
|
124
|
+
return entry.exitCode === null || entry.exitCode === undefined ? '已退出' : `已退出(${entry.exitCode})`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 胶囊的两段文案。窄的时候应该让"工程名"省略,而不是把目标名截掉——
|
|
129
|
+
* 目标名才是可操作的那部分,工程名只是上下文。
|
|
130
|
+
* @param {Record<string, any> | undefined} project
|
|
131
|
+
* @param {Record<string, any> | undefined} target
|
|
132
|
+
* @param {{ multipleProjects?: boolean }} [options]
|
|
133
|
+
* @returns {{ project: string | undefined, target: string }}
|
|
134
|
+
*/
|
|
135
|
+
export function capsuleParts(project, target, options = {}) {
|
|
136
|
+
const targetText = capsuleText(project, target)
|
|
137
|
+
if (project === undefined || target === undefined || options.multipleProjects !== true) {
|
|
138
|
+
return { project: undefined, target: targetText }
|
|
139
|
+
}
|
|
140
|
+
return { project: String(project.modName ?? project.name ?? project.root), target: targetText }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 胶囊文案。工作区里有多个工程时把工程名带上——否则只显示 "Client" 没人知道
|
|
145
|
+
* 它是哪个工程的(一个工作区里每工程都记着自己的选择)。
|
|
146
|
+
* @param {Record<string, any> | undefined} project
|
|
147
|
+
* @param {Record<string, any> | undefined} target
|
|
148
|
+
* @param {{ multipleProjects?: boolean }} [options]
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function capsuleSummary(project, target, options = {}) {
|
|
152
|
+
const parts = capsuleParts(project, target, options)
|
|
153
|
+
return parts.project === undefined ? parts.target : `${parts.project} · ${parts.target}`
|
|
154
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端共享状态与数据加载。
|
|
3
|
+
*
|
|
4
|
+
* 一次 `projects.scan` 结果给三个地方用:标题栏胶囊、浮层、右侧栏详情;
|
|
5
|
+
* 所以放在模块级 store 里,而不是每个组件各拉一次。
|
|
6
|
+
* @module minecraft-dev-starter/client/state
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import { call } from './api.js'
|
|
11
|
+
import { openLogTab } from './runtime.js'
|
|
12
|
+
import { readSelection, writeSelection } from './selection.js'
|
|
13
|
+
import { createStore } from './store.js'
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ store: ReturnType<typeof createStore> }} Never */
|
|
16
|
+
|
|
17
|
+
export const store = createStore({
|
|
18
|
+
loading: false,
|
|
19
|
+
/** @type {string | undefined} */
|
|
20
|
+
error: undefined,
|
|
21
|
+
/** @type {{ root: string, projects: Array<Record<string, any>>, truncated: boolean, scannedAt: string } | undefined} */
|
|
22
|
+
scan: undefined,
|
|
23
|
+
/** @type {string | undefined} */
|
|
24
|
+
selectedRoot: undefined,
|
|
25
|
+
flyoutOpen: false,
|
|
26
|
+
/** @type {Record<string, string>} 每个工程记住的目标 key */
|
|
27
|
+
selection: readSelection(),
|
|
28
|
+
/** @type {{ autoOpenLogTab?: boolean }} 宿主下发的部署配置 */
|
|
29
|
+
config: { autoOpenLogTab: true },
|
|
30
|
+
/** @type {string | undefined} 当前会话的工作区(由胶囊从会话上下文注入) */
|
|
31
|
+
workspaceRoot: undefined,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 拉一次宿主配置(启动时自动打开日志标签等),只取一次。
|
|
36
|
+
*/
|
|
37
|
+
export async function loadStateInfo() {
|
|
38
|
+
try {
|
|
39
|
+
const info = await call('state.info', {})
|
|
40
|
+
store.set({ config: { autoOpenLogTab: true, ...(info?.config ?? {}) } })
|
|
41
|
+
} catch {
|
|
42
|
+
/* 拉不到就用默认值 */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 记住某个工程选中的目标。
|
|
48
|
+
* @param {string} root
|
|
49
|
+
* @param {string} key
|
|
50
|
+
*/
|
|
51
|
+
export function selectTarget(root, key) {
|
|
52
|
+
const selection = { ...store.get().selection, [root]: key }
|
|
53
|
+
writeSelection(selection)
|
|
54
|
+
store.set({ selection })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @type {Promise<unknown> | undefined} */
|
|
58
|
+
let inFlight
|
|
59
|
+
/** @type {string | undefined} */
|
|
60
|
+
let inFlightRoot
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 拉一次扫描结果。
|
|
64
|
+
*
|
|
65
|
+
* `root` 默认取当前会话的工作区(由胶囊从会话上下文里读出来再塞进 store)。
|
|
66
|
+
* 切会话时 `root` 变了,必须清掉旧结果,否则胶囊会短暂显示上一个工作区的工程。
|
|
67
|
+
* @param {{ root?: string, refresh?: boolean }} [options]
|
|
68
|
+
* @returns {Promise<void>}
|
|
69
|
+
*/
|
|
70
|
+
export async function loadScan(options = {}) {
|
|
71
|
+
const root = options.root ?? store.get().workspaceRoot
|
|
72
|
+
if (inFlight !== undefined) {
|
|
73
|
+
if (root === inFlightRoot) return inFlight
|
|
74
|
+
await inFlight.catch(() => {})
|
|
75
|
+
}
|
|
76
|
+
inFlightRoot = root
|
|
77
|
+
store.set({ loading: true, error: undefined })
|
|
78
|
+
inFlight = call('projects.scan', {
|
|
79
|
+
...(root === undefined ? {} : { root }),
|
|
80
|
+
...(options.refresh === true ? { refresh: true } : {}),
|
|
81
|
+
})
|
|
82
|
+
.then((result) => {
|
|
83
|
+
const scan = /** @type {NonNullable<ReturnType<typeof store.get>['scan']>} */ (result)
|
|
84
|
+
const current = store.get().selectedRoot
|
|
85
|
+
const stillThere = scan.projects.some((project) => project.root === current)
|
|
86
|
+
store.set({
|
|
87
|
+
loading: false,
|
|
88
|
+
scan,
|
|
89
|
+
workspaceRoot: scan.root,
|
|
90
|
+
error: undefined,
|
|
91
|
+
selectedRoot: stillThere ? current : scan.projects[0]?.root,
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
.catch((error) => {
|
|
95
|
+
store.set({ loading: false, error: error instanceof Error ? error.message : String(error) })
|
|
96
|
+
})
|
|
97
|
+
.finally(() => {
|
|
98
|
+
inFlight = undefined
|
|
99
|
+
inFlightRoot = undefined
|
|
100
|
+
})
|
|
101
|
+
return inFlight
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 当前选中的工程(默认第一个)。 */
|
|
105
|
+
export function selectedProject() {
|
|
106
|
+
const state = store.get()
|
|
107
|
+
const projects = state.scan?.projects ?? []
|
|
108
|
+
return projects.find((project) => project.root === state.selectedRoot) ?? projects[0]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* React 绑定:订阅 store 并在变化时重渲染。
|
|
113
|
+
* @returns {ReturnType<typeof store.get>}
|
|
114
|
+
*/
|
|
115
|
+
export function useStoreState() {
|
|
116
|
+
const [state, setState] = useState(store.get())
|
|
117
|
+
useEffect(() => store.subscribe(setState), [])
|
|
118
|
+
return state
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── 运行实例 ──────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** 实例列表单独一个 store:轮询频率比扫描高,别互相拖累。 */
|
|
124
|
+
export const launchStore = createStore({
|
|
125
|
+
/** @type {Array<Record<string, any>>} */
|
|
126
|
+
list: [],
|
|
127
|
+
/** @type {string | undefined} */
|
|
128
|
+
error: undefined,
|
|
129
|
+
/** @type {string | undefined} 正在启停的目标 key,用来禁用按钮 */
|
|
130
|
+
busy: undefined,
|
|
131
|
+
/** @type {number} 每次拉取递增,供 tail 刷新用 */
|
|
132
|
+
rev: 0,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 拉一次实例列表。
|
|
137
|
+
* @param {string | undefined} [root]
|
|
138
|
+
*/
|
|
139
|
+
export async function loadLaunches(root) {
|
|
140
|
+
try {
|
|
141
|
+
const list = await call('launch.list', root === undefined ? {} : { root })
|
|
142
|
+
launchStore.set({ list: /** @type {Array<Record<string, any>>} */ (list), error: undefined, rev: Date.now() })
|
|
143
|
+
} catch (error) {
|
|
144
|
+
launchStore.set({ error: error instanceof Error ? error.message : String(error) })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} root
|
|
150
|
+
* @returns {Record<string, any> | undefined}
|
|
151
|
+
*/
|
|
152
|
+
export function activeLaunchFor(root) {
|
|
153
|
+
return launchStore.get().list.find(
|
|
154
|
+
(entry) => String(entry.root).toLowerCase() === root.toLowerCase() && (entry.status === 'starting' || entry.status === 'running'),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 启动一个目标。
|
|
160
|
+
* @param {Record<string, any>} project
|
|
161
|
+
* @param {Record<string, any>} target
|
|
162
|
+
*/
|
|
163
|
+
export async function startTarget(project, target) {
|
|
164
|
+
const key = `${project.root}:${target.taskPath}`
|
|
165
|
+
launchStore.set({ busy: key, error: undefined })
|
|
166
|
+
try {
|
|
167
|
+
await call('launch.start', {
|
|
168
|
+
root: project.root,
|
|
169
|
+
taskPath: target.taskPath,
|
|
170
|
+
label: target.label,
|
|
171
|
+
kind: target.kind,
|
|
172
|
+
module: target.module,
|
|
173
|
+
})
|
|
174
|
+
// 启动成功就把日志摆到人眼前(部署配置可关)
|
|
175
|
+
if (store.get().config.autoOpenLogTab !== false) openLogTab()
|
|
176
|
+
} catch (error) {
|
|
177
|
+
launchStore.set({ error: error instanceof Error ? error.message : String(error) })
|
|
178
|
+
} finally {
|
|
179
|
+
launchStore.set({ busy: undefined })
|
|
180
|
+
await loadLaunches()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 停止一个实例。
|
|
186
|
+
* @param {string} id
|
|
187
|
+
*/
|
|
188
|
+
export async function stopLaunch(id) {
|
|
189
|
+
launchStore.set({ busy: id, error: undefined })
|
|
190
|
+
try {
|
|
191
|
+
await call('launch.stop', { id })
|
|
192
|
+
} catch (error) {
|
|
193
|
+
launchStore.set({ error: error instanceof Error ? error.message : String(error) })
|
|
194
|
+
} finally {
|
|
195
|
+
launchStore.set({ busy: undefined })
|
|
196
|
+
await loadLaunches()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* React 绑定 + 轮询(页面不可见时停轮询)。
|
|
202
|
+
* @param {number} [intervalMs]
|
|
203
|
+
* @returns {ReturnType<typeof launchStore.get>}
|
|
204
|
+
*/
|
|
205
|
+
export function useLaunches(intervalMs = 1500) {
|
|
206
|
+
const [state, setState] = useState(launchStore.get())
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const unsubscribe = launchStore.subscribe(setState)
|
|
209
|
+
void loadLaunches()
|
|
210
|
+
let timer
|
|
211
|
+
const tick = () => {
|
|
212
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
|
|
213
|
+
void loadLaunches()
|
|
214
|
+
}
|
|
215
|
+
timer = setInterval(tick, intervalMs)
|
|
216
|
+
return () => {
|
|
217
|
+
unsubscribe()
|
|
218
|
+
clearInterval(timer)
|
|
219
|
+
}
|
|
220
|
+
}, [intervalMs])
|
|
221
|
+
return state
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 拉一次某个实例的日志尾部。
|
|
226
|
+
* @param {string} id
|
|
227
|
+
* @param {number} limit
|
|
228
|
+
* @returns {Promise<string[]>}
|
|
229
|
+
*/
|
|
230
|
+
export async function fetchLogTail(id, limit = 40) {
|
|
231
|
+
const result = await call('launch.logs', { id, offset: 0, limit })
|
|
232
|
+
return /** @type {string[]} */ (result.lines ?? [])
|
|
233
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 极简可订阅状态。客户端不需要状态库:一个扫描结果 + 一个选中项就够。
|
|
3
|
+
* @module minecraft-dev-starter/client/store
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @template {Record<string, unknown>} T
|
|
8
|
+
* @param {T} initial
|
|
9
|
+
*/
|
|
10
|
+
export function createStore(initial) {
|
|
11
|
+
/** @type {T} */
|
|
12
|
+
let value = initial
|
|
13
|
+
const listeners = new Set()
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
/** @returns {T} */
|
|
17
|
+
get() {
|
|
18
|
+
return value
|
|
19
|
+
},
|
|
20
|
+
/** @param {Partial<T>} next */
|
|
21
|
+
set(next) {
|
|
22
|
+
value = { ...value, ...next }
|
|
23
|
+
for (const listener of listeners) listener(value)
|
|
24
|
+
},
|
|
25
|
+
/**
|
|
26
|
+
* @param {(value: T) => void} listener
|
|
27
|
+
* @returns {() => void}
|
|
28
|
+
*/
|
|
29
|
+
subscribe(listener) {
|
|
30
|
+
listeners.add(listener)
|
|
31
|
+
return () => listeners.delete(listener)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|