dsh-nerd-mode 0.4.3 → 0.4.5

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/client.js CHANGED
@@ -59,7 +59,110 @@ window.__ModuleLoader__.load({
59
59
  && left.unseenQuestionType === right.unseenQuestionType;
60
60
  }
61
61
 
62
- function NerdModeSettingsEntry({ configForms, close }) {
62
+ const MARKET_API = '/dsh-market/api/v1';
63
+ const PACKAGE = 'dsh-nerd-mode';
64
+ const UPDATE_SCHEMA = 'dsh-market/update-api/v1';
65
+ const REQUEST_TIMEOUT = 10000;
66
+ const POLL_INTERVAL = 1000;
67
+
68
+ function useT(locale) {
69
+ React.useSyncExternalStore(
70
+ (listener) => locale.subscribe(listener),
71
+ () => locale.getSnapshot(),
72
+ () => locale.getSnapshot(),
73
+ );
74
+ return (text) => locale.resolveText(text);
75
+ }
76
+
77
+ // Every Market call carries the session cookies and gives up after 10 s.
78
+ async function marketRequest(url, options = {}, life = null) {
79
+ const controller = new AbortController();
80
+ if (life !== null) life.controller = controller;
81
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
82
+ try {
83
+ const response = await fetch(url, {
84
+ credentials: 'same-origin',
85
+ ...options,
86
+ signal: controller.signal,
87
+ });
88
+ const body = await response.json().catch(() => null);
89
+ return { ok: response.ok, status: response.status, body };
90
+ } catch (error) {
91
+ if (controller.signal.aborted) throw new Error('DSH Market did not answer within 10 seconds.');
92
+ throw error;
93
+ } finally {
94
+ clearTimeout(timer);
95
+ if (life !== null && life.controller === controller) life.controller = null;
96
+ }
97
+ }
98
+
99
+ function marketFailure(result, fallback) {
100
+ const body = result?.body ?? {};
101
+ return body.error
102
+ ?? body.failure?.message
103
+ ?? body.failure?.code
104
+ ?? `${fallback} (HTTP ${result?.status ?? 0}).`;
105
+ }
106
+
107
+ // The schema is optional on the operation endpoints, but a wrong one is a different API.
108
+ const wrongSchema = (body) => body?.schema !== undefined && body.schema !== UPDATE_SCHEMA;
109
+
110
+ async function readCapabilities(life) {
111
+ const result = await marketRequest(`${MARKET_API}/capabilities`, {}, life);
112
+ const body = result.body ?? {};
113
+ if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not answer'));
114
+ if (body.schema !== UPDATE_SCHEMA || body.features?.update !== true) {
115
+ throw new Error('DSH Market does not support plugin updates.');
116
+ }
117
+ const endpoints = body.endpoints ?? {};
118
+ if (typeof endpoints.updates !== 'string' || typeof endpoints.operations !== 'string') {
119
+ throw new Error('DSH Market returned no update endpoints.');
120
+ }
121
+ return {
122
+ endpoints: { updates: endpoints.updates, operations: endpoints.operations },
123
+ profile: typeof body.profile === 'string' ? body.profile : null,
124
+ restart: body.features.restart === true && typeof endpoints.restart === 'string' ? endpoints.restart : null,
125
+ };
126
+ }
127
+
128
+ async function readRelease(market, force, life) {
129
+ const url = `${market.endpoints.updates}?name=${encodeURIComponent(PACKAGE)}${force ? '&force=1' : ''}`;
130
+ const result = await marketRequest(url, {}, life);
131
+ const body = result.body ?? {};
132
+ if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not check for an update'));
133
+ if (body.schema !== UPDATE_SCHEMA || typeof body.package !== 'object' || body.package === null) {
134
+ throw new Error('DSH Market returned no release for this plugin.');
135
+ }
136
+ return body.package;
137
+ }
138
+
139
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
140
+
141
+ // Only a plain `major.minor.patch` pair can prove the local copy is newer.
142
+ function compareVersions(left, right) {
143
+ const first = VERSION.exec(String(left ?? ''));
144
+ const second = VERSION.exec(String(right ?? ''));
145
+ if (first === null || second === null) return 0;
146
+ for (let at = 1; at <= 3; at += 1) {
147
+ const difference = Number(first[at]) - Number(second[at]);
148
+ if (difference !== 0) return difference;
149
+ }
150
+ return 0;
151
+ }
152
+
153
+ const manualCommand = (profile) => `dsh plugin --profile ${profile ?? '<profile>'} add ${PACKAGE}`;
154
+
155
+ function NerdModeSettingsEntry({ configForms, close, locale }) {
156
+ return React.createElement(
157
+ 'div',
158
+ { 'data-nerd-mode-settings': '', style: settingsColumnStyle },
159
+ React.createElement(NerdModeSettingsBody, { configForms, close, locale }),
160
+ React.createElement(AboutGroup, { locale }),
161
+ );
162
+ }
163
+
164
+ function NerdModeSettingsBody({ configForms, close, locale }) {
165
+ const t = useT(locale);
63
166
  const description = configForms.describe();
64
167
  const described = React.useSyncExternalStore(
65
168
  (listener) => description.subscribe(listener),
@@ -69,30 +172,32 @@ window.__ModuleLoader__.load({
69
172
  const settingsExist = described.view?.namespaces.some(({ ns }) => ns === SETTINGS_NAMESPACE) === true;
70
173
  if (!settingsExist) {
71
174
  const loading = (described.status === 'idle' || described.status === 'loading') && !described.error;
72
- return React.createElement('div', { 'data-nerd-mode-settings': '' },
175
+ return React.createElement(React.Fragment, null,
73
176
  React.createElement('p', {
74
177
  role: 'status',
75
178
  'aria-busy': loading,
76
179
  style: feedbackStyle,
77
180
  }, loading
78
- ? 'Loading Nerd Mode settings…'
181
+ ? t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' })
79
182
  : described.status === 'ready'
80
- ? 'Nerd Mode settings are unavailable in this profile.'
81
- : 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
183
+ ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
184
+ : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
82
185
  loading ? null : React.createElement('button', {
83
186
  type: 'button',
84
187
  onClick: close,
85
188
  style: secondaryButtonStyle,
86
- }, 'Close'),
189
+ }, t({ en: 'Close', zh: '关闭' })),
87
190
  );
88
191
  }
89
192
  return React.createElement(NerdModeSettings, {
90
193
  close,
194
+ locale,
91
195
  scope: configForms.get(SETTINGS_NAMESPACE),
92
196
  });
93
197
  }
94
198
 
95
- function NerdModeSettings({ scope, close }) {
199
+ function NerdModeSettings({ scope, close, locale }) {
200
+ const t = useT(locale);
96
201
  const snapshot = React.useSyncExternalStore(
97
202
  (listener) => scope.subscribe(listener),
98
203
  () => scope.getSnapshot(),
@@ -111,19 +216,19 @@ window.__ModuleLoader__.load({
111
216
  role: 'status',
112
217
  'aria-busy': true,
113
218
  style: feedbackStyle,
114
- }, 'Loading Nerd Mode settings…');
219
+ }, t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' }));
115
220
  }
116
221
  if (snapshot?.status !== 'ready') {
117
- return React.createElement('div', { 'data-nerd-mode-settings': '' },
222
+ return React.createElement(React.Fragment, null,
118
223
  React.createElement('p', { role: 'status', style: feedbackStyle },
119
224
  snapshot?.status === 'unavailable'
120
- ? 'Nerd Mode settings are unavailable in this profile.'
121
- : 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
225
+ ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
226
+ : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
122
227
  React.createElement('button', {
123
228
  type: 'button',
124
229
  onClick: close,
125
230
  style: secondaryButtonStyle,
126
- }, 'Close'),
231
+ }, t({ en: 'Close', zh: '关闭' })),
127
232
  );
128
233
  }
129
234
  const current = settingsValues(snapshot);
@@ -153,38 +258,39 @@ window.__ModuleLoader__.load({
153
258
  setFeedback(null);
154
259
  try {
155
260
  const accepted = await scope.mutate(operations, latest.revision);
156
- if (accepted === false) throw new Error('The profile rejected these settings.');
261
+ if (accepted === false) {
262
+ throw new Error(t({ en: 'The profile rejected these settings.', zh: 'profile 拒绝了这些设置。' }));
263
+ }
157
264
  setDraft(settingsValues(scope.getSnapshot()));
158
- setFeedback('Saved to this profile. Changes apply to the next study-plus session.');
265
+ setFeedback({
266
+ tone: 'status',
267
+ text: {
268
+ en: 'Saved to this profile. Changes apply to the next study-plus session.',
269
+ zh: '已保存到此 profile。更改将在下次 study-plus 会话中生效。',
270
+ },
271
+ });
159
272
  } catch (error) {
160
- setFeedback(`Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`);
273
+ setFeedback({
274
+ tone: 'alert',
275
+ text: {
276
+ en: `Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`,
277
+ zh: `无法保存设置:${error?.message ?? 'profile 拒绝了这些更改'}`,
278
+ },
279
+ });
161
280
  } finally {
162
281
  setSaving(false);
163
282
  }
164
283
  };
165
284
 
166
285
  return React.createElement(
167
- 'div',
168
- {
169
- 'data-nerd-mode-settings': '',
170
- style: {
171
- boxSizing: 'border-box',
172
- color: 'var(--dsw-alias-label-primary)',
173
- display: 'flex',
174
- flexDirection: 'column',
175
- fontFamily: 'var(--dsw-font-family, inherit)',
176
- gap: 16,
177
- maxWidth: 720,
178
- paddingBottom: 24,
179
- width: '100%',
180
- },
181
- },
286
+ React.Fragment,
287
+ null,
182
288
  React.createElement(
183
289
  'header',
184
290
  { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
185
291
  React.createElement('h2', {
186
292
  style: { fontSize: 16, fontWeight: 600, lineHeight: '24px', margin: 0 },
187
- }, 'Nerd Mode'),
293
+ }, t({ en: 'Nerd Mode', zh: 'Nerd Mode' })),
188
294
  React.createElement('p', {
189
295
  style: {
190
296
  color: 'var(--dsw-alias-label-tertiary)',
@@ -192,7 +298,7 @@ window.__ModuleLoader__.load({
192
298
  lineHeight: '22px',
193
299
  margin: 0,
194
300
  },
195
- }, 'Profile defaults for new study-plus sessions.'),
301
+ }, t({ en: 'Profile defaults for new study-plus sessions.', zh: '新 study-plus 会话的 profile 默认值。' })),
196
302
  ),
197
303
  React.createElement(
198
304
  'section',
@@ -203,8 +309,8 @@ window.__ModuleLoader__.load({
203
309
  React.createElement(
204
310
  'div',
205
311
  { style: { minWidth: 0 } },
206
- React.createElement('h3', { style: groupTitleStyle }, 'Recall Session / 课前回忆'),
207
- React.createElement('p', { style: groupDescriptionStyle }, 'One ungraded recall question before teaching.'),
312
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Recall Session / 课前回忆', zh: '课前回忆' })),
313
+ React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'One ungraded recall question before teaching.', zh: '讲授前的一次不计分课前回忆。' })),
208
314
  ),
209
315
  React.createElement(
210
316
  'button',
@@ -212,14 +318,16 @@ window.__ModuleLoader__.load({
212
318
  type: 'button',
213
319
  role: 'switch',
214
320
  'aria-checked': draft.recallSessionEnabled,
215
- 'aria-label': 'Recall Session',
321
+ 'aria-label': t({ en: 'Recall Session', zh: '课前回忆' }),
216
322
  'data-setting': 'recallSessionEnabled',
217
323
  'data-state': draft.recallSessionEnabled ? 'on' : 'off',
218
324
  disabled: !writable || saving,
219
325
  onClick: () => updateDraft('recallSessionEnabled', !draft.recallSessionEnabled),
220
326
  style: switchStyle(draft.recallSessionEnabled, !writable || saving),
221
327
  },
222
- React.createElement('span', null, draft.recallSessionEnabled ? 'On' : 'Off'),
328
+ React.createElement('span', null, draft.recallSessionEnabled
329
+ ? t({ en: 'On', zh: '开' })
330
+ : t({ en: 'Off', zh: '关' })),
223
331
  React.createElement('span', {
224
332
  'aria-hidden': 'true',
225
333
  style: {
@@ -241,8 +349,8 @@ window.__ModuleLoader__.load({
241
349
  React.createElement(
242
350
  'div',
243
351
  null,
244
- React.createElement('h3', { style: groupTitleStyle }, 'Post-lecture questions'),
245
- React.createElement('p', { style: groupDescriptionStyle }, 'Choose how many unseen questions follow each lecture.'),
352
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Post-lecture questions', zh: '课后提问' })),
353
+ React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'Choose how many unseen questions follow each lecture.', zh: '选择每节课后出几道题。' })),
246
354
  ),
247
355
  React.createElement(
248
356
  'div',
@@ -250,7 +358,7 @@ window.__ModuleLoader__.load({
250
358
  React.createElement(
251
359
  'label',
252
360
  { htmlFor: 'nerd-mode-question-count', style: fieldLabelStyle },
253
- React.createElement('span', null, 'Question count'),
361
+ React.createElement('span', null, t({ en: 'Question count', zh: '题数' })),
254
362
  React.createElement('input', {
255
363
  id: 'nerd-mode-question-count',
256
364
  type: 'number',
@@ -264,12 +372,12 @@ window.__ModuleLoader__.load({
264
372
  onChange: (event) => updateDraft('unseenQuestionCount', event.target.value),
265
373
  style: fieldControlStyle,
266
374
  }),
267
- React.createElement('span', { style: fieldHintStyle }, 'Use 0 to skip unseen questions.'),
375
+ React.createElement('span', { style: fieldHintStyle }, t({ en: 'Use 0 to skip unseen questions.', zh: '设为 0 可跳过课后提问。' })),
268
376
  ),
269
377
  React.createElement(
270
378
  'label',
271
379
  { htmlFor: 'nerd-mode-question-type', style: fieldLabelStyle },
272
- React.createElement('span', null, 'Question type'),
380
+ React.createElement('span', null, t({ en: 'Question type', zh: '题型' })),
273
381
  React.createElement(
274
382
  'select',
275
383
  {
@@ -280,11 +388,11 @@ window.__ModuleLoader__.load({
280
388
  onChange: (event) => updateDraft('unseenQuestionType', event.target.value),
281
389
  style: fieldControlStyle,
282
390
  },
283
- React.createElement('option', { value: 'short-answer' }, 'Short answer'),
284
- React.createElement('option', { value: 'multiple-choice' }, 'Multiple choice'),
285
- React.createElement('option', { value: 'mixed' }, 'Mixed'),
391
+ React.createElement('option', { value: 'short-answer' }, t({ en: 'Short answer', zh: '简答' })),
392
+ React.createElement('option', { value: 'multiple-choice' }, t({ en: 'Multiple choice', zh: '四选一' })),
393
+ React.createElement('option', { value: 'mixed' }, t({ en: 'Mixed', zh: '混合' })),
286
394
  ),
287
- React.createElement('span', { style: fieldHintStyle }, 'Mixed chooses a type for each question.'),
395
+ React.createElement('span', { style: fieldHintStyle }, t({ en: 'Mixed chooses a type for each question.', zh: '混合会为每道题随机选题型。' })),
288
396
  ),
289
397
  ),
290
398
  ),
@@ -306,7 +414,7 @@ window.__ModuleLoader__.load({
306
414
  disabled: !writable || saving,
307
415
  onClick: restoreDefaults,
308
416
  style: secondaryButtonStyle,
309
- }, 'Restore defaults'),
417
+ }, t({ en: 'Restore defaults', zh: '恢复默认' })),
310
418
  React.createElement(
311
419
  'div',
312
420
  { style: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
@@ -315,27 +423,301 @@ window.__ModuleLoader__.load({
315
423
  disabled: !dirty || saving,
316
424
  onClick: cancel,
317
425
  style: secondaryButtonStyle,
318
- }, 'Cancel'),
426
+ }, t({ en: 'Cancel', zh: '取消' })),
319
427
  React.createElement('button', {
320
428
  type: 'button',
321
429
  disabled: !dirty || !validSettings(draft) || !writable || saving,
322
430
  onClick: save,
323
431
  style: primaryButtonStyle,
324
- }, saving ? 'Saving…' : 'Save'),
432
+ }, saving ? t({ en: 'Saving…', zh: '保存中…' }) : t({ en: 'Save', zh: '保存' })),
325
433
  ),
326
434
  ),
327
435
  snapshot?.writable === false
328
- ? React.createElement('p', { role: 'status', style: feedbackStyle }, 'These profile settings are read-only.')
436
+ ? React.createElement('p', { role: 'status', style: feedbackStyle }, t({ en: 'These profile settings are read-only.', zh: '这些 profile 设置为只读。' }))
329
437
  : null,
330
438
  feedback === null
331
439
  ? null
332
440
  : React.createElement('p', {
333
- role: feedback.startsWith('Could not save') ? 'alert' : 'status',
441
+ role: feedback.tone,
334
442
  style: feedbackStyle,
335
- }, feedback),
443
+ }, t(feedback.text)),
444
+ );
445
+ }
446
+
447
+ function AboutGroup({ locale }) {
448
+ const t = useT(locale);
449
+ const [life] = React.useState(() => ({ cancelled: false, timer: null, controller: null }));
450
+ const [view, setView] = React.useState({ phase: 'checking' });
451
+
452
+ // An answer that arrives after the panel closed writes nothing.
453
+ const show = (next) => {
454
+ if (!life.cancelled) setView(next);
455
+ };
456
+
457
+ async function check(force) {
458
+ setView({ phase: 'checking' });
459
+ let market;
460
+ try {
461
+ market = await readCapabilities(life);
462
+ } catch {
463
+ show({ phase: 'unavailable' });
464
+ return;
465
+ }
466
+ try {
467
+ const release = await readRelease(market, force, life);
468
+ show({ phase: 'ready', market, release, confirming: false, linkedNote: false });
469
+ } catch (error) {
470
+ show({
471
+ phase: 'failed',
472
+ market,
473
+ release: null,
474
+ message: error?.message ?? 'The update check failed.',
475
+ retry: 'check',
476
+ force: false,
477
+ });
478
+ }
479
+ }
480
+
481
+ function follow(market, operationId, release) {
482
+ return new Promise((resolve, reject) => {
483
+ const tick = async () => {
484
+ if (life.cancelled) {
485
+ resolve(null);
486
+ return;
487
+ }
488
+ let result;
489
+ try {
490
+ result = await marketRequest(`${market.endpoints.operations}?operationId=${encodeURIComponent(operationId)}`, {}, life);
491
+ } catch (error) {
492
+ reject(error);
493
+ return;
494
+ }
495
+ if (life.cancelled) {
496
+ resolve(null);
497
+ return;
498
+ }
499
+ if (!result.ok || wrongSchema(result.body)) {
500
+ reject(new Error(marketFailure(result, 'The update could not be followed')));
501
+ return;
502
+ }
503
+ const operation = result.body?.operation ?? {};
504
+ if (operation.state === 'succeeded') {
505
+ resolve(operation);
506
+ return;
507
+ }
508
+ if (operation.state === 'failed' || operation.state === 'cancelled' || operation.state === 'rolled-back') {
509
+ const error = new Error(operation.failure?.message ?? operation.failure?.code ?? `The update ${operation.state}.`);
510
+ error.code = operation.failure?.code ?? null;
511
+ reject(error);
512
+ return;
513
+ }
514
+ show({ phase: 'updating', market, release, progress: operation.state ?? 'running' });
515
+ life.timer = setTimeout(tick, POLL_INTERVAL);
516
+ };
517
+ tick();
518
+ });
519
+ }
520
+
521
+ async function install(market, release, force) {
522
+ setView({ phase: 'updating', market, release, progress: 'queued' });
523
+ try {
524
+ const result = await marketRequest(market.endpoints.updates, {
525
+ method: 'POST',
526
+ headers: { 'content-type': 'application/json' },
527
+ body: JSON.stringify({ packageName: PACKAGE, ...(force ? { force: true } : {}) }),
528
+ }, life);
529
+ if (!result.ok || wrongSchema(result.body)) {
530
+ const error = new Error(marketFailure(result, 'The update did not start'));
531
+ error.code = result.body?.failure?.code ?? null;
532
+ throw error;
533
+ }
534
+ const operationId = result.body?.operation?.operationId;
535
+ if (typeof operationId !== 'string') throw new Error('DSH Market returned no operation to follow.');
536
+ const operation = await follow(market, operationId, release);
537
+ if (operation === null) return;
538
+ show({
539
+ phase: 'installed',
540
+ market,
541
+ release,
542
+ version: operation.installedVersion ?? release.latestVersion,
543
+ restartNeeded: operation.outcome?.restartRequired === true,
544
+ restarting: false,
545
+ restartSent: false,
546
+ });
547
+ } catch (error) {
548
+ const code = error?.code ?? null;
549
+ show({
550
+ phase: 'failed',
551
+ market,
552
+ release,
553
+ message: error?.message ?? 'The update failed.',
554
+ retry: 'install',
555
+ force: code === 'RELEASE_TOO_FRESH' || code === 'VERSION_UNCHANGED',
556
+ });
557
+ }
558
+ }
559
+
560
+ async function restart(current) {
561
+ setView({ ...current, restarting: true });
562
+ try {
563
+ const result = await marketRequest(current.market.restart, {
564
+ method: 'POST',
565
+ headers: { 'content-type': 'application/json' },
566
+ body: '{}',
567
+ }, life);
568
+ if (!result.ok) throw new Error(marketFailure(result, 'DSH could not restart'));
569
+ show({ ...current, restarting: false, restartSent: true });
570
+ } catch (error) {
571
+ show({
572
+ phase: 'failed',
573
+ market: current.market,
574
+ release: current.release,
575
+ message: error?.message ?? 'DSH could not restart.',
576
+ retry: 'check',
577
+ force: false,
578
+ });
579
+ }
580
+ }
581
+
582
+ React.useEffect(() => {
583
+ check(false);
584
+ return () => {
585
+ life.cancelled = true;
586
+ if (life.timer !== null) clearTimeout(life.timer);
587
+ if (life.controller !== null) life.controller.abort();
588
+ };
589
+ }, []);
590
+
591
+ const busy = view.phase === 'checking' || view.phase === 'updating' || view.restarting === true;
592
+ const button = (text, onClick, disabled = false, primary = false) => React.createElement('button', {
593
+ type: 'button',
594
+ disabled,
595
+ onClick,
596
+ style: primary ? primaryButtonStyle : secondaryButtonStyle,
597
+ }, text);
598
+ const code = (text) => React.createElement('code', { style: commandStyle }, text);
599
+ const checkAgain = button(t({ en: 'Check again', zh: '重新检查' }), () => check(true));
600
+ const versions = (release) => t({
601
+ en: `Installed v${release.installedVersion} · latest v${release.latestVersion}`,
602
+ zh: `已安装 v${release.installedVersion} · 最新 v${release.latestVersion}`,
603
+ });
604
+
605
+ let versionLine = null;
606
+ const feedback = [];
607
+ let actions = null;
608
+
609
+ if (view.phase === 'checking') {
610
+ versionLine = t({ en: 'Checking…', zh: '正在检查…' });
611
+ } else if (view.phase === 'unavailable') {
612
+ feedback.push(t({
613
+ en: 'DSH Market is not available on this host, so Nerd Mode cannot check for updates. Run this command in a terminal:',
614
+ zh: '此主机上没有 DSH Market,因此 Nerd Mode 无法检查更新。请在终端运行:',
615
+ }));
616
+ feedback.push(code(manualCommand(null)));
617
+ } else if (view.phase === 'failed') {
618
+ if (view.release !== null) versionLine = versions(view.release);
619
+ feedback.push(t({ en: 'The update did not finish: ', zh: '更新未完成:' }));
620
+ feedback.push(view.message);
621
+ feedback.push(t({ en: ' Install it by hand with:', zh: ' 可手动安装:' }));
622
+ feedback.push(code(manualCommand(view.market?.profile ?? null)));
623
+ actions = [button(t({ en: 'Try again', zh: '重试' }), () => {
624
+ if (view.retry === 'install') install(view.market, view.release, view.force === true);
625
+ else check(true);
626
+ })];
627
+ } else if (view.phase === 'installed') {
628
+ versionLine = t({ en: `Installed v${view.version}`, zh: `已安装 v${view.version}` });
629
+ feedback.push(view.restartSent
630
+ ? t({ en: `DSH is restarting to load v${view.version}.`, zh: `DSH 正在重启以加载 v${view.version}。` })
631
+ : t({ en: `Restart DSH to use v${view.version}.`, zh: `重启 DSH 以使用 v${view.version}。` }));
632
+ actions = view.restartNeeded && view.market.restart !== null && view.restartSent !== true
633
+ ? [button(
634
+ t({ en: view.restarting ? 'Restarting…' : 'Restart now', zh: view.restarting ? '正在重启…' : '立即重启' }),
635
+ () => restart(view),
636
+ view.restarting === true,
637
+ true,
638
+ )]
639
+ : [checkAgain];
640
+ } else if (view.release.source === 'linked') {
641
+ versionLine = t({ en: `Local v${view.release.installedVersion}`, zh: `本地 v${view.release.installedVersion}` });
642
+ actions = [button(t({ en: 'About local installs', zh: '关于本地安装' }), () => {
643
+ setView({ ...view, linkedNote: view.linkedNote !== true });
644
+ })];
645
+ if (view.linkedNote === true) {
646
+ feedback.push(t({ en: 'This copy is linked to a folder. Update it with git, not npm.', zh: '此副本链接到本地文件夹。请用 git 更新,不要用 npm。' }));
647
+ }
648
+ } else if (view.phase === 'updating') {
649
+ versionLine = versions(view.release);
650
+ feedback.push(t({ en: `Update in progress (${view.progress}).`, zh: `更新进行中(${view.progress})。` }));
651
+ actions = [button(t({ en: 'Updating…', zh: '正在更新…' }), () => {}, true)];
652
+ } else if (view.release.updateAvailable === true) {
653
+ versionLine = versions(view.release);
654
+ if (view.confirming === true) {
655
+ feedback.push(t({
656
+ en: `Install v${view.release.latestVersion} and restart DSH afterwards?`,
657
+ zh: `安装 v${view.release.latestVersion} 并在之后重启 DSH?`,
658
+ }));
659
+ actions = [
660
+ button(t({ en: 'Cancel', zh: '取消' }), () => setView({ ...view, confirming: false })),
661
+ button(t({ en: 'Update', zh: '更新' }), () => install(view.market, view.release, false), false, true),
662
+ ];
663
+ } else {
664
+ actions = [button(
665
+ t({ en: `Update to v${view.release.latestVersion}`, zh: `更新到 v${view.release.latestVersion}` }),
666
+ () => setView({ ...view, confirming: true }),
667
+ false,
668
+ true,
669
+ )];
670
+ }
671
+ } else if (compareVersions(view.release.installedVersion, view.release.latestVersion) > 0) {
672
+ versionLine = t({
673
+ en: `Local v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
674
+ zh: `本地 v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
675
+ });
676
+ feedback.push(t({ en: 'This copy is newer than npm. No update.', zh: '此副本比 npm 上的版本更新,无需更新。' }));
677
+ actions = [checkAgain];
678
+ } else {
679
+ versionLine = versions(view.release);
680
+ actions = [checkAgain];
681
+ }
682
+
683
+ return React.createElement(
684
+ 'div',
685
+ { 'data-settings-group': 'about', style: { display: 'flex', flexDirection: 'column', gap: 8 } },
686
+ React.createElement(
687
+ 'div',
688
+ { style: groupStyle },
689
+ React.createElement(
690
+ 'div',
691
+ { style: { minWidth: 0 } },
692
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'About', zh: '关于' })),
693
+ versionLine === null ? null : React.createElement('p', { style: groupDescriptionStyle }, versionLine),
694
+ ),
695
+ actions === null ? null : React.createElement(
696
+ 'div',
697
+ { style: { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
698
+ ...actions,
699
+ ),
700
+ ),
701
+ React.createElement('p', {
702
+ role: view.phase === 'failed' ? 'alert' : 'status',
703
+ 'aria-busy': busy,
704
+ style: feedbackStyle,
705
+ }, ...feedback),
336
706
  );
337
707
  }
338
708
 
709
+ const settingsColumnStyle = {
710
+ boxSizing: 'border-box',
711
+ color: 'var(--dsw-alias-label-primary)',
712
+ display: 'flex',
713
+ flexDirection: 'column',
714
+ fontFamily: 'var(--dsw-font-family, inherit)',
715
+ gap: 16,
716
+ maxWidth: 720,
717
+ paddingBottom: 24,
718
+ width: '100%',
719
+ };
720
+
339
721
  const groupStyle = {
340
722
  alignItems: 'center',
341
723
  background: 'var(--dsw-alias-bg-layer-1)',
@@ -408,6 +790,16 @@ window.__ModuleLoader__.load({
408
790
  lineHeight: '20px',
409
791
  margin: 0,
410
792
  };
793
+ const commandStyle = {
794
+ background: 'var(--dsw-alias-bg-layer-2)',
795
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
796
+ display: 'inline-block',
797
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
798
+ fontSize: 12,
799
+ lineHeight: '18px',
800
+ padding: '2px 6px',
801
+ wordBreak: 'break-all',
802
+ };
411
803
 
412
804
  function switchStyle(active, disabled) {
413
805
  const accent = 'var(--dsw-alias-state-business-primary)';
@@ -1349,17 +1741,20 @@ window.__ModuleLoader__.load({
1349
1741
  }),
1350
1742
  }, NerdModeToggle),
1351
1743
  );
1352
- ctx.slots.inject(SETTINGS_SLOT, () =>
1353
- ctx.slots.register({
1354
- name: SETTINGS_SLOT,
1355
- id: 'dsh-nerd-mode',
1356
- order: 30,
1357
- label: LABEL,
1358
- }, ({ close }) => React.createElement(NerdModeSettingsEntry, {
1359
- close,
1360
- configForms: ctx.configForms,
1361
- })),
1362
- );
1744
+ ctx.inject(['locale'], (localeCtx) => {
1745
+ ctx.slots.inject(SETTINGS_SLOT, () =>
1746
+ ctx.slots.register({
1747
+ name: SETTINGS_SLOT,
1748
+ id: 'dsh-nerd-mode',
1749
+ order: 30,
1750
+ label: LABEL,
1751
+ }, ({ close }) => React.createElement(NerdModeSettingsEntry, {
1752
+ close,
1753
+ configForms: ctx.configForms,
1754
+ locale: localeCtx.locale,
1755
+ })),
1756
+ );
1757
+ });
1363
1758
  ctx.slots.inject(VIEW_SLOT, () =>
1364
1759
  ctx.slots.register({
1365
1760
  name: VIEW_SLOT,