@dsh-workbench/module-tools 0.3.1 → 0.3.3

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.
@@ -1,16 +1,9 @@
1
- import { createElement as h } from 'react';
2
- import { kit } from '@dsh-workbench/core/client';
1
+ import type { ReactElement } from 'react';
2
+ import { OverviewCard } from './components/OverviewCard';
3
3
  import { messages } from './messages';
4
4
 
5
5
  const UNIT_ID = '__UNIT__';
6
- const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
7
- const LIST_LIMIT = 5;
8
- const KB = 1024;
9
6
 
10
- const { Card, CenteredState, EmptyState, Row, Rows, StateBlock, IconPanelScreen } = kit.components;
11
- const { useApiResource } = kit.hooks;
12
-
13
- type Rendered = ReturnType<typeof h>;
14
7
  type Translate = (key: string, params?: Record<string, unknown>) => string;
15
8
 
16
9
  interface WorkspaceView {
@@ -27,7 +20,7 @@ interface BoardProps {
27
20
  interface UnitClientFace {
28
21
  readonly unit: string;
29
22
  readonly messages: { readonly zh: Readonly<Record<string, string>>; readonly en: Readonly<Record<string, string>> };
30
- readonly card: (props: BoardProps) => Rendered;
23
+ readonly card: (props: BoardProps) => ReactElement;
31
24
  }
32
25
 
33
26
  interface UnitClientApi {
@@ -39,148 +32,16 @@ interface ClientContext {
39
32
  effect(effect: () => void | (() => void), label?: string): () => void;
40
33
  }
41
34
 
42
- export interface OverviewFile {
43
- readonly name: string;
44
- readonly bytes: number;
45
- readonly mtime: string;
46
- }
47
-
48
- export interface OverviewPayload {
49
- readonly path: string;
50
- readonly files: number;
51
- readonly dirs: number;
52
- readonly bytes: number;
53
- readonly largest: readonly OverviewFile[];
54
- }
55
-
56
- type OverviewReady = { readonly ok: true } & OverviewPayload;
57
-
58
- interface Failure {
59
- readonly code: string;
60
- readonly message: string;
61
- }
62
-
63
- export type OverviewState =
64
- | { readonly kind: 'idle' }
65
- | { readonly kind: 'loading' }
66
- | { readonly kind: 'ready'; readonly payload: OverviewPayload }
67
- | { readonly kind: 'empty'; readonly payload: OverviewPayload }
68
- | { readonly kind: 'error'; readonly detail: string };
69
-
70
- export interface OverviewInput {
71
- readonly phase: 'idle' | 'loading' | 'ready' | 'error';
72
- readonly value: OverviewReady | undefined;
73
- readonly error: Failure | undefined;
74
- }
75
-
76
- export function formatBytes(bytes: number): string {
77
- if (bytes < KB) return `${String(bytes)} B`;
78
- if (bytes < KB * KB) return `${(bytes / KB).toFixed(1)} KB`;
79
- if (bytes < KB * KB * KB) return `${(bytes / (KB * KB)).toFixed(1)} MB`;
80
- return `${(bytes / (KB * KB * KB)).toFixed(1)} GB`;
81
- }
82
-
83
- function payloadState(payload: OverviewPayload): OverviewState {
84
- const blank = payload.files === 0 && payload.dirs === 0;
85
- return blank ? { kind: 'empty', payload } : { kind: 'ready', payload };
86
- }
87
-
88
- export function overviewStateOf(input: OverviewInput): OverviewState {
89
- if (input.phase === 'idle') return { kind: 'idle' };
90
- if (input.phase === 'error') {
91
- const error = input.error;
92
- return { kind: 'error', detail: error === undefined ? '' : `${error.code}: ${error.message}` };
93
- }
94
- if (input.value !== undefined && (input.phase === 'ready' || input.phase === 'loading')) {
95
- return payloadState(input.value);
96
- }
97
- return { kind: 'loading' };
98
- }
99
-
100
- function bodyOf(state: OverviewState, t: Translate): Rendered {
101
- if (state.kind === 'idle') {
102
- return h(CenteredState, {
103
- icon: null,
104
- title: t(`${UNIT_ID}.needProject`),
105
- compact: true,
106
- });
107
- }
108
- if (state.kind === 'loading') {
109
- return h(CenteredState, {
110
- icon: null,
111
- title: t(`${UNIT_ID}.loading`),
112
- hint: t(`${UNIT_ID}.loadingHint`),
113
- compact: true,
114
- });
115
- }
116
- if (state.kind === 'error') {
117
- return h(CenteredState, {
118
- icon: null,
119
- title: t(`${UNIT_ID}.errorTitle`),
120
- detail: state.detail,
121
- hint: t(`${UNIT_ID}.errorHint`),
122
- compact: true,
123
- });
124
- }
125
- if (state.kind === 'empty') {
126
- return h(EmptyState, {
127
- text: t(`${UNIT_ID}.empty`),
128
- icon: h(IconPanelScreen, { size: 20 }),
129
- compact: true,
130
- });
131
- }
132
- const payload = state.payload;
133
- return h(
134
- StateBlock,
135
- {
136
- title: payload.path,
137
- hint:
138
- `${String(payload.files)}${t(`${UNIT_ID}.fileUnit`)} · ` +
139
- `${String(payload.dirs)}${t(`${UNIT_ID}.dirUnit`)} · ${formatBytes(payload.bytes)}`,
140
- resolving: t(`${UNIT_ID}.largest`),
141
- },
142
- h(
143
- Rows,
144
- null,
145
- ...payload.largest.map((file) =>
146
- h(Row, {
147
- key: file.name,
148
- label: file.name,
149
- value: `${formatBytes(file.bytes)} · ${file.mtime}`,
150
- }),
151
- ),
152
- ),
153
- );
154
- }
155
-
156
35
  const face: UnitClientFace = {
157
36
  unit: UNIT_ID,
158
37
  messages,
159
- card: (props) => {
160
- const workspace = props.world.current.workspace;
161
- const path = workspace?.path ?? '';
162
- const resource = useApiResource<OverviewReady>(
163
- OVERVIEW_ROUTE,
164
- { path: path === '' ? undefined : path, limit: LIST_LIMIT },
165
- props.refreshToken,
166
- { enabled: path !== '' },
167
- );
168
- const state = overviewStateOf({
169
- phase: resource.phase,
170
- value: resource.value,
171
- error: resource.error,
172
- });
173
- return h(
174
- Card,
175
- {
176
- t: props.t,
177
- title: props.t(`${UNIT_ID}.cardTitle`),
178
- refreshing: resource.phase === 'loading',
179
- onRefresh: props.onRefresh,
180
- },
181
- bodyOf(state, props.t),
182
- );
183
- },
38
+ card: (props) =>
39
+ OverviewCard({
40
+ path: props.world.current.workspace?.path ?? '',
41
+ refreshToken: props.refreshToken,
42
+ onRefresh: props.onRefresh,
43
+ t: props.t,
44
+ }),
184
45
  };
185
46
 
186
47
  export const inject = ['workbenchUnits'];
@@ -0,0 +1,5 @@
1
+ .largest {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--wb-len-8);
5
+ }
@@ -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
+ }