@dsh-workbench/module-tools 0.1.14 → 0.1.16
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/README.md +71 -0
- package/bin/dsh-wb-module.mjs +4 -2
- package/package.json +3 -2
- package/src/build.mjs +2 -11
- package/src/check.mjs +88 -7
- package/src/checks/alias.mjs +51 -29
- package/src/checks/bundles.mjs +44 -31
- package/src/checks/cards.mjs +119 -107
- package/src/checks/contracts.mjs +621 -116
- package/src/checks/kit.mjs +127 -64
- package/src/checks/markers.mjs +55 -32
- package/src/checks/primitives.mjs +82 -57
- package/src/checks/source.mjs +106 -92
- package/src/checks/standalone.mjs +54 -24
- package/src/checks/style.mjs +133 -94
- package/src/css-injection.mjs +27 -0
- package/src/design.mjs +424 -0
- package/src/discover.mjs +1 -1
- package/src/init.mjs +6 -1
- package/src/publish.mjs +134 -0
- package/template/scripts/smoke.mjs.tpl +104 -4
- package/template-diy/README.md.tpl +282 -0
- package/template-diy/cordis.patch.yml.tpl +3 -0
- package/template-diy/design.json.tpl +38 -0
- package/template-diy/package.json.tpl +43 -0
- package/template-diy/scripts/smoke.mjs.tpl +232 -0
- package/template-diy/src/client.tsx.tpl +258 -0
- package/template-diy/src/host.ts.tpl +131 -0
- package/template-diy/src/messages.ts.tpl +28 -0
- package/template-diy/src/plugin.test.ts.tpl +359 -0
- package/template-diy/tsconfig.json +20 -0
- package/template-diy/types/globals.d.ts +27 -0
- package/template-diy/vitest.config.ts +14 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// 模块包的接线自检:把 client.js 当成浏览器里的加载器那样跑一遍,再把宿主产物当 ESM 跑一遍。
|
|
2
|
+
//
|
|
3
|
+
// 为什么值得有:宿主侧挂没挂上,curl /panels 就能看见;浏览器侧挂没挂上,过去只能靠"刷个页面看看"。
|
|
4
|
+
// 这个脚本复刻 dsh 加载器的**契约**(`window.__ModuleLoader__.load({ id, factory })` + `factory(require)`),
|
|
5
|
+
// 于是"模块注册的卡片到底进没进外壳"变成一条机器判据,而不再是人的眼睛。
|
|
6
|
+
//
|
|
7
|
+
// ★ 这份脚本验的是**形状**:它认不出成员名,所以换数据源、换卡片内容都不用改它。
|
|
8
|
+
// 下面那一段"那条只读路由真读盘"是**这份模板自己的故事**(`<unit>/overview`):你把数据源换成
|
|
9
|
+
// 别的之后,它会自动跳过并提醒你,在同一个位置补一条**你自己的**真读断言 —— 卡片画的数从哪来,
|
|
10
|
+
// `check` 的十条判据一条都不碰,只有这里会真的去调一次。
|
|
11
|
+
//
|
|
12
|
+
// 负例在最后一段:服务不在的时候必须**报出来**,不许静默通过 —— 静默的模块是最难查的那种坏。
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const PLUGIN_ID = '__NAME__';
|
|
19
|
+
const UNIT_ID = '__UNIT__';
|
|
20
|
+
const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
|
|
21
|
+
const failures = [];
|
|
22
|
+
|
|
23
|
+
function check(name, ok, detail) {
|
|
24
|
+
if (ok) {
|
|
25
|
+
console.log(`✅ ${name}`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
failures.push(name);
|
|
29
|
+
console.log(`❌ ${name}${detail === undefined ? '' : ` —— ${detail}`}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 什么属性都拿得到、什么调用都返回自己的替身 —— 这样"模块用了 kit 的哪个成员"就不再是
|
|
34
|
+
* 这份脚本要知道的事(`check:cards` 用的是同一招)。真红只留给模块自己的代码。
|
|
35
|
+
*/
|
|
36
|
+
function anyStub(path = 'stub') {
|
|
37
|
+
const target = function () {
|
|
38
|
+
return anyStub(`${path}()`);
|
|
39
|
+
};
|
|
40
|
+
return new Proxy(target, {
|
|
41
|
+
get(_target, key) {
|
|
42
|
+
if (key === Symbol.toPrimitive) return () => 0;
|
|
43
|
+
if (key === Symbol.iterator) return function* () {};
|
|
44
|
+
if (key === 'then') return undefined;
|
|
45
|
+
if (key === 'length') return 0;
|
|
46
|
+
if (key === 'toString') return () => path;
|
|
47
|
+
return anyStub(`${path}.${String(key)}`);
|
|
48
|
+
},
|
|
49
|
+
apply() {
|
|
50
|
+
return anyStub(`${path}()`);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 外壳 kit 的替身:**三个桶都必须在** —— 卡片是在模块顶层解构 kit 的,少一个桶是 import 那一刻就抛。 */
|
|
56
|
+
function fakeKit() {
|
|
57
|
+
return {
|
|
58
|
+
hooks: new Proxy({}, { get: () => () => anyStub('hook') }),
|
|
59
|
+
components: new Proxy({}, { get: () => () => null }),
|
|
60
|
+
api: new Proxy({}, { get: () => () => anyStub('api') }),
|
|
61
|
+
lib: anyStub('lib'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function moduleRequire(name) {
|
|
66
|
+
if (name === 'react') {
|
|
67
|
+
// 必须是**真的元素对象**:返回 null 的话,"卡片画得出来"这类断言会永远假红。
|
|
68
|
+
return { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }) };
|
|
69
|
+
}
|
|
70
|
+
if (name === '@dsh-workbench/core/client') return { kit: fakeKit() };
|
|
71
|
+
throw new Error(`模块包不该 require ${JSON.stringify(name)}:模块表里只有外壳的 seed 词与外部声明`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let boot = null;
|
|
75
|
+
globalThis.window = {
|
|
76
|
+
__ModuleLoader__: {
|
|
77
|
+
load(entry) {
|
|
78
|
+
boot = entry;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await import(new URL('../client.js', import.meta.url).href);
|
|
84
|
+
|
|
85
|
+
check('client.js 调用 __ModuleLoader__.load()', boot !== null);
|
|
86
|
+
check('模块 id 等于包名', boot?.id === PLUGIN_ID, boot?.id);
|
|
87
|
+
if (boot === null) {
|
|
88
|
+
console.error(`\n${failures.length} 条没过`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pluginExports = boot.factory(moduleRequire);
|
|
93
|
+
check(
|
|
94
|
+
'注入 workbenchUnits(外壳交出来的那个服务名)',
|
|
95
|
+
Array.isArray(pluginExports.inject) && pluginExports.inject.includes('workbenchUnits'),
|
|
96
|
+
String(pluginExports.inject),
|
|
97
|
+
);
|
|
98
|
+
check('导出了 apply', typeof pluginExports.apply === 'function');
|
|
99
|
+
|
|
100
|
+
const faces = [];
|
|
101
|
+
const ctx = {
|
|
102
|
+
get: (name) =>
|
|
103
|
+
name === 'workbenchUnits'
|
|
104
|
+
? {
|
|
105
|
+
registerUnit(face) {
|
|
106
|
+
faces.push(face);
|
|
107
|
+
return () => {};
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
: undefined,
|
|
111
|
+
effect: (fn) => {
|
|
112
|
+
fn();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
pluginExports.apply(ctx);
|
|
117
|
+
check('卡片真的注册进了外壳', faces.length === 1, `注册了 ${faces.length} 个`);
|
|
118
|
+
check(`注册的是 ${UNIT_ID} 单元`, faces[0]?.manifest.id === UNIT_ID, faces[0]?.manifest.id);
|
|
119
|
+
check('卡片是个能画的组件', typeof faces[0]?.card === 'function');
|
|
120
|
+
check(
|
|
121
|
+
'卡片自己带标题与顺序(外壳的看板据此排)',
|
|
122
|
+
typeof faces[0]?.manifest.card?.titleKey === 'string' &&
|
|
123
|
+
faces[0].manifest.card.titleKey !== '' &&
|
|
124
|
+
typeof faces[0]?.manifest.card?.order === 'number',
|
|
125
|
+
JSON.stringify(faces[0]?.manifest.card),
|
|
126
|
+
);
|
|
127
|
+
check(
|
|
128
|
+
'卡片在空数据下也画得出来(不许抛)',
|
|
129
|
+
(() => {
|
|
130
|
+
try {
|
|
131
|
+
return (
|
|
132
|
+
faces[0].card({ world: { current: {} }, refreshToken: 0, onRefresh: () => {}, t: (key) => key }) != null
|
|
133
|
+
);
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
})(),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const warnings = [];
|
|
141
|
+
const realWarn = console.warn;
|
|
142
|
+
console.warn = (...args) => warnings.push(args.join(' '));
|
|
143
|
+
pluginExports.apply({
|
|
144
|
+
get: () => undefined,
|
|
145
|
+
effect: (fn) => {
|
|
146
|
+
fn();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
console.warn = realWarn;
|
|
150
|
+
|
|
151
|
+
check(
|
|
152
|
+
'负例:外壳没交服务时必须报出来(不许静默通过)',
|
|
153
|
+
warnings.some((line) => line.includes('workbenchUnits')),
|
|
154
|
+
warnings.join(' | '),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// ── 宿主产物:面注册得对不对 ──────────────────────────────────────────────
|
|
158
|
+
const hostExports = await import(new URL('../dist/host.js', import.meta.url).href);
|
|
159
|
+
check('宿主产物导出了 apply', typeof hostExports.apply === 'function');
|
|
160
|
+
check(
|
|
161
|
+
'宿主产物也注入 workbenchUnits',
|
|
162
|
+
Array.isArray(hostExports.inject) && hostExports.inject.includes('workbenchUnits'),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const hostFaces = [];
|
|
166
|
+
hostExports.apply({
|
|
167
|
+
get: (name) =>
|
|
168
|
+
name === 'workbenchUnits'
|
|
169
|
+
? {
|
|
170
|
+
register(face) {
|
|
171
|
+
hostFaces.push(face);
|
|
172
|
+
return () => {};
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
: undefined,
|
|
176
|
+
effect: (fn) => {
|
|
177
|
+
fn();
|
|
178
|
+
},
|
|
179
|
+
logger: { warn: () => {} },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
check('宿主面注册进了外壳', hostFaces.length === 1, `注册了 ${hostFaces.length} 个`);
|
|
183
|
+
// 给一份最小 deps:宿主面的 `mount(deps)` 会去 `deps.ctx.get('shell')` 拿服务,
|
|
184
|
+
// 不给 deps 的话**任何真的用了 `mount(deps)` 的模块**都会在这里崩 —— 而那不是模块的错。
|
|
185
|
+
let mounted = {};
|
|
186
|
+
try {
|
|
187
|
+
mounted = hostFaces[0]?.mount({ ctx: { get: () => undefined }, logger: { warn: () => {} } }) ?? {};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
failures.push(`宿主面的 mount(deps) 抛了:${error.message}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const routes = mounted.routes ?? {};
|
|
193
|
+
const strays = Object.keys(routes).filter((key) => key !== UNIT_ID && !key.startsWith(`${UNIT_ID}/`));
|
|
194
|
+
check(`每条路由都住在自己那一格(${UNIT_ID} 或 ${UNIT_ID}/…)`, strays.length === 0, strays.join(', '));
|
|
195
|
+
check(
|
|
196
|
+
'registerTools() 交得出一份清单(这份模块没有工具时是空数组)',
|
|
197
|
+
Array.isArray(mounted.registerTools?.()),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ── 这份模板自己的故事:那条只读路由**真读了本目录** ──────────────────────
|
|
201
|
+
//
|
|
202
|
+
// 换了数据源的模块在这里会落到 else 那一支:**跳过**而不是假绿 —— 并且在同一条路径上提醒你
|
|
203
|
+
// 补一条自己的真读断言(不然"卡片画的数从哪来"就没人验了)。
|
|
204
|
+
if (typeof routes[OVERVIEW_ROUTE] === 'function') {
|
|
205
|
+
const here = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
206
|
+
const answer = await routes[OVERVIEW_ROUTE](new URLSearchParams({ path: here, limit: '3' }));
|
|
207
|
+
check('路由真读了本目录:ok 且数得出文件', answer?.ok === true, JSON.stringify(answer?.error ?? answer?.path));
|
|
208
|
+
check(
|
|
209
|
+
'读到的文件数与最大的几个文件都是真的(不是写死的)',
|
|
210
|
+
answer?.files >= 1 && answer?.largest?.length >= 1,
|
|
211
|
+
`files=${answer?.files} largest=${answer?.largest?.length}`,
|
|
212
|
+
);
|
|
213
|
+
check(
|
|
214
|
+
'读不到时回 ok:false 的 error,而不是抛',
|
|
215
|
+
(await routes[OVERVIEW_ROUTE](new URLSearchParams({ path: `${here}/不存在的目录` })))?.ok === false,
|
|
216
|
+
);
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`○ 跳过「真读本目录」那一组:这份模块已经没有 ${OVERVIEW_ROUTE}(换过数据源了)。`);
|
|
219
|
+
console.log(' 在同一个位置补上**你自己那条读**的断言 —— 卡片画的数从哪来,只有这里能真调一次。');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── 需求底稿在不在:这份骨架的"设计记录"住 README,别只留在对话里 ──────────
|
|
223
|
+
//
|
|
224
|
+
// 只查**形状在不在**(那几节有没有被删掉),不查内容填得好不好 —— 内容由指引守,
|
|
225
|
+
// 判据硬管内容只会逼出"把占位符原样留着"这种应付。
|
|
226
|
+
const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');
|
|
227
|
+
for (const section of ['## 需求底稿', '### 表 1 面板', '### 表 2 agent', '### 表 3 联动', '### 数据源探明记录']) {
|
|
228
|
+
check(`README 里有「${section}」(需求与验收的落点)`, readme.includes(section));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log(`\n${failures.length === 0 ? '模块自检通过' : `${failures.length} 条没过`}`);
|
|
232
|
+
if (failures.length > 0) process.exit(1);
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { kit } from '@dsh-workbench/core/client';
|
|
3
|
+
import { messages } from './messages';
|
|
4
|
+
|
|
5
|
+
const UNIT_ID = '__UNIT__';
|
|
6
|
+
const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
|
|
7
|
+
const LIST_LIMIT = 5;
|
|
8
|
+
const KB = 1024;
|
|
9
|
+
|
|
10
|
+
const { Card, MockStrip, CenteredState, EmptyState, StateBlock, IconPanelScreen } = kit.components;
|
|
11
|
+
const { useApiResource, useFixturePick } = kit.hooks;
|
|
12
|
+
|
|
13
|
+
interface UnitManifest {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
readonly card: {
|
|
17
|
+
readonly titleKey: string;
|
|
18
|
+
readonly order: number;
|
|
19
|
+
readonly enabledByDefault: boolean;
|
|
20
|
+
} | null;
|
|
21
|
+
readonly tools: readonly unknown[];
|
|
22
|
+
readonly host: { readonly inject: readonly string[] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Rendered = ReturnType<typeof h>;
|
|
26
|
+
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
|
27
|
+
|
|
28
|
+
interface WorkspaceView {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface BoardProps {
|
|
33
|
+
readonly world: { readonly current: { readonly workspace?: WorkspaceView | null } };
|
|
34
|
+
readonly refreshToken: number;
|
|
35
|
+
readonly onRefresh: () => void;
|
|
36
|
+
readonly t: Translate;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface UnitClientFace {
|
|
40
|
+
readonly manifest: UnitManifest;
|
|
41
|
+
readonly messages: { readonly zh: Readonly<Record<string, string>>; readonly en: Readonly<Record<string, string>> };
|
|
42
|
+
readonly card: (props: BoardProps) => Rendered;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface UnitClientApi {
|
|
46
|
+
registerUnit(face: UnitClientFace): () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ClientContext {
|
|
50
|
+
get<T = unknown>(name: string): T | undefined;
|
|
51
|
+
effect(effect: () => void | (() => void), label?: string): () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OverviewFile {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly bytes: number;
|
|
57
|
+
readonly mtime: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OverviewPayload {
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly files: number;
|
|
63
|
+
readonly dirs: number;
|
|
64
|
+
readonly bytes: number;
|
|
65
|
+
readonly largest: readonly OverviewFile[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type OverviewReady = { readonly ok: true } & OverviewPayload;
|
|
69
|
+
|
|
70
|
+
interface Failure {
|
|
71
|
+
readonly code: string;
|
|
72
|
+
readonly message: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Fixture {
|
|
76
|
+
readonly id: string;
|
|
77
|
+
readonly label: string;
|
|
78
|
+
readonly payload?: OverviewPayload;
|
|
79
|
+
readonly error?: Failure;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type OverviewState =
|
|
83
|
+
| { readonly kind: 'idle' }
|
|
84
|
+
| { readonly kind: 'loading' }
|
|
85
|
+
| { readonly kind: 'ready'; readonly payload: OverviewPayload }
|
|
86
|
+
| { readonly kind: 'empty'; readonly payload: OverviewPayload }
|
|
87
|
+
| { readonly kind: 'error'; readonly detail: string };
|
|
88
|
+
|
|
89
|
+
export interface OverviewInput {
|
|
90
|
+
readonly fixture: Fixture | undefined;
|
|
91
|
+
readonly phase: 'idle' | 'loading' | 'ready' | 'error';
|
|
92
|
+
readonly value: OverviewReady | undefined;
|
|
93
|
+
readonly error: Failure | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const fixtureFiles: readonly OverviewFile[] = [
|
|
97
|
+
{ name: 'client.js', bytes: 182400, mtime: '2026-01-05T09:12:44.000Z' },
|
|
98
|
+
{ name: 'host.js', bytes: 96256, mtime: '2026-01-05T09:12:44.000Z' },
|
|
99
|
+
{ name: 'pnpm-lock.yaml', bytes: 41984, mtime: '2026-01-04T18:02:10.000Z' },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
export const overviewFixtures: readonly Fixture[] = [
|
|
103
|
+
{
|
|
104
|
+
id: 'ready',
|
|
105
|
+
label: '有数据',
|
|
106
|
+
payload: { path: '/mock/workbench', files: 128, dirs: 12, bytes: 3145728, largest: fixtureFiles },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'empty',
|
|
110
|
+
label: '空目录',
|
|
111
|
+
payload: { path: '/mock/empty', files: 0, dirs: 0, bytes: 0, largest: [] },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'error',
|
|
115
|
+
label: '读不到',
|
|
116
|
+
error: { code: 'overview-unreadable', message: 'EACCES: permission denied, scandir /mock/secret' },
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
export function formatBytes(bytes: number): string {
|
|
121
|
+
if (bytes < KB) return `${String(bytes)} B`;
|
|
122
|
+
if (bytes < KB * KB) return `${(bytes / KB).toFixed(1)} KB`;
|
|
123
|
+
if (bytes < KB * KB * KB) return `${(bytes / (KB * KB)).toFixed(1)} MB`;
|
|
124
|
+
return `${(bytes / (KB * KB * KB)).toFixed(1)} GB`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function payloadState(payload: OverviewPayload): OverviewState {
|
|
128
|
+
const blank = payload.files === 0 && payload.dirs === 0;
|
|
129
|
+
return blank ? { kind: 'empty', payload } : { kind: 'ready', payload };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function overviewStateOf(input: OverviewInput): OverviewState {
|
|
133
|
+
const fixture = input.fixture;
|
|
134
|
+
if (fixture !== undefined) {
|
|
135
|
+
if (fixture.error !== undefined) return { kind: 'error', detail: fixture.error.message };
|
|
136
|
+
if (fixture.payload !== undefined) return payloadState(fixture.payload);
|
|
137
|
+
}
|
|
138
|
+
if (input.phase === 'idle') return { kind: 'idle' };
|
|
139
|
+
if (input.phase === 'error') {
|
|
140
|
+
const error = input.error;
|
|
141
|
+
return { kind: 'error', detail: error === undefined ? '' : `${error.code}: ${error.message}` };
|
|
142
|
+
}
|
|
143
|
+
if (input.value !== undefined && (input.phase === 'ready' || input.phase === 'loading')) {
|
|
144
|
+
return payloadState(input.value);
|
|
145
|
+
}
|
|
146
|
+
return { kind: 'loading' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const manifest: UnitManifest = {
|
|
150
|
+
id: UNIT_ID,
|
|
151
|
+
kind: 'board',
|
|
152
|
+
card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
|
|
153
|
+
tools: [],
|
|
154
|
+
host: { inject: [] },
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function bodyOf(state: OverviewState, t: Translate): Rendered {
|
|
158
|
+
if (state.kind === 'idle') {
|
|
159
|
+
return h(CenteredState, {
|
|
160
|
+
icon: null,
|
|
161
|
+
title: t(`${UNIT_ID}.needProject`),
|
|
162
|
+
compact: true,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (state.kind === 'loading') {
|
|
166
|
+
return h(CenteredState, {
|
|
167
|
+
icon: null,
|
|
168
|
+
title: t(`${UNIT_ID}.loading`),
|
|
169
|
+
hint: t(`${UNIT_ID}.loadingHint`),
|
|
170
|
+
compact: true,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (state.kind === 'error') {
|
|
174
|
+
return h(CenteredState, {
|
|
175
|
+
icon: null,
|
|
176
|
+
title: t(`${UNIT_ID}.errorTitle`),
|
|
177
|
+
detail: state.detail,
|
|
178
|
+
hint: t(`${UNIT_ID}.errorHint`),
|
|
179
|
+
compact: true,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (state.kind === 'empty') {
|
|
183
|
+
return h(EmptyState, {
|
|
184
|
+
text: t(`${UNIT_ID}.empty`),
|
|
185
|
+
icon: h(IconPanelScreen, { size: 20 }),
|
|
186
|
+
compact: true,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const payload = state.payload;
|
|
190
|
+
return h(
|
|
191
|
+
StateBlock,
|
|
192
|
+
{
|
|
193
|
+
title: payload.path,
|
|
194
|
+
hint:
|
|
195
|
+
`${String(payload.files)}${t(`${UNIT_ID}.fileUnit`)} · ` +
|
|
196
|
+
`${String(payload.dirs)}${t(`${UNIT_ID}.dirUnit`)} · ${formatBytes(payload.bytes)}`,
|
|
197
|
+
resolving: t(`${UNIT_ID}.largest`),
|
|
198
|
+
},
|
|
199
|
+
h(
|
|
200
|
+
'ul',
|
|
201
|
+
null,
|
|
202
|
+
...payload.largest.map((file) =>
|
|
203
|
+
h('li', { key: file.name }, `${file.name} · ${formatBytes(file.bytes)} · ${file.mtime}`),
|
|
204
|
+
),
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const face: UnitClientFace = {
|
|
210
|
+
manifest,
|
|
211
|
+
messages,
|
|
212
|
+
card: (props) => {
|
|
213
|
+
const workspace = props.world.current.workspace;
|
|
214
|
+
const path = workspace?.path ?? '';
|
|
215
|
+
const resource = useApiResource<OverviewReady>(
|
|
216
|
+
OVERVIEW_ROUTE,
|
|
217
|
+
{ path: path === '' ? undefined : path, limit: LIST_LIMIT },
|
|
218
|
+
props.refreshToken,
|
|
219
|
+
{ enabled: path !== '' },
|
|
220
|
+
);
|
|
221
|
+
const mock = useFixturePick<Fixture>({ fixtures: overviewFixtures, initial: 'ready' });
|
|
222
|
+
const state = overviewStateOf({
|
|
223
|
+
fixture: mock.enabled ? mock.current : undefined,
|
|
224
|
+
phase: resource.phase,
|
|
225
|
+
value: resource.value,
|
|
226
|
+
error: resource.error,
|
|
227
|
+
});
|
|
228
|
+
return h(
|
|
229
|
+
Card,
|
|
230
|
+
{
|
|
231
|
+
t: props.t,
|
|
232
|
+
title: props.t(`${UNIT_ID}.cardTitle`),
|
|
233
|
+
refreshing: resource.phase === 'loading',
|
|
234
|
+
onRefresh: props.onRefresh,
|
|
235
|
+
},
|
|
236
|
+
mock.enabled
|
|
237
|
+
? h(MockStrip, {
|
|
238
|
+
mocks: overviewFixtures,
|
|
239
|
+
index: mock.index,
|
|
240
|
+
onPick: (next: number) => mock.pick(overviewFixtures[next]?.id ?? null),
|
|
241
|
+
note: props.t(`${UNIT_ID}.mockNote`),
|
|
242
|
+
})
|
|
243
|
+
: null,
|
|
244
|
+
bodyOf(state, props.t),
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const inject = ['workbenchUnits'];
|
|
250
|
+
|
|
251
|
+
export function apply(ctx: ClientContext): void {
|
|
252
|
+
const units = ctx.get<UnitClientApi>('workbenchUnits');
|
|
253
|
+
if (units === undefined) {
|
|
254
|
+
console.warn(`[${UNIT_ID}] 外壳没有提供 workbenchUnits 服务:这张卡片装不进来`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
ctx.effect(() => units.registerUnit(face), `__NAME__: 注册 ${UNIT_ID}`);
|
|
258
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const UNIT_ID = '__UNIT__';
|
|
5
|
+
const PLUGIN_ID = '__NAME__';
|
|
6
|
+
const DEFAULT_LIMIT = 5;
|
|
7
|
+
const MAX_LIMIT = 50;
|
|
8
|
+
|
|
9
|
+
export const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
|
|
10
|
+
|
|
11
|
+
export interface OverviewFile {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly bytes: number;
|
|
14
|
+
readonly mtime: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OverviewPayload {
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly files: number;
|
|
20
|
+
readonly dirs: number;
|
|
21
|
+
readonly bytes: number;
|
|
22
|
+
readonly largest: readonly OverviewFile[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RouteHandler = (search: URLSearchParams) => Promise<unknown> | unknown;
|
|
26
|
+
|
|
27
|
+
export function readOverview(path: string, limit: number): OverviewPayload {
|
|
28
|
+
const entries = readdirSync(path, { withFileTypes: true });
|
|
29
|
+
const files: OverviewFile[] = [];
|
|
30
|
+
let dirs = 0;
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
dirs += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!entry.isFile()) continue;
|
|
38
|
+
const info = statSync(join(path, entry.name));
|
|
39
|
+
bytes += info.size;
|
|
40
|
+
files.push({ name: entry.name, bytes: info.size, mtime: new Date(info.mtimeMs).toISOString() });
|
|
41
|
+
}
|
|
42
|
+
files.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name));
|
|
43
|
+
return { path, files: files.length, dirs, bytes, largest: files.slice(0, limit) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function searchParam(search: URLSearchParams, name: string): string | undefined {
|
|
47
|
+
const raw = search.get(name);
|
|
48
|
+
return raw === null || raw === '' ? undefined : raw;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function limitOf(search: URLSearchParams): number {
|
|
52
|
+
const raw = searchParam(search, 'limit');
|
|
53
|
+
if (raw === undefined) return DEFAULT_LIMIT;
|
|
54
|
+
const parsed = Number(raw);
|
|
55
|
+
if (!Number.isFinite(parsed)) return DEFAULT_LIMIT;
|
|
56
|
+
return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(parsed)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface UnitManifest {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly kind: string;
|
|
62
|
+
readonly card: {
|
|
63
|
+
readonly titleKey: string;
|
|
64
|
+
readonly order: number;
|
|
65
|
+
readonly enabledByDefault: boolean;
|
|
66
|
+
} | null;
|
|
67
|
+
readonly tools: readonly unknown[];
|
|
68
|
+
readonly host: { readonly inject: readonly string[] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface UnitHostFace {
|
|
72
|
+
readonly manifest: UnitManifest;
|
|
73
|
+
mount(): { routes: Record<string, RouteHandler>; registerTools(): Array<() => void> };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface UnitRegistry {
|
|
77
|
+
register(face: UnitHostFace): () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface HostContext {
|
|
81
|
+
get<T = unknown>(name: string): T | undefined;
|
|
82
|
+
effect(effect: () => void | (() => void), label?: string): () => void;
|
|
83
|
+
logger?: { warn?(message: string): void } | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const manifest: UnitManifest = {
|
|
87
|
+
id: UNIT_ID,
|
|
88
|
+
kind: 'board',
|
|
89
|
+
card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
|
|
90
|
+
tools: [],
|
|
91
|
+
host: { inject: [] },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const face: UnitHostFace = {
|
|
95
|
+
manifest,
|
|
96
|
+
mount: () => ({
|
|
97
|
+
routes: {
|
|
98
|
+
[OVERVIEW_ROUTE]: async (search: URLSearchParams) => {
|
|
99
|
+
const target = searchParam(search, 'path');
|
|
100
|
+
if (target === undefined) {
|
|
101
|
+
return { ok: false, error: { code: 'no-path', message: '没告诉我要看哪个目录' } };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return { ok: true, ...readOverview(target, limitOf(search)) };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: {
|
|
109
|
+
code: 'overview-unreadable',
|
|
110
|
+
message: error instanceof Error ? error.message : String(error),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
registerTools: () => [],
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const name = PLUGIN_ID;
|
|
121
|
+
|
|
122
|
+
export const inject = ['workbenchUnits'];
|
|
123
|
+
|
|
124
|
+
export function apply(ctx: HostContext): void {
|
|
125
|
+
const units = ctx.get<UnitRegistry>('workbenchUnits');
|
|
126
|
+
if (units === undefined) {
|
|
127
|
+
ctx.logger?.warn?.(`[${UNIT_ID}] 外壳没有提供 workbenchUnits 服务:这个模块装不进来`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
ctx.effect(() => units.register(face), `${PLUGIN_ID}: 注册 ${UNIT_ID}`);
|
|
131
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const messages = {
|
|
2
|
+
zh: {
|
|
3
|
+
'__UNIT__.cardTitle': '目录概览',
|
|
4
|
+
'__UNIT__.needProject': '先打开一个项目',
|
|
5
|
+
'__UNIT__.loading': '正在读目录…',
|
|
6
|
+
'__UNIT__.loadingHint': '宿主侧真读盘:文件数 / 目录数 / 总字节 / 最大的几个文件',
|
|
7
|
+
'__UNIT__.errorTitle': '读不到这个目录',
|
|
8
|
+
'__UNIT__.errorHint': '点右上角刷新重试;路径不存在、没权限、不是目录都会走到这里',
|
|
9
|
+
'__UNIT__.empty': '这个目录是空的:没有文件,也没有子目录',
|
|
10
|
+
'__UNIT__.largest': '最大的几个文件',
|
|
11
|
+
'__UNIT__.fileUnit': ' 个文件',
|
|
12
|
+
'__UNIT__.dirUnit': ' 个目录',
|
|
13
|
+
'__UNIT__.mockNote': '夹具:只切状态,不发请求',
|
|
14
|
+
},
|
|
15
|
+
en: {
|
|
16
|
+
'__UNIT__.cardTitle': 'Directory overview',
|
|
17
|
+
'__UNIT__.needProject': 'Open a project first',
|
|
18
|
+
'__UNIT__.loading': 'Reading the directory…',
|
|
19
|
+
'__UNIT__.loadingHint': 'The host really reads the disk: files / dirs / bytes / largest files',
|
|
20
|
+
'__UNIT__.errorTitle': 'Cannot read this directory',
|
|
21
|
+
'__UNIT__.errorHint': 'Hit refresh in the header; a missing path, no permission, or a non-directory lands here',
|
|
22
|
+
'__UNIT__.empty': 'This directory is empty: no files and no subdirectories',
|
|
23
|
+
'__UNIT__.largest': 'Largest files',
|
|
24
|
+
'__UNIT__.fileUnit': ' files',
|
|
25
|
+
'__UNIT__.dirUnit': ' dirs',
|
|
26
|
+
'__UNIT__.mockNote': 'Fixtures: switches state only, no request',
|
|
27
|
+
},
|
|
28
|
+
};
|