@workbench-kit/shell-react 0.0.2-prototype.0.2.33 → 0.0.2-prototype.0.2.34

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.
Files changed (51) hide show
  1. package/README.md +32 -0
  2. package/package.json +9 -9
  3. package/src/commands/use-command-descriptors.ts +7 -3
  4. package/src/commands/use-extension-registry-command-descriptors.ts +23 -6
  5. package/src/devtools/use-workbench-devtools-snapshot.ts +39 -14
  6. package/src/devtools/workbench-devtools-snapshot.ts +32 -13
  7. package/src/editor/area.tsx +3 -2
  8. package/src/editor/state-storage.ts +31 -0
  9. package/src/editor/tab-context-menu.ts +8 -8
  10. package/src/editor/use-editor.ts +6 -6
  11. package/src/explorer/context-menu.ts +8 -4
  12. package/src/explorer/view.tsx +4 -3
  13. package/src/extensions/canonical-extension-descriptions.ts +58 -0
  14. package/src/extensions/context-menu.ts +9 -6
  15. package/src/extensions/extension-enablement-context.ts +15 -0
  16. package/src/extensions/extension-enablement-controller.ts +453 -0
  17. package/src/extensions/management-model.ts +111 -47
  18. package/src/extensions/theme-selection-protection.ts +98 -0
  19. package/src/extensions/uninstall-eligibility.ts +103 -0
  20. package/src/extensions/use-extension-management.ts +151 -67
  21. package/src/extensions/view.tsx +4 -0
  22. package/src/field-remap/chrome-labels.ts +58 -2
  23. package/src/field-remap/convert-palette.tsx +151 -6
  24. package/src/field-remap/demo.tsx +31 -2
  25. package/src/field-remap/flow.tsx +57 -13
  26. package/src/field-remap/history.ts +99 -0
  27. package/src/field-remap/index.ts +9 -1
  28. package/src/field-remap/panel.tsx +371 -97
  29. package/src/field-remap/preview-controller.ts +122 -0
  30. package/src/field-remap/preview.tsx +171 -0
  31. package/src/field-remap/view.css +72 -0
  32. package/src/index.ts +16 -1
  33. package/src/management/keybinding-overrides-storage.ts +28 -0
  34. package/src/management/preference-settings-storage.ts +28 -0
  35. package/src/management/settings.tsx +2 -0
  36. package/src/management/use-command-management.ts +31 -26
  37. package/src/management/use-keybinding-management.ts +25 -12
  38. package/src/shell/focused-extension-services.ts +138 -0
  39. package/src/shell/host-shell.tsx +13 -10
  40. package/src/shell/persistence-diagnostic-context.ts +11 -0
  41. package/src/shell/provider.tsx +373 -69
  42. package/src/shell/settings.tsx +25 -16
  43. package/src/shell/shell.tsx +119 -78
  44. package/src/shell/view-host.tsx +23 -22
  45. package/src/storage/local-json-storage.ts +43 -29
  46. package/src/storage/persistence-diagnostics.ts +72 -0
  47. package/src/workbench/appearance-storage.ts +28 -0
  48. package/src/workbench/command-host.tsx +19 -22
  49. package/src/workbench/command-palette.ts +14 -13
  50. package/src/workbench/layout-storage.ts +52 -5
  51. package/src/workbench/use-persisted-appearance.ts +38 -12
@@ -1,22 +1,18 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
2
2
  import {
3
3
  DEFAULT_EXTENSION_CATALOG_TRUST_POLICY,
4
4
  DEFAULT_EXTENSION_INSTALL_TRUST_STORAGE_KEY,
5
- DEFAULT_INSTALLED_EXTENSIONS_STORAGE_KEY,
6
5
  ExtensionInstallApprovalRequiredError,
7
6
  applyExtensionInstallPlanToRecords,
8
7
  assertExtensionCatalogUrlAllowed,
9
8
  loadExtensionInstallTrustRecords,
10
- loadInstalledExtensions,
11
9
  parseExtensionCatalog,
12
10
  recordExtensionInstallTrust,
13
- saveExtensionInstallTrustRecords,
14
- saveInstalledExtensions,
15
- toggleInstalledExtensionEnabled,
11
+ saveExtensionInstallTrustRecordsResult,
16
12
  type ExtensionCatalogEntry,
17
13
  type ExtensionCatalogTrustPolicy,
18
14
  type ExtensionInstallTrustRecord,
19
- type InstalledExtensionRecord,
15
+ type WorkbenchPersistenceDiagnosticHandler,
20
16
  } from '@workbench-kit/workbench-core';
21
17
  import type {
22
18
  ExtensionCatalogBrowseEntry,
@@ -30,61 +26,67 @@ import {
30
26
  createExtensionCatalogBrowseEntries,
31
27
  createExtensionInstallPlanningContext,
32
28
  createExtensionManagementEntries,
29
+ type ExtensionUninstallActionConstraint,
33
30
  } from './management-model.js';
34
31
  import { useWorkbench, type WorkbenchStorageAdapter } from '../shell/provider.js';
32
+ import { useExtensionEnablementController } from './extension-enablement-context.js';
33
+ import type {
34
+ ExtensionEnablementTransitionResult,
35
+ ExtensionUninstallActionResult,
36
+ } from './extension-enablement-controller.js';
37
+ import { useWorkbenchPersistenceDiagnosticHandler } from '../shell/persistence-diagnostic-context.js';
38
+ import {
39
+ reportPersistenceWriteResult,
40
+ usePersistenceDiagnosticHandlerRef,
41
+ } from '../storage/persistence-diagnostics.js';
35
42
 
36
43
  export interface UseExtensionManagementModelOptions {
37
44
  catalogTrustPolicy?: ExtensionCatalogTrustPolicy | undefined;
38
45
  catalogUrl?: string | undefined;
39
- installedExtensionsStorage?: WorkbenchStorageAdapter | undefined;
40
- installedExtensionsStorageKey?: string | undefined;
41
46
  installTrustStorage?: WorkbenchStorageAdapter | undefined;
42
47
  installTrustStorageKey?: string | undefined;
48
+ onPersistenceDiagnostic?: WorkbenchPersistenceDiagnosticHandler | undefined;
43
49
  }
44
50
 
45
51
  export function useExtensionManagementModel({
46
52
  catalogTrustPolicy = DEFAULT_EXTENSION_CATALOG_TRUST_POLICY,
47
53
  catalogUrl = '/extension-catalog.json',
48
- installedExtensionsStorage,
49
- installedExtensionsStorageKey,
50
54
  installTrustStorage,
51
55
  installTrustStorageKey = DEFAULT_EXTENSION_INSTALL_TRUST_STORAGE_KEY,
56
+ onPersistenceDiagnostic,
52
57
  }: UseExtensionManagementModelOptions = {}) {
53
58
  const workbench = useWorkbench();
54
- const { availableExtensions, extensionRegistry } = workbench;
55
- const resolvedInstalledExtensionsStorage =
56
- installedExtensionsStorage ?? workbench.installedExtensionsStorage;
57
- const resolvedInstalledExtensionsStorageKey =
58
- installedExtensionsStorageKey ??
59
- workbench.installedExtensionsStorageKey ??
60
- DEFAULT_INSTALLED_EXTENSIONS_STORAGE_KEY;
61
- const resolvedInstallTrustStorage = installTrustStorage ?? resolvedInstalledExtensionsStorage;
59
+ const providerPersistenceDiagnostic = useWorkbenchPersistenceDiagnosticHandler();
60
+ const diagnosticHandlerRef = usePersistenceDiagnosticHandlerRef(
61
+ onPersistenceDiagnostic ?? providerPersistenceDiagnostic,
62
+ );
63
+ const extensionEnablement = useExtensionEnablementController();
64
+ const { availableExtensions, extensionCatalog } = workbench;
65
+ const resolvedInstallTrustStorage = installTrustStorage ?? workbench.installedExtensionsStorage;
66
+ const installedRecords = useSyncExternalStore(
67
+ extensionEnablement.subscribeInstalledRecords,
68
+ extensionEnablement.getInstalledRecordsSnapshot,
69
+ extensionEnablement.getInstalledRecordsSnapshot,
70
+ );
62
71
  const [catalogEntries, setCatalogEntries] = useState<readonly ExtensionCatalogEntry[]>([]);
63
72
  const [catalogLoading, setCatalogLoading] = useState(Boolean(catalogUrl));
64
73
  const [catalogError, setCatalogError] = useState<string | undefined>();
65
74
  const [pendingAction, setPendingAction] = useState<
66
75
  ExtensionManagementPendingAction | undefined
67
76
  >();
68
- const [installedRecords, setInstalledRecords] = useState<readonly InstalledExtensionRecord[]>(
69
- () =>
70
- loadInstalledExtensions(
71
- resolvedInstalledExtensionsStorageKey,
72
- resolvedInstalledExtensionsStorage,
73
- ),
74
- );
77
+ const [pendingUninstallEntry, setPendingUninstallEntry] = useState<
78
+ ExtensionManagementEntry | undefined
79
+ >();
80
+ const [uninstallActionConstraint, setUninstallActionConstraint] = useState<
81
+ ExtensionUninstallActionConstraint | undefined
82
+ >();
83
+ const [lastTransition, setLastTransition] = useState<
84
+ ExtensionEnablementTransitionResult | undefined
85
+ >();
75
86
  const [installTrustRecords, setInstallTrustRecords] = useState<
76
87
  readonly ExtensionInstallTrustRecord[]
77
88
  >(() => loadExtensionInstallTrustRecords(installTrustStorageKey, resolvedInstallTrustStorage));
78
89
 
79
- useEffect(() => {
80
- setInstalledRecords(
81
- loadInstalledExtensions(
82
- resolvedInstalledExtensionsStorageKey,
83
- resolvedInstalledExtensionsStorage,
84
- ),
85
- );
86
- }, [resolvedInstalledExtensionsStorage, resolvedInstalledExtensionsStorageKey]);
87
-
88
90
  useEffect(() => {
89
91
  setInstallTrustRecords(
90
92
  loadExtensionInstallTrustRecords(installTrustStorageKey, resolvedInstallTrustStorage),
@@ -138,28 +140,66 @@ export function useExtensionManagementModel({
138
140
  }, [catalogTrustPolicy, catalogUrl]);
139
141
 
140
142
  const installedEntries = useMemo<readonly ExtensionManagementEntry[]>(() => {
141
- return createExtensionManagementEntries({
143
+ const projectedEntries = createExtensionManagementEntries({
142
144
  availableExtensions,
143
- extensionRegistry,
145
+ extensionCatalog,
144
146
  installedRecords,
147
+ transition: lastTransition,
148
+ uninstallActionConstraint,
145
149
  });
146
- }, [availableExtensions, extensionRegistry, installedRecords]);
150
+ if (!pendingUninstallEntry) {
151
+ return projectedEntries;
152
+ }
153
+
154
+ return [
155
+ ...projectedEntries.filter((entry) => entry.id !== pendingUninstallEntry.id),
156
+ pendingUninstallEntry,
157
+ ].sort((left, right) => left.displayName.localeCompare(right.displayName));
158
+ }, [
159
+ availableExtensions,
160
+ extensionCatalog,
161
+ installedRecords,
162
+ lastTransition,
163
+ pendingUninstallEntry,
164
+ uninstallActionConstraint,
165
+ ]);
166
+
167
+ const handleTransition = useCallback(
168
+ (
169
+ result: ExtensionEnablementTransitionResult,
170
+ pendingKind: ExtensionManagementPendingAction['kind'],
171
+ ) => {
172
+ setLastTransition(result);
173
+ if (result.kind !== 'reloadRequired') {
174
+ setPendingAction(undefined);
175
+ return;
176
+ }
177
+
178
+ setPendingAction({ entryId: result.extensionId, kind: pendingKind });
179
+ if (typeof window !== 'undefined') {
180
+ window.requestAnimationFrame(() => {
181
+ window.location.reload();
182
+ });
183
+ }
184
+ },
185
+ [],
186
+ );
147
187
 
148
188
  const browseEntries = useMemo<readonly ExtensionCatalogBrowseEntry[]>(() => {
149
189
  return createExtensionCatalogBrowseEntries({
150
190
  availableExtensions,
151
191
  catalogEntries,
152
- extensionRegistry,
192
+ extensionCatalog,
153
193
  installedRecords,
154
194
  });
155
- }, [availableExtensions, catalogEntries, extensionRegistry, installedRecords]);
195
+ }, [availableExtensions, catalogEntries, extensionCatalog, installedRecords]);
156
196
 
157
197
  const installCatalogEntry = useCallback(
158
198
  (entry: ExtensionCatalogBrowseEntry, options?: ExtensionInstallOptions) => {
159
199
  const installContext = createExtensionInstallPlanningContext({
160
200
  availableExtensions,
161
201
  catalogEntries,
162
- extensionRegistry,
202
+ extensionCatalog,
163
203
  installedRecords,
164
204
  });
165
205
  const plan = createCatalogEntryInstallPlan(entry, installContext);
@@ -178,26 +218,15 @@ export function useExtensionManagementModel({
178
218
  installSources: installContext.installSources,
179
219
  plan,
180
220
  });
181
- saveInstalledExtensions(
182
- next,
183
- resolvedInstalledExtensionsStorageKey,
184
- resolvedInstalledExtensionsStorage,
185
- );
186
- setInstalledRecords(next);
187
- setPendingAction({ entryId: entry.id, kind: 'install' });
188
- if (typeof window !== 'undefined') {
189
- window.requestAnimationFrame(() => {
190
- window.location.reload();
191
- });
192
- }
221
+ handleTransition(extensionEnablement.commitInstalledRecords(next, entry.id), 'install');
193
222
  },
194
223
  [
195
224
  availableExtensions,
196
225
  catalogEntries,
197
- extensionRegistry,
226
+ extensionCatalog,
198
227
  installedRecords,
199
- resolvedInstalledExtensionsStorage,
200
- resolvedInstalledExtensionsStorageKey,
228
+ extensionEnablement,
229
+ handleTransition,
201
230
  ],
202
231
  );
203
232
 
@@ -207,31 +236,58 @@ export function useExtensionManagementModel({
207
236
  return;
208
237
  }
209
238
 
210
- const next = toggleInstalledExtensionEnabled(
211
- entry.id,
212
- enabled,
213
- resolvedInstalledExtensionsStorageKey,
214
- resolvedInstalledExtensionsStorage,
215
- );
216
- setInstalledRecords(next);
217
- setPendingAction({ entryId: entry.id, kind: 'toggle' });
239
+ handleTransition(extensionEnablement.toggleInstalledExtension(entry.id, enabled), 'toggle');
240
+ },
241
+ [extensionEnablement, handleTransition],
242
+ );
243
+
244
+ const uninstallInstalledEntry = useCallback(
245
+ (entry: ExtensionManagementEntry) => {
246
+ if (entry.source !== 'installed' || entry.id.startsWith('workbench-kit.builtin.')) {
247
+ return;
248
+ }
249
+
250
+ const result = extensionEnablement.uninstallInstalledExtension(entry.id);
251
+ if (result.kind === 'blocked' || result.kind === 'ineligibleTarget') {
252
+ setUninstallActionConstraint(toExtensionUninstallActionConstraint(result));
253
+ return;
254
+ }
255
+
256
+ setUninstallActionConstraint(undefined);
257
+ setLastTransition(result);
258
+ if (result.kind !== 'reloadRequired') {
259
+ setPendingUninstallEntry(undefined);
260
+ return;
261
+ }
262
+
263
+ setPendingUninstallEntry(entry);
218
264
  if (typeof window !== 'undefined') {
219
265
  window.requestAnimationFrame(() => {
220
266
  window.location.reload();
221
267
  });
222
268
  }
223
269
  },
224
- [resolvedInstalledExtensionsStorage, resolvedInstalledExtensionsStorageKey],
270
+ [extensionEnablement],
225
271
  );
226
272
 
227
273
  const rememberInstallTrust = useCallback(
228
274
  (entry: ExtensionCatalogBrowseEntry) => {
229
275
  const permissions = entry.installPlan?.permissions ?? [];
230
276
  const next = recordExtensionInstallTrust(entry.id, permissions, installTrustRecords);
231
- saveExtensionInstallTrustRecords(next, installTrustStorageKey, resolvedInstallTrustStorage);
232
277
  setInstallTrustRecords(next);
278
+ const persistence = saveExtensionInstallTrustRecordsResult(
279
+ next,
280
+ installTrustStorageKey,
281
+ resolvedInstallTrustStorage,
282
+ );
283
+ reportPersistenceWriteResult(persistence, diagnosticHandlerRef);
233
284
  },
234
- [installTrustRecords, installTrustStorageKey, resolvedInstallTrustStorage],
285
+ [
286
+ diagnosticHandlerRef,
287
+ installTrustRecords,
288
+ installTrustStorageKey,
289
+ resolvedInstallTrustStorage,
290
+ ],
235
291
  );
236
292
 
237
293
  return {
@@ -242,7 +298,35 @@ export function useExtensionManagementModel({
242
298
  installedEntries,
243
299
  installTrustRecords,
244
300
  pendingAction,
301
+ pendingUninstallEntryId: pendingUninstallEntry?.id,
245
302
  rememberInstallTrust,
246
303
  toggleInstalledEntry,
304
+ uninstallInstalledEntry,
305
+ };
306
+ }
307
+
308
+ function toExtensionUninstallActionConstraint(
309
+ result: Extract<
310
+ ExtensionUninstallActionResult,
311
+ { readonly kind: 'blocked' | 'ineligibleTarget' }
312
+ >,
313
+ ): ExtensionUninstallActionConstraint {
314
+ if (result.kind === 'blocked') {
315
+ return {
316
+ eligibility: {
317
+ dependentExtensionIds: result.dependentExtensionIds,
318
+ kind: result.kind,
319
+ unresolvedExtensionIds: result.unresolvedExtensionIds,
320
+ },
321
+ extensionId: result.extensionId,
322
+ };
323
+ }
324
+ return {
325
+ eligibility: {
326
+ diagnosticExtensionIds: result.diagnosticExtensionIds,
327
+ kind: result.kind,
328
+ reason: result.reason,
329
+ },
330
+ extensionId: result.extensionId,
247
331
  };
248
332
  }
@@ -32,8 +32,10 @@ export function BuiltinExtensionsView({
32
32
  installedEntries,
33
33
  installTrustRecords,
34
34
  pendingAction,
35
+ pendingUninstallEntryId,
35
36
  rememberInstallTrust,
36
37
  toggleInstalledEntry,
38
+ uninstallInstalledEntry,
37
39
  } = useExtensionManagementModel({ catalogTrustPolicy, catalogUrl });
38
40
 
39
41
  const isInstallTrusted = useCallback(
@@ -55,9 +57,11 @@ export function BuiltinExtensionsView({
55
57
  isInstallTrusted={isInstallTrusted}
56
58
  missingExtensionIds={missingExtensionIds}
57
59
  pendingAction={pendingAction}
60
+ pendingUninstallEntryId={pendingUninstallEntryId}
58
61
  onInstall={installCatalogEntry}
59
62
  onRememberInstallTrust={rememberInstallTrust}
60
63
  onToggleEnabled={toggleInstalledEntry}
64
+ onUninstall={uninstallInstalledEntry}
61
65
  />
62
66
  );
63
67
  }
@@ -18,6 +18,11 @@ export interface FieldRemapChromeLabels {
18
18
  readonly convertPaletteDescription: string;
19
19
  readonly convertPaletteAriaLabel: string;
20
20
  readonly convertsListAriaLabel: string;
21
+ /** Additive filter copy. Omitted legacy label objects use the English defaults. */
22
+ readonly convertFilterLabel?: string;
23
+ readonly convertFilterPlaceholder?: string;
24
+ readonly clearConvertFilter?: string;
25
+ readonly noMatchingConverts?: string;
21
26
  readonly placeConvert: string;
22
27
  readonly addCombine: string;
23
28
  readonly addSplit: string;
@@ -32,15 +37,29 @@ export interface FieldRemapChromeLabels {
32
37
  readonly hideHiddenFields: string;
33
38
  readonly emptyDetailTitle: string;
34
39
  readonly emptyDetailDescription: string;
40
+ /** Additive Flow preview copy. Omitted legacy label objects use English defaults. */
41
+ readonly previewTitle?: string;
42
+ readonly previewLoading?: string;
43
+ readonly previewError?: string;
44
+ readonly previewDocumentResult?: string;
45
+ readonly previewBindingResult?: string;
46
+ readonly previewStepIntermediateUnavailable?: string;
47
+ readonly previewOperatorIntermediateUnavailable?: string;
48
+ readonly previewDraftUnavailable?: string;
49
+ readonly previewSelectionUnavailable?: string;
35
50
  }
36
51
 
37
- export const defaultFieldRemapChromeLabels: FieldRemapChromeLabels = {
52
+ export const defaultFieldRemapChromeLabels = {
38
53
  bindingsTitle: 'Bindings',
39
54
  convertPaletteTitle: 'Convert palette',
40
55
  convertPaletteDescription:
41
56
  'Place a convert first, then wire source → draft → target. Drafts stay off the document until both ports bind.',
42
57
  convertPaletteAriaLabel: 'Convert palette',
43
58
  convertsListAriaLabel: 'Converts',
59
+ convertFilterLabel: 'Filter converts',
60
+ convertFilterPlaceholder: 'Filter by label or id',
61
+ clearConvertFilter: 'Clear convert filter',
62
+ noMatchingConverts: 'No matching converts.',
44
63
  placeConvert: 'Place convert',
45
64
  addCombine: 'Add combine',
46
65
  addSplit: 'Add split',
@@ -57,7 +76,18 @@ export const defaultFieldRemapChromeLabels: FieldRemapChromeLabels = {
57
76
  emptyDetailTitle: 'Start with a convert',
58
77
  emptyDetailDescription:
59
78
  'Use the Convert palette to place a convert, then wire source → draft → target. Or select an existing binding / convert note on the canvas.',
60
- };
79
+ previewTitle: 'Sample preview',
80
+ previewLoading: 'Updating preview…',
81
+ previewError: 'Preview failed',
82
+ previewDocumentResult: 'Final document output',
83
+ previewBindingResult: 'Final binding value',
84
+ previewStepIntermediateUnavailable:
85
+ 'Per-step intermediate values are unavailable; this is the final binding value.',
86
+ previewOperatorIntermediateUnavailable:
87
+ 'Selected-operator intermediate values are unavailable; this is the final document output.',
88
+ previewDraftUnavailable: 'Draft converts are not executable until they become a binding.',
89
+ previewSelectionUnavailable: 'The selected binding or operator is no longer available.',
90
+ } as const satisfies Required<FieldRemapChromeLabels>;
61
91
 
62
92
  /** Stable capability ids for optional `t()` injection (not free prose). */
63
93
  export const fieldRemapChromeLabelKeys = {
@@ -66,6 +96,10 @@ export const fieldRemapChromeLabelKeys = {
66
96
  convertPaletteDescription: 'fieldRemap.convertPaletteDescription',
67
97
  convertPaletteAriaLabel: 'fieldRemap.convertPaletteAriaLabel',
68
98
  convertsListAriaLabel: 'fieldRemap.convertsListAriaLabel',
99
+ convertFilterLabel: 'fieldRemap.convertFilterLabel',
100
+ convertFilterPlaceholder: 'fieldRemap.convertFilterPlaceholder',
101
+ clearConvertFilter: 'fieldRemap.clearConvertFilter',
102
+ noMatchingConverts: 'fieldRemap.noMatchingConverts',
69
103
  placeConvert: 'fieldRemap.placeConvert',
70
104
  addCombine: 'fieldRemap.addCombine',
71
105
  addSplit: 'fieldRemap.addSplit',
@@ -80,6 +114,15 @@ export const fieldRemapChromeLabelKeys = {
80
114
  hideHiddenFields: 'fieldRemap.hideHiddenFields',
81
115
  emptyDetailTitle: 'fieldRemap.emptyDetailTitle',
82
116
  emptyDetailDescription: 'fieldRemap.emptyDetailDescription',
117
+ previewTitle: 'fieldRemap.previewTitle',
118
+ previewLoading: 'fieldRemap.previewLoading',
119
+ previewError: 'fieldRemap.previewError',
120
+ previewDocumentResult: 'fieldRemap.previewDocumentResult',
121
+ previewBindingResult: 'fieldRemap.previewBindingResult',
122
+ previewStepIntermediateUnavailable: 'fieldRemap.previewStepIntermediateUnavailable',
123
+ previewOperatorIntermediateUnavailable: 'fieldRemap.previewOperatorIntermediateUnavailable',
124
+ previewDraftUnavailable: 'fieldRemap.previewDraftUnavailable',
125
+ previewSelectionUnavailable: 'fieldRemap.previewSelectionUnavailable',
83
126
  } as const satisfies Record<keyof FieldRemapChromeLabels, string>;
84
127
 
85
128
  export function resolveFieldRemapChromeLabels(
@@ -101,6 +144,10 @@ export function resolveFieldRemapChromeLabels(
101
144
  convertPaletteDescription: resolve('convertPaletteDescription'),
102
145
  convertPaletteAriaLabel: resolve('convertPaletteAriaLabel'),
103
146
  convertsListAriaLabel: resolve('convertsListAriaLabel'),
147
+ convertFilterLabel: resolve('convertFilterLabel'),
148
+ convertFilterPlaceholder: resolve('convertFilterPlaceholder'),
149
+ clearConvertFilter: resolve('clearConvertFilter'),
150
+ noMatchingConverts: resolve('noMatchingConverts'),
104
151
  placeConvert: resolve('placeConvert'),
105
152
  addCombine: resolve('addCombine'),
106
153
  addSplit: resolve('addSplit'),
@@ -115,5 +162,14 @@ export function resolveFieldRemapChromeLabels(
115
162
  hideHiddenFields: resolve('hideHiddenFields'),
116
163
  emptyDetailTitle: resolve('emptyDetailTitle'),
117
164
  emptyDetailDescription: resolve('emptyDetailDescription'),
165
+ previewTitle: resolve('previewTitle'),
166
+ previewLoading: resolve('previewLoading'),
167
+ previewError: resolve('previewError'),
168
+ previewDocumentResult: resolve('previewDocumentResult'),
169
+ previewBindingResult: resolve('previewBindingResult'),
170
+ previewStepIntermediateUnavailable: resolve('previewStepIntermediateUnavailable'),
171
+ previewOperatorIntermediateUnavailable: resolve('previewOperatorIntermediateUnavailable'),
172
+ previewDraftUnavailable: resolve('previewDraftUnavailable'),
173
+ previewSelectionUnavailable: resolve('previewSelectionUnavailable'),
118
174
  };
119
175
  }
@@ -1,6 +1,6 @@
1
- import type { JSX } from 'react';
1
+ import { useRef, useState, type JSX, type KeyboardEvent } from 'react';
2
2
  import { SideBarList, SideBarListItem } from '@workbench-kit/react/layout';
3
- import { IconButton } from '@workbench-kit/react/primitives';
3
+ import { ClearableTextInput, IconButton } from '@workbench-kit/react/primitives';
4
4
  import type { ValueTransformRegistry } from '@workbench-kit/field-remap';
5
5
 
6
6
  import { defaultFieldRemapChromeLabels, type FieldRemapChromeLabels } from './chrome-labels.js';
@@ -32,6 +32,120 @@ export function FieldRemapConvertPalette({
32
32
  // The registry supports replacing definitions through `register`; resolve
33
33
  // display metadata on each host render even when the registry object is stable.
34
34
  const catalog = transforms.list().filter((definition) => definition.id !== 'identity');
35
+ const [filterQuery, setFilterQuery] = useState('');
36
+ const [requestedRovingTransformId, setRequestedRovingTransformId] = useState(() =>
37
+ catalog.some((definition) => definition.id === selectedTransformId)
38
+ ? selectedTransformId
39
+ : (catalog[0]?.id ?? ''),
40
+ );
41
+ const optionRefs = useRef(new Map<string, HTMLButtonElement>());
42
+ const normalizedFilterQuery = filterQuery.trim().toLowerCase();
43
+ const visibleCatalog = catalog.filter((definition) => {
44
+ if (!normalizedFilterQuery) {
45
+ return true;
46
+ }
47
+ return (
48
+ definition.id.toLowerCase().includes(normalizedFilterQuery) ||
49
+ definition.label.toLowerCase().includes(normalizedFilterQuery)
50
+ );
51
+ });
52
+ const visibleTransformIds = visibleCatalog.map((definition) => definition.id);
53
+ const selectedTransformIsVisible = visibleTransformIds.includes(selectedTransformId);
54
+ const rovingTransformId = visibleTransformIds.includes(requestedRovingTransformId)
55
+ ? requestedRovingTransformId
56
+ : selectedTransformIsVisible
57
+ ? selectedTransformId
58
+ : (visibleTransformIds[0] ?? '');
59
+ const filterLabel =
60
+ chromeLabels.convertFilterLabel ?? defaultFieldRemapChromeLabels.convertFilterLabel;
61
+ const filterPlaceholder =
62
+ chromeLabels.convertFilterPlaceholder ?? defaultFieldRemapChromeLabels.convertFilterPlaceholder;
63
+ const clearFilterLabel =
64
+ chromeLabels.clearConvertFilter ?? defaultFieldRemapChromeLabels.clearConvertFilter;
65
+ const noMatchingConverts =
66
+ chromeLabels.noMatchingConverts ?? defaultFieldRemapChromeLabels.noMatchingConverts;
67
+
68
+ const updateFilterQuery = (nextQuery: string) => {
69
+ const normalizedNextQuery = nextQuery.trim().toLowerCase();
70
+ const nextVisibleIds = catalog
71
+ .filter(
72
+ (definition) =>
73
+ !normalizedNextQuery ||
74
+ definition.id.toLowerCase().includes(normalizedNextQuery) ||
75
+ definition.label.toLowerCase().includes(normalizedNextQuery),
76
+ )
77
+ .map((definition) => definition.id);
78
+ setFilterQuery(nextQuery);
79
+ setRequestedRovingTransformId(
80
+ nextVisibleIds.includes(selectedTransformId)
81
+ ? selectedTransformId
82
+ : (nextVisibleIds[0] ?? ''),
83
+ );
84
+ };
85
+
86
+ const focusVisibleTransform = (transformId: string) => {
87
+ setRequestedRovingTransformId(transformId);
88
+ optionRefs.current.get(transformId)?.focus();
89
+ };
90
+
91
+ const handleFilterKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
92
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
93
+ return;
94
+ }
95
+ const targetId = selectedTransformIsVisible
96
+ ? selectedTransformId
97
+ : event.key === 'ArrowDown'
98
+ ? visibleTransformIds[0]
99
+ : visibleTransformIds[visibleTransformIds.length - 1];
100
+ if (!targetId) {
101
+ return;
102
+ }
103
+ event.preventDefault();
104
+ focusVisibleTransform(targetId);
105
+ };
106
+
107
+ const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, transformId: string) => {
108
+ if (event.key === 'Enter') {
109
+ event.preventDefault();
110
+ if (transformId !== selectedTransformId) {
111
+ onSelectedTransformIdChange(transformId);
112
+ }
113
+ onPlaceDraft(transformId);
114
+ return;
115
+ }
116
+
117
+ const currentIndex = visibleTransformIds.indexOf(transformId);
118
+ if (currentIndex < 0) {
119
+ return;
120
+ }
121
+ let nextIndex: number | undefined;
122
+ switch (event.key) {
123
+ case 'ArrowDown':
124
+ nextIndex = Math.min(currentIndex + 1, visibleTransformIds.length - 1);
125
+ break;
126
+ case 'ArrowUp':
127
+ nextIndex = Math.max(currentIndex - 1, 0);
128
+ break;
129
+ case 'Home':
130
+ nextIndex = 0;
131
+ break;
132
+ case 'End':
133
+ nextIndex = visibleTransformIds.length - 1;
134
+ break;
135
+ default:
136
+ return;
137
+ }
138
+
139
+ const nextTransformId = visibleTransformIds[nextIndex];
140
+ if (!nextTransformId) {
141
+ return;
142
+ }
143
+ event.preventDefault();
144
+ focusVisibleTransform(nextTransformId);
145
+ if (nextTransformId !== selectedTransformId) {
146
+ onSelectedTransformIdChange(nextTransformId);
147
+ }
148
+ };
35
149
 
36
150
  return (
37
151
  <aside
@@ -49,11 +163,11 @@ export function FieldRemapConvertPalette({
49
163
  compact
50
164
  type="button"
51
165
  data-testid="field-remap-place-draft"
52
- disabled={!selectedTransformId}
166
+ disabled={!selectedTransformId || !selectedTransformIsVisible}
53
167
  icon="codicon-add"
54
168
  label={chromeLabels.placeConvert}
55
169
  onClick={() => {
56
- if (!selectedTransformId) {
170
+ if (!selectedTransformId || !selectedTransformIsVisible) {
57
171
  return;
58
172
  }
59
173
  onPlaceDraft(selectedTransformId);
@@ -61,24 +175,50 @@ export function FieldRemapConvertPalette({
61
175
  />
62
176
  </div>
63
177
 
178
+ <ClearableTextInput
179
+ aria-label={filterLabel}
180
+ className="workbench-field-remap-convert-palette__filter"
181
+ clearLabel={clearFilterLabel}
182
+ controlWidth="full"
183
+ placeholder={filterPlaceholder}
184
+ type="search"
185
+ value={filterQuery}
186
+ onClear={() => updateFilterQuery('')}
187
+ onKeyDown={handleFilterKeyDown}
188
+ onValueChange={updateFilterQuery}
189
+ />
190
+
64
191
  <SideBarList
65
192
  className="workbench-field-remap-convert-palette__list"
66
193
  role="listbox"
67
194
  aria-label={chromeLabels.convertsListAriaLabel}
68
195
  >
69
- {catalog.map((definition) => {
196
+ {visibleCatalog.map((definition) => {
70
197
  const selected = definition.id === selectedTransformId;
71
198
  return (
72
199
  <SideBarListItem
200
+ ref={(element) => {
201
+ if (element) {
202
+ optionRefs.current.set(definition.id, element);
203
+ } else {
204
+ optionRefs.current.delete(definition.id);
205
+ }
206
+ }}
73
207
  key={definition.id}
74
208
  aria-selected={selected}
75
209
  data-testid={`field-remap-palette-item-${definition.id}`}
76
210
  role="option"
77
211
  selected={selected}
212
+ tabIndex={definition.id === rovingTransformId ? 0 : -1}
78
213
  variant="stacked"
79
214
  wrapperProps={{ role: 'presentation' }}
80
- onClick={() => onSelectedTransformIdChange(definition.id)}
215
+ onClick={() => {
216
+ setRequestedRovingTransformId(definition.id);
217
+ onSelectedTransformIdChange(definition.id);
218
+ }}
81
219
  onDoubleClick={() => onPlaceDraft(definition.id)}
220
+ onFocus={() => setRequestedRovingTransformId(definition.id)}
221
+ onKeyDown={(event) => handleOptionKeyDown(event, definition.id)}
82
222
  >
83
223
  <strong>{definition.label}</strong>
84
224
  <code title={definition.id}>{definition.id}</code>
@@ -86,6 +226,11 @@ export function FieldRemapConvertPalette({
86
226
  );
87
227
  })}
88
228
  </SideBarList>
229
+ {visibleCatalog.length === 0 ? (
230
+ <p className="workbench-field-remap-convert-palette__empty" role="status">
231
+ {noMatchingConverts}
232
+ </p>
233
+ ) : null}
89
234
 
90
235
  {onAddCombine || onAddSplit ? (
91
236
  <div