Picking a project in Settings called setActiveProject, which relocates the chat, the session list, the file tree and the Git surface. Reading another project's MCP servers or agents moved the user's whole app. It had to, because the configuration stores resolved the directory themselves from the active project and held one flat list. Each of them now takes an explicit directory — omitted still means the active project, so every caller outside Settings is unchanged — and keys loaded data by directory next to a flat mirror of the active project. Chat, autocompletes and pickers keep reading that mirror; a load for another directory writes only the map. A failed load restores that directory's previous list. Settings resolves its own directory through useSettingsDirectory, backed by a session-local settingsProjectPath that follows the active project until the user picks something else.
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
import React from 'react';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
|
import { Button } from '@/components/ui/button';
|
|
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
|
|
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { cn } from '@/lib/utils';
|
|
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
|
|
|
const ADD_PROVIDER_ID = '__add_provider__';
|
|
|
|
interface ProviderSourceInfo {
|
|
exists: boolean;
|
|
path?: string | null;
|
|
}
|
|
|
|
interface ProviderSources {
|
|
auth: ProviderSourceInfo;
|
|
user: ProviderSourceInfo;
|
|
project: ProviderSourceInfo;
|
|
custom?: ProviderSourceInfo;
|
|
}
|
|
|
|
const getCurrentDirectory = (): string | null => {
|
|
const dir = opencodeClient.getDirectory();
|
|
if (typeof dir === 'string' && dir.trim().length > 0) {
|
|
return dir.trim();
|
|
}
|
|
return null;
|
|
};
|
|
|
|
interface ProvidersSidebarProps {
|
|
onItemSelect?: () => void;
|
|
}
|
|
|
|
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
|
|
const { t } = useI18n();
|
|
// Settings browses whichever project its own selector points at; the app
|
|
// stays where it is.
|
|
const settingsDirectory = useSettingsDirectory();
|
|
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
|
|
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
|
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
|
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
|
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
|
|
const directory = React.useMemo(() => {
|
|
if (settingsDirectory) return settingsDirectory;
|
|
// tie refresh to active project changes (directory is stored in the client)
|
|
void activeProjectId;
|
|
return getCurrentDirectory();
|
|
}, [activeProjectId, settingsDirectory]);
|
|
|
|
// The app only loads providers for the project it is on; Settings has to ask
|
|
// for the one it is looking at.
|
|
const loadProviders = useConfigStore((state) => state.loadProviders);
|
|
React.useEffect(() => {
|
|
if (!settingsDirectory) return;
|
|
void loadProviders({ directory: settingsDirectory, source: 'settings:providers' });
|
|
}, [loadProviders, settingsDirectory]);
|
|
|
|
React.useEffect(() => {
|
|
if (providers.length === 0) {
|
|
setSourcesByProvider({});
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const loadAllSources = async () => {
|
|
const tasks = providers.map(async (provider) => {
|
|
try {
|
|
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
|
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
|
|
// not local auth/source-file provenance used by this settings sidebar.
|
|
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
const payload = await response.json().catch(() => null);
|
|
const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
|
|
if (!sources) {
|
|
return;
|
|
}
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setSourcesByProvider((prev) => ({
|
|
...prev,
|
|
[provider.id]: sources,
|
|
}));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
await Promise.all(tasks);
|
|
};
|
|
|
|
void loadAllSources();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [directory, providers]);
|
|
|
|
const bgClass = 'bg-background';
|
|
|
|
const projectProviders = React.useMemo(() => {
|
|
return providers.filter((p) => Boolean(sourcesByProvider[p.id]?.project?.exists));
|
|
}, [providers, sourcesByProvider]);
|
|
|
|
const userProviders = React.useMemo(() => {
|
|
return providers.filter((p) => !sourcesByProvider[p.id]?.project?.exists);
|
|
}, [providers, sourcesByProvider]);
|
|
|
|
return (
|
|
<div className={cn('flex h-full flex-col', bgClass)}>
|
|
<div className="border-b px-3 pt-4 pb-3">
|
|
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.providers.sidebar.title')}</h2>
|
|
<SettingsProjectSelector className="mb-3" />
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
|
|
<Button size="sm"
|
|
variant="ghost"
|
|
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
|
onClick={() => {
|
|
setSelectedProvider(ADD_PROVIDER_ID);
|
|
onItemSelect?.();
|
|
}}
|
|
aria-label={t('settings.providers.sidebar.actions.connectProviderAria')}
|
|
title={t('settings.providers.sidebar.actions.connectProviderTitle')}
|
|
>
|
|
<Icon name="add" className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
|
{providers.length === 0 ? (
|
|
<div className="py-12 px-4 text-center text-muted-foreground">
|
|
<Icon name="stack" className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
|
<p className="typography-ui-label font-medium">{t('settings.providers.sidebar.empty.title')}</p>
|
|
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.sidebar.empty.description')}</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{userProviders.length > 0 && (
|
|
<>
|
|
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{t('settings.providers.sidebar.section.userProviders')}
|
|
</div>
|
|
{userProviders.map((provider) => (
|
|
<ProviderListItem
|
|
key={provider.id}
|
|
provider={provider}
|
|
selectedProviderId={selectedProviderId}
|
|
onSelect={() => {
|
|
setSelectedProvider(provider.id);
|
|
onItemSelect?.();
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{projectProviders.length > 0 && (
|
|
<>
|
|
<div className={cn('px-2 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground', userProviders.length > 0 ? 'pt-3' : 'pt-2')}>
|
|
{t('settings.providers.sidebar.section.projectProviders')}
|
|
</div>
|
|
{projectProviders.map((provider) => (
|
|
<ProviderListItem
|
|
key={provider.id}
|
|
provider={provider}
|
|
selectedProviderId={selectedProviderId}
|
|
onSelect={() => {
|
|
setSelectedProvider(provider.id);
|
|
onItemSelect?.();
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</ScrollableOverlay>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ProviderListItem: React.FC<{
|
|
provider: { id: string; name?: string; models?: unknown[] };
|
|
selectedProviderId: string;
|
|
onSelect: () => void;
|
|
}> = ({ provider, selectedProviderId, onSelect }) => {
|
|
const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
|
|
const isSelected = provider.id === selectedProviderId;
|
|
|
|
return (
|
|
<div
|
|
key={provider.id}
|
|
className={cn(
|
|
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
|
|
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onSelect}
|
|
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
tabIndex={0}
|
|
>
|
|
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
|
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
|
|
{provider.name || provider.id}
|
|
</span>
|
|
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
|
|
{modelCount}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|