feat(settings): retire the third-party plugin integrations
Settings → Integrations offered install cards for the Claude Code and Cursor provider plugins. They are gone: the section, its plugin catalog, its own i18n module and tests, the settings search entries, the page keywords, and the two sprite icons only it used. The page now holds the built-in GitHub and Linear cards, so it is hidden in VS Code where neither applies; its title and description live in the settings dictionaries. Docs follow: the Integrations page in every locale now documents GitHub (pointing at its own page) and Linear in full, the GitHub page names Settings → Integrations as the place to connect and covers linking an issue or PR to a message, and the Providers pages no longer promise Claude or Cursor subscriptions. Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
This commit is contained in:
@@ -52,7 +52,6 @@ export const iconSpriteData = {
|
||||
"checkbox-blank-circle-fill": `<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"/>`,
|
||||
"checkbox-circle": `<path d="M4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM17.4571 9.45711L16.0429 8.04289L11 13.0858L8.20711 10.2929L6.79289 11.7071L11 15.9142L17.4571 9.45711Z" fill="currentColor"/>`,
|
||||
"checkbox-multiple": `<path d="M6.99979 7V3C6.99979 2.44772 7.4475 2 7.99979 2H20.9998C21.5521 2 21.9998 2.44772 21.9998 3V16C21.9998 16.5523 21.5521 17 20.9998 17H17V20.9925C17 21.5489 16.551 22 15.9925 22H3.00728C2.45086 22 2 21.5511 2 20.9925L2.00276 8.00748C2.00288 7.45107 2.4518 7 3.01025 7H6.99979ZM8.99979 7H15.9927C16.549 7 17 7.44892 17 8.00748V15H19.9998V4H8.99979V7ZM15 9H4.00255L4.00021 20H15V9ZM8.50242 18L4.96689 14.4645L6.3811 13.0503L8.50242 15.1716L12.7451 10.9289L14.1593 12.3431L8.50242 18Z" fill="currentColor"/>`,
|
||||
"claude-code": `<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" fill="currentColor"/>`,
|
||||
"clipboard": `<path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
|
||||
"close": `<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" fill="currentColor"/>`,
|
||||
"close-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 10.5858L14.8284 7.75736L16.2426 9.17157L13.4142 12L16.2426 14.8284L14.8284 16.2426L12 13.4142L9.17157 16.2426L7.75736 14.8284L10.5858 12L7.75736 9.17157L9.17157 7.75736L12 10.5858Z" fill="currentColor"/>`,
|
||||
@@ -69,7 +68,6 @@ export const iconSpriteData = {
|
||||
"computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`,
|
||||
"contract-up-down": `<path d="M5.79285 5.20718 12 11.4143 18.2071 5.20718 16.7928 3.79297 12 8.58586 7.20706 3.79297 5.79285 5.20718ZM18.2072 18.7928 12.0001 12.5857 5.793 18.7928 7.20721 20.207 12.0001 15.4141 16.793 20.207 18.2072 18.7928Z" fill="currentColor"/>`,
|
||||
"corner-down-left": `<path d="M19.0001 13.9999L19.0002 5L17.0002 4.99997L17.0001 11.9999L6.8283 12L10.778 8.05024L9.36382 6.63603L2.99986 13L9.36382 19.364L10.778 17.9497L6.82826 14L19.0001 13.9999Z" fill="currentColor"/>`,
|
||||
"cursor": `<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="currentColor"/>`,
|
||||
"database-2": `<path d="M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z" fill="currentColor"/>`,
|
||||
"delete-bin": `<path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
|
||||
"discord-fill": `<path d="M19.3034 5.33716C17.9344 4.71103 16.4805 4.2547 14.9629 4C14.7719 4.32899 14.5596 4.77471 14.411 5.12492C12.7969 4.89144 11.1944 4.89144 9.60255 5.12492C9.45397 4.77471 9.2311 4.32899 9.05068 4C7.52251 4.2547 6.06861 4.71103 4.70915 5.33716C1.96053 9.39111 1.21766 13.3495 1.5891 17.2549C3.41443 18.5815 5.17612 19.388 6.90701 19.9187C7.33151 19.3456 7.71356 18.73 8.04255 18.0827C7.41641 17.8492 6.82211 17.5627 6.24904 17.2231C6.39762 17.117 6.5462 17.0003 6.68416 16.8835C10.1438 18.4648 13.8911 18.4648 17.3082 16.8835C17.4568 17.0003 17.5948 17.117 17.7434 17.2231C17.1703 17.5627 16.576 17.8492 15.9499 18.0827C16.2789 18.73 16.6609 19.3456 17.0854 19.9187C18.8152 19.388 20.5875 18.5815 22.4033 17.2549C22.8596 12.7341 21.6806 8.80747 19.3034 5.33716ZM8.5201 14.8459C7.48007 14.8459 6.63107 13.9014 6.63107 12.7447C6.63107 11.5879 7.45884 10.6434 8.5201 10.6434C9.57071 10.6434 10.4303 11.5879 10.4091 12.7447C10.4091 13.9014 9.57071 14.8459 8.5201 14.8459ZM15.4936 14.8459C14.4535 14.8459 13.6034 13.9014 13.6034 12.7447C13.6034 11.5879 14.4323 10.6434 15.4936 10.6434C16.5442 10.6434 17.4038 11.5879 17.3825 12.7447C17.3825 13.9014 16.5548 14.8459 15.4936 14.8459Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -6,23 +6,13 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { GitHubIntegration } from './GitHubIntegration';
|
||||
import { LinearSettings } from './LinearSettings';
|
||||
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
|
||||
onOpenPluginManager: () => void;
|
||||
}
|
||||
|
||||
export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
onOpenProviderSetup,
|
||||
onOpenPluginManager,
|
||||
}) => {
|
||||
export const IntegrationsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
// GitHub sign-in is an OpenChamber server feature; the VS Code extension
|
||||
// uses the editor's own GitHub session instead.
|
||||
const hasGitHub = !isVSCodeRuntime();
|
||||
const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear);
|
||||
const hasBuiltIn = hasGitHub || hasLinear;
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
@@ -30,23 +20,16 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
description={t('settings.page.integrations.description')}
|
||||
showSaveStatus
|
||||
>
|
||||
{hasBuiltIn ? (
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.firstParty.title')}
|
||||
info={t('settings.integrations.firstParty.info')}
|
||||
divider={false}
|
||||
settingsItem="integrations.first-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
{hasGitHub ? <GitHubIntegration /> : null}
|
||||
{hasLinear ? <LinearSettings /> : null}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
<ThirdPartyIntegrationsSection
|
||||
divider={hasBuiltIn}
|
||||
onOpenProviderSetup={onOpenProviderSetup}
|
||||
onOpenPluginManager={onOpenPluginManager}
|
||||
/>
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.firstParty.title')}
|
||||
info={t('settings.integrations.firstParty.info')}
|
||||
divider={false}
|
||||
settingsItem="integrations.first-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
{hasGitHub ? <GitHubIntegration /> : null}
|
||||
{hasLinear ? <LinearSettings /> : null}
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
usePluginsStore,
|
||||
type PluginMutationResult,
|
||||
} from '@/stores/usePluginsStore';
|
||||
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
|
||||
import {
|
||||
getCatalogPluginPrimaryAction,
|
||||
getCatalogPluginPresentation,
|
||||
getCatalogPluginState,
|
||||
getLatestNpmSpec,
|
||||
THIRD_PARTY_PLUGINS,
|
||||
type ThirdPartyPluginDefinition,
|
||||
} from './thirdPartyPlugins';
|
||||
|
||||
type PendingAction = 'install' | 'update' | 'setup' | 'remove';
|
||||
|
||||
type RemoveTarget = ThirdPartyPluginDefinition | null;
|
||||
|
||||
interface ThirdPartyIntegrationsSectionProps {
|
||||
divider?: boolean;
|
||||
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
|
||||
onOpenPluginManager: () => void;
|
||||
}
|
||||
|
||||
const requiresRestart = (result: PluginMutationResult): boolean =>
|
||||
result.restartDeferred === true
|
||||
|| result.requiresManualRestart === true
|
||||
|| result.reloadFailed === true;
|
||||
|
||||
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
|
||||
divider = true,
|
||||
onOpenProviderSetup,
|
||||
onOpenPluginManager,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
entries,
|
||||
registryInfo,
|
||||
loadPlugins,
|
||||
loadRegistryInfo,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
} = usePluginsStore(
|
||||
useShallow((state) => ({
|
||||
entries: state.entries,
|
||||
registryInfo: state.registryInfo,
|
||||
loadPlugins: state.loadPlugins,
|
||||
loadRegistryInfo: state.loadRegistryInfo,
|
||||
createEntry: state.createEntry,
|
||||
updateEntry: state.updateEntry,
|
||||
deleteEntry: state.deleteEntry,
|
||||
})),
|
||||
);
|
||||
|
||||
const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<{
|
||||
pluginId: string;
|
||||
action: PendingAction;
|
||||
} | null>(null);
|
||||
const [restartRequiredIds, setRestartRequiredIds] = React.useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [providerUnavailableIds, setProviderUnavailableIds] = React.useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [removeTarget, setRemoveTarget] = React.useState<RemoveTarget>(null);
|
||||
const [openPluginIds, setOpenPluginIds] = React.useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
const pluginsLoaded = await loadPlugins({ force: true });
|
||||
if (!pluginsLoaded) {
|
||||
setRegistryLoadFailed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestEntries = usePluginsStore.getState().entries;
|
||||
const specs = new Set(THIRD_PARTY_PLUGINS.map((plugin) => plugin.packageName));
|
||||
for (const entry of latestEntries) {
|
||||
if (THIRD_PARTY_PLUGINS.some((plugin) => entry.spec === plugin.packageName || entry.spec.startsWith(`${plugin.packageName}@`))) {
|
||||
specs.add(entry.spec);
|
||||
}
|
||||
}
|
||||
const registryLoaded = await loadRegistryInfo({ specs: [...specs], force: true });
|
||||
setRegistryLoadFailed(!registryLoaded);
|
||||
}, [loadPlugins, loadRegistryInfo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const pendingPluginRestartCount = usePendingOpenCodeRestartStore(
|
||||
(state) => state.changes.filter((change) => change.scope === 'plugins').length,
|
||||
);
|
||||
const isApplyingRestart = usePendingOpenCodeRestartStore((state) => state.isApplying);
|
||||
const previousPluginRestartCountRef = React.useRef(pendingPluginRestartCount);
|
||||
|
||||
// When deferred plugin restarts are applied (pending plugins scope clears), drop
|
||||
// local restart/unavailable flags and reload so statuses update immediately.
|
||||
React.useEffect(() => {
|
||||
const previousCount = previousPluginRestartCountRef.current;
|
||||
previousPluginRestartCountRef.current = pendingPluginRestartCount;
|
||||
|
||||
if (isApplyingRestart) {
|
||||
return;
|
||||
}
|
||||
if (previousCount <= 0 || pendingPluginRestartCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestartRequiredIds(new Set());
|
||||
setProviderUnavailableIds(new Set());
|
||||
void refresh();
|
||||
}, [isApplyingRestart, pendingPluginRestartCount, refresh]);
|
||||
|
||||
const setRestartRequired = React.useCallback((pluginId: string, required: boolean) => {
|
||||
setRestartRequiredIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (required) next.add(pluginId);
|
||||
else next.delete(pluginId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setProviderUnavailable = React.useCallback((pluginId: string, unavailable: boolean) => {
|
||||
setProviderUnavailableIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (unavailable) next.add(pluginId);
|
||||
else next.delete(pluginId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const runMutation = React.useCallback(async (
|
||||
plugin: ThirdPartyPluginDefinition,
|
||||
action: Exclude<PendingAction, 'setup'>,
|
||||
run: () => Promise<PluginMutationResult>,
|
||||
) => {
|
||||
setPendingAction({ pluginId: plugin.id, action });
|
||||
try {
|
||||
const result = await run();
|
||||
if (!result.ok) {
|
||||
toast.error(t('settings.integrations.thirdParty.toast.actionFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderUnavailable(plugin.id, false);
|
||||
const restartNeeded = requiresRestart(result);
|
||||
setRestartRequired(plugin.id, restartNeeded);
|
||||
const toastOptions = restartNeeded
|
||||
? { description: t('settings.integrations.thirdParty.toast.restartRequired') }
|
||||
: undefined;
|
||||
if (action === 'install') {
|
||||
toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }), toastOptions);
|
||||
} else if (action === 'update') {
|
||||
toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }), toastOptions);
|
||||
} else {
|
||||
toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }), toastOptions);
|
||||
}
|
||||
await refresh();
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}, [refresh, setProviderUnavailable, setRestartRequired, t]);
|
||||
|
||||
const handlePrimaryAction = React.useCallback(async (plugin: ThirdPartyPluginDefinition) => {
|
||||
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
|
||||
const action = getCatalogPluginPrimaryAction(state, plugin.packageName);
|
||||
|
||||
if (action === 'manage') {
|
||||
onOpenPluginManager();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'setup') {
|
||||
setPendingAction({ pluginId: plugin.id, action });
|
||||
try {
|
||||
const opened = await onOpenProviderSetup(plugin.providerId);
|
||||
setProviderUnavailable(plugin.id, !opened);
|
||||
if (!opened) {
|
||||
toast.error(t('settings.integrations.thirdParty.toast.providerUnavailable'));
|
||||
}
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
|
||||
if (!latestSpec) {
|
||||
setRegistryLoadFailed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'install') {
|
||||
await runMutation(plugin, 'install', () => createEntry({ spec: latestSpec, scope: 'user' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.userEntry) {
|
||||
await runMutation(plugin, 'update', () => updateEntry(state.userEntry!.id, { spec: latestSpec }));
|
||||
}
|
||||
}, [createEntry, entries, onOpenPluginManager, onOpenProviderSetup, registryInfo, runMutation, setProviderUnavailable, t, updateEntry]);
|
||||
|
||||
const handleRemove = React.useCallback(async () => {
|
||||
const plugin = removeTarget;
|
||||
if (!plugin) return;
|
||||
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
|
||||
if (!state.userEntry || state.userEntryIsAmbiguous) {
|
||||
setRemoveTarget(null);
|
||||
onOpenPluginManager();
|
||||
return;
|
||||
}
|
||||
setRemoveTarget(null);
|
||||
await runMutation(plugin, 'remove', () => deleteEntry(state.userEntry!.id));
|
||||
}, [deleteEntry, entries, onOpenPluginManager, registryInfo, removeTarget, runMutation]);
|
||||
|
||||
const setPluginOpen = React.useCallback((pluginId: string, open: boolean) => {
|
||||
setOpenPluginIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (open) next.add(pluginId);
|
||||
else next.delete(pluginId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderPlugin = (plugin: ThirdPartyPluginDefinition) => {
|
||||
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
|
||||
const primaryAction = getCatalogPluginPrimaryAction(state, plugin.packageName);
|
||||
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
|
||||
const isPending = pendingAction?.pluginId === plugin.id;
|
||||
const isRestartRequired = restartRequiredIds.has(plugin.id);
|
||||
const isProviderUnavailable = providerUnavailableIds.has(plugin.id);
|
||||
const registryUnavailable = registryLoadFailed || state.registry?.kind === 'npm-network';
|
||||
const actionDisabled = isPending
|
||||
|| isRestartRequired
|
||||
|| ((primaryAction === 'install' || primaryAction === 'update') && (registryUnavailable || !latestSpec));
|
||||
const presentation = getCatalogPluginPresentation(state, {
|
||||
registryUnavailable,
|
||||
restartRequired: isRestartRequired,
|
||||
providerUnavailable: isProviderUnavailable,
|
||||
});
|
||||
let status: string;
|
||||
switch (presentation.status) {
|
||||
case 'installed-version':
|
||||
status = presentation.latestVersion
|
||||
? t('settings.integrations.thirdParty.status.installedVersion', {
|
||||
version: presentation.latestVersion,
|
||||
})
|
||||
: t('settings.integrations.thirdParty.status.installed');
|
||||
break;
|
||||
case 'update-available':
|
||||
status = presentation.latestVersion
|
||||
? t('settings.integrations.thirdParty.status.updateAvailable', {
|
||||
version: presentation.latestVersion,
|
||||
})
|
||||
: t('settings.integrations.thirdParty.status.unpinned');
|
||||
break;
|
||||
case 'not-installed':
|
||||
status = t('settings.integrations.thirdParty.status.notInstalled');
|
||||
break;
|
||||
case 'installed':
|
||||
status = t('settings.integrations.thirdParty.status.installed');
|
||||
break;
|
||||
case 'unpinned':
|
||||
status = t('settings.integrations.thirdParty.status.unpinned');
|
||||
break;
|
||||
case 'ambiguous':
|
||||
status = t('settings.integrations.thirdParty.status.ambiguous');
|
||||
break;
|
||||
case 'restart-required':
|
||||
status = t('settings.integrations.thirdParty.status.restartRequired');
|
||||
break;
|
||||
case 'registry-unavailable':
|
||||
status = t('settings.integrations.thirdParty.status.registryUnavailable');
|
||||
break;
|
||||
case 'provider-unavailable':
|
||||
status = t('settings.integrations.thirdParty.status.providerUnavailable');
|
||||
break;
|
||||
}
|
||||
const statusClassName = presentation.status === 'installed-version'
|
||||
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
|
||||
: presentation.status === 'update-available'
|
||||
|| presentation.status === 'ambiguous'
|
||||
|| presentation.status === 'restart-required'
|
||||
|| presentation.status === 'registry-unavailable'
|
||||
|| presentation.status === 'provider-unavailable'
|
||||
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
|
||||
: 'bg-[var(--surface-muted)] text-muted-foreground';
|
||||
|
||||
const primaryLabel = {
|
||||
install: t('settings.integrations.thirdParty.actions.install'),
|
||||
update: t('settings.integrations.thirdParty.actions.update'),
|
||||
setup: t('settings.integrations.thirdParty.actions.setup'),
|
||||
manage: t('settings.integrations.thirdParty.actions.managePlugins'),
|
||||
}[primaryAction];
|
||||
|
||||
const open = openPluginIds.has(plugin.id);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={plugin.id}
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => setPluginOpen(plugin.id, nextOpen)}
|
||||
>
|
||||
<div
|
||||
data-settings-item={`integrations.third-party.${plugin.id}`}
|
||||
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
|
||||
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
|
||||
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
|
||||
{t(plugin.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
statusClassName,
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-down-s"
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{state.projectEntries.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.thirdParty.status.projectInstalled')}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={primaryAction === 'manage' ? 'outline' : 'default'}
|
||||
onClick={() => void handlePrimaryAction(plugin)}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : primaryAction === 'setup' ? (
|
||||
<Icon name="plug-2" className="size-3.5" />
|
||||
) : null}
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void openExternalUrl(plugin.homepage)}
|
||||
>
|
||||
<Icon name="external-link" className="size-3.5" />
|
||||
{t('settings.integrations.thirdParty.actions.docs')}
|
||||
</Button>
|
||||
{state.userEntry && !state.userEntryIsAmbiguous ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setRemoveTarget(plugin)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-3.5" />
|
||||
{t('settings.integrations.thirdParty.actions.remove')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.thirdParty.title')}
|
||||
info={t('settings.integrations.thirdParty.info')}
|
||||
divider={divider}
|
||||
settingsItem="integrations.third-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.integrations.experimentalWarning')}
|
||||
</p>
|
||||
</div>
|
||||
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog open={removeTarget !== null} onOpenChange={(open) => !open && setRemoveTarget(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.integrations.thirdParty.dialog.remove.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.integrations.thirdParty.dialog.remove.description', {
|
||||
name: removeTarget ? t(removeTarget.nameKey) : '',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setRemoveTarget(null)}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="destructive" onClick={() => void handleRemove()}>
|
||||
{t('settings.integrations.thirdParty.actions.remove')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
|
||||
import * as thirdPartyCatalog from './thirdPartyPlugins';
|
||||
import {
|
||||
getCatalogPluginState,
|
||||
getCatalogPluginPrimaryAction,
|
||||
getLatestNpmSpec,
|
||||
specMatchesPackage,
|
||||
} from './thirdPartyPlugins';
|
||||
|
||||
type CatalogPresentationStatus =
|
||||
| 'not-installed'
|
||||
| 'installed'
|
||||
| 'installed-version'
|
||||
| 'update-available'
|
||||
| 'unpinned'
|
||||
| 'ambiguous'
|
||||
| 'restart-required'
|
||||
| 'registry-unavailable'
|
||||
| 'provider-unavailable';
|
||||
|
||||
type GetCatalogPluginPresentation = (
|
||||
state: ReturnType<typeof getCatalogPluginState>,
|
||||
options?: {
|
||||
registryUnavailable?: boolean;
|
||||
restartRequired?: boolean;
|
||||
providerUnavailable?: boolean;
|
||||
},
|
||||
) => {
|
||||
status: CatalogPresentationStatus;
|
||||
latestVersion: string | null;
|
||||
};
|
||||
|
||||
const getCatalogPluginPresentation = (
|
||||
thirdPartyCatalog as unknown as {
|
||||
getCatalogPluginPresentation?: GetCatalogPluginPresentation;
|
||||
}
|
||||
).getCatalogPluginPresentation;
|
||||
|
||||
const claudePackage = '@openchamber/opencode-claude';
|
||||
|
||||
const entry = (spec: string, scope: PluginEntry['scope'] = 'user'): PluginEntry => ({
|
||||
id: `config:${scope}:${spec}`,
|
||||
spec,
|
||||
scope,
|
||||
kind: 'config',
|
||||
parsedKind: 'npm',
|
||||
});
|
||||
|
||||
const registry = (spec: string, currentVersion: string | null, latestVersion = '0.7.0'): RegistryResult => ({
|
||||
kind: 'npm-ok',
|
||||
spec,
|
||||
name: claudePackage,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
versions: ['0.6.0', latestVersion],
|
||||
hasUpdate: currentVersion !== null && currentVersion !== latestVersion,
|
||||
});
|
||||
|
||||
describe('third-party plugin catalog helpers', () => {
|
||||
test('derives compact-card status with explicit transient-state priority', () => {
|
||||
expect(typeof getCatalogPluginPresentation).toBe('function');
|
||||
if (!getCatalogPluginPresentation) return;
|
||||
|
||||
const notInstalled = getCatalogPluginState([], claudePackage, {});
|
||||
expect(getCatalogPluginPresentation(notInstalled)).toEqual({
|
||||
status: 'not-installed',
|
||||
latestVersion: null,
|
||||
});
|
||||
|
||||
const current = getCatalogPluginState(
|
||||
[entry(`${claudePackage}@0.7.0`)],
|
||||
claudePackage,
|
||||
{ [`${claudePackage}@0.7.0`]: registry(`${claudePackage}@0.7.0`, '0.7.0') },
|
||||
);
|
||||
expect(getCatalogPluginPresentation(current)).toEqual({
|
||||
status: 'installed-version',
|
||||
latestVersion: '0.7.0',
|
||||
});
|
||||
|
||||
const outdated = getCatalogPluginState(
|
||||
[entry(`${claudePackage}@0.6.0`)],
|
||||
claudePackage,
|
||||
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
|
||||
);
|
||||
expect(getCatalogPluginPresentation(outdated)).toEqual({
|
||||
status: 'update-available',
|
||||
latestVersion: '0.7.0',
|
||||
});
|
||||
expect(getCatalogPluginPresentation(outdated, { registryUnavailable: true })).toEqual({
|
||||
status: 'registry-unavailable',
|
||||
latestVersion: '0.7.0',
|
||||
});
|
||||
expect(getCatalogPluginPresentation(outdated, { providerUnavailable: true })).toEqual({
|
||||
status: 'provider-unavailable',
|
||||
latestVersion: '0.7.0',
|
||||
});
|
||||
expect(getCatalogPluginPresentation(outdated, {
|
||||
providerUnavailable: true,
|
||||
restartRequired: true,
|
||||
})).toEqual({
|
||||
status: 'restart-required',
|
||||
latestVersion: '0.7.0',
|
||||
});
|
||||
|
||||
const ambiguous = getCatalogPluginState(
|
||||
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
|
||||
claudePackage,
|
||||
{},
|
||||
);
|
||||
expect(getCatalogPluginPresentation(ambiguous)).toEqual({
|
||||
status: 'ambiguous',
|
||||
latestVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('matches only a package or its versioned spec', () => {
|
||||
expect(specMatchesPackage(claudePackage, claudePackage)).toBe(true);
|
||||
expect(specMatchesPackage(`${claudePackage}@0.6.0`, claudePackage)).toBe(true);
|
||||
expect(specMatchesPackage('@openchamber/opencode-claude-extra@0.6.0', claudePackage)).toBe(false);
|
||||
});
|
||||
|
||||
test('points catalog plugins at the OpenChamber GitHub and npm packages', () => {
|
||||
expect(thirdPartyCatalog.THIRD_PARTY_PLUGINS.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
packageName: plugin.packageName,
|
||||
homepage: plugin.homepage,
|
||||
}))).toEqual([
|
||||
{
|
||||
id: 'opencode-claude',
|
||||
packageName: '@openchamber/opencode-claude',
|
||||
homepage: 'https://github.com/openchamber/opencode-claude',
|
||||
},
|
||||
{
|
||||
id: 'opencode-cursor-oauth',
|
||||
packageName: '@openchamber/opencode-cursor',
|
||||
homepage: 'https://github.com/openchamber/opencode-cursor',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the configured user entry and its registry result', () => {
|
||||
const installed = entry(`${claudePackage}@0.6.0`);
|
||||
const state = getCatalogPluginState(
|
||||
[installed],
|
||||
claudePackage,
|
||||
{ [installed.spec]: registry(installed.spec, '0.6.0') },
|
||||
);
|
||||
|
||||
expect(state.userEntry).toEqual(installed);
|
||||
expect(state.userEntryIsAmbiguous).toBe(false);
|
||||
expect(state.projectEntries).toEqual([]);
|
||||
expect(state.registry).toEqual(registry(installed.spec, '0.6.0'));
|
||||
});
|
||||
|
||||
test('does not choose an entry when multiple user specs would make a mutation ambiguous', () => {
|
||||
const state = getCatalogPluginState(
|
||||
[entry(claudePackage), entry(`${claudePackage}@0.6.0`), entry(claudePackage, 'project')],
|
||||
claudePackage,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(state.userEntry).toBeNull();
|
||||
expect(state.userEntryIsAmbiguous).toBe(true);
|
||||
expect(state.projectEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('returns an exact latest spec only from a valid npm registry result', () => {
|
||||
expect(getLatestNpmSpec(claudePackage, registry(claudePackage, null))).toBe(`${claudePackage}@0.7.0`);
|
||||
expect(getLatestNpmSpec(claudePackage, {
|
||||
kind: 'npm-network',
|
||||
spec: claudePackage,
|
||||
error: 'offline',
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
test('chooses an update for a bare or outdated user-wide entry', () => {
|
||||
const bare = getCatalogPluginState(
|
||||
[entry(claudePackage)],
|
||||
claudePackage,
|
||||
{ [claudePackage]: registry(claudePackage, null) },
|
||||
);
|
||||
const outdated = getCatalogPluginState(
|
||||
[entry(`${claudePackage}@0.6.0`)],
|
||||
claudePackage,
|
||||
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
|
||||
);
|
||||
|
||||
expect(getCatalogPluginPrimaryAction(bare, claudePackage)).toBe('update');
|
||||
expect(getCatalogPluginPrimaryAction(outdated, claudePackage)).toBe('update');
|
||||
});
|
||||
|
||||
test('keeps setup as the primary action once the exact latest spec is installed', () => {
|
||||
const installed = entry(`${claudePackage}@0.7.0`);
|
||||
const state = getCatalogPluginState(
|
||||
[installed],
|
||||
claudePackage,
|
||||
{ [installed.spec]: registry(installed.spec, '0.7.0') },
|
||||
);
|
||||
|
||||
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('setup');
|
||||
});
|
||||
|
||||
test('sends ambiguous entries to manual plugin management', () => {
|
||||
const state = getCatalogPluginState(
|
||||
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
|
||||
claudePackage,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('manage');
|
||||
});
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
|
||||
|
||||
export interface ThirdPartyPluginDefinition {
|
||||
id: string;
|
||||
packageName: string;
|
||||
providerId: string;
|
||||
icon: IconName;
|
||||
/** Brand mark tint (e.g. Claude orange); neutral marks use text-foreground. */
|
||||
brandClassName: string;
|
||||
nameKey: I18nKey;
|
||||
descriptionKey: I18nKey;
|
||||
homepage: string;
|
||||
}
|
||||
|
||||
export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
|
||||
{
|
||||
id: 'opencode-claude',
|
||||
packageName: '@openchamber/opencode-claude',
|
||||
providerId: 'claude-code',
|
||||
icon: 'claude-code',
|
||||
brandClassName: 'text-[#D97757]',
|
||||
nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
|
||||
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
|
||||
homepage: 'https://github.com/openchamber/opencode-claude',
|
||||
},
|
||||
{
|
||||
id: 'opencode-cursor-oauth',
|
||||
packageName: '@openchamber/opencode-cursor',
|
||||
providerId: 'cursor',
|
||||
icon: 'cursor',
|
||||
brandClassName: 'text-foreground',
|
||||
nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
|
||||
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
|
||||
homepage: 'https://github.com/openchamber/opencode-cursor',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export interface CatalogPluginState {
|
||||
userEntry: PluginEntry | null;
|
||||
userEntryIsAmbiguous: boolean;
|
||||
projectEntries: PluginEntry[];
|
||||
registry: RegistryResult | null;
|
||||
}
|
||||
|
||||
export type CatalogPluginPrimaryAction = 'install' | 'update' | 'setup' | 'manage';
|
||||
|
||||
type CatalogPluginPresentationStatus =
|
||||
| 'not-installed'
|
||||
| 'installed'
|
||||
| 'installed-version'
|
||||
| 'update-available'
|
||||
| 'unpinned'
|
||||
| 'ambiguous'
|
||||
| 'restart-required'
|
||||
| 'registry-unavailable'
|
||||
| 'provider-unavailable';
|
||||
|
||||
interface CatalogPluginPresentationOptions {
|
||||
registryUnavailable?: boolean;
|
||||
restartRequired?: boolean;
|
||||
providerUnavailable?: boolean;
|
||||
}
|
||||
|
||||
interface CatalogPluginPresentation {
|
||||
status: CatalogPluginPresentationStatus;
|
||||
latestVersion: string | null;
|
||||
}
|
||||
|
||||
export const specMatchesPackage = (spec: string, packageName: string): boolean =>
|
||||
spec === packageName || spec.startsWith(`${packageName}@`);
|
||||
|
||||
export function getCatalogPluginState(
|
||||
entries: PluginEntry[],
|
||||
packageName: string,
|
||||
registryInfo: Record<string, RegistryResult>,
|
||||
): CatalogPluginState {
|
||||
const matchingEntries = entries.filter((entry) => specMatchesPackage(entry.spec, packageName));
|
||||
const userEntries = matchingEntries.filter((entry) => entry.scope === 'user');
|
||||
const projectEntries = matchingEntries.filter((entry) => entry.scope === 'project');
|
||||
const userEntry = userEntries.length === 1 ? userEntries[0] : null;
|
||||
const registry = registryInfo[userEntry?.spec ?? packageName] ?? registryInfo[packageName] ?? null;
|
||||
|
||||
return {
|
||||
userEntry,
|
||||
userEntryIsAmbiguous: userEntries.length > 1,
|
||||
projectEntries,
|
||||
registry,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLatestNpmSpec(
|
||||
packageName: string,
|
||||
registry: RegistryResult | null | undefined,
|
||||
): string | null {
|
||||
if (registry?.kind !== 'npm-ok' || registry.name !== packageName || !registry.latestVersion) {
|
||||
return null;
|
||||
}
|
||||
return `${packageName}@${registry.latestVersion}`;
|
||||
}
|
||||
|
||||
export function getCatalogPluginPrimaryAction(
|
||||
state: CatalogPluginState,
|
||||
packageName: string,
|
||||
): CatalogPluginPrimaryAction {
|
||||
if (state.userEntryIsAmbiguous) {
|
||||
return 'manage';
|
||||
}
|
||||
|
||||
if (!state.userEntry) {
|
||||
return 'install';
|
||||
}
|
||||
|
||||
const latestSpec = getLatestNpmSpec(packageName, state.registry);
|
||||
return latestSpec && latestSpec !== state.userEntry.spec ? 'update' : 'setup';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts catalog and temporary mutation state into the one compact-card
|
||||
* status. Transient states intentionally outrank installed/version metadata.
|
||||
*/
|
||||
export function getCatalogPluginPresentation(
|
||||
state: CatalogPluginState,
|
||||
options: CatalogPluginPresentationOptions = {},
|
||||
): CatalogPluginPresentation {
|
||||
const latestVersion = state.registry?.kind === 'npm-ok'
|
||||
? state.registry.latestVersion
|
||||
: null;
|
||||
|
||||
if (state.userEntryIsAmbiguous) {
|
||||
return { status: 'ambiguous', latestVersion };
|
||||
}
|
||||
if (options.restartRequired) {
|
||||
return { status: 'restart-required', latestVersion };
|
||||
}
|
||||
if (options.providerUnavailable) {
|
||||
return { status: 'provider-unavailable', latestVersion };
|
||||
}
|
||||
if (options.registryUnavailable) {
|
||||
return { status: 'registry-unavailable', latestVersion };
|
||||
}
|
||||
if (!state.userEntry) {
|
||||
return { status: 'not-installed', latestVersion };
|
||||
}
|
||||
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === state.registry.latestVersion) {
|
||||
return { status: 'installed-version', latestVersion };
|
||||
}
|
||||
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === null) {
|
||||
return { status: 'unpinned', latestVersion };
|
||||
}
|
||||
if (state.registry?.kind === 'npm-ok' && latestVersion) {
|
||||
return { status: 'update-available', latestVersion };
|
||||
}
|
||||
return { status: 'installed', latestVersion };
|
||||
}
|
||||
@@ -310,24 +310,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
|
||||
}, [isMobile, setSettingsPage]);
|
||||
|
||||
const openThirdPartyProviderSetup = React.useCallback(async (providerId: string): Promise<boolean> => {
|
||||
const configStore = useConfigStore.getState();
|
||||
await configStore.loadProviders({ source: 'settings:third-party-provider-setup' });
|
||||
const providerAvailable = useConfigStore.getState().providers.some(
|
||||
(provider) => provider.id === providerId,
|
||||
);
|
||||
if (!providerAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
configStore.setSelectedProvider(providerId);
|
||||
openPage('providers');
|
||||
if (isMobile) {
|
||||
setMobileStage('page-content');
|
||||
}
|
||||
return true;
|
||||
}, [isMobile, openPage]);
|
||||
|
||||
const activePageMeta = React.useMemo(() => {
|
||||
return getSettingsPageMeta(settingsSlug);
|
||||
}, [settingsSlug]);
|
||||
@@ -679,12 +661,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
case 'git':
|
||||
return <GitPage />;
|
||||
case 'integrations':
|
||||
return (
|
||||
<IntegrationsPage
|
||||
onOpenProviderSetup={openThirdPartyProviderSetup}
|
||||
onOpenPluginManager={() => openPage('plugins')}
|
||||
/>
|
||||
);
|
||||
return <IntegrationsPage />;
|
||||
case 'general':
|
||||
case 'appearance':
|
||||
case 'chat':
|
||||
@@ -700,7 +677,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [openChamberSectionBySlug, openPage, openThirdPartyProviderSetup, renderUnavailable, runtimeCtx, t]);
|
||||
}, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
|
||||
|
||||
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
|
||||
React.useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user