dshmarket 1.2.3 → 1.2.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/lib/install.js CHANGED
@@ -19,7 +19,7 @@ import { logEvent } from './log.js';
19
19
  */
20
20
  export async function withHoistRecovery(run, profile, pluginArgs) {
21
21
  let result = await run(profile, pluginArgs);
22
- const ok = (r) => r.exitCode === 0 && !r.timedOut;
22
+ const ok = (r) => r.exitCode === 0 && !r.timedOut && !r.cancelled;
23
23
  if (!ok(result) && classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)?.code === 'hoist-pattern-diff') {
24
24
  logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`);
25
25
  // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
@@ -28,7 +28,7 @@ export async function withHoistRecovery(run, profile, pluginArgs) {
28
28
  if (ok(rebuild))
29
29
  result = await run(profile, pluginArgs);
30
30
  }
31
- if (!ok(result)) {
31
+ if (!ok(result) && !result.cancelled) {
32
32
  const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`);
33
33
  if (failure !== null)
34
34
  result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` };
@@ -108,3 +108,25 @@ export function isStaleUpdate(check) {
108
108
  ? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
109
109
  : check.beforeVersion !== null && check.afterVersion === check.beforeVersion;
110
110
  }
111
+ /**
112
+ * Package names pnpm reported as having their build scripts ignored
113
+ * ("Ignored build scripts: esbuild, koffi."). Empty when none.
114
+ * (#6 by @qichuang321.)
115
+ */
116
+ export function parseIgnoredBuilds(stdout, stderr) {
117
+ const m = /Ignored build scripts:?\s*([^\n]+)/i.exec(`${stdout}\n${stderr}`);
118
+ if (m === null)
119
+ return [];
120
+ const found = [];
121
+ for (const chunk of m[1].split(',')) {
122
+ // Entries may carry a version suffix and the sentence's final period.
123
+ const trimmed = chunk.trim().replace(/\.$/, '');
124
+ if (trimmed === '')
125
+ continue;
126
+ const at = trimmed.lastIndexOf('@');
127
+ const name = at > 0 ? trimmed.slice(0, at) : trimmed;
128
+ if (name !== '' && !found.includes(name))
129
+ found.push(name);
130
+ }
131
+ return found;
132
+ }
package/lib/profile.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * profile directory (manifest, lockfile, installed package trees). Pure
4
4
  * functions of the directory contents; no processes, no network.
5
5
  */
6
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  /** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
@@ -11,13 +11,25 @@ export function profileDir(profile) {
11
11
  const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
12
12
  return join(home, 'profiles', profile);
13
13
  }
14
- /** Community dependencies of the profile (official in-box scope filtered out). */
14
+ /**
15
+ * The in-box bundles dsh's profile templates install themselves — the ONLY
16
+ * names the market hides from the installed list. Community plugins may
17
+ * legitimately publish under the official scope (#28), so a whole-scope
18
+ * filter would make them invisible and fail install validation.
19
+ * (Diagnosis and fix proposed in #28 by @Lograthmic.)
20
+ */
21
+ const INBOX_BUNDLES = new Set([
22
+ '@deepseek-ai/dsh-base',
23
+ '@deepseek-ai/dsh-web-app',
24
+ '@deepseek-ai/dsh-headless',
25
+ ]);
26
+ /** Community dependencies of the profile (in-box bundles filtered out). */
15
27
  export function readInstalled(profile) {
16
28
  try {
17
29
  const manifest = JSON.parse(readFileSync(join(profileDir(profile), 'package.json'), 'utf8'));
18
30
  const installed = {};
19
31
  for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
20
- if (!name.startsWith('@deepseek-ai/'))
32
+ if (!INBOX_BUNDLES.has(name))
21
33
  installed[name] = spec;
22
34
  }
23
35
  return installed;
@@ -119,3 +131,36 @@ export function pluginSubdirs(root) {
119
131
  }
120
132
  return found.slice(0, 8);
121
133
  }
134
+ /**
135
+ * Allow the given packages' build scripts in the profile's
136
+ * pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
137
+ * merging with existing entries and leaving the rest of the yaml intact.
138
+ * (#6 by @qichuang321.)
139
+ * @returns every package now allowed.
140
+ */
141
+ export function setAllowBuilds(profile, packages) {
142
+ const file = join(profileDir(profile), 'pnpm-workspace.yaml');
143
+ let yaml = '';
144
+ try {
145
+ yaml = readFileSync(file, 'utf8');
146
+ }
147
+ catch { /* created below */ }
148
+ const blockRe = /allowBuilds:\n((?:[ \t]+[^\n]*\n?)*)/;
149
+ const map = {};
150
+ const blockMatch = blockRe.exec(yaml);
151
+ if (blockMatch !== null) {
152
+ for (const line of blockMatch[1].split('\n')) {
153
+ const m = /^[ \t]+([^:\s]+(?:\/[^:\s]+)?)\s*:\s*(\S.*)?$/.exec(line);
154
+ if (m !== null)
155
+ map[m[1]] = m[2] ?? 'true';
156
+ }
157
+ }
158
+ for (const pkg of packages) {
159
+ if (/^[A-Za-z0-9@/_.-]+$/.test(pkg))
160
+ map[pkg] = 'true';
161
+ }
162
+ const block = Object.entries(map).map(([k, v]) => ` ${k}: ${v}`).join('\n');
163
+ const blockText = `allowBuilds:\n${block}\n`;
164
+ writeFileSync(file, blockMatch !== null ? yaml.replace(blockRe, blockText) : `${yaml.replace(/\n?$/, '\n')}${blockText}`);
165
+ return Object.keys(map);
166
+ }
package/lib/routes.js CHANGED
@@ -7,14 +7,15 @@
7
7
  * Security: the install route executes a shell command, so it accepts only
8
8
  * same-origin POSTs and only sources present in the curated registry.
9
9
  */
10
- import { readFileSync } from 'node:fs';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
11
12
  import { loadRegistry } from './registry.js';
12
13
  import { cleanHotDir, hotMount, hotUnmount, listHotMounts, mountClientOnlyDeps, readDisabledThemes, } from './hot.js';
13
14
  import { exportLogs, logEvent } from './log.js';
14
- import { BOOT_ID, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.js';
15
- import { profileDir, readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
15
+ import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin } from './dsh-cli.js';
16
+ import { profileDir, readInstalled, readInstalledVersion, readLockCommits, setAllowBuilds } from './profile.js';
16
17
  import { findInstalledAlias, installTargetFor } from './sources.js';
17
- import { isStaleUpdate, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
18
+ import { isStaleUpdate, parseIgnoredBuilds, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
18
19
  import { checkUpdates, invalidateUpdates } from './updates.js';
19
20
  import { createThemeManager } from './themes.js';
20
21
  import { readJsonBody, sameOrigin, sendJson } from './http.js';
@@ -58,6 +59,20 @@ export function mountMarketRoutes(host, config) {
58
59
  void themes.setEntryDisabled(name, true);
59
60
  });
60
61
  let installing = false;
62
+ /**
63
+ * Drop live hot mounts whose package was removed outside the market
64
+ * (e.g. `dsh plugin remove` in a terminal): the stale mount would keep
65
+ * serving a client bundle that 404s after refresh, wedging the page
66
+ * until a restart (#29 by @SunYanbox).
67
+ */
68
+ async function dropStaleHotMounts() {
69
+ for (const name of listHotMounts()) {
70
+ if (existsSync(join(profileDir(config.profile), 'node_modules', name, 'package.json')))
71
+ continue;
72
+ await hotUnmount(name);
73
+ logEvent('warn', 'hot-sweep', `${name}: package removed outside the market — live mount dropped`);
74
+ }
75
+ }
61
76
  /** Every plugin command goes through the pnpm-drift recovery wrapper (#20). */
62
77
  const runPlugin = (profile, args) => withHoistRecovery(runDshPlugin, profile, args);
63
78
  const disposers = [
@@ -82,12 +97,13 @@ export function mountMarketRoutes(host, config) {
82
97
  host.webServer.register({
83
98
  kind: 'exact',
84
99
  path: '/dsh-market/installed',
85
- handler: (request, response) => {
100
+ handler: async (request, response) => {
86
101
  if (request.method !== 'GET') {
87
102
  response.writeHead(405, { allow: 'GET' });
88
103
  response.end();
89
104
  return;
90
105
  }
106
+ await dropStaleHotMounts();
91
107
  sendJson(response, 200, {
92
108
  profile: config.profile,
93
109
  installed: readInstalled(config.profile),
@@ -137,6 +153,7 @@ export function mountMarketRoutes(host, config) {
137
153
  response.end();
138
154
  return;
139
155
  }
156
+ await dropStaleHotMounts();
140
157
  sendJson(response, 200, {
141
158
  active: progress.active,
142
159
  target: progress.target,
@@ -236,7 +253,8 @@ export function mountMarketRoutes(host, config) {
236
253
  // default one-day safety wait; scoped to this single command.
237
254
  const addArgs = force ? ['add', '--config.minimumReleaseAge=0', target] : ['add', target];
238
255
  const result = await runPlugin(config.profile, addArgs);
239
- let ok = result.exitCode === 0 && !result.timedOut;
256
+ const cancelled = result.cancelled;
257
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
240
258
  let stale = false;
241
259
  if (ok) {
242
260
  stale = isStaleUpdate({
@@ -254,9 +272,11 @@ export function mountMarketRoutes(host, config) {
254
272
  const staleError = stale
255
273
  ? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
256
274
  : null;
257
- logEvent(ok ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
258
- sendJson(response, ok ? 200 : 502, {
275
+ logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ' STALE(minimumReleaseAge?)' : ''}${ok || cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
276
+ // A user-cancelled run is a quiet outcome, not an error.
277
+ sendJson(response, ok || cancelled ? 200 : 502, {
259
278
  ok,
279
+ cancelled: cancelled || undefined,
260
280
  stale: stale || undefined,
261
281
  error: staleError ?? undefined,
262
282
  exitCode: result.exitCode,
@@ -299,6 +319,64 @@ export function mountMarketRoutes(host, config) {
299
319
  }
300
320
  },
301
321
  }),
322
+ host.webServer.register({
323
+ kind: 'exact',
324
+ path: '/dsh-market/approve-builds',
325
+ handler: async (request, response) => {
326
+ if (request.method !== 'POST') {
327
+ response.writeHead(405, { allow: 'POST' });
328
+ response.end();
329
+ return;
330
+ }
331
+ if (!sameOrigin(request)) {
332
+ sendJson(response, 403, { error: 'untrusted origin' });
333
+ return;
334
+ }
335
+ try {
336
+ // One-click build-script approval (#6 by @qichuang321): only
337
+ // packages ALREADY installed in the profile can be allowed — the
338
+ // list is not free input.
339
+ const body = (await readJsonBody(request));
340
+ const installed = readInstalled(config.profile);
341
+ const packages = (Array.isArray(body.packages) ? body.packages.map(String) : [])
342
+ .filter(name => installed[name] !== undefined);
343
+ if (packages.length === 0) {
344
+ sendJson(response, 400, { error: 'no installed packages given' });
345
+ return;
346
+ }
347
+ const approved = setAllowBuilds(config.profile, packages);
348
+ logEvent('info', 'approve-builds', `allowed build scripts: ${approved.join(', ')}`);
349
+ sendJson(response, 200, { ok: true, approved });
350
+ }
351
+ catch (error) {
352
+ const message = error instanceof Error ? error.message : String(error);
353
+ logEvent('error', 'approve-builds', `route error: ${message}`);
354
+ sendJson(response, 500, { error: message });
355
+ }
356
+ },
357
+ }),
358
+ host.webServer.register({
359
+ kind: 'exact',
360
+ path: '/dsh-market/cancel',
361
+ handler: async (request, response) => {
362
+ if (request.method !== 'POST') {
363
+ response.writeHead(405, { allow: 'POST' });
364
+ response.end();
365
+ return;
366
+ }
367
+ if (!sameOrigin(request)) {
368
+ sendJson(response, 403, { error: 'untrusted origin' });
369
+ return;
370
+ }
371
+ // Cancel flow contributed in #6 by @qichuang321.
372
+ if (!cancelActive()) {
373
+ sendJson(response, 400, { error: 'no operation is running' });
374
+ return;
375
+ }
376
+ logEvent('info', 'cancel', `cancelled ${progress.target || 'operation'}`);
377
+ sendJson(response, 200, { ok: true, cancelled: true, target: progress.target });
378
+ },
379
+ }),
302
380
  host.webServer.register({
303
381
  kind: 'exact',
304
382
  path: '/dsh-market/uninstall',
@@ -330,15 +408,17 @@ export function mountMarketRoutes(host, config) {
330
408
  installing = true;
331
409
  try {
332
410
  const result = await runPlugin(config.profile, ['remove', name]);
333
- const ok = result.exitCode === 0 && !result.timedOut;
411
+ const cancelled = result.cancelled;
412
+ const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
334
413
  let hot = false;
335
414
  if (ok) {
336
415
  invalidateUpdates();
337
416
  hot = await hotUnmount(name);
338
417
  }
339
- logEvent(ok ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${ok ? ` live-removed=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
340
- sendJson(response, ok ? 200 : 502, {
418
+ logEvent(ok || cancelled ? 'info' : 'error', 'uninstall', `${name} exit=${String(result.exitCode)}${cancelled ? ' CANCELLED' : ''}${ok ? ` live-removed=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
419
+ sendJson(response, ok || cancelled ? 200 : 502, {
341
420
  ok,
421
+ cancelled: cancelled || undefined,
342
422
  hot,
343
423
  exitCode: result.exitCode,
344
424
  stdout: result.stdout,
@@ -405,7 +485,8 @@ export function mountMarketRoutes(host, config) {
405
485
  try {
406
486
  const before = new Set(Object.keys(readInstalled(config.profile)));
407
487
  const result = await runPlugin(config.profile, ['add', target]);
408
- let ok = result.exitCode === 0 && !result.timedOut;
488
+ const cancelled = result.cancelled;
489
+ let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
409
490
  if (ok)
410
491
  invalidateUpdates();
411
492
  if (ok) {
@@ -420,7 +501,7 @@ export function mountMarketRoutes(host, config) {
420
501
  // must never survive to brick the next boot.
421
502
  let notAPlugin = false;
422
503
  let removedBroken = [];
423
- if (result.exitCode === 0 && !result.timedOut) {
504
+ if (result.exitCode === 0 && !result.timedOut && !cancelled) {
424
505
  const validated = await validateAddedPlugins(runPlugin, config.profile, before);
425
506
  removedBroken = validated.removedBroken;
426
507
  if (removedBroken.length > 0) {
@@ -453,10 +534,12 @@ export function mountMarketRoutes(host, config) {
453
534
  }
454
535
  }
455
536
  }
456
- logEvent(ok ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${ok ? ` hot=${String(hot)}` : ` stderr=${result.stderr.slice(-300)}`}`);
457
- sendJson(response, ok ? 200 : 502, {
537
+ logEvent(ok || cancelled ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` stderr=${result.stderr.slice(-300)}`}`);
538
+ sendJson(response, ok || cancelled ? 200 : 502, {
458
539
  ok,
540
+ cancelled: cancelled || undefined,
459
541
  hot,
542
+ ignoredBuilds: (() => { const list = parseIgnoredBuilds(result.stdout, result.stderr); return list.length > 0 ? list : undefined; })(),
460
543
  error: notAPlugin ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志' : undefined,
461
544
  exitCode: result.exitCode,
462
545
  timedOut: result.timedOut,
@@ -29,6 +29,8 @@ export interface InstallResult {
29
29
  timedOut: boolean;
30
30
  stdout: string;
31
31
  stderr: string;
32
+ /** True when the run ended because the user cancelled it. */
33
+ cancelled: boolean;
32
34
  }
33
35
  /** The shape every orchestration function takes to run plugin commands (injectable in tests). */
34
36
  export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<InstallResult>;
@@ -38,6 +40,11 @@ export type PluginRunner = (profile: string, pluginArgs: string[]) => Promise<In
38
40
  * (Contributed in #7 by @mraing.)
39
41
  */
40
42
  export declare function killChild(child: ChildProcess): void;
43
+ /**
44
+ * Cancel the plugin command currently running.
45
+ * @returns true when there was one to cancel.
46
+ */
47
+ export declare function cancelActive(): boolean;
41
48
  /** Probe `pnpm --version` on PATH. */
42
49
  export declare function probePnpm(): Promise<boolean>;
43
50
  /**
@@ -47,3 +47,9 @@ export declare function isStaleUpdate(check: {
47
47
  beforeCommit: string | null;
48
48
  afterCommit: string | null;
49
49
  }): boolean;
50
+ /**
51
+ * Package names pnpm reported as having their build scripts ignored
52
+ * ("Ignored build scripts: esbuild, koffi."). Empty when none.
53
+ * (#6 by @qichuang321.)
54
+ */
55
+ export declare function parseIgnoredBuilds(stdout: string, stderr: string): string[];
@@ -5,7 +5,7 @@
5
5
  */
6
6
  /** Resolve a profile name to its directory under DSH_HOME (default ~/.dsh). */
7
7
  export declare function profileDir(profile: string): string;
8
- /** Community dependencies of the profile (official in-box scope filtered out). */
8
+ /** Community dependencies of the profile (in-box bundles filtered out). */
9
9
  export declare function readInstalled(profile: string): Record<string, string>;
10
10
  /** The version actually present in the profile's node_modules, or null. */
11
11
  export declare function readInstalledVersion(profile: string, name: string): string | null;
@@ -22,3 +22,11 @@ export declare function hasDshManifest(dir: string): boolean;
22
22
  export declare function entryArtifactExists(dir: string): boolean;
23
23
  /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
24
24
  export declare function pluginSubdirs(root: string): string[];
25
+ /**
26
+ * Allow the given packages' build scripts in the profile's
27
+ * pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
28
+ * merging with existing entries and leaving the rest of the yaml intact.
29
+ * (#6 by @qichuang321.)
30
+ * @returns every package now allowed.
31
+ */
32
+ export declare function setAllowBuilds(profile: string, packages: string[]): string[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.2.3",
4
+ "version": "1.2.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -72,3 +72,7 @@
72
72
  .dangerBtn.dangerBtn,.warnBtn.warnBtn,.dangerArmed.dangerArmed{white-space:nowrap;flex-shrink:0}
73
73
  .spec{font-size:11px;color:var(--dsw-alias-label-secondary,#9ca3af);font-family:ui-monospace,Menlo,monospace}
74
74
  .staleAction{margin-top:8px}
75
+ .pct{font-weight:600;font-size:11px;flex-shrink:0;color:var(--dsw-alias-label-secondary,#6b7280)}
76
+ .cancelBtn{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:6px;padding:2px 10px;font:inherit;font-size:11px;line-height:16px;cursor:pointer;flex-shrink:0;white-space:nowrap}
77
+ .cancelBtn:hover{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626)}
78
+ .sentinel{height:1px}
@@ -38,6 +38,14 @@ function OwnerAvatar({ name, owner }: { name: string; owner: string }) {
38
38
  )
39
39
  }
40
40
 
41
+ /**
42
+ * Module-scope caches so re-entering the section renders instantly instead
43
+ * of refetching and rebuilding from a spinner (#30 by @StarsTom). Module
44
+ * state survives section switches; a background refetch keeps it current.
45
+ */
46
+ let cachedRegistry: Registry | null = null
47
+ let cachedInstalled: InstalledMap | null = null
48
+
41
49
  export interface MarketSectionProps {
42
50
  t: Translate
43
51
  locale: {
@@ -63,9 +71,10 @@ export function MarketSection(props: MarketSectionProps) {
63
71
  props.themeStore.subscribe,
64
72
  props.themeStore.getSnapshot,
65
73
  )
66
- const [data, setData] = useState<Registry | null>(null)
74
+ const [data, setData] = useState<Registry | null>(cachedRegistry)
67
75
  const [loadError, setLoadError] = useState(false)
68
- const [installed, setInstalled] = useState<InstalledMap>({})
76
+ const [installed, setInstalledState] = useState<InstalledMap>(cachedInstalled ?? {})
77
+ const setInstalled = useCallback((value: InstalledMap) => { cachedInstalled = value; setInstalledState(value) }, [])
69
78
  const [skins, setSkins] = useState<string[]>([])
70
79
  const [tab, setTab] = useState(() => {
71
80
  const saved = sessionStorage.getItem('dshm-tab')
@@ -82,6 +91,26 @@ export function MarketSection(props: MarketSectionProps) {
82
91
  const [updatingName, setUpdatingName] = useState<string | null>(null)
83
92
  // Plugin blocked by pnpm's fresh-release safety wait; arms the update-now button.
84
93
  const [staleName, setStaleName] = useState<string | null>(null)
94
+ /**
95
+ * Cards rendered so far — the discover grid renders incrementally (60,
96
+ * then +120 as the sentinel scrolls into view) instead of all 400+ at
97
+ * once, which built a ~70k px DOM and made section switches lag (#30).
98
+ */
99
+ const [visibleCount, setVisibleCount] = useState(60)
100
+ const sentinelRef = useCallback((node: HTMLDivElement | null) => {
101
+ if (node === null) return
102
+ const observer = new IntersectionObserver((entries) => {
103
+ if (entries.some(entry => entry.isIntersecting)) setVisibleCount(count => count + 120)
104
+ }, { rootMargin: '600px' })
105
+ observer.observe(node)
106
+ // Cleanup runs when React detaches the node and calls back with null;
107
+ // the observer dies with the node, so a one-shot observe suffices.
108
+ }, [])
109
+
110
+ /** Determinate percent parsed from pnpm's Progress line, when available. */
111
+ const [progressPct, setProgressPct] = useState<number | null>(null)
112
+ /** Blocked build scripts from the last install: enables approve-and-retry (#6). */
113
+ const [buildsSkipped, setBuildsSkipped] = useState<{ plugin: RegistryPlugin; names: string[] } | null>(null)
85
114
  const [updatingAll, setUpdatingAll] = useState(false)
86
115
  const [updatedNames, setUpdatedNames] = useState<string[]>([])
87
116
  const [hotUrls, setHotUrls] = useState<string[]>([])
@@ -121,7 +150,7 @@ export function MarketSection(props: MarketSectionProps) {
121
150
  useEffect(() => {
122
151
  fetch('/dsh-market/registry', { cache: 'no-store' })
123
152
  .then(res => { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json() })
124
- .then(body => setData(body.registry))
153
+ .then(body => { cachedRegistry = body.registry; setData(body.registry) })
125
154
  .catch(() => setLoadError(true))
126
155
  fetch('/dsh-market/status', { cache: 'no-store' })
127
156
  .then(res => res.json())
@@ -192,8 +221,14 @@ export function MarketSection(props: MarketSectionProps) {
192
221
  .then(status => {
193
222
  if (status.active) {
194
223
  setProgressLine((status.lastLine || '…') + ' (' + status.seconds + 's)')
224
+ const m = /resolved (\d+), reused (\d+), downloaded (\d+), added (\d+)/.exec(status.lastLine || '')
225
+ if (m !== null && Number(m[1]) > 0) {
226
+ const done = Number(m[2]) + Number(m[3]) + Number(m[4])
227
+ setProgressPct(Math.max(4, Math.min(96, Math.round(done / Number(m[1]) * 100))))
228
+ }
195
229
  } else {
196
230
  setProgressLine(null)
231
+ setProgressPct(null)
197
232
  setInstalled(status.installed || {})
198
233
  const pending = readSession('dshm-pending')
199
234
  if (pending !== null && busyUrl !== null) {
@@ -216,7 +251,10 @@ export function MarketSection(props: MarketSectionProps) {
216
251
  () => (data === null ? [] : visiblePlugins(data.plugins, { category: cat, query: q, lang, sort })),
217
252
  [data, q, cat, lang, sort])
218
253
 
254
+ useEffect(() => { setVisibleCount(60) }, [q, cat, sort])
255
+
219
256
  const doInstall = useCallback((plugin: RegistryPlugin) => {
257
+ setBuildsSkipped(null)
220
258
  setConfirming(null)
221
259
  setInstallError(null)
222
260
  setBusyUrl(plugin.url)
@@ -237,6 +275,11 @@ export function MarketSection(props: MarketSectionProps) {
237
275
  location.reload()
238
276
  return
239
277
  }
278
+ if (body.cancelled === true) {
279
+ // User-cancelled: quiet reset, nothing to report.
280
+ refreshInstalled()
281
+ return
282
+ }
240
283
  if (status === 200 && body.ok) {
241
284
  sessionStorage.setItem('dshm-tab', 'installed')
242
285
  if (body.hot) {
@@ -247,6 +290,13 @@ export function MarketSection(props: MarketSectionProps) {
247
290
  }
248
291
  refreshInstalled()
249
292
  } else {
293
+ if (status === 409) {
294
+ setInstallError(t('busyWait'))
295
+ return
296
+ }
297
+ if (Array.isArray(body.ignoredBuilds) && body.ignoredBuilds.length > 0) {
298
+ setBuildsSkipped({ plugin, names: body.ignoredBuilds.map(String) })
299
+ }
250
300
  const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
251
301
  const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
252
302
  setInstallError(t('installFail') + ': ' + plugin.name + ' — ' + detail.trim().slice(-600))
@@ -259,6 +309,12 @@ export function MarketSection(props: MarketSectionProps) {
259
309
  .finally(() => setBusyUrl(null))
260
310
  }, [refreshInstalled, t])
261
311
 
312
+ /** Cancel the running plugin command (#6 by @qichuang321). */
313
+ const doCancel = useCallback(() => {
314
+ fetch('/dsh-market/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
315
+ .catch(() => {})
316
+ }, [])
317
+
262
318
  const doUpdate = useCallback((name: string, force = false) => {
263
319
  setInstallError(null)
264
320
  setStaleName(null)
@@ -270,10 +326,15 @@ export function MarketSection(props: MarketSectionProps) {
270
326
  })
271
327
  .then(res => res.json().then(body => ({ status: res.status, body })))
272
328
  .then(({ status, body }) => {
329
+ if (body.cancelled === true) {
330
+ refreshInstalled()
331
+ return
332
+ }
273
333
  if (status === 200 && body.ok) {
274
334
  setUpdatedNames(names => names.concat(name))
275
335
  refreshInstalled()
276
336
  } else {
337
+ if (status === 409) { setInstallError(t('busyWait')); return }
277
338
  if (body.stale === true) setStaleName(name)
278
339
  const text = (v: unknown) => typeof v === 'string' ? v : (v && typeof (v as any).text === 'string') ? (v as any).text : v == null ? '' : JSON.stringify(v)
279
340
  const detail = text(body.error) || [text(body.stderr), text(body.stdout)].filter(Boolean).join('\n').trim() || ('exit ' + body.exitCode)
@@ -400,6 +461,8 @@ export function MarketSection(props: MarketSectionProps) {
400
461
  <div className={css.progress}>
401
462
  <span className={css.spin} />
402
463
  <code className={css.grow}>{progressLine || t('progressHint')}</code>
464
+ {progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
465
+ <button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
403
466
  </div>
404
467
  )}
405
468
  </div>
@@ -578,6 +641,30 @@ export function MarketSection(props: MarketSectionProps) {
578
641
  </div>
579
642
  )}
580
643
  </div>
644
+ {buildsSkipped !== null && (
645
+ <div className={css.restart}>
646
+ <span className={css.grow}>{t('buildsSkipped')} {buildsSkipped.names.join(', ')}</span>
647
+ <Button
648
+ size="sm"
649
+ disabled={busyUrl !== null}
650
+ onClick={() => {
651
+ const { plugin, names } = buildsSkipped
652
+ setBuildsSkipped(null)
653
+ fetch('/dsh-market/approve-builds', {
654
+ method: 'POST',
655
+ headers: { 'content-type': 'application/json' },
656
+ body: JSON.stringify({ packages: names }),
657
+ })
658
+ .then(res => res.json())
659
+ .then((body) => {
660
+ if (body.ok) doInstall(plugin)
661
+ else setInstallError(String(body.error || 'approve failed'))
662
+ })
663
+ .catch(error => setInstallError(String(error)))
664
+ }}
665
+ >{t('approveBuilds')}</Button>
666
+ </div>
667
+ )}
581
668
  {installError !== null && (
582
669
  <div className={css.err}>
583
670
  {installError}
@@ -643,7 +730,12 @@ export function MarketSection(props: MarketSectionProps) {
643
730
  </div>
644
731
  {plugins.length === 0
645
732
  ? <div className={css.empty}>{t('empty')}</div>
646
- : <div className={css.grid}>{plugins.map(pluginCard)}</div>}
733
+ : (
734
+ <>
735
+ <div className={css.grid}>{plugins.slice(0, visibleCount).map(pluginCard)}</div>
736
+ {plugins.length > visibleCount && <div ref={sentinelRef} className={css.sentinel} />}
737
+ </>
738
+ )}
647
739
  </>
648
740
  )
649
741
  : tab === 'themes' && themeSnap !== null
@@ -692,6 +784,8 @@ export function MarketSection(props: MarketSectionProps) {
692
784
  <div className={css.progress}>
693
785
  <span className={css.spin} />
694
786
  <code className={css.grow}>{progressLine || t('progressHint')}</code>
787
+ {progressPct !== null && <span className={css.pct}>{progressPct}%</span>}
788
+ <button type="button" className={css.cancelBtn} onClick={doCancel}>{t('cancelOp')}</button>
695
789
  </div>
696
790
  )}
697
791
  </div>
@@ -29,6 +29,11 @@ export const zh = {
29
29
  update: '更新',
30
30
  updating: '更新中…',
31
31
  updated: '✓ 已更新,重启后生效',
32
+ cancelOp: '取消',
33
+ cancelled: '已取消',
34
+ busyWait: '已有操作正在进行,请等它结束(同一时间只执行一个安装/更新/卸载)',
35
+ approveBuilds: '放行构建脚本并重试',
36
+ buildsSkipped: '该插件需要运行构建脚本才能工作,出于安全默认被拦下。点击下方按钮为它放行并重装:',
32
37
  updateNow: '立即更新',
33
38
  updateFail: '更新失败',
34
39
  upToDate: '已是最新',
@@ -92,6 +97,11 @@ export const en: Record<MarketKey, string> = {
92
97
  update: 'Update',
93
98
  updating: 'Updating…',
94
99
  updated: '✓ Updated — restart to apply',
100
+ cancelOp: 'Cancel',
101
+ cancelled: 'Cancelled',
102
+ busyWait: 'Another operation is already running — please wait for it to finish (one install/update/uninstall at a time)',
103
+ approveBuilds: 'Allow build scripts and retry',
104
+ buildsSkipped: 'This plugin needs its build scripts to run; they are blocked by default for safety. Click below to allow them and reinstall:',
95
105
  updateNow: 'Update now',
96
106
  updateFail: 'Update failed',
97
107
  upToDate: 'Up to date',