@modesto_ma/dsh-channels 0.1.0 → 0.2.0

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.
@@ -23,6 +23,7 @@ import type {
23
23
  AccessMutationResponse,
24
24
  AccessWire,
25
25
  CatalogWire,
26
+ ChannelPhaseWire,
26
27
  ChannelStatusWire,
27
28
  CleanupPreviewWire,
28
29
  CleanupResponse,
@@ -51,15 +52,30 @@ import {
51
52
  } from './overlay.ts'
52
53
  import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
53
54
  import type { SettingsCatalog } from './catalog.ts'
55
+ import type { SettingsDirectoryPicker } from './directory-picker.ts'
54
56
 
55
57
  /** The slice of `FeishuService` the panel needs (structural, easy to fake). */
56
58
  export interface SettingsTarget {
57
59
  isRunning(): boolean
58
60
  isRestarting(): boolean
61
+ /** True while an explicit connect action is in flight (panel button busy state). */
62
+ isConnecting(): boolean
59
63
  health(): HealthSnapshot
60
64
  startedAtMs(): number | undefined
61
65
  uptimeMs(): number | undefined
62
66
  startError(): string | undefined
67
+ /**
68
+ * Whether the Feishu application credentials resolve. A false value means the
69
+ * plugin is loaded but deliberately holds no connection: the panel guides the
70
+ * user through configuring credentials instead of reporting a failure.
71
+ */
72
+ isConfigured(): boolean
73
+ /** Human-readable names of the missing configuration items (never values). */
74
+ missingConfig(): readonly string[]
75
+ /** Re-read credential presence (the credentials store is hot-reloaded on disk). */
76
+ refreshReadiness(): Promise<void>
77
+ /** Connect with the current config; throws when credentials are incomplete or the handshake fails. */
78
+ connect(): Promise<void>
63
79
  configSnapshot(): FeishuConfig
64
80
  baseConfigSnapshot(): FeishuConfig
65
81
  runningConfigSnapshot(): FeishuConfig | undefined
@@ -164,7 +180,7 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
164
180
  },
165
181
  authRequestTtlDays: {
166
182
  label: '授权申请有效期(天)',
167
- help: '入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可直接在「访问与授权」面板处理)。',
183
+ help: '入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可在本页下方「待处理申请」中处理)。',
168
184
  min: 1,
169
185
  max: 30,
170
186
  step: 1,
@@ -190,9 +206,9 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
190
206
  threadFallback: 'behavior',
191
207
  welcomeOnboarding: 'behavior',
192
208
  allowPermissionSwitch: 'behavior',
193
- adminOpenId: 'behavior',
194
- accessMode: 'behavior',
195
- authRequestTtlDays: 'behavior',
209
+ adminOpenId: 'access',
210
+ accessMode: 'access',
211
+ authRequestTtlDays: 'access',
196
212
  acceptAttachments: 'content',
197
213
  provider: 'model',
198
214
  model: 'model',
@@ -296,6 +312,23 @@ function withCurrentOption(
296
312
  return inherit === undefined ? [extra] : [inherit, extra, ...rest]
297
313
  }
298
314
 
315
+ /**
316
+ * Mark the workspace-root field as browsable when the host can open its native
317
+ * directory chooser. Every other host (no `directoryPicker`, or the `browse`
318
+ * backend of remote/headless sessions) keeps the plain text input.
319
+ *
320
+ * @param fields - Host field descriptors after the selector catalog is applied.
321
+ * @param available - whether a native directory chooser can be opened.
322
+ * @returns the descriptors with `browse: 'directory'` on `baseDir` when usable.
323
+ */
324
+ export function withDirectoryBrowse(
325
+ fields: readonly EditableFieldWire[],
326
+ available: boolean,
327
+ ): EditableFieldWire[] {
328
+ if (!available) return [...fields]
329
+ return fields.map(field => (field.key === 'baseDir' ? { ...field, browse: 'directory' } : field))
330
+ }
331
+
299
332
  function messageOf(error: unknown): string {
300
333
  return error instanceof Error ? error.message : String(error)
301
334
  }
@@ -377,12 +410,34 @@ export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
377
410
  ]
378
411
  }
379
412
 
413
+ /**
414
+ * Derive the panel phase from readiness + runtime facts. The order matters:
415
+ * an incomplete configuration always wins (no connection is attempted), and a
416
+ * failed attempt is only reported while the channel is stopped.
417
+ */
418
+ function phaseOf(target: SettingsTarget): ChannelPhaseWire {
419
+ // A connection attempt in flight is the honest label even though `start()`
420
+ // flips `started` before the handshake finishes. A live connection outranks
421
+ // missing credentials: removing a stored value does not drop the open socket,
422
+ // so the panel keeps the connection actions available and flags the missing
423
+ // credential separately (`configured: false`).
424
+ if (target.isRestarting()) return 'connecting'
425
+ if (target.isConnecting()) return target.isConfigured() ? 'connecting' : 'unconfigured'
426
+ if (target.isRunning()) return 'connected'
427
+ if (!target.isConfigured()) return 'unconfigured'
428
+ if (target.startError() !== undefined) return 'error'
429
+ return 'idle'
430
+ }
431
+
380
432
  /** Assemble the status half of the panel payload. */
381
433
  function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
382
434
  const health = target.health()
383
435
  const connection = health.connection
384
436
  return {
385
437
  running: target.isRunning(),
438
+ configured: target.isConfigured(),
439
+ phase: phaseOf(target),
440
+ missingConfig: [...target.missingConfig()],
386
441
  level: health.level,
387
442
  startedAt: target.startedAtMs(),
388
443
  uptimeMs: target.uptimeMs(),
@@ -402,7 +457,7 @@ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatus
402
457
  reasons: health.reasons,
403
458
  version: DSH_CHANNELS_VERSION,
404
459
  overlayPath,
405
- busy: target.isRestarting(),
460
+ busy: target.isRestarting() || target.isConnecting(),
406
461
  }
407
462
  }
408
463
 
@@ -419,10 +474,35 @@ export class SettingsSurface {
419
474
  private readonly store: OverlayStore,
420
475
  private readonly logger?: OverlayLogger,
421
476
  private readonly catalog?: SettingsCatalog,
477
+ private readonly directoryPicker?: SettingsDirectoryPicker,
422
478
  ) {}
423
479
 
480
+ /**
481
+ * Re-read credential presence. The credentials store is hot-reloaded from
482
+ * disk, so a value edited outside the panel becomes visible within one poll
483
+ * instead of only after a restart/connect.
484
+ */
485
+ private async syncReadiness(): Promise<void> {
486
+ try {
487
+ await this.target.refreshReadiness()
488
+ } catch (error) {
489
+ this.logger?.warn?.('dsh-channels: 凭据状态刷新失败:%s', messageOf(error))
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Reject panel writes while the connection lifecycle is in flight: saving an
495
+ * overlay or credentials then would race `applyOverlay()`/`start()` and can
496
+ * record a running-config snapshot that never took effect.
497
+ */
498
+ private writeBusy(): { ok: false; error: string } | undefined {
499
+ if (!this.target.isRestarting() && !this.target.isConnecting()) return undefined
500
+ return { ok: false, error: '飞书连接正在连接或重启,请稍候再试' }
501
+ }
502
+
424
503
  /** Wire status only (used by the client's 5 s poll). */
425
504
  async status(): Promise<ChannelStatusWire> {
505
+ await this.syncReadiness()
426
506
  return buildStatus(this.target, this.store.file)
427
507
  }
428
508
 
@@ -443,6 +523,7 @@ export class SettingsSurface {
443
523
 
444
524
  /** Status + editable config with provenance + read-only facts. */
445
525
  async panel(): Promise<PanelWire> {
526
+ await this.syncReadiness()
446
527
  const base = this.target.baseConfigSnapshot()
447
528
  const effective = this.target.configSnapshot()
448
529
  const overlay = await this.store.load()
@@ -452,12 +533,15 @@ export class SettingsSurface {
452
533
  ? RESTART_KEYS.filter(key => effective[key] !== running[key])
453
534
  : []
454
535
  const catalog = await this.catalogFor(effective.provider, effective.model)
455
- const fields = applyCatalog(FIELD_DESCRIPTORS, catalog, {
456
- provider: effective.provider,
457
- model: effective.model,
458
- reasoningEffort: effective.reasoningEffort,
459
- agentPreset: effective.agentPreset,
460
- })
536
+ const fields = withDirectoryBrowse(
537
+ applyCatalog(FIELD_DESCRIPTORS, catalog, {
538
+ provider: effective.provider,
539
+ model: effective.model,
540
+ reasoningEffort: effective.reasoningEffort,
541
+ agentPreset: effective.agentPreset,
542
+ }),
543
+ this.directoryPicker?.available === true,
544
+ )
461
545
 
462
546
  let credentialStates: { appId: CredentialInfo; appSecret: CredentialInfo } = {
463
547
  appId: { configured: false, writable: false },
@@ -507,6 +591,7 @@ export class SettingsSurface {
507
591
  access = {
508
592
  mode: effective.accessMode,
509
593
  admin: { configured: false },
594
+ loaded: false,
510
595
  groups: [],
511
596
  users: [],
512
597
  requests: [],
@@ -532,6 +617,8 @@ export class SettingsSurface {
532
617
 
533
618
  /** Validate + persist a partial edit; live fields take effect immediately. */
534
619
  async save(values: EditableValues): Promise<SaveConfigResponse> {
620
+ const busy = this.writeBusy()
621
+ if (busy !== undefined) return busy
535
622
  const unknown = Object.keys(values).filter(key => !(EDITABLE_KEYS as readonly string[]).includes(key))
536
623
  if (unknown.length > 0) {
537
624
  return { ok: false, error: `未知配置项:${unknown.join(', ')}` }
@@ -611,6 +698,8 @@ export class SettingsSurface {
611
698
 
612
699
  /** Drop every Web override and fall back to the profile config. */
613
700
  async clear(): Promise<ClearConfigResponse> {
701
+ const busy = this.writeBusy()
702
+ if (busy !== undefined) return busy
614
703
  try {
615
704
  await this.store.clear()
616
705
  } catch (error) {
@@ -622,6 +711,8 @@ export class SettingsSurface {
622
711
 
623
712
  /** Write provided credential values through the host seam (never echoed back). */
624
713
  async saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
714
+ const busy = this.writeBusy()
715
+ if (busy !== undefined) return busy
625
716
  const writes: Array<{ field: CredentialField; value: string }> = []
626
717
  const issues: FieldIssueWire[] = []
627
718
  for (const field of ['appId', 'appSecret'] as const) {
@@ -647,6 +738,8 @@ export class SettingsSurface {
647
738
 
648
739
  /** Remove the stored value behind one credential reference. */
649
740
  async clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
741
+ const busy = this.writeBusy()
742
+ if (busy !== undefined) return busy
650
743
  if (field !== 'appId' && field !== 'appSecret') {
651
744
  return { ok: false, error: '未知的凭据字段' }
652
745
  }
@@ -659,36 +752,67 @@ export class SettingsSurface {
659
752
  }
660
753
 
661
754
  /**
662
- * Credential values are resolved on connect. When the channel is stopped,
663
- * retry immediately (nothing to interrupt); when it is running, ask the user
664
- * to reconnect. A failed retry still reports the save as successful.
755
+ * Credential values are resolved on connect. Opening a connection is gated on
756
+ * readiness: saving one half of the pair stores the value but keeps the
757
+ * plugin idle until the other half exists. When the channel is stopped and
758
+ * the pair is complete, connect immediately (nothing to interrupt); when it
759
+ * is running, the user reconnects to pick the value up.
665
760
  */
666
761
  private async finishCredentialChange(kind: 'save' | 'clear'): Promise<SaveCredentialsResponse> {
667
762
  if (this.target.isRunning()) {
668
763
  return { ok: true, panel: await this.panel(), reconnect: 'pending' }
669
764
  }
670
- if (kind === 'clear') {
671
- return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
765
+ if (kind === 'clear' || !this.target.isConfigured()) {
766
+ return { ok: true, panel: await this.panel(), reconnect: kind === 'clear' ? 'not-needed' : 'unconfigured' }
672
767
  }
673
768
  try {
674
- await this.target.restart()
769
+ await this.target.connect()
675
770
  return { ok: true, panel: await this.panel(), reconnect: 'auto' }
676
771
  } catch (error) {
772
+ // The value is stored, but the channel did not come up: report the
773
+ // failure state instead of claiming a successful connection.
677
774
  return {
678
775
  ok: true,
679
776
  panel: await this.panel(),
680
- reconnect: 'auto',
777
+ reconnect: 'failed',
681
778
  autoReconnectError: messageOf(error),
682
779
  }
683
780
  }
684
781
  }
685
782
 
686
- /** Close and reopen the Feishu long connection with the latest config. */
783
+ /**
784
+ * The panel's single connect/reconnect action: reopen a running connection,
785
+ * or open the first one when the channel is stopped. Credentials must be
786
+ * complete — otherwise the panel points the user back at the credentials form
787
+ * instead of surfacing a startup error.
788
+ */
687
789
  async reconnect(): Promise<ReconnectResponse> {
790
+ // Credentials may have been edited on disk since the last poll; re-check
791
+ // presence before refusing the action.
792
+ await this.syncReadiness()
793
+ if (!this.target.isConfigured()) {
794
+ return {
795
+ ok: false,
796
+ error: '飞书应用凭据尚未配置完整,请先填写 App ID 与 App Secret 并保存',
797
+ unconfigured: true,
798
+ }
799
+ }
800
+ const wasRunning = this.target.isRunning()
688
801
  try {
689
- await this.target.restart()
802
+ if (wasRunning) await this.target.restart()
803
+ else await this.target.connect()
690
804
  } catch (error) {
691
- return { ok: false, error: `重连失败:${messageOf(error)}` }
805
+ return { ok: false, error: `${wasRunning ? '重连' : '连接'}失败:${messageOf(error)}` }
806
+ }
807
+ // `restart()` intentionally does not throw when the credentials vanished;
808
+ // verify the connection actually exists before reporting success.
809
+ if (!this.target.isRunning()) {
810
+ return {
811
+ ok: false,
812
+ unconfigured: !this.target.isConfigured(),
813
+ error: this.target.startError()
814
+ ?? (this.target.isConfigured() ? '飞书连接未建立' : '飞书应用凭据尚未配置完整,请先填写并保存凭据'),
815
+ }
692
816
  }
693
817
  return { ok: true, panel: await this.panel() }
694
818
  }
@@ -754,12 +878,26 @@ export class SettingsSurface {
754
878
  }
755
879
  }
756
880
 
881
+ /**
882
+ * Open the host's native directory chooser (the panel's「选择目录…」button).
883
+ *
884
+ * @param signal - caller lifetime; abort closes the OS dialog.
885
+ * @returns the chosen absolute path, or `null` when the operator cancels.
886
+ * @throws when the host provides no native directory chooser.
887
+ */
888
+ async pickDirectory(signal: AbortSignal): Promise<string | null> {
889
+ if (this.directoryPicker === undefined) throw new Error(PANEL_COPY.directoryPickFailed)
890
+ return await this.directoryPicker.pick(signal)
891
+ }
892
+
757
893
  /**
758
894
  * Run the workspace-retention cleanup once. Irreversible deletions are the
759
895
  * target's responsibility; the surface only reports the outcome and returns
760
896
  * a refreshed panel (its cleanup preview then reflects the remaining workspaces).
761
897
  */
762
898
  async runCleanup(): Promise<CleanupResponse> {
899
+ const busy = this.writeBusy()
900
+ if (busy !== undefined) return busy
763
901
  let result: CleanupRunWire
764
902
  try {
765
903
  result = await this.target.runWorkspaceCleanup()