@dxos/plugin-registry 0.8.4-main.422d1c7879 → 0.8.4-main.4f23b4e393

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 (193) hide show
  1. package/dist/lib/neutral/RegistryPlugin.mjs +27 -0
  2. package/dist/lib/neutral/RegistryPlugin.mjs.map +7 -0
  3. package/dist/lib/{browser/cli/index.mjs → neutral/RegistryPlugin.node.mjs} +29 -95
  4. package/dist/lib/neutral/RegistryPlugin.node.mjs.map +7 -0
  5. package/dist/lib/neutral/chunk-XK2JMH3H.mjs +33 -0
  6. package/dist/lib/neutral/chunk-XK2JMH3H.mjs.map +7 -0
  7. package/dist/lib/neutral/chunk-YV7A523U.mjs +29 -0
  8. package/dist/lib/neutral/chunk-YV7A523U.mjs.map +7 -0
  9. package/dist/lib/neutral/index.mjs +21 -0
  10. package/dist/lib/neutral/index.mjs.map +7 -0
  11. package/dist/lib/neutral/meta.json +1 -0
  12. package/dist/lib/neutral/open-plugin-registry-II735N76.mjs +15 -0
  13. package/dist/lib/neutral/open-plugin-registry-II735N76.mjs.map +7 -0
  14. package/dist/lib/neutral/plugin.mjs +18 -0
  15. package/dist/lib/neutral/plugin.mjs.map +7 -0
  16. package/dist/lib/neutral/translations.mjs +61 -0
  17. package/dist/lib/neutral/translations.mjs.map +7 -0
  18. package/dist/types/src/RegistryPlugin.d.ts +1 -0
  19. package/dist/types/src/RegistryPlugin.d.ts.map +1 -1
  20. package/dist/types/src/{cli/plugin.d.ts → RegistryPlugin.node.d.ts} +2 -1
  21. package/dist/types/src/RegistryPlugin.node.d.ts.map +1 -0
  22. package/dist/types/src/RegistryPlugin.test.d.ts +2 -0
  23. package/dist/types/src/RegistryPlugin.test.d.ts.map +1 -0
  24. package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -1
  25. package/dist/types/src/capabilities/dev-plugin-loader.d.ts +11 -0
  26. package/dist/types/src/capabilities/dev-plugin-loader.d.ts.map +1 -0
  27. package/dist/types/src/capabilities/index.d.ts +11 -2
  28. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  29. package/dist/types/src/capabilities/operation-handler.d.ts +1 -1
  30. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -1
  31. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
  32. package/dist/types/src/capabilities/settings.d.ts +14 -0
  33. package/dist/types/src/capabilities/settings.d.ts.map +1 -0
  34. package/dist/types/src/{cli/commands → commands}/disable.d.ts +2 -2
  35. package/dist/types/src/commands/disable.d.ts.map +1 -0
  36. package/dist/types/src/{cli/commands → commands}/enable.d.ts +2 -2
  37. package/dist/types/src/commands/enable.d.ts.map +1 -0
  38. package/dist/types/src/commands/index.d.ts.map +1 -0
  39. package/dist/types/src/commands/list.d.ts +7 -0
  40. package/dist/types/src/commands/list.d.ts.map +1 -0
  41. package/dist/types/src/commands/util.d.ts.map +1 -0
  42. package/dist/types/src/components/PluginDetail/PluginDetail.d.ts +26 -2
  43. package/dist/types/src/components/PluginDetail/PluginDetail.d.ts.map +1 -1
  44. package/dist/types/src/components/PluginDetail/PluginDetail.stories.d.ts +49 -23
  45. package/dist/types/src/components/PluginDetail/PluginDetail.stories.d.ts.map +1 -1
  46. package/dist/types/src/components/PluginFailureBadge/PluginFailureBadge.d.ts +20 -0
  47. package/dist/types/src/components/PluginFailureBadge/PluginFailureBadge.d.ts.map +1 -0
  48. package/dist/types/src/components/PluginFailureBadge/index.d.ts +2 -0
  49. package/dist/types/src/components/PluginFailureBadge/index.d.ts.map +1 -0
  50. package/dist/types/src/components/PluginList/PluginItem.d.ts +17 -3
  51. package/dist/types/src/components/PluginList/PluginItem.d.ts.map +1 -1
  52. package/dist/types/src/components/PluginList/PluginList.d.ts +14 -4
  53. package/dist/types/src/components/PluginList/PluginList.d.ts.map +1 -1
  54. package/dist/types/src/components/PluginList/PluginList.stories.d.ts +49 -23
  55. package/dist/types/src/components/PluginList/PluginList.stories.d.ts.map +1 -1
  56. package/dist/types/src/components/RegistrySettings/RegistrySettings.d.ts +18 -0
  57. package/dist/types/src/components/RegistrySettings/RegistrySettings.d.ts.map +1 -0
  58. package/dist/types/src/components/RegistrySettings/index.d.ts +2 -0
  59. package/dist/types/src/components/RegistrySettings/index.d.ts.map +1 -0
  60. package/dist/types/src/components/index.d.ts +2 -0
  61. package/dist/types/src/components/index.d.ts.map +1 -1
  62. package/dist/types/src/containers/BaseRegistryArticle/BaseRegistryArticle.d.ts +20 -0
  63. package/dist/types/src/containers/BaseRegistryArticle/BaseRegistryArticle.d.ts.map +1 -0
  64. package/dist/types/src/containers/BaseRegistryArticle/index.d.ts +2 -0
  65. package/dist/types/src/containers/BaseRegistryArticle/index.d.ts.map +1 -0
  66. package/dist/types/src/containers/LoadPluginDialog/LoadPluginDialog.d.ts.map +1 -1
  67. package/dist/types/src/containers/PluginArticle/PluginArticle.d.ts.map +1 -1
  68. package/dist/types/src/containers/PublicRegistryArticle/PublicRegistryArticle.d.ts +8 -0
  69. package/dist/types/src/containers/PublicRegistryArticle/PublicRegistryArticle.d.ts.map +1 -0
  70. package/dist/types/src/containers/PublicRegistryArticle/index.d.ts +2 -0
  71. package/dist/types/src/containers/PublicRegistryArticle/index.d.ts.map +1 -0
  72. package/dist/types/src/containers/RegistryArticle/RegistryArticle.d.ts +14 -0
  73. package/dist/types/src/containers/RegistryArticle/RegistryArticle.d.ts.map +1 -0
  74. package/dist/types/src/containers/RegistryArticle/index.d.ts +2 -0
  75. package/dist/types/src/containers/RegistryArticle/index.d.ts.map +1 -0
  76. package/dist/types/src/containers/RegistrySettingsContainer/RegistrySettingsContainer.d.ts +13 -0
  77. package/dist/types/src/containers/RegistrySettingsContainer/RegistrySettingsContainer.d.ts.map +1 -0
  78. package/dist/types/src/containers/RegistrySettingsContainer/index.d.ts +2 -0
  79. package/dist/types/src/containers/RegistrySettingsContainer/index.d.ts.map +1 -0
  80. package/dist/types/src/containers/index.d.ts +3 -2
  81. package/dist/types/src/containers/index.d.ts.map +1 -1
  82. package/dist/types/src/hooks/index.d.ts +2 -1
  83. package/dist/types/src/hooks/index.d.ts.map +1 -1
  84. package/dist/types/src/hooks/use-auto-tags.d.ts +4 -4
  85. package/dist/types/src/hooks/use-auto-tags.d.ts.map +1 -1
  86. package/dist/types/src/hooks/use-registry-plugins.d.ts +15 -0
  87. package/dist/types/src/hooks/use-registry-plugins.d.ts.map +1 -0
  88. package/dist/types/src/hooks/use-update-available.d.ts +18 -0
  89. package/dist/types/src/hooks/use-update-available.d.ts.map +1 -0
  90. package/dist/types/src/index.d.ts +1 -1
  91. package/dist/types/src/index.d.ts.map +1 -1
  92. package/dist/types/src/meta.d.ts.map +1 -1
  93. package/dist/types/src/operations/index.d.ts +1 -1
  94. package/dist/types/src/operations/index.d.ts.map +1 -1
  95. package/dist/types/src/operations/open-plugin-registry.d.ts +1 -1
  96. package/dist/types/src/operations/open-plugin-registry.d.ts.map +1 -1
  97. package/dist/types/src/plugin.d.ts +4 -0
  98. package/dist/types/src/plugin.d.ts.map +1 -0
  99. package/dist/types/src/{cli/storage.d.ts → storage.d.ts} +1 -1
  100. package/dist/types/src/storage.d.ts.map +1 -0
  101. package/dist/types/src/translations.d.ts +48 -23
  102. package/dist/types/src/translations.d.ts.map +1 -1
  103. package/dist/types/src/types.d.ts +28 -10
  104. package/dist/types/src/types.d.ts.map +1 -1
  105. package/dist/types/tsconfig.tsbuildinfo +1 -1
  106. package/package.json +46 -36
  107. package/src/{cli/plugin.ts → RegistryPlugin.node.ts} +2 -0
  108. package/src/RegistryPlugin.test.ts +24 -0
  109. package/src/RegistryPlugin.tsx +9 -7
  110. package/src/capabilities/app-graph-builder.ts +25 -17
  111. package/src/capabilities/dev-plugin-loader.ts +39 -0
  112. package/src/capabilities/index.ts +3 -2
  113. package/src/capabilities/operation-handler.ts +1 -1
  114. package/src/capabilities/react-surface.tsx +63 -47
  115. package/src/capabilities/settings.ts +34 -0
  116. package/src/components/PluginDetail/PluginDetail.stories.tsx +3 -2
  117. package/src/components/PluginDetail/PluginDetail.tsx +105 -15
  118. package/src/components/PluginFailureBadge/PluginFailureBadge.tsx +68 -0
  119. package/src/components/PluginFailureBadge/index.ts +5 -0
  120. package/src/components/PluginList/PluginItem.tsx +46 -7
  121. package/src/components/PluginList/PluginList.stories.tsx +1 -1
  122. package/src/components/PluginList/PluginList.tsx +28 -5
  123. package/src/components/RegistrySettings/RegistrySettings.tsx +120 -0
  124. package/src/components/RegistrySettings/index.ts +5 -0
  125. package/src/components/index.ts +2 -0
  126. package/src/containers/BaseRegistryArticle/BaseRegistryArticle.tsx +163 -0
  127. package/src/containers/BaseRegistryArticle/index.ts +5 -0
  128. package/src/containers/LoadPluginDialog/LoadPluginDialog.tsx +4 -3
  129. package/src/containers/PluginArticle/PluginArticle.tsx +296 -32
  130. package/src/containers/PublicRegistryArticle/PublicRegistryArticle.tsx +192 -0
  131. package/src/containers/PublicRegistryArticle/index.ts +5 -0
  132. package/src/containers/RegistryArticle/RegistryArticle.tsx +53 -0
  133. package/src/containers/RegistryArticle/index.ts +5 -0
  134. package/src/containers/RegistrySettingsContainer/RegistrySettingsContainer.tsx +59 -0
  135. package/src/containers/RegistrySettingsContainer/index.ts +5 -0
  136. package/src/containers/index.ts +3 -2
  137. package/src/hooks/index.ts +2 -1
  138. package/src/hooks/use-auto-tags.ts +7 -8
  139. package/src/hooks/use-registry-plugins.ts +29 -0
  140. package/src/hooks/use-update-available.ts +31 -0
  141. package/src/index.ts +1 -1
  142. package/src/meta.ts +2 -0
  143. package/src/operations/index.ts +1 -1
  144. package/src/operations/open-plugin-registry.ts +1 -1
  145. package/src/plugin.ts +11 -0
  146. package/src/translations.ts +33 -3
  147. package/src/types.ts +21 -14
  148. package/dist/lib/browser/cli/index.mjs.map +0 -7
  149. package/dist/lib/browser/index.mjs +0 -78
  150. package/dist/lib/browser/index.mjs.map +0 -7
  151. package/dist/lib/browser/meta.json +0 -1
  152. package/dist/lib/node-esm/cli/index.mjs +0 -221
  153. package/dist/lib/node-esm/cli/index.mjs.map +0 -7
  154. package/dist/lib/node-esm/index.mjs +0 -80
  155. package/dist/lib/node-esm/index.mjs.map +0 -7
  156. package/dist/lib/node-esm/meta.json +0 -1
  157. package/dist/types/src/capabilities/state.d.ts +0 -7
  158. package/dist/types/src/capabilities/state.d.ts.map +0 -1
  159. package/dist/types/src/cli/commands/disable.d.ts.map +0 -1
  160. package/dist/types/src/cli/commands/enable.d.ts.map +0 -1
  161. package/dist/types/src/cli/commands/index.d.ts.map +0 -1
  162. package/dist/types/src/cli/commands/list.d.ts +0 -7
  163. package/dist/types/src/cli/commands/list.d.ts.map +0 -1
  164. package/dist/types/src/cli/commands/util.d.ts.map +0 -1
  165. package/dist/types/src/cli/index.d.ts +0 -3
  166. package/dist/types/src/cli/index.d.ts.map +0 -1
  167. package/dist/types/src/cli/plugin.d.ts.map +0 -1
  168. package/dist/types/src/cli/storage.d.ts.map +0 -1
  169. package/dist/types/src/containers/CommunityRegistry/CommunityRegistry.d.ts +0 -8
  170. package/dist/types/src/containers/CommunityRegistry/CommunityRegistry.d.ts.map +0 -1
  171. package/dist/types/src/containers/CommunityRegistry/index.d.ts +0 -2
  172. package/dist/types/src/containers/CommunityRegistry/index.d.ts.map +0 -1
  173. package/dist/types/src/containers/PluginRegistry/PluginRegistry.d.ts +0 -14
  174. package/dist/types/src/containers/PluginRegistry/PluginRegistry.d.ts.map +0 -1
  175. package/dist/types/src/containers/PluginRegistry/index.d.ts +0 -2
  176. package/dist/types/src/containers/PluginRegistry/index.d.ts.map +0 -1
  177. package/dist/types/src/hooks/use-community-plugins.d.ts +0 -16
  178. package/dist/types/src/hooks/use-community-plugins.d.ts.map +0 -1
  179. package/src/capabilities/state.ts +0 -40
  180. package/src/cli/index.ts +0 -6
  181. package/src/containers/CommunityRegistry/CommunityRegistry.tsx +0 -181
  182. package/src/containers/CommunityRegistry/index.ts +0 -5
  183. package/src/containers/PluginRegistry/PluginRegistry.tsx +0 -98
  184. package/src/containers/PluginRegistry/index.ts +0 -5
  185. package/src/hooks/use-community-plugins.ts +0 -35
  186. /package/dist/types/src/{cli/commands → commands}/index.d.ts +0 -0
  187. /package/dist/types/src/{cli/commands → commands}/util.d.ts +0 -0
  188. /package/src/{cli/commands → commands}/disable.ts +0 -0
  189. /package/src/{cli/commands → commands}/enable.ts +0 -0
  190. /package/src/{cli/commands → commands}/index.ts +0 -0
  191. /package/src/{cli/commands → commands}/list.ts +0 -0
  192. /package/src/{cli/commands → commands}/util.ts +0 -0
  193. /package/src/{cli/storage.ts → storage.ts} +0 -0
@@ -0,0 +1,163 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { useAtomValue } from '@effect-atom/atom-react';
6
+ import * as Effect from 'effect/Effect';
7
+ import React, { type ReactNode, useCallback, useMemo, useState } from 'react';
8
+
9
+ import { type Plugin } from '@dxos/app-framework';
10
+ import { useCapabilities, useOperationInvoker, usePluginManager } from '@dxos/app-framework/ui';
11
+ import { AppCapabilities, LayoutOperation, SettingsOperation } from '@dxos/app-toolkit';
12
+ import { runAndForwardErrors } from '@dxos/effect';
13
+ import { ObservabilityOperation } from '@dxos/plugin-observability';
14
+ import { Input, Panel, ScrollArea, Toolbar, useTranslation } from '@dxos/react-ui';
15
+ import { composable, composableProps } from '@dxos/ui-theme';
16
+
17
+ import { PluginList, type PluginListProps } from '#components';
18
+ import { getPluginPath, meta } from '#meta';
19
+
20
+ const matchesFilter = (plugin: Plugin.Plugin, query: string) => {
21
+ const haystack = `${plugin.meta.name ?? ''} ${plugin.meta.id}`.toLowerCase();
22
+ return haystack.includes(query);
23
+ };
24
+
25
+ export type BaseRegistryArticleProps = {
26
+ /** Article id used as the pivotId when opening a plugin's detail surface. */
27
+ id: string;
28
+ /** Plugins to display, pre-sorted by the caller. */
29
+ plugins: readonly Plugin.Plugin[];
30
+ /**
31
+ * Distinguishes the source of toggle events for observability
32
+ * (e.g. `'registry'` for the public registry surface).
33
+ */
34
+ source?: string;
35
+ /** Rendered in place of the list when no plugins match the current filter. */
36
+ empty?: ReactNode;
37
+ } & Pick<
38
+ PluginListProps,
39
+ | 'installed'
40
+ | 'installing'
41
+ | 'updating'
42
+ | 'updateAvailableIds'
43
+ | 'extraTagsById'
44
+ | 'failuresById'
45
+ | 'onInstall'
46
+ | 'onUpdate'
47
+ >;
48
+
49
+ export const BaseRegistryArticle = composable<HTMLDivElement, BaseRegistryArticleProps>(
50
+ (
51
+ {
52
+ id,
53
+ plugins,
54
+ source,
55
+ empty,
56
+ installed,
57
+ installing,
58
+ updating,
59
+ updateAvailableIds,
60
+ extraTagsById,
61
+ failuresById,
62
+ onInstall,
63
+ onUpdate,
64
+ ...props
65
+ },
66
+ forwardedRef,
67
+ ) => {
68
+ const { t } = useTranslation(meta.id);
69
+ const manager = usePluginManager();
70
+ const { invoke, invokePromise } = useOperationInvoker();
71
+ const allSettings = useCapabilities(AppCapabilities.Settings);
72
+ const enabled = useAtomValue(manager.enabled);
73
+ const [filter, setFilter] = useState('');
74
+
75
+ const filtered = useMemo(() => {
76
+ const query = filter.trim().toLowerCase();
77
+ return query.length === 0 ? plugins : plugins.filter((plugin) => matchesFilter(plugin, query));
78
+ }, [plugins, filter]);
79
+
80
+ const handleChange = useCallback(
81
+ (pluginId: string, nextEnabled: boolean) =>
82
+ Effect.gen(function* () {
83
+ if (nextEnabled) {
84
+ yield* manager.enable(pluginId);
85
+ } else {
86
+ yield* manager.disable(pluginId);
87
+ }
88
+ yield* invoke(ObservabilityOperation.SendEvent, {
89
+ name: 'plugins.toggle',
90
+ properties: source
91
+ ? { plugin: pluginId, enabled: nextEnabled, source }
92
+ : { plugin: pluginId, enabled: nextEnabled },
93
+ });
94
+ }).pipe(runAndForwardErrors),
95
+ [invoke, manager, source],
96
+ );
97
+
98
+ const handleClick = useCallback(
99
+ (pluginId: string) =>
100
+ invokePromise(LayoutOperation.Open, {
101
+ subject: [getPluginPath(pluginId)],
102
+ pivotId: id,
103
+ positioning: 'end',
104
+ }),
105
+ [invokePromise, id],
106
+ );
107
+
108
+ const hasSettings = useCallback(
109
+ (pluginId: string) => allSettings.some((setting) => setting.prefix === pluginId),
110
+ [allSettings],
111
+ );
112
+
113
+ const handleSettings = useCallback(
114
+ (pluginId: string) => invokePromise(SettingsOperation.Open, { plugin: pluginId }),
115
+ [invokePromise],
116
+ );
117
+
118
+ return (
119
+ <Panel.Root {...composableProps(props)} ref={forwardedRef}>
120
+ <Panel.Toolbar asChild>
121
+ <Toolbar.Root>
122
+ <Input.Root>
123
+ <Input.Label srOnly>{t('filter.label')}</Input.Label>
124
+ <Input.TextInput
125
+ placeholder={t('filter.placeholder')}
126
+ value={filter}
127
+ onChange={(event) => setFilter(event.target.value)}
128
+ />
129
+ </Input.Root>
130
+ </Toolbar.Root>
131
+ </Panel.Toolbar>
132
+ <Panel.Content asChild>
133
+ <ScrollArea.Root orientation='vertical'>
134
+ <ScrollArea.Viewport>
135
+ {filtered.length > 0 ? (
136
+ <PluginList
137
+ plugins={filtered}
138
+ enabled={enabled}
139
+ installed={installed}
140
+ installing={installing}
141
+ updating={updating}
142
+ updateAvailableIds={updateAvailableIds}
143
+ extraTagsById={extraTagsById}
144
+ failuresById={failuresById}
145
+ onClick={handleClick}
146
+ onChange={handleChange}
147
+ onInstall={onInstall}
148
+ onUpdate={onUpdate}
149
+ hasSettings={hasSettings}
150
+ onSettings={handleSettings}
151
+ />
152
+ ) : (
153
+ empty
154
+ )}
155
+ </ScrollArea.Viewport>
156
+ </ScrollArea.Root>
157
+ </Panel.Content>
158
+ </Panel.Root>
159
+ );
160
+ },
161
+ );
162
+
163
+ BaseRegistryArticle.displayName = 'BaseRegistryArticle';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './BaseRegistryArticle';
@@ -22,7 +22,8 @@ export const LoadPluginDialog = () => {
22
22
  const closeRef = useRef<HTMLButtonElement | null>(null);
23
23
 
24
24
  const handleLoad = useCallback(() => {
25
- if (!url.trim()) {
25
+ const trimmed = url.trim();
26
+ if (!trimmed) {
26
27
  return;
27
28
  }
28
29
 
@@ -30,7 +31,7 @@ export const LoadPluginDialog = () => {
30
31
  setError(null);
31
32
 
32
33
  void Effect.gen(function* () {
33
- const plugin = yield* manager.add(url.trim());
34
+ const plugin = yield* manager.add(trimmed);
34
35
  yield* manager.enable(plugin.meta.id);
35
36
  closeRef.current?.click();
36
37
  }).pipe(
@@ -58,7 +59,7 @@ export const LoadPluginDialog = () => {
58
59
  <Input.Root validationValence={error ? 'error' : undefined}>
59
60
  <Input.Label>{t('plugin-url.label')}</Input.Label>
60
61
  <Input.TextInput
61
- placeholder='https://example.com/plugin.mjs'
62
+ placeholder='https://example.com/manifest.json'
62
63
  value={url}
63
64
  onChange={(event) => {
64
65
  setUrl(event.target.value);
@@ -4,49 +4,257 @@
4
4
 
5
5
  import { useAtomValue } from '@effect-atom/atom-react';
6
6
  import * as Effect from 'effect/Effect';
7
- import React, { useCallback, useMemo, useState } from 'react';
7
+ import React, { type Dispatch, type SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
8
8
 
9
- import { type Plugin } from '@dxos/app-framework';
9
+ import { type Plugin, type PluginManager, type Registry, UrlLoader } from '@dxos/app-framework';
10
10
  import { usePluginManager } from '@dxos/app-framework/ui';
11
11
  import { runAndForwardErrors } from '@dxos/effect';
12
12
 
13
13
  import { PluginDetail } from '#components';
14
14
 
15
- import { useCommunityPlugins } from '../../hooks';
15
+ import { useRegistryPluginProvider, useRegistryPlugins, useRemotePluginIds } from '../../hooks';
16
16
 
17
17
  // TODO(burdon): Convert to ECHO type.
18
18
  export type PluginArticleProps = { subject: Plugin.Plugin };
19
19
 
20
20
  export const PluginArticle = ({ subject: plugin }: PluginArticleProps) => {
21
+ const pluginId = plugin.meta.id;
21
22
  const manager = usePluginManager();
22
23
  const plugins = useAtomValue(manager.plugins);
23
- const { entries } = useCommunityPlugins();
24
- const [installing, setInstalling] = useState(false);
24
+ const remotePluginIds = useRemotePluginIds();
25
+ const provider = useRegistryPluginProvider();
26
+
27
+ const { catalogEntry, moduleUrl, repo } = useCatalogEntry(pluginId);
28
+ const { installedVersionTag, syncInstalledVersion } = useInstalledVersionTag(pluginId, plugins);
29
+ const { pickerVersions, selectedVersionTag, setSelectedVersionTag } = useVersionPicker({
30
+ provider,
31
+ pluginId,
32
+ repo,
33
+ moduleUrl,
34
+ installedVersionTag,
35
+ });
36
+
37
+ const enabled = manager.getEnabled().includes(pluginId);
38
+ const failed = useAtomValue(manager.failed);
39
+ const failure = useMemo(() => failed.find((entry) => entry.id === pluginId), [failed, pluginId]);
40
+ const isInstalled = useMemo(() => plugins.some((candidate) => candidate.meta.id === pluginId), [plugins, pluginId]);
41
+ const isCore = manager.getCore().includes(pluginId);
42
+ const canUninstall = isInstalled && !isCore && remotePluginIds.has(pluginId);
43
+ const hasUpdate =
44
+ isInstalled && !!catalogEntry && !!installedVersionTag && installedVersionTag !== catalogEntry.version;
45
+
46
+ const actions = usePluginActions({
47
+ manager,
48
+ pluginId,
49
+ isInstalled,
50
+ moduleUrl,
51
+ catalogEntry,
52
+ pickerVersions,
53
+ selectedVersionTag,
54
+ syncInstalledVersion,
55
+ });
25
56
 
26
- const enabled = manager.getEnabled().includes(plugin.meta.id);
27
- const isInstalled = useMemo(
28
- () => plugins.some((candidate) => candidate.meta.id === plugin.meta.id),
29
- [plugins, plugin.meta.id],
57
+ return (
58
+ <PluginDetail
59
+ plugin={plugin}
60
+ enabled={enabled}
61
+ onEnabledChange={actions.handleEnableChange}
62
+ onInstall={!isInstalled && moduleUrl ? actions.handleInstall : undefined}
63
+ installing={actions.installing}
64
+ onUpdate={hasUpdate ? actions.handleUpdate : undefined}
65
+ hasUpdate={hasUpdate}
66
+ updating={actions.updating}
67
+ onUninstall={canUninstall ? actions.handleUninstall : undefined}
68
+ versions={pickerVersions}
69
+ selectedVersionTag={selectedVersionTag}
70
+ onVersionChange={setSelectedVersionTag}
71
+ onInstallVersion={pickerVersions.length > 0 ? actions.handleInstallVersion : undefined}
72
+ installedVersionTag={installedVersionTag}
73
+ failure={failure}
74
+ />
30
75
  );
31
- const isCore = manager.getCore().includes(plugin.meta.id);
32
- const canUninstall = isInstalled && !isCore;
76
+ };
77
+
78
+ //
79
+ // Hooks
80
+ //
81
+ // Each of the hooks below isolates a self-contained slice of the article's
82
+ // state machine. They're declared at module scope (rather than inline in
83
+ // `PluginArticle`) to keep the component readable as a thin wiring layer over
84
+ // `PluginDetail`.
85
+ //
33
86
 
34
- const moduleUrl = useMemo(
35
- () => entries.find((entry) => entry.meta.id === plugin.meta.id)?.moduleUrl,
36
- [entries, plugin.meta.id],
87
+ /**
88
+ * Resolves the registry catalog entry for a plugin by id, plus the two fields
89
+ * (`moduleUrl`, `repo`) the rest of the article cares about.
90
+ */
91
+ const useCatalogEntry = (
92
+ pluginId: string,
93
+ ): {
94
+ catalogEntry: Registry.Plugin | undefined;
95
+ moduleUrl: string | undefined;
96
+ repo: string | undefined;
97
+ } => {
98
+ const { entries } = useRegistryPlugins();
99
+ const catalogEntry = useMemo(() => entries.find((entry) => entry.id === pluginId), [entries, pluginId]);
100
+ return { catalogEntry, moduleUrl: catalogEntry?.moduleUrl, repo: catalogEntry?.repo };
101
+ };
102
+
103
+ /**
104
+ * Tracks the per-plugin installed version (sourced from `UrlLoader` localStorage)
105
+ * as React state so post-install/update re-renders are deterministic — the value
106
+ * lives in component state rather than being read inline on every render.
107
+ *
108
+ * - Initialised from localStorage at mount.
109
+ * - Auto-resyncs when the plugin manager's plugin list changes (catches external
110
+ * installs / removes triggered by the list view).
111
+ * - `syncInstalledVersion()` is the explicit handle install/update operations
112
+ * call inside their `Effect.ensuring` so the post-mutation render sees both
113
+ * the new version and the cleared `installing`/`updating` flag together.
114
+ */
115
+ const useInstalledVersionTag = (
116
+ pluginId: string,
117
+ plugins: readonly Plugin.Plugin[],
118
+ ): {
119
+ installedVersionTag: string | undefined;
120
+ syncInstalledVersion: () => void;
121
+ } => {
122
+ const [installedVersionTag, setInstalledVersionTag] = useState<string | undefined>(() =>
123
+ UrlLoader.getInstalledVersion(pluginId),
37
124
  );
38
125
 
126
+ const syncInstalledVersion = useCallback(() => {
127
+ setInstalledVersionTag(UrlLoader.getInstalledVersion(pluginId));
128
+ }, [pluginId]);
129
+
130
+ useEffect(() => {
131
+ syncInstalledVersion();
132
+ }, [plugins, syncInstalledVersion]);
133
+
134
+ return { installedVersionTag, syncInstalledVersion };
135
+ };
136
+
137
+ /**
138
+ * Owns the version picker's state machine: fetches the available versions list
139
+ * from the provider, prepends the installed version when the catalog stub omits
140
+ * it, and keeps the selection clamped to whatever's currently in the list (so
141
+ * an external update doesn't strand the trigger on a tag that's no longer
142
+ * available).
143
+ */
144
+ const useVersionPicker = ({
145
+ provider,
146
+ pluginId,
147
+ repo,
148
+ moduleUrl,
149
+ installedVersionTag,
150
+ }: {
151
+ provider: Registry.Manager;
152
+ pluginId: string;
153
+ repo: string | undefined;
154
+ moduleUrl: string | undefined;
155
+ installedVersionTag: string | undefined;
156
+ }): {
157
+ pickerVersions: readonly Registry.PluginVersion[];
158
+ selectedVersionTag: string | undefined;
159
+ setSelectedVersionTag: Dispatch<SetStateAction<string | undefined>>;
160
+ } => {
161
+ const [versions, setVersions] = useState<readonly Registry.PluginVersion[]>([]);
162
+ const [selectedVersionTag, setSelectedVersionTag] = useState<string | undefined>();
163
+
164
+ // Load version list once the catalog entry's repo is known.
165
+ useEffect(() => {
166
+ if (!repo) {
167
+ return;
168
+ }
169
+ void provider.listVersions(repo).pipe(
170
+ Effect.match({
171
+ onSuccess: (vs) => {
172
+ setVersions(vs);
173
+ // Default selection: the currently installed version, or the latest.
174
+ const installedVersion = UrlLoader.getInstalledVersion(pluginId);
175
+ setSelectedVersionTag(installedVersion ?? vs[0]?.tag);
176
+ },
177
+ onFailure: () => {
178
+ // Non-critical; version picker stays empty.
179
+ },
180
+ }),
181
+ runAndForwardErrors,
182
+ );
183
+ }, [provider, repo, pluginId]);
184
+
185
+ // Make sure the picker always lists the installed version, even if the catalog
186
+ // hasn't surfaced it (the current `listVersions` stub only returns latest).
187
+ const pickerVersions = useMemo<readonly Registry.PluginVersion[]>(() => {
188
+ if (!installedVersionTag) {
189
+ return versions;
190
+ }
191
+ if (versions.some((entry) => entry.tag === installedVersionTag)) {
192
+ return versions;
193
+ }
194
+ const installedEntry: Registry.PluginVersion = {
195
+ tag: installedVersionTag,
196
+ // Picker won't be re-installing this entry unless the user selects + clicks Install,
197
+ // and the catalog moduleUrl is the closest stand-in we have.
198
+ moduleUrl: moduleUrl ?? '',
199
+ };
200
+ return [installedEntry, ...versions];
201
+ }, [versions, installedVersionTag, moduleUrl]);
202
+
203
+ // Keep `selectedVersionTag` in sync when picker contents change (e.g. the user
204
+ // updated the plugin from the list view — installed version moved, picker
205
+ // shrunk, prior selection is no longer in the list).
206
+ useEffect(() => {
207
+ if (pickerVersions.length === 0) {
208
+ return;
209
+ }
210
+ if (selectedVersionTag && pickerVersions.some((entry) => entry.tag === selectedVersionTag)) {
211
+ return;
212
+ }
213
+ setSelectedVersionTag(installedVersionTag ?? pickerVersions[0]?.tag);
214
+ }, [pickerVersions, selectedVersionTag, installedVersionTag]);
215
+
216
+ return { pickerVersions, selectedVersionTag, setSelectedVersionTag };
217
+ };
218
+
219
+ /**
220
+ * The five user-facing actions the article exposes — enable/disable, uninstall,
221
+ * install (latest), update (to latest catalog version), and install-from-picker —
222
+ * plus the `installing`/`updating` flags they drive. Each handler ends with a
223
+ * `syncInstalledVersion()` call inside its `Effect.ensuring` so the cached
224
+ * installed version snaps to the freshly-written localStorage value in the same
225
+ * render that clears the in-flight flag.
226
+ */
227
+ const usePluginActions = ({
228
+ manager,
229
+ pluginId,
230
+ isInstalled,
231
+ moduleUrl,
232
+ catalogEntry,
233
+ pickerVersions,
234
+ selectedVersionTag,
235
+ syncInstalledVersion,
236
+ }: {
237
+ manager: PluginManager.PluginManager;
238
+ pluginId: string;
239
+ isInstalled: boolean;
240
+ moduleUrl: string | undefined;
241
+ catalogEntry: Registry.Plugin | undefined;
242
+ pickerVersions: readonly Registry.PluginVersion[];
243
+ selectedVersionTag: string | undefined;
244
+ syncInstalledVersion: () => void;
245
+ }) => {
246
+ const [installing, setInstalling] = useState(false);
247
+ const [updating, setUpdating] = useState(false);
248
+
39
249
  const handleEnableChange = useCallback(
40
250
  (enabled: boolean) =>
41
- enabled
42
- ? runAndForwardErrors(manager.enable(plugin.meta.id))
43
- : runAndForwardErrors(manager.disable(plugin.meta.id)),
44
- [manager, plugin.meta.id],
251
+ enabled ? runAndForwardErrors(manager.enable(pluginId)) : runAndForwardErrors(manager.disable(pluginId)),
252
+ [manager, pluginId],
45
253
  );
46
254
 
47
255
  const handleUninstall = useCallback(() => {
48
- manager.remove(plugin.meta.id);
49
- }, [manager, plugin.meta.id]);
256
+ void runAndForwardErrors(manager.remove(pluginId));
257
+ }, [manager, pluginId]);
50
258
 
51
259
  const handleInstall = useCallback(() => {
52
260
  if (!moduleUrl) {
@@ -56,17 +264,73 @@ export const PluginArticle = ({ subject: plugin }: PluginArticleProps) => {
56
264
  void Effect.gen(function* () {
57
265
  const added = yield* manager.add(moduleUrl);
58
266
  yield* manager.enable(added.meta.id);
59
- }).pipe(Effect.ensuring(Effect.sync(() => setInstalling(false))), runAndForwardErrors);
60
- }, [manager, moduleUrl]);
267
+ if (catalogEntry) {
268
+ UrlLoader.setInstalledVersion(added.meta.id, catalogEntry.version);
269
+ }
270
+ }).pipe(
271
+ Effect.ensuring(
272
+ Effect.sync(() => {
273
+ setInstalling(false);
274
+ syncInstalledVersion();
275
+ }),
276
+ ),
277
+ runAndForwardErrors,
278
+ );
279
+ }, [manager, moduleUrl, catalogEntry, syncInstalledVersion]);
61
280
 
62
- return (
63
- <PluginDetail
64
- plugin={plugin}
65
- enabled={enabled}
66
- onEnabledChange={handleEnableChange}
67
- onInstall={!isInstalled && moduleUrl ? handleInstall : undefined}
68
- installing={installing}
69
- onUninstall={canUninstall ? handleUninstall : undefined}
70
- />
71
- );
281
+ const handleUpdate = useCallback(() => {
282
+ if (!moduleUrl || !catalogEntry) {
283
+ return;
284
+ }
285
+ setUpdating(true);
286
+ void Effect.gen(function* () {
287
+ yield* manager.remove(pluginId);
288
+ const added = yield* manager.add(moduleUrl);
289
+ yield* manager.enable(added.meta.id);
290
+ UrlLoader.setInstalledVersion(added.meta.id, catalogEntry.version);
291
+ }).pipe(
292
+ Effect.ensuring(
293
+ Effect.sync(() => {
294
+ setUpdating(false);
295
+ syncInstalledVersion();
296
+ }),
297
+ ),
298
+ runAndForwardErrors,
299
+ );
300
+ }, [manager, pluginId, moduleUrl, catalogEntry, syncInstalledVersion]);
301
+
302
+ const handleInstallVersion = useCallback(() => {
303
+ const version = pickerVersions.find((candidate) => candidate.tag === selectedVersionTag);
304
+ if (!version || !version.moduleUrl) {
305
+ return;
306
+ }
307
+ setInstalling(true);
308
+ void Effect.gen(function* () {
309
+ // Remove existing version before installing a different one.
310
+ if (isInstalled) {
311
+ yield* manager.remove(pluginId);
312
+ }
313
+ const added = yield* manager.add(version.moduleUrl);
314
+ yield* manager.enable(added.meta.id);
315
+ UrlLoader.setInstalledVersion(added.meta.id, version.tag);
316
+ }).pipe(
317
+ Effect.ensuring(
318
+ Effect.sync(() => {
319
+ setInstalling(false);
320
+ syncInstalledVersion();
321
+ }),
322
+ ),
323
+ runAndForwardErrors,
324
+ );
325
+ }, [manager, pluginId, isInstalled, selectedVersionTag, pickerVersions, syncInstalledVersion]);
326
+
327
+ return {
328
+ installing,
329
+ updating,
330
+ handleEnableChange,
331
+ handleUninstall,
332
+ handleInstall,
333
+ handleUpdate,
334
+ handleInstallVersion,
335
+ };
72
336
  };