@dsh-workbench/module-tools 0.3.2 → 0.3.4
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 +134 -7
- package/bin/dsh-wb-module.mjs +53 -4
- package/package.json +11 -3
- package/src/arch.mjs +256 -0
- package/src/build.mjs +4 -1
- package/src/check-flags.mjs +22 -0
- package/src/check.mjs +23 -2
- package/src/checks/arch.mjs +127 -0
- package/src/checks/kit.mjs +68 -19
- package/src/checks/lint.mjs +351 -0
- package/src/checks/primitives.mjs +239 -5
- package/src/design.mjs +47 -5
- package/src/discover.mjs +9 -1
- package/src/init.mjs +33 -0
- package/src/lint.mjs +309 -0
- package/src/primitives-surface.mjs +120 -0
- package/src/shell-type-files.mjs +103 -0
- package/template/README.md.tpl +18 -3
- package/template/arch.config.mjs +2 -0
- package/template/eslint.config.mjs +2 -0
- package/template/package.json.tpl +4 -1
- package/template/src/client.tsx.tpl +4 -18
- package/template/src/components/HelloCard.tsx.tpl +28 -0
- package/template-diy/README.md.tpl +44 -19
- package/template-diy/arch.config.mjs +2 -0
- package/template-diy/design.json.tpl +1 -0
- package/template-diy/eslint.config.mjs +2 -0
- package/template-diy/package.json.tpl +4 -1
- package/template-diy/src/client.tsx.tpl +10 -149
- package/template-diy/src/components/OverviewCard.module.css.tpl +5 -0
- package/template-diy/src/components/OverviewCard.test.ts.tpl +118 -0
- package/template-diy/src/components/OverviewCard.tsx.tpl +92 -0
- package/template-diy/src/host.ts.tpl +1 -14
- package/template-diy/src/model/overview.test.ts.tpl +49 -0
- package/template-diy/src/model/overview.ts.tpl +61 -0
- package/template-diy/src/plugin.test.ts.tpl +4 -140
- package/template-diy/types/styles.d.ts +14 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const shell = vi.hoisted(() => ({
|
|
4
|
+
resource: { phase: 'loading' } as {
|
|
5
|
+
readonly phase: string;
|
|
6
|
+
readonly value?: unknown;
|
|
7
|
+
readonly error?: { readonly code: string; readonly message: string };
|
|
8
|
+
},
|
|
9
|
+
readEnabled: undefined as boolean | undefined,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('@dsh-workbench/core/client', () => ({
|
|
13
|
+
kit: {
|
|
14
|
+
hooks: {
|
|
15
|
+
useApiResource: (_route: string, _query: unknown, _refreshToken: number, options?: { readonly enabled?: boolean }) => {
|
|
16
|
+
shell.readEnabled = options?.enabled;
|
|
17
|
+
return shell.resource;
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
components: {
|
|
21
|
+
Card: () => null,
|
|
22
|
+
CenteredState: () => null,
|
|
23
|
+
EmptyState: () => null,
|
|
24
|
+
StateBlock: () => null,
|
|
25
|
+
IconPanelScreen: () => null,
|
|
26
|
+
Rows: () => null,
|
|
27
|
+
Row: () => null,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { OverviewCard } from './OverviewCard';
|
|
33
|
+
|
|
34
|
+
function textOf(value: unknown, out: string[] = []): string[] {
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
out.push(value);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
if (typeof value === 'number') {
|
|
40
|
+
out.push(String(value));
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
if (typeof value === 'boolean' || value === null || value === undefined) return out;
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
for (const one of value) textOf(one, out);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === 'object') {
|
|
49
|
+
const record = value as Record<string, unknown>;
|
|
50
|
+
const source = 'type' in record && 'props' in record ? (record.props as Record<string, unknown>) : record;
|
|
51
|
+
for (const one of Object.values(source)) {
|
|
52
|
+
if (typeof one !== 'function') textOf(one, out);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cardProps = {
|
|
59
|
+
path: '/tmp/__UNIT__-workspace',
|
|
60
|
+
refreshToken: 1,
|
|
61
|
+
onRefresh: () => {},
|
|
62
|
+
t: (key: string) => key,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('OverviewCard:待机 / 加载 / 就绪 / 就绪但空 / 出错', () => {
|
|
66
|
+
it('没有选中工作区时不发请求,画待机那一屏', () => {
|
|
67
|
+
shell.resource = { phase: 'idle' };
|
|
68
|
+
|
|
69
|
+
expect(textOf(OverviewCard({ ...cardProps, path: '' })).join(' ')).toContain('__UNIT__.needProject');
|
|
70
|
+
expect(shell.readEnabled).toBe(false);
|
|
71
|
+
|
|
72
|
+
shell.resource = { phase: 'loading' };
|
|
73
|
+
expect(textOf(OverviewCard(cardProps)).join(' ')).not.toContain('__UNIT__.needProject');
|
|
74
|
+
expect(shell.readEnabled).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('五态各画得出来', () => {
|
|
78
|
+
shell.resource = { phase: 'loading' };
|
|
79
|
+
expect(textOf(OverviewCard(cardProps)).join(' ')).toContain('__UNIT__.loading');
|
|
80
|
+
|
|
81
|
+
shell.resource = { phase: 'error', error: { code: 'internal', message: 'boom' } };
|
|
82
|
+
expect(textOf(OverviewCard(cardProps)).join(' ')).toContain('internal: boom');
|
|
83
|
+
|
|
84
|
+
shell.resource = {
|
|
85
|
+
phase: 'ready',
|
|
86
|
+
value: {
|
|
87
|
+
ok: true,
|
|
88
|
+
path: '/real/workbench',
|
|
89
|
+
files: 2,
|
|
90
|
+
dirs: 1,
|
|
91
|
+
bytes: 2048,
|
|
92
|
+
largest: [{ name: 'big.bin', bytes: 2048, mtime: '2026-01-02T03:04:05.000Z' }],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const ready = textOf(OverviewCard(cardProps)).join(' ');
|
|
96
|
+
expect(ready).toContain('/real/workbench');
|
|
97
|
+
expect(ready).toContain('big.bin');
|
|
98
|
+
expect(ready).toContain('2.0 KB');
|
|
99
|
+
expect(ready).toContain('__UNIT__.fileUnit');
|
|
100
|
+
expect(ready).toContain('__UNIT__.largest');
|
|
101
|
+
|
|
102
|
+
shell.resource = {
|
|
103
|
+
phase: 'ready',
|
|
104
|
+
value: { ok: true, path: '/real/empty', files: 0, dirs: 0, bytes: 0, largest: [] },
|
|
105
|
+
};
|
|
106
|
+
expect(textOf(OverviewCard(cardProps)).join(' ')).toContain('__UNIT__.empty');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('刷新中旧数据还在(不退回加载态)', () => {
|
|
110
|
+
shell.resource = {
|
|
111
|
+
phase: 'loading',
|
|
112
|
+
value: { ok: true, path: '/real/workbench', files: 1, dirs: 0, bytes: 10, largest: [] },
|
|
113
|
+
};
|
|
114
|
+
const text = textOf(OverviewCard(cardProps)).join(' ');
|
|
115
|
+
expect(text).toContain('/real/workbench');
|
|
116
|
+
expect(text).not.toContain('__UNIT__.loading');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { kit } from '@dsh-workbench/core/client';
|
|
3
|
+
import { LIST_LIMIT, formatBytes, overviewStateOf, type OverviewReady, type OverviewState } from '../model/overview';
|
|
4
|
+
import styles from './OverviewCard.module.css';
|
|
5
|
+
|
|
6
|
+
const UNIT_ID = '__UNIT__';
|
|
7
|
+
const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
|
|
8
|
+
|
|
9
|
+
const { Card, CenteredState, EmptyState, IconPanelScreen, Row, Rows, StateBlock } = kit.components;
|
|
10
|
+
const { useApiResource } = kit.hooks;
|
|
11
|
+
|
|
12
|
+
type Rendered = ReturnType<typeof h>;
|
|
13
|
+
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
|
14
|
+
|
|
15
|
+
export interface OverviewCardProps {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly refreshToken: number;
|
|
18
|
+
readonly onRefresh: () => void;
|
|
19
|
+
readonly t: Translate;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function bodyOf(state: OverviewState, t: Translate): Rendered {
|
|
23
|
+
if (state.kind === 'idle') {
|
|
24
|
+
return h(CenteredState, { icon: null, title: t(`${UNIT_ID}.needProject`), compact: true });
|
|
25
|
+
}
|
|
26
|
+
if (state.kind === 'loading') {
|
|
27
|
+
return h(CenteredState, {
|
|
28
|
+
icon: null,
|
|
29
|
+
title: t(`${UNIT_ID}.loading`),
|
|
30
|
+
hint: t(`${UNIT_ID}.loadingHint`),
|
|
31
|
+
compact: true,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (state.kind === 'error') {
|
|
35
|
+
return h(CenteredState, {
|
|
36
|
+
icon: null,
|
|
37
|
+
title: t(`${UNIT_ID}.errorTitle`),
|
|
38
|
+
detail: state.detail,
|
|
39
|
+
hint: t(`${UNIT_ID}.errorHint`),
|
|
40
|
+
compact: true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (state.kind === 'empty') {
|
|
44
|
+
return h(EmptyState, { text: t(`${UNIT_ID}.empty`), icon: h(IconPanelScreen, { size: 20 }), compact: true });
|
|
45
|
+
}
|
|
46
|
+
const payload = state.payload;
|
|
47
|
+
return h(
|
|
48
|
+
StateBlock,
|
|
49
|
+
{
|
|
50
|
+
title: payload.path,
|
|
51
|
+
hint:
|
|
52
|
+
`${String(payload.files)}${t(`${UNIT_ID}.fileUnit`)} · ` +
|
|
53
|
+
`${String(payload.dirs)}${t(`${UNIT_ID}.dirUnit`)} · ${formatBytes(payload.bytes)}`,
|
|
54
|
+
resolving: t(`${UNIT_ID}.largest`),
|
|
55
|
+
},
|
|
56
|
+
h(
|
|
57
|
+
'div',
|
|
58
|
+
{ className: styles.largest },
|
|
59
|
+
h(
|
|
60
|
+
Rows,
|
|
61
|
+
null,
|
|
62
|
+
...payload.largest.map((file) =>
|
|
63
|
+
h(Row, {
|
|
64
|
+
key: file.name,
|
|
65
|
+
label: file.name,
|
|
66
|
+
value: `${formatBytes(file.bytes)} · ${file.mtime}`,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function OverviewCard(props: OverviewCardProps): Rendered {
|
|
75
|
+
const resource = useApiResource<OverviewReady>(
|
|
76
|
+
OVERVIEW_ROUTE,
|
|
77
|
+
{ path: props.path === '' ? undefined : props.path, limit: LIST_LIMIT },
|
|
78
|
+
props.refreshToken,
|
|
79
|
+
{ enabled: props.path !== '' },
|
|
80
|
+
);
|
|
81
|
+
const state = overviewStateOf({ phase: resource.phase, value: resource.value, error: resource.error });
|
|
82
|
+
return h(
|
|
83
|
+
Card,
|
|
84
|
+
{
|
|
85
|
+
t: props.t,
|
|
86
|
+
title: props.t(`${UNIT_ID}.cardTitle`),
|
|
87
|
+
refreshing: resource.phase === 'loading',
|
|
88
|
+
onRefresh: props.onRefresh,
|
|
89
|
+
},
|
|
90
|
+
bodyOf(state, props.t),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import type { OverviewFile, OverviewPayload } from './model/overview';
|
|
3
4
|
|
|
4
5
|
const UNIT_ID = '__UNIT__';
|
|
5
6
|
const PLUGIN_ID = '__NAME__';
|
|
@@ -8,20 +9,6 @@ const MAX_LIMIT = 50;
|
|
|
8
9
|
|
|
9
10
|
export const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
|
|
10
11
|
|
|
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
12
|
export type RouteHandler = (search: URLSearchParams) => Promise<unknown> | unknown;
|
|
26
13
|
|
|
27
14
|
export function readOverview(path: string, limit: number): OverviewPayload {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatBytes, overviewStateOf } from './overview';
|
|
3
|
+
|
|
4
|
+
describe('overviewStateOf', () => {
|
|
5
|
+
it('待机就是待机', () => {
|
|
6
|
+
expect(overviewStateOf({ phase: 'idle', value: undefined, error: undefined })).toEqual({ kind: 'idle' });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('还在读、又没有旧值,就是加载那一屏', () => {
|
|
10
|
+
expect(overviewStateOf({ phase: 'loading', value: undefined, error: undefined })).toEqual({ kind: 'loading' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('读到了、还有东西,就是就绪', () => {
|
|
14
|
+
const payload = { path: '/p', files: 2, dirs: 1, bytes: 10, largest: [] };
|
|
15
|
+
const value = { ok: true as const, ...payload };
|
|
16
|
+
expect(overviewStateOf({ phase: 'ready', value, error: undefined })).toEqual({ kind: 'ready', payload: value });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('读到了但两个数都是零就是空态', () => {
|
|
20
|
+
const payload = { path: '/tmp', files: 0, dirs: 0, bytes: 0, largest: [] };
|
|
21
|
+
const value = { ok: true as const, ...payload };
|
|
22
|
+
expect(overviewStateOf({ phase: 'ready', value, error: undefined })).toEqual({ kind: 'empty', payload: value });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('出错时把 code 与 message 拼成 detail', () => {
|
|
26
|
+
const state = overviewStateOf({
|
|
27
|
+
phase: 'error',
|
|
28
|
+
value: undefined,
|
|
29
|
+
error: { code: 'network', message: '宿主没有响应' },
|
|
30
|
+
});
|
|
31
|
+
expect(state).toEqual({ kind: 'error', detail: 'network: 宿主没有响应' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('刷新时旧值还在:继续画旧数据,不退回加载态', () => {
|
|
35
|
+
const payload = { path: '/p', files: 3, dirs: 1, bytes: 100, largest: [] };
|
|
36
|
+
const value = { ok: true as const, ...payload };
|
|
37
|
+
expect(overviewStateOf({ phase: 'loading', value, error: undefined })).toEqual({ kind: 'ready', payload: value });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('formatBytes', () => {
|
|
42
|
+
it('四档单位', () => {
|
|
43
|
+
expect(formatBytes(0)).toBe('0 B');
|
|
44
|
+
expect(formatBytes(12)).toBe('12 B');
|
|
45
|
+
expect(formatBytes(2048)).toBe('2.0 KB');
|
|
46
|
+
expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB');
|
|
47
|
+
expect(formatBytes(4 * 1024 * 1024 * 1024)).toBe('4.0 GB');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface OverviewFile {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly bytes: number;
|
|
4
|
+
readonly mtime: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface OverviewPayload {
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly files: number;
|
|
10
|
+
readonly dirs: number;
|
|
11
|
+
readonly bytes: number;
|
|
12
|
+
readonly largest: readonly OverviewFile[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type OverviewReady = { readonly ok: true } & OverviewPayload;
|
|
16
|
+
|
|
17
|
+
export interface Failure {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type OverviewState =
|
|
23
|
+
| { readonly kind: 'idle' }
|
|
24
|
+
| { readonly kind: 'loading' }
|
|
25
|
+
| { readonly kind: 'ready'; readonly payload: OverviewPayload }
|
|
26
|
+
| { readonly kind: 'empty'; readonly payload: OverviewPayload }
|
|
27
|
+
| { readonly kind: 'error'; readonly detail: string };
|
|
28
|
+
|
|
29
|
+
export interface OverviewInput {
|
|
30
|
+
readonly phase: 'idle' | 'loading' | 'ready' | 'error';
|
|
31
|
+
readonly value: OverviewReady | undefined;
|
|
32
|
+
readonly error: Failure | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const LIST_LIMIT = 5;
|
|
36
|
+
|
|
37
|
+
const KB = 1024;
|
|
38
|
+
|
|
39
|
+
export function formatBytes(bytes: number): string {
|
|
40
|
+
if (bytes < KB) return `${String(bytes)} B`;
|
|
41
|
+
if (bytes < KB * KB) return `${(bytes / KB).toFixed(1)} KB`;
|
|
42
|
+
if (bytes < KB * KB * KB) return `${(bytes / (KB * KB)).toFixed(1)} MB`;
|
|
43
|
+
return `${(bytes / (KB * KB * KB)).toFixed(1)} GB`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function payloadState(payload: OverviewPayload): OverviewState {
|
|
47
|
+
const blank = payload.files === 0 && payload.dirs === 0;
|
|
48
|
+
return blank ? { kind: 'empty', payload } : { kind: 'ready', payload };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function overviewStateOf(input: OverviewInput): OverviewState {
|
|
52
|
+
if (input.phase === 'idle') return { kind: 'idle' };
|
|
53
|
+
if (input.phase === 'error') {
|
|
54
|
+
const error = input.error;
|
|
55
|
+
return { kind: 'error', detail: error === undefined ? '' : `${error.code}: ${error.message}` };
|
|
56
|
+
}
|
|
57
|
+
if (input.value !== undefined && (input.phase === 'ready' || input.phase === 'loading')) {
|
|
58
|
+
return payloadState(input.value);
|
|
59
|
+
}
|
|
60
|
+
return { kind: 'loading' };
|
|
61
|
+
}
|
|
@@ -3,35 +3,23 @@ import { tmpdir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { afterAll, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
|
|
6
|
-
const shell = vi.hoisted(() => ({
|
|
7
|
-
resource: { phase: 'loading' } as {
|
|
8
|
-
readonly phase: string;
|
|
9
|
-
readonly value?: unknown;
|
|
10
|
-
readonly error?: { readonly code: string; readonly message: string };
|
|
11
|
-
},
|
|
12
|
-
readEnabled: undefined as boolean | undefined,
|
|
13
|
-
}));
|
|
14
|
-
|
|
15
6
|
vi.mock('@dsh-workbench/core/client', () => ({
|
|
16
7
|
kit: {
|
|
17
|
-
hooks: {
|
|
18
|
-
useApiResource: (_route: string, _query: unknown, _refreshToken: number, options?: { readonly enabled?: boolean }) => {
|
|
19
|
-
shell.readEnabled = options?.enabled;
|
|
20
|
-
return shell.resource;
|
|
21
|
-
},
|
|
22
|
-
},
|
|
8
|
+
hooks: { useApiResource: () => ({ phase: 'loading' }) },
|
|
23
9
|
components: {
|
|
24
10
|
Card: () => null,
|
|
25
11
|
CenteredState: () => null,
|
|
26
12
|
EmptyState: () => null,
|
|
27
13
|
StateBlock: () => null,
|
|
28
14
|
IconPanelScreen: () => null,
|
|
15
|
+
Rows: () => null,
|
|
16
|
+
Row: () => null,
|
|
29
17
|
},
|
|
30
18
|
},
|
|
31
19
|
}));
|
|
32
20
|
|
|
33
21
|
import { apply, inject, name, OVERVIEW_ROUTE, readOverview } from './host';
|
|
34
|
-
import { apply as applyClient,
|
|
22
|
+
import { apply as applyClient, inject as injectClient } from './client';
|
|
35
23
|
|
|
36
24
|
interface HostFace {
|
|
37
25
|
readonly unit: string;
|
|
@@ -99,42 +87,11 @@ function overview(search: Record<string, string>): Promise<unknown> {
|
|
|
99
87
|
return hostFace().mount().routes[OVERVIEW_ROUTE](new URLSearchParams(search));
|
|
100
88
|
}
|
|
101
89
|
|
|
102
|
-
function textOf(value: unknown, out: string[] = []): string[] {
|
|
103
|
-
if (typeof value === 'string') {
|
|
104
|
-
out.push(value);
|
|
105
|
-
return out;
|
|
106
|
-
}
|
|
107
|
-
if (typeof value === 'number') {
|
|
108
|
-
out.push(String(value));
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
if (typeof value === 'boolean' || value === null || value === undefined) return out;
|
|
112
|
-
if (Array.isArray(value)) {
|
|
113
|
-
for (const one of value) textOf(one, out);
|
|
114
|
-
return out;
|
|
115
|
-
}
|
|
116
|
-
if (typeof value === 'object') {
|
|
117
|
-
const record = value as Record<string, unknown>;
|
|
118
|
-
const source = 'type' in record && 'props' in record ? (record.props as Record<string, unknown>) : record;
|
|
119
|
-
for (const one of Object.values(source)) {
|
|
120
|
-
if (typeof one !== 'function') textOf(one, out);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
90
|
const scratch = mkdtempSync(join(tmpdir(), 'dsh-wb-diy-'));
|
|
127
91
|
afterAll(() => {
|
|
128
92
|
rmSync(scratch, { recursive: true, force: true });
|
|
129
93
|
});
|
|
130
94
|
|
|
131
|
-
const cardProps = {
|
|
132
|
-
world: { current: { workspace: { path: '/tmp/__UNIT__-workspace' } } },
|
|
133
|
-
refreshToken: 1,
|
|
134
|
-
onRefresh: () => {},
|
|
135
|
-
t: (key: string) => key,
|
|
136
|
-
};
|
|
137
|
-
|
|
138
95
|
describe('__NAME__ 的面', () => {
|
|
139
96
|
it('插件名就是包名', () => {
|
|
140
97
|
expect(name).toBe('__NAME__');
|
|
@@ -200,96 +157,3 @@ describe('宿主侧的只读路由', () => {
|
|
|
200
157
|
expect(readOverview(join(scratch, 'sample'), 5).bytes).toBe(160);
|
|
201
158
|
});
|
|
202
159
|
});
|
|
203
|
-
|
|
204
|
-
describe('卡片:待机 / 加载 / 就绪 / 就绪但空 / 出错', () => {
|
|
205
|
-
it('没有选中工作区时不发请求,画待机那一屏', () => {
|
|
206
|
-
const face = clientFace();
|
|
207
|
-
shell.resource = { phase: 'idle' };
|
|
208
|
-
const withoutProject = { ...cardProps, world: { current: { workspace: null } } };
|
|
209
|
-
|
|
210
|
-
expect(textOf(face.card(withoutProject)).join(' ')).toContain('__UNIT__.needProject');
|
|
211
|
-
expect(shell.readEnabled).toBe(false);
|
|
212
|
-
|
|
213
|
-
shell.resource = { phase: 'loading' };
|
|
214
|
-
expect(textOf(face.card(cardProps)).join(' ')).not.toContain('__UNIT__.needProject');
|
|
215
|
-
expect(shell.readEnabled).toBe(true);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('四态各画得出来', () => {
|
|
219
|
-
const face = clientFace();
|
|
220
|
-
|
|
221
|
-
shell.resource = { phase: 'loading' };
|
|
222
|
-
expect(textOf(face.card(cardProps)).join(' ')).toContain('__UNIT__.loading');
|
|
223
|
-
|
|
224
|
-
shell.resource = { phase: 'error', error: { code: 'internal', message: 'boom' } };
|
|
225
|
-
expect(textOf(face.card(cardProps)).join(' ')).toContain('internal: boom');
|
|
226
|
-
|
|
227
|
-
shell.resource = {
|
|
228
|
-
phase: 'ready',
|
|
229
|
-
value: {
|
|
230
|
-
ok: true,
|
|
231
|
-
path: '/real/workbench',
|
|
232
|
-
files: 2,
|
|
233
|
-
dirs: 1,
|
|
234
|
-
bytes: 2048,
|
|
235
|
-
largest: [{ name: 'big.bin', bytes: 2048, mtime: '2026-01-02T03:04:05.000Z' }],
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
const ready = textOf(face.card(cardProps)).join(' ');
|
|
239
|
-
expect(ready).toContain('/real/workbench');
|
|
240
|
-
expect(ready).toContain('big.bin');
|
|
241
|
-
expect(ready).toContain('2.0 KB');
|
|
242
|
-
expect(ready).toContain('__UNIT__.fileUnit');
|
|
243
|
-
expect(ready).toContain('__UNIT__.largest');
|
|
244
|
-
|
|
245
|
-
shell.resource = {
|
|
246
|
-
phase: 'ready',
|
|
247
|
-
value: { ok: true, path: '/real/empty', files: 0, dirs: 0, bytes: 0, largest: [] },
|
|
248
|
-
};
|
|
249
|
-
expect(textOf(face.card(cardProps)).join(' ')).toContain('__UNIT__.empty');
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('刷新中旧数据还在(不退回加载态)', () => {
|
|
253
|
-
const face = clientFace();
|
|
254
|
-
shell.resource = {
|
|
255
|
-
phase: 'loading',
|
|
256
|
-
value: { ok: true, path: '/real/workbench', files: 1, dirs: 0, bytes: 10, largest: [] },
|
|
257
|
-
};
|
|
258
|
-
const text = textOf(face.card(cardProps)).join(' ');
|
|
259
|
-
expect(text).toContain('/real/workbench');
|
|
260
|
-
expect(text).not.toContain('__UNIT__.loading');
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
describe('状态映射', () => {
|
|
266
|
-
it('phase 与读数一起解出那一屏(待机 / 加载 / 就绪 / 就绪但空 / 出错)', () => {
|
|
267
|
-
const at = (input: Parameters<typeof overviewStateOf>[0]): string => overviewStateOf(input).kind;
|
|
268
|
-
|
|
269
|
-
expect(at({ phase: 'idle', value: undefined, error: undefined })).toBe('idle');
|
|
270
|
-
expect(at({ phase: 'loading', value: undefined, error: undefined })).toBe('loading');
|
|
271
|
-
expect(
|
|
272
|
-
at({
|
|
273
|
-
phase: 'ready',
|
|
274
|
-
value: { ok: true, path: '/p', files: 2, dirs: 1, bytes: 10, largest: [] },
|
|
275
|
-
error: undefined,
|
|
276
|
-
}),
|
|
277
|
-
).toBe('ready');
|
|
278
|
-
expect(
|
|
279
|
-
at({
|
|
280
|
-
phase: 'ready',
|
|
281
|
-
value: { ok: true, path: '/p', files: 0, dirs: 0, bytes: 0, largest: [] },
|
|
282
|
-
error: undefined,
|
|
283
|
-
}),
|
|
284
|
-
).toBe('empty');
|
|
285
|
-
expect(at({ phase: 'error', value: undefined, error: { code: 'internal', message: 'boom' } })).toBe('error');
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
describe('格式化', () => {
|
|
290
|
-
it('字节格式化不炸也不乱', () => {
|
|
291
|
-
expect(formatBytes(0)).toBe('0 B');
|
|
292
|
-
expect(formatBytes(2048)).toBe('2.0 KB');
|
|
293
|
-
expect(formatBytes(3145728)).toBe('3.0 MB');
|
|
294
|
-
});
|
|
295
|
-
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组件样式(CSS Modules)的类名表。
|
|
3
|
+
*
|
|
4
|
+
* 构建时类名会被哈希,`import styles from './X.module.css'` 拿到的是**表**而不是文本
|
|
5
|
+
* (构建让它进产物里的那份内联样式);少了这条声明,`styles.xxx` 会被当成 string 的属性,
|
|
6
|
+
* `pnpm typecheck` 直接红。
|
|
7
|
+
*
|
|
8
|
+
* 单独一个文件:`types/globals.d.ts` 里有 import,是模块,里面的 `declare module`
|
|
9
|
+
* 会变成模块增强而不是环境声明 —— 与外壳 `packages/workbench/types/assets.d.ts` 同一个理由。
|
|
10
|
+
*/
|
|
11
|
+
declare module '*.module.css' {
|
|
12
|
+
const classes: Record<string, string>;
|
|
13
|
+
export default classes;
|
|
14
|
+
}
|