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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user