Browse Source

refactor(web): share the enable switch, the notice line and the form observer plumbing

The duplication gate flagged three clones the plugin manager and the scoped
settings cards had grown: the bundle enable switch on a package's card and
page, the last-operation notice on the package list and a preset's section,
and the subscribe/bind/publish plumbing of CardForm and ScopedCardForms. Each
now has one owner: EnableSwitch, NoticeLine, and the ObservableForm base.
Yichen Jiang 2 weeks ago
parent
commit
ea624def34

+ 29 - 0
packages/client/ui-settings-plugin-manager/src/client/NoticeLine.tsx

@@ -0,0 +1,29 @@
+/**
+ * The outcome line of the last plugin operation, shared by the package list
+ * and a preset's section.
+ */
+
+import type { ReactNode } from 'react'
+import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ManagerNotice } from './manager-store.ts'
+import { noticeText, type Translate } from './presentation.ts'
+import css from './PluginManagerSettingsTab.module.css'
+
+/**
+ * Render the last operation's notice with its dismiss button, or nothing while there is none.
+ * @param props - the notice, the translator, and the dismiss handler.
+ * @returns the notice paragraph, or null.
+ */
+export function NoticeLine({ notice, t, onDismiss }: {
+  readonly notice: ManagerNotice | null
+  readonly t: Translate
+  readonly onDismiss: () => void
+}): ReactNode {
+  if (notice === null) return null
+  return (
+    <p className={css.notice} data-kind={notice.kind} role={notice.kind === 'failed' ? 'alert' : 'status'}>
+      <span>{noticeText(notice, t)}</span>
+      <Button variant="ghost" size="sm" onClick={onDismiss}>{t('dismiss')}</Button>
+    </p>
+  )
+}

+ 26 - 32
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -22,7 +22,8 @@ import {
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { PluginManagerLocaleKey } from './locales.ts'
 import { rowKey, type ConfirmState, type InstallState, type PluginManagerFace, type PresetGroup } from './manager-store.ts'
-import { noticeText, packageOf, refusalText, rowLabel, shortName, type Translate } from './presentation.ts'
+import { NoticeLine } from './NoticeLine.tsx'
+import { packageOf, refusalText, rowLabel, shortName, type Translate } from './presentation.ts'
 import css from './PluginManagerSettingsTab.module.css'
 
 /** Full component props assembled by the Settings slot renderer. */
@@ -357,6 +358,27 @@ function addTargets(
   ]
 }
 
+/** A bundle's enable switch on its card and its page: locked for a built-in bundle, off and locked for one the profile cannot enable. */
+function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: {
+  readonly pkg: PluginPackageView
+  readonly title: string
+  readonly t: Translate
+  readonly busy: boolean
+  readonly onSetEnabled: (enabled: boolean) => void
+}): ReactNode {
+  if (pkg.kind !== 'bundle') return null
+  const builtin = pkg.trust === 'builtin'
+  return (
+    <Switch
+      checked={pkg.enabled}
+      label={t('enableToggle', { name: title })}
+      disabled={busy || builtin || (!pkg.enabled && pkg.status === 'not-enableable')}
+      {...builtin ? { title: t('builtinLocked') } : {}}
+      onChange={onSetEnabled}
+    />
+  )
+}
+
 /** One installed package as a card: its name, its one-liner, its tags, its switch or its **Add to…** menu, and the way into its page. */
 function PackageCard({ pkg, t, busy, presets, globalModules, presetName, onOpen, onSetEnabled, onAddRow }: {
   readonly pkg: PluginPackageView
@@ -371,7 +393,6 @@ function PackageCard({ pkg, t, busy, presets, globalModules, presetName, onOpen,
 }): ReactNode {
   const [addMenu, setAddMenu] = useState(false)
   const title = pkg.title ?? shortName(pkg.name)
-  const bundle = pkg.kind === 'bundle'
   const builtin = pkg.trust === 'builtin'
   const status = cardStatus(pkg)
   const addable = pkg.addable.filter(entry => entry.ok)
@@ -395,17 +416,7 @@ function PackageCard({ pkg, t, busy, presets, globalModules, presetName, onOpen,
           {pkg.description === undefined ? null : <span className={css.cardDesc}>{pkg.description}</span>}
         </div>
         <div className={css.cardEnd}>
-          {bundle
-            ? (
-              <Switch
-                checked={pkg.enabled}
-                label={t('enableToggle', { name: title })}
-                disabled={busy || builtin || (!pkg.enabled && pkg.status === 'not-enableable')}
-                {...builtin ? { title: t('builtinLocked') } : {}}
-                onChange={onSetEnabled}
-              />
-            )
-            : null}
+          <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
           {menuItems.length === 0
             ? null
             : (
@@ -501,17 +512,7 @@ function PackageDetail({
           </div>
           <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
         </div>
-        {bundle
-          ? (
-            <Switch
-              checked={pkg.enabled}
-              label={t('enableToggle', { name: title })}
-              disabled={busy || builtin || (!pkg.enabled && pkg.status === 'not-enableable')}
-              {...builtin ? { title: t('builtinLocked') } : {}}
-              onChange={onSetEnabled}
-            />
-          )
-          : null}
+        <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
       </div>
       {pkg.reason === undefined || status === 'waiting' ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
       <dl className={css.facts}>
@@ -802,14 +803,7 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
       {restartPending.length > 0
         ? <p className={css.banner} role="status">{t('restartBanner', { names: restartPending.join(', ') })}</p>
         : null}
-      {state.notice === null
-        ? null
-        : (
-          <p className={css.notice} data-kind={state.notice.kind} role={state.notice.kind === 'failed' ? 'alert' : 'status'}>
-            <span>{noticeText(state.notice, t)}</span>
-            <Button variant="ghost" size="sm" onClick={props.dismissNotice}>{t('dismiss')}</Button>
-          </p>
-        )}
+      <NoticeLine notice={state.notice} t={t} onDismiss={props.dismissNotice} />
       {loaded && openPkg !== undefined
         ? (
           <PackageDetail

+ 3 - 9
packages/client/ui-settings-plugin-manager/src/client/PresetPluginsSection.tsx

@@ -15,7 +15,8 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
 import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
 import type { PluginManagerFace, PresetGroup, PresetRow } from './manager-store.ts'
 import { rowKey } from './manager-store.ts'
-import { noticeText, presetRowCopy, rowIdOf, shortName, type Translate } from './presentation.ts'
+import { NoticeLine } from './NoticeLine.tsx'
+import { presetRowCopy, rowIdOf, shortName, type Translate } from './presentation.ts'
 import css from './PluginManagerSettingsTab.module.css'
 
 /** Full component props assembled by the slot renderer. */
@@ -152,14 +153,7 @@ export function PresetPluginsSection(props: PresetPluginsSectionProps): ReactNod
       {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
       {state.status === 'unavailable' ? <p className={css.status} role="status">{t('unavailable')}</p> : null}
       {state.status === 'error' ? <p className={css.reason} role="alert">{t('error')}</p> : null}
-      {state.notice === null
-        ? null
-        : (
-          <p className={css.notice} data-kind={state.notice.kind} role={state.notice.kind === 'failed' ? 'alert' : 'status'}>
-            <span>{noticeText(state.notice, t)}</span>
-            <Button variant="ghost" size="sm" onClick={props.dismissNotice}>{t('dismiss')}</Button>
-          </p>
-        )}
+      <NoticeLine notice={state.notice} t={t} onDismiss={props.dismissNotice} />
       {!loaded || preset !== undefined ? null : <p className={css.empty}>{t('presetMissing')}</p>}
       {preset?.broken === undefined ? null : <p className={css.reason} role="alert">{preset.broken}</p>}
       {preset === undefined || preset.broken !== undefined

+ 3 - 27
packages/client/ui-settings-plugins/src/client/card-form.ts

@@ -13,8 +13,8 @@
  * override equal to the composition default is still an override.
  */
 
-import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
+import { ObservableForm } from './observable-form.ts'
 
 /** The write one field's staged text performs when the card is saved. */
 export type FieldWrite =
@@ -179,11 +179,10 @@ export function linesField(field: string): CardFieldSpec {
  * through a snapshot selector, while both the scope and the local drafts
  * change underneath; every projection is rebuilt from the two together.
  */
-export class CardForm<T> {
+export class CardForm<T> extends ObservableForm {
   private readonly specs: Map<string, CardFieldSpec>
   private readonly secretSpecs: Map<string, CardSecretSpec>
   private readonly staged = new Map<string, StagedEdit>()
-  private readonly listeners = new Set<() => void>()
   private saving = false
   private failed = false
 
@@ -197,6 +196,7 @@ export class CardForm<T> {
     specs: CardFieldSpec[],
     secrets: CardSecretSpec[] = [],
   ) {
+    super()
     this.specs = new Map(specs.map(spec => [spec.field, spec]))
     this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
     scope.subscribe(() => { this.publish() })
@@ -210,27 +210,6 @@ export class CardForm<T> {
     return this.scope
   }
 
-  /**
-   * Observe the form: the scope moved or a draft changed.
-   * @param listener - invoked after each change.
-   * @returns the disposer removing this listener.
-   */
-  subscribe(listener: () => void): () => void {
-    this.listeners.add(listener)
-    return () => { this.listeners.delete(listener) }
-  }
-
-  /**
-   * Publish a projection of this form, rebuilt whenever the scope or a draft changes.
-   * @param project - build the card's state from the form's current reads.
-   * @returns the store the card's component reads through its bound selector.
-   */
-  bind<S>(project: () => S): SnapshotStore<S> {
-    const store = createSnapshotStore(project())
-    this.subscribe(() => { store.set(project()) })
-    return store
-  }
-
   /**
    * Read the card-level state: what the Host serves, and what a save would do.
    * @returns the form state every card shares.
@@ -414,7 +393,4 @@ export class CardForm<T> {
     return user !== undefined && Object.hasOwn(user, field)
   }
 
-  private publish(): void {
-    for (const listener of this.listeners) listener()
-  }
 }

+ 37 - 0
packages/client/ui-settings-plugins/src/client/observable-form.ts

@@ -0,0 +1,37 @@
+/**
+ * The observer plumbing the card forms share: subscribe, publish, and the
+ * snapshot-store projection slot components read through.
+ */
+
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+
+/** A form whose reads change underneath its observers, which it tells after each change. */
+export abstract class ObservableForm {
+  private readonly listeners = new Set<() => void>()
+
+  /**
+   * Observe the form: every change it publishes.
+   * @param listener - invoked after each change.
+   * @returns the disposer removing this listener.
+   */
+  subscribe(listener: () => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /**
+   * Publish a projection of this form, rebuilt after every change it publishes.
+   * @param project - build the card's state from the form's current reads.
+   * @returns the store the card's component reads through its bound selector.
+   */
+  bind<S>(project: () => S): SnapshotStore<S> {
+    const store = createSnapshotStore(project())
+    this.subscribe(() => { store.set(project()) })
+    return store
+  }
+
+  /** Tell every observer that a read changed. */
+  protected publish(): void {
+    for (const listener of this.listeners) listener()
+  }
+}

+ 4 - 27
packages/client/ui-settings-plugins/src/client/scoped-form.ts

@@ -12,11 +12,12 @@
  * call time, so one registration serves both surfaces.
  */
 
-import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
 import {
   CardForm, type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell,
 } from './card-form.ts'
+import { ObservableForm } from './observable-form.ts'
 
 /** The scope the cards are editing. */
 export interface ScopeSelectionState {
@@ -76,9 +77,8 @@ export type BindScope<T> = (scope: string | undefined) => SettingsScope<T>
  * page's lifetime; the global form is bound at once, because the card's
  * availability is read from it before any switch.
  */
-export class ScopedCardForms<T> {
+export class ScopedCardForms<T> extends ObservableForm {
   private readonly forms = new Map<string, CardForm<T>>()
-  private readonly listeners = new Set<() => void>()
 
   /**
    * @param selection - the scope selection shared with the card surfaces.
@@ -92,6 +92,7 @@ export class ScopedCardForms<T> {
     private readonly specs: CardFieldSpec[],
     private readonly secrets: CardSecretSpec[] = [],
   ) {
+    super()
     this.formFor(undefined)
     selection.subscribe(() => {
       this.formFor(selection.current())
@@ -115,27 +116,6 @@ export class ScopedCardForms<T> {
     return this.current().scopeOf()
   }
 
-  /**
-   * Observe the selected form: a scope switch, a Host acceptance, or a draft change.
-   * @param listener - invoked after each change.
-   * @returns the disposer removing this listener.
-   */
-  subscribe(listener: () => void): () => void {
-    this.listeners.add(listener)
-    return () => { this.listeners.delete(listener) }
-  }
-
-  /**
-   * Publish a projection of the selected form, rebuilt whenever it moves.
-   * @param project - build the card's state from the current reads.
-   * @returns the store the card's component reads through its bound selector.
-   */
-  bind<S>(project: () => S): SnapshotStore<S> {
-    const store = createSnapshotStore(project())
-    this.subscribe(() => { store.set(project()) })
-    return store
-  }
-
   /**
    * Read the selected form's card-level state.
    * @returns the form state every card shares.
@@ -200,7 +180,4 @@ export class ScopedCardForms<T> {
     return form
   }
 
-  private publish(): void {
-    for (const listener of this.listeners) listener()
-  }
 }