feat(web,ui): per-project forced git provider and settings gating

Adds a per-project forced provider (github|gitlab|gitea) on top of the
per-project API base URL overrides: stored under gitProviders.provider in
projects/<projectId>.json, sanitized server-side, and winning over remote-host
detection both in useGitProvider and in server repo resolution
(parseGitLabRemoteUrl/parseGiteaRemoteUrl accept any host when the provider
is forced). The Projects settings page replaces the three always-visible URL
fields with a provider selector (auto-detect + the three forges) and one URL
override for the active provider. Global provider override fields on the
GitHub/GitLab/Gitea settings tabs now render only once an account is
connected, and Settings search availability matches that gating.

Also fixes the useConfigStore/useDirectoryStore circular-import TDZ in the
bundled chunk via the window-registered store handle and defers the directory
subscription to a microtask; fixes the Gitea PR merge payload (Do carries the
merge-style string enum, not a boolean + MergeMethod); and adds a documented
Gitea client live-test harness (scripts/gitea-live-test.ts + client.d.ts).
This commit is contained in:
2026-08-17 09:57:54 +00:00
parent 66edc74fac
commit 45437a4221
38 changed files with 1306 additions and 125 deletions
@@ -442,8 +442,16 @@ export const GitHubSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fa
)}
<div className={cn('flex flex-col gap-4', embedded ? 'border-t border-[var(--surface-subtle)] pt-4' : 'pt-4')}>
<ProviderApiBaseUrlInput provider="github" />
<ProviderDetectUrlsInput provider="github" />
{connected ? (
<>
<ProviderApiBaseUrlInput provider="github" />
<ProviderDetectUrlsInput provider="github" />
</>
) : (
<p className="typography-meta text-muted-foreground">
{t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.github') })}
</p>
)}
</div>
</>
);
@@ -292,8 +292,16 @@ export const GitLabSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fa
</div>
<div className={cn('flex flex-col gap-4', embedded ? 'border-t border-[var(--surface-subtle)] pt-4' : 'pt-4')}>
<ProviderApiBaseUrlInput provider="gitlab" />
<ProviderDetectUrlsInput provider="gitlab" />
{connected ? (
<>
<ProviderApiBaseUrlInput provider="gitlab" />
<ProviderDetectUrlsInput provider="gitlab" />
</>
) : (
<p className="typography-meta text-muted-foreground">
{t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitlab') })}
</p>
)}
</div>
</>
);
@@ -302,8 +302,16 @@ export const GiteaSettings: React.FC<{ embedded?: boolean }> = ({ embedded = fal
</div>
<div className={cn('flex flex-col gap-4', embedded ? 'border-t border-[var(--surface-subtle)] pt-4' : 'pt-4')}>
<ProviderApiBaseUrlInput provider="gitea" />
<ProviderDetectUrlsInput provider="gitea" />
{connected ? (
<>
<ProviderApiBaseUrlInput provider="gitea" />
<ProviderDetectUrlsInput provider="gitea" />
</>
) : (
<p className="typography-meta text-muted-foreground">
{t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.gitea') })}
</p>
)}
</div>
</>
);
@@ -1,5 +1,6 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { reportSettingsSaveState } from '@/lib/persistence';
import { ProjectSettingsSubsection } from '@/components/sections/projects/ProjectSettingsSubsection';
@@ -10,12 +11,21 @@ import {
type GitProviderApiBaseUrls,
type GitProviderName,
} from '@/stores/useGitProviderDomainsStore';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { useGitProvider } from '@/lib/gitProvider';
import type { ProjectRef } from '@/lib/openchamberConfig';
const GIT_PROVIDERS: GitProviderName[] = ['github', 'gitlab', 'gitea'];
const EMPTY_API_BASE_URLS: GitProviderApiBaseUrls = { github: '', gitlab: '', gitea: '' };
const PROVIDER_ICONS: Record<GitProviderName, IconName> = {
github: 'github-fill',
gitlab: 'gitlab',
gitea: 'gitea',
};
/**
* Read the per-provider `apiBaseUrl` overrides out of an untyped server
* `gitProviders` payload. Unknown or malformed entries collapse to ''.
@@ -37,21 +47,40 @@ const readProjectApiBaseUrls = (gitProviders: unknown): GitProviderApiBaseUrls =
return result;
};
/**
* Read the forced `provider` out of an untyped server `gitProviders` payload.
* Anything outside github|gitlab|gitea collapses to null (auto-detect).
*/
const readProjectProvider = (gitProviders: unknown): GitProviderName | null => {
if (!gitProviders || typeof gitProviders !== 'object' || Array.isArray(gitProviders)) {
return null;
}
const provider = (gitProviders as Record<string, unknown>).provider;
return typeof provider === 'string' && GIT_PROVIDERS.includes(provider as GitProviderName)
? (provider as GitProviderName)
: null;
};
/**
* Build the full per-project `gitProviders` object for the server. The server
* replaces the whole `gitProviders` key on PUT, so every provider is sent
* together; providers with an empty override are omitted.
* together; providers with an empty override are omitted, and the forced
* `provider` is included only when one is selected.
*/
const buildGitProvidersPayload = (
drafts: GitProviderApiBaseUrls,
): Partial<Record<GitProviderName, { apiBaseUrl: string }>> => {
const payload: Partial<Record<GitProviderName, { apiBaseUrl: string }>> = {};
for (const provider of GIT_PROVIDERS) {
const url = drafts[provider].trim();
provider: GitProviderName | null,
): { provider?: GitProviderName } & Partial<Record<GitProviderName, { apiBaseUrl: string }>> => {
const payload: Partial<Record<GitProviderName, { apiBaseUrl: string }>> & { provider?: GitProviderName } = {};
for (const entryProvider of GIT_PROVIDERS) {
const url = drafts[entryProvider].trim();
if (url) {
payload[provider] = { apiBaseUrl: url };
payload[entryProvider] = { apiBaseUrl: url };
}
}
if (provider) {
payload.provider = provider;
}
return payload;
};
@@ -60,16 +89,20 @@ type ProjectGitProvidersSectionProps = {
};
/**
* Per-project git provider API base URL overrides. Each provider's override is
* persisted through the project-scoped `/api/projects/:id/git-providers` route;
* empty overrides fall back to the global server settings value. Commits on
* blur or Enter and re-hydrates the detection store so a new host applies
* immediately.
* Per-project git provider overrides on top of auto-detection: a forced
* provider (auto-detect or github/gitlab/gitea) and a single API base URL
* override for the active provider. Persisted through the project-scoped
* `/api/projects/:id/git-providers` route; empty overrides fall back to the
* global server settings value. The one API URL field follows the provider
* selector — the selected provider when forced, otherwise the currently
* detected one. Commits on blur/Enter (or provider selection) and re-hydrates
* the detection store so a new host/provider applies immediately.
*/
export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProps> = ({ projectRef }) => {
const { t } = useI18n();
const globalApiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
const [drafts, setDrafts] = React.useState<GitProviderApiBaseUrls>({ ...EMPTY_API_BASE_URLS });
const [provider, setProvider] = React.useState<GitProviderName | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const hasEditedRef = React.useRef(false);
const committedSnapshotRef = React.useRef('');
@@ -84,10 +117,12 @@ export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProp
return;
}
const loaded = readProjectApiBaseUrls(gitProviders);
const loadedProvider = readProjectProvider(gitProviders);
// Never clobber an edit the user started before the read resolved.
if (!hasEditedRef.current) {
setDrafts(loaded);
committedSnapshotRef.current = JSON.stringify(buildGitProvidersPayload(loaded));
setProvider(loadedProvider);
committedSnapshotRef.current = JSON.stringify(buildGitProvidersPayload(loaded, loadedProvider));
}
setIsLoading(false);
})();
@@ -96,8 +131,9 @@ export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProp
};
}, [projectRef.id]);
const commit = React.useCallback(() => {
const payload = buildGitProvidersPayload(drafts);
const commit = React.useCallback((nextProvider?: GitProviderName | null) => {
const providerValue = nextProvider === undefined ? provider : nextProvider;
const payload = buildGitProvidersPayload(drafts, providerValue);
const snapshot = JSON.stringify(payload);
if (snapshot === committedSnapshotRef.current) {
// Blur with no real change: drop incidental whitespace from the drafts.
@@ -121,7 +157,75 @@ export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProp
reportSettingsSaveState('error');
}
});
}, [drafts, projectRef.id]);
}, [drafts, provider, projectRef.id]);
const handleProviderChange = React.useCallback((value: string) => {
hasEditedRef.current = true;
const next = value === 'auto' ? null : (value as GitProviderName);
setProvider(next);
commit(next);
}, [commit]);
// The live auto-detection result for this project's remote (null/'other'
// when nothing recognizable was found). Only feeds the field attribution
// when no provider is forced.
const detectedProvider = useGitProvider(projectRef.path);
const knownDetected = detectedProvider && detectedProvider !== 'other' ? detectedProvider : null;
// One API URL override, always for the active provider: the selected
// (forced) provider when set, otherwise whatever auto-detection currently
// yields for this project's remote.
const activeUrlProvider = provider ?? knownDetected;
const renderBaseUrlField = (entryProvider: GitProviderName) => {
const isEmpty = drafts[entryProvider].trim().length === 0;
// Show what the project inherits when no override is set: the global
// setting when present, otherwise the provider's default placeholder.
const inheritedUrl =
globalApiBaseUrls[entryProvider] || t(`settings.${entryProvider}.page.apiBaseUrl.placeholder`);
return (
<SettingsStackedField
key={entryProvider}
label={(
<span className="inline-flex items-center gap-1.5">
<Icon name={PROVIDER_ICONS[entryProvider]} className="h-3.5 w-3.5" />
{t(`settings.git.tabs.${entryProvider}`)}
</span>
)}
settingsItem={`projects.git-providers.${entryProvider}`}
descriptionPlacement="after"
description={
isEmpty && !isLoading
? provider
? t('settings.projects.page.gitProviders.inheritsGlobal', { url: inheritedUrl })
: t('settings.projects.page.gitProviders.provider.detectedAs', {
provider: t(`settings.git.tabs.${entryProvider}`),
url: inheritedUrl,
})
: undefined
}
>
<Input
type="text"
value={drafts[entryProvider]}
onChange={(event) => {
hasEditedRef.current = true;
setDrafts((prev) => ({ ...prev, [entryProvider]: event.target.value }));
}}
onBlur={() => commit()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
}}
placeholder={t(`settings.${entryProvider}.page.apiBaseUrl.placeholder`)}
aria-label={t(`settings.${entryProvider}.page.apiBaseUrl.label`)}
className="h-9"
/>
</SettingsStackedField>
);
};
return (
<ProjectSettingsSubsection
@@ -129,45 +233,39 @@ export const ProjectGitProvidersSection: React.FC<ProjectGitProvidersSectionProp
info={t('settings.projects.page.gitProviders.description')}
settingsItem="projects.git-providers"
>
{GIT_PROVIDERS.map((provider) => {
const isEmpty = drafts[provider].trim().length === 0;
// Show what the project inherits when no override is set: the global
// setting when present, otherwise the provider's default placeholder.
const inheritedUrl =
globalApiBaseUrls[provider] || t(`settings.${provider}.page.apiBaseUrl.placeholder`);
return (
<SettingsStackedField
key={provider}
label={t(`settings.${provider}.page.apiBaseUrl.label`)}
settingsItem={`projects.git-providers.${provider}`}
descriptionPlacement="after"
description={
isEmpty && !isLoading
? t('settings.projects.page.gitProviders.inheritsGlobal', { url: inheritedUrl })
: undefined
}
>
<Input
type="text"
value={drafts[provider]}
onChange={(event) => {
hasEditedRef.current = true;
setDrafts((prev) => ({ ...prev, [provider]: event.target.value }));
}}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
}}
placeholder={t(`settings.${provider}.page.apiBaseUrl.placeholder`)}
aria-label={t(`settings.${provider}.page.apiBaseUrl.label`)}
className="h-9"
/>
</SettingsStackedField>
);
})}
<div className="flex flex-col gap-4 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-muted)] p-4">
<SettingsStackedField
label={t('settings.projects.page.gitProviders.provider.label')}
description={t('settings.projects.page.gitProviders.provider.description')}
descriptionPlacement="after"
settingsItem="projects.git-providers.provider"
>
<Select value={provider ?? 'auto'} onValueChange={handleProviderChange}>
<SelectTrigger className="h-9 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('settings.projects.page.gitProviders.provider.auto')}</SelectItem>
{GIT_PROVIDERS.map((entryProvider) => (
<SelectItem key={entryProvider} value={entryProvider}>
<span className="inline-flex items-center gap-1.5">
<Icon name={PROVIDER_ICONS[entryProvider]} className="h-3.5 w-3.5" />
{t(`settings.git.tabs.${entryProvider}`)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsStackedField>
{activeUrlProvider ? (
renderBaseUrlField(activeUrlProvider)
) : (
<p className="typography-meta text-muted-foreground">
{t('settings.projects.page.gitProviders.provider.autoUnknown')}
</p>
)}
</div>
</ProjectSettingsSubsection>
);
};
};
@@ -9,6 +9,9 @@ import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
import { Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { AgentsSidebar } from '@/components/sections/agents/AgentsSidebar';
@@ -236,6 +239,31 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const runtimeCtx = React.useMemo(() => buildRuntimeContext(isDesktopApp, isMobile), [isDesktopApp, isMobile]);
const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const gitlabConnected = useGitLabAuthStore((state) => state.status?.connected ?? false);
const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked);
const refreshGitLabAuthStatus = useGitLabAuthStore((state) => state.refreshStatus);
const giteaConnected = useGiteaAuthStore((state) => state.status?.connected ?? false);
const giteaAuthChecked = useGiteaAuthStore((state) => state.hasChecked);
const refreshGiteaAuthStatus = useGiteaAuthStore((state) => state.refreshStatus);
// Populate git provider connection state on mount so search availability for
// the provider override fields matches what the settings page will render.
// refreshStatus dedupes when already checked and falls back to runtimeFetch.
React.useEffect(() => {
if (!githubAuthChecked) {
void refreshGitHubAuthStatus();
}
if (!gitlabAuthChecked) {
void refreshGitLabAuthStatus();
}
if (!giteaAuthChecked) {
void refreshGiteaAuthStatus();
}
}, [githubAuthChecked, refreshGitHubAuthStatus, gitlabAuthChecked, refreshGitLabAuthStatus, giteaAuthChecked, refreshGiteaAuthStatus]);
const visiblePages = React.useMemo(() => {
const allowedPages = visiblePageSlugs ? new Set<SettingsPageSlug>(visiblePageSlugs) : null;
return SETTINGS_PAGE_METADATA
@@ -394,12 +422,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const settingsSearchResults = React.useMemo(() => {
return buildSettingsSearchResults({
query: settingsSearchQuery,
runtimeCtx: { ...runtimeCtx, isDesktopLocalOrigin, isMac, isWindows, isLinux, isWindowsArm64 },
runtimeCtx: {
...runtimeCtx,
isDesktopLocalOrigin,
isMac,
isWindows,
isLinux,
isWindowsArm64,
gitProvidersConnected: { github: githubConnected, gitlab: gitlabConnected, gitea: giteaConnected },
},
visiblePageSlugs,
t,
getPageTitle,
});
}, [getPageTitle, isWindowsArm64, isDesktopLocalOrigin, isMac, isWindows, isLinux, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
}, [getPageTitle, githubConnected, gitlabConnected, giteaConnected, isWindowsArm64, isDesktopLocalOrigin, isMac, isWindows, isLinux, runtimeCtx, settingsSearchQuery, t, visiblePageSlugs]);
const prepareSettingsSearchTarget = React.useCallback((result: SettingsSearchResult): string => {
if (result.id.startsWith('agents.')) {
+13 -1
View File
@@ -8,6 +8,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import {
mergeGitProviderApiBaseUrls,
resolveProjectApiBaseUrls,
resolveProjectIdForDirectory,
} from '@/lib/projectGitProviders';
import {
useGitProviderDomainsStore,
@@ -181,8 +182,14 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
const domains = useGitProviderDomainsStore((state) => state.domains);
const apiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
const projectApiBaseUrls = useGitProviderDomainsStore((state) => state.projectApiBaseUrls);
const projectProviders = useGitProviderDomainsStore((state) => state.projectProviders);
const projects = useProjectsStore((state) => state.projects);
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const projectId = useMemo(
() => resolveProjectIdForDirectory(directory, projects, worktreesByProject),
[directory, projects, worktreesByProject],
);
const forcedProvider = projectId ? (projectProviders[projectId] ?? null) : null;
const hosts = useMemo<GitProviderHosts>(
() => {
// Precedence per provider: project override > global server settings.
@@ -201,6 +208,11 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
setProvider(null);
return;
}
// A per-project forced provider wins over remote-host detection.
if (forcedProvider) {
setProvider(forcedProvider);
return;
}
let cancelled = false;
void resolveGitProvider(directory, hosts).then((resolved) => {
if (!cancelled) {
@@ -210,7 +222,7 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
return () => {
cancelled = true;
};
}, [directory, hosts]);
}, [directory, hosts, forcedProvider]);
return provider;
};
@@ -1127,6 +1127,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'API-Basis-URLs der Git-Anbieter',
'settings.projects.page.gitProviders.description': 'Überschreibt die globale API-Basis-URL für dieses Projekt. Ist kein Wert gesetzt, wird die globale Einstellung verwendet.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Erbt: {url}',
'settings.projects.page.gitProviders.provider.label': 'Git-Anbieter',
'settings.projects.page.gitProviders.provider.description': 'Erzwingt die Verwendung des ausgewählten Forges für das Repository dieses Projekts. Ohne Auswahl wird automatisch anhand des Remotes erkannt.',
'settings.projects.page.gitProviders.provider.auto': 'Automatisch erkennen',
'settings.projects.page.gitProviders.provider.autoUnknown': 'Der Anbieter konnte anhand des Remotes nicht ermittelt werden. Wähle oben einen Anbieter, um eine API-Basis-URL festzulegen.',
'settings.projects.page.gitProviders.provider.detectedAs': 'Automatisch als {provider} erkannt. Erbt: {url}',
'settings.usage.sidebar.title': 'Nutzung',
'settings.usage.sidebar.total': 'Gesamt {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Automatisches Aktualisieren umschalten',
@@ -1708,6 +1713,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': 'Erkennungs-URL hinzufügen',
'settings.gitProviders.detectUrls.remove': '{host} entfernen',
'settings.gitProviders.detectUrls.invalid': 'Gib eine gültige SSH- oder HTTPS-URL bzw. einen gültigen Hostnamen ein.',
'settings.gitProviders.overridesLocked.description': 'Verbinden Sie ein {provider}-Konto, um die API-Basis-URL und die Erkennungs-URLs zu konfigurieren.',
'settings.notifications.page.delivery.title': 'Benachrichtigungsübermittlung',
'settings.notifications.page.delivery.enableAria': 'Benachrichtigungen aktivieren',
'settings.notifications.page.delivery.enableLabel': 'Benachrichtigungen aktivieren',
@@ -1189,6 +1189,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git Provider API Base URLs',
'settings.projects.page.gitProviders.description': 'Override the global API base URL for this project. When unset, the global setting is used.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Inherits: {url}',
'settings.projects.page.gitProviders.provider.label': 'Git provider',
'settings.projects.page.gitProviders.provider.description': 'Force this project\'s repository to use the selected forge. Auto-detects from the remote when unset.',
'settings.projects.page.gitProviders.provider.auto': 'Auto-detect',
'settings.projects.page.gitProviders.provider.autoUnknown': 'Couldn\'t identify the provider from this project\'s remote. Choose one above to set an API base URL.',
'settings.projects.page.gitProviders.provider.detectedAs': 'Auto-detected as {provider}. Inherits: {url}',
'settings.usage.sidebar.title': 'Usage',
'settings.usage.sidebar.total': 'Total {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Toggle auto refresh',
@@ -1774,6 +1779,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': 'Add a detection URL',
'settings.gitProviders.detectUrls.remove': 'Remove {host}',
'settings.gitProviders.detectUrls.invalid': 'Enter a valid SSH or HTTPS URL or hostname.',
'settings.gitProviders.overridesLocked.description': 'Connect a {provider} account to configure the API base URL and detection URLs.',
'settings.notifications.page.delivery.title': 'Notification Delivery',
'settings.notifications.page.delivery.enableAria': 'Enable notifications',
'settings.notifications.page.delivery.enableLabel': 'Enable Notifications',
@@ -1157,6 +1157,11 @@ export const settingsDict = {
"settings.projects.page.gitProviders.title": "URLs base de la API de los proveedores de Git",
"settings.projects.page.gitProviders.description": "Anula la URL base de la API global para este proyecto. Si no se define, se usa la configuración global.",
"settings.projects.page.gitProviders.inheritsGlobal": "Hereda: {url}",
"settings.projects.page.gitProviders.provider.label": "Proveedor de Git",
"settings.projects.page.gitProviders.provider.description": "Fuerza el repositorio de este proyecto a usar la plataforma seleccionada. Detecta automáticamente desde el remoto si no está definido.",
"settings.projects.page.gitProviders.provider.auto": "Detección automática",
"settings.projects.page.gitProviders.provider.autoUnknown": "No se pudo identificar el proveedor desde el remoto de este proyecto. Elige un proveedor arriba para definir una URL base de la API.",
"settings.projects.page.gitProviders.provider.detectedAs": "Detectado automáticamente como {provider}. Hereda: {url}",
"settings.usage.sidebar.title": "Uso",
"settings.usage.sidebar.total": "Total {count}",
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar refresco automático",
@@ -1751,6 +1756,7 @@ export const settingsDict = {
"settings.gitProviders.detectUrls.add": "Añadir una URL de detección",
"settings.gitProviders.detectUrls.remove": "Quitar {host}",
"settings.gitProviders.detectUrls.invalid": "Introduce una URL SSH o HTTPS o un nombre de host válido.",
"settings.gitProviders.overridesLocked.description": "Conecta una cuenta de {provider} para configurar la URL base de la API y las URL de detección.",
"settings.notifications.page.delivery.title": "Entrega de notificaciones",
"settings.notifications.page.delivery.enableAria": "Habilitar notificaciones",
"settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones",
@@ -1075,6 +1075,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'URL de base de l\'API des fournisseurs Git',
'settings.projects.page.gitProviders.description': 'Remplace l\'URL de base de l\'API globale pour ce projet. Si non défini, la valeur globale est utilisée.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Hérite de : {url}',
'settings.projects.page.gitProviders.provider.label': 'Fournisseur Git',
'settings.projects.page.gitProviders.provider.description': 'Force le dépôt de ce projet à utiliser la forge sélectionnée. Détection automatique à partir du remote si non défini.',
'settings.projects.page.gitProviders.provider.auto': 'Détection automatique',
'settings.projects.page.gitProviders.provider.autoUnknown': 'Impossible d\'identifier le fournisseur depuis le remote de ce projet. Choisissez un fournisseur ci-dessus pour définir une URL de base de l\'API.',
'settings.projects.page.gitProviders.provider.detectedAs': 'Détecté automatiquement comme {provider}. Hérite de : {url}',
'settings.usage.sidebar.title': 'Usage',
'settings.usage.sidebar.total': 'Total {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Activer l\'actualisation automatique',
@@ -1669,6 +1674,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': 'Ajouter une URL de détection',
'settings.gitProviders.detectUrls.remove': 'Retirer {host}',
'settings.gitProviders.detectUrls.invalid': 'Saisissez une URL SSH ou HTTPS ou un nom dhôte valide.',
'settings.gitProviders.overridesLocked.description': 'Connectez un compte {provider} pour configurer l\'URL de base de l\'API et les URL de détection.',
'settings.notifications.page.delivery.title': 'Envoi des notifications',
'settings.notifications.page.delivery.enableAria': 'Activer les notifications',
'settings.notifications.page.delivery.enableLabel': 'Activer les notifications',
@@ -1190,6 +1190,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git プロバイダーの API ベース URL',
'settings.projects.page.gitProviders.description': 'このプロジェクトの API ベース URL をグローバル設定で上書きします。未設定の場合はグローバル設定が使用されます。',
'settings.projects.page.gitProviders.inheritsGlobal': '継承: {url}',
'settings.projects.page.gitProviders.provider.label': 'Git プロバイダー',
'settings.projects.page.gitProviders.provider.description': 'このプロジェクトのリポジトリを選択したフォージに固定します。未設定の場合はリモートから自動検出します。',
'settings.projects.page.gitProviders.provider.auto': '自動検出',
'settings.projects.page.gitProviders.provider.autoUnknown': 'このプロジェクトのリモートからプロバイダーを特定できませんでした。API ベース URL を設定するには、上でプロバイダーを選択してください。',
'settings.projects.page.gitProviders.provider.detectedAs': 'リモートから {provider} として自動検出されました。継承: {url}',
'settings.usage.sidebar.title': '使用量',
'settings.usage.sidebar.total': '合計 {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '自動更新の切替',
@@ -1784,6 +1789,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '検出 URL を追加',
'settings.gitProviders.detectUrls.remove': '{host} を削除',
'settings.gitProviders.detectUrls.invalid': '有効な SSH または HTTPS の URL またはホスト名を入力してください。',
'settings.gitProviders.overridesLocked.description': '{provider} アカウントを接続して、API ベース URL と検出 URL を設定します。',
'settings.notifications.page.delivery.title': '通知配信',
'settings.notifications.page.delivery.enableAria': '通知を有効化',
'settings.notifications.page.delivery.enableLabel': '通知を有効化',
@@ -1157,6 +1157,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git 공급자 API 기본 URL',
'settings.projects.page.gitProviders.description': '이 프로젝트의 API 기본 URL을 전역 설정으로 재정의합니다. 설정하지 않으면 전역 설정이 사용됩니다.',
'settings.projects.page.gitProviders.inheritsGlobal': '상속: {url}',
'settings.projects.page.gitProviders.provider.label': 'Git 공급자',
'settings.projects.page.gitProviders.provider.description': '이 프로젝트의 저장소를 선택한 포지로 강제합니다. 설정하지 않으면 원격 저장소에서 자동 감지합니다.',
'settings.projects.page.gitProviders.provider.auto': '자동 감지',
'settings.projects.page.gitProviders.provider.autoUnknown': '이 프로젝트의 원격 저장소에서 공급자를 식별할 수 없습니다. API 기본 URL을 설정하려면 위에서 공급자를 선택하세요.',
'settings.projects.page.gitProviders.provider.detectedAs': '원격 저장소에서 {provider}(으)로 자동 감지되었습니다. 상속: {url}',
'settings.usage.sidebar.title': '사용량',
'settings.usage.sidebar.total': '총 {count}개',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '자동 새로고침 토글',
@@ -1751,6 +1756,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '감지 URL 추가',
'settings.gitProviders.detectUrls.remove': '{host} 제거',
'settings.gitProviders.detectUrls.invalid': '유효한 SSH 또는 HTTPS URL이나 호스트 이름을 입력하세요.',
'settings.gitProviders.overridesLocked.description': '{provider} 계정을 연결하여 API 기본 URL 및 감지 URL을 구성하세요.',
'settings.notifications.page.delivery.title': '알림',
'settings.notifications.page.delivery.enableAria': '알림 활성화',
'settings.notifications.page.delivery.enableLabel': '알림 활성화',
@@ -396,6 +396,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': 'Dodaj adres URL wykrywania',
'settings.gitProviders.detectUrls.remove': 'Usuń {host}',
'settings.gitProviders.detectUrls.invalid': 'Podaj prawidłowy adres URL SSH lub HTTPS albo nazwę hosta.',
'settings.gitProviders.overridesLocked.description': 'Połącz konto {provider}, aby skonfigurować adres URL API i adresy URL wykrywania.',
'settings.magicPrompts.page.actions.resetAllOverrides': 'Zresetuj wszystkie nadpisania',
'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych',
'settings.magicPrompts.page.actions.resetting': 'Resetowanie...',
@@ -1451,6 +1452,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Adresy URL API dostawców Git',
'settings.projects.page.gitProviders.description': 'Zastępuje globalny adres URL API dla tego projektu. Jeśli nie ustawiono, używany jest globalny adres URL.',
'settings.projects.page.gitProviders.inheritsGlobal': 'Dziedziczy: {url}',
'settings.projects.page.gitProviders.provider.label': 'Dostawca Git',
'settings.projects.page.gitProviders.provider.description': 'Wymusza używanie wybranego serwisu forów dla repozytorium tego projektu. Gdy nie ustawiono, wykrywane automatycznie na podstawie zdalnego repozytorium.',
'settings.projects.page.gitProviders.provider.auto': 'Wykryj automatycznie',
'settings.projects.page.gitProviders.provider.autoUnknown': 'Nie udało się zidentyfikować dostawcy na podstawie zdalnego repozytorium tego projektu. Wybierz dostawcę powyżej, aby ustawić adres URL API.',
'settings.projects.page.gitProviders.provider.detectedAs': 'Automatycznie wykryto jako {provider}. Dziedziczy: {url}',
'settings.projects.page.toast.iconRemoved': 'Ikona projektu została usunięta',
'settings.projects.page.toast.iconUpdated': 'Ikona projektu została zaktualizowana',
'settings.projects.page.toast.removeIconFailed': 'Nie udało się usunąć ikony projektu',
@@ -1157,6 +1157,11 @@ export const settingsDict = {
"settings.projects.page.gitProviders.title": "URLs base da API dos provedores de Git",
"settings.projects.page.gitProviders.description": "Substitui a URL base da API global para este projeto. Quando não definido, usa a configuração global.",
"settings.projects.page.gitProviders.inheritsGlobal": "Herda: {url}",
"settings.projects.page.gitProviders.provider.label": "Provedor Git",
"settings.projects.page.gitProviders.provider.description": "Força o repositório deste projeto a usar a plataforma selecionada. Detecta automaticamente pelo remote quando não definido.",
"settings.projects.page.gitProviders.provider.auto": "Detecção automática",
"settings.projects.page.gitProviders.provider.autoUnknown": "Não foi possível identificar o provedor pelo remote deste projeto. Escolha um provedor acima para definir uma URL base da API.",
"settings.projects.page.gitProviders.provider.detectedAs": "Detectado automaticamente como {provider}. Herda: {url}",
"settings.usage.sidebar.title": "Uso",
"settings.usage.sidebar.total": "Total {count}",
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar atualização automática",
@@ -1751,6 +1756,7 @@ export const settingsDict = {
"settings.gitProviders.detectUrls.add": "Adicionar uma URL de detecção",
"settings.gitProviders.detectUrls.remove": "Remover {host}",
"settings.gitProviders.detectUrls.invalid": "Digite uma URL SSH ou HTTPS ou um nome de host válido.",
"settings.gitProviders.overridesLocked.description": "Conecte uma conta do {provider} para configurar a URL base da API e as URLs de detecção.",
"settings.notifications.page.delivery.title": "Entrega de notificações",
"settings.notifications.page.delivery.enableAria": "Ativar notificações",
"settings.notifications.page.delivery.enableLabel": "Ativar notificações",
@@ -1157,6 +1157,11 @@ export const settingsDict = {
"settings.projects.page.gitProviders.title": "Базові URL-адреси API постачальників Git",
"settings.projects.page.gitProviders.description": "Перевизначає глобальну базову URL-адресу API для цього проєкту. Якщо не задано, використовується глобальне значення.",
"settings.projects.page.gitProviders.inheritsGlobal": "Успадковує: {url}",
"settings.projects.page.gitProviders.provider.label": "Git-провайдер",
"settings.projects.page.gitProviders.provider.description": "Примушує репозиторій цього проєкту використовувати вибрану платформу. Якщо не задано — визначається автоматично з віддаленого репозиторію.",
"settings.projects.page.gitProviders.provider.auto": "Автовизначення",
"settings.projects.page.gitProviders.provider.autoUnknown": "Не вдалося визначити провайдера з віддаленого репозиторію цього проєкту. Виберіть провайдера вище, щоб задати базову URL-адресу API.",
"settings.projects.page.gitProviders.provider.detectedAs": "Автоматично визначено як {provider}. Успадковує: {url}",
"settings.usage.sidebar.title": "Використання",
"settings.usage.sidebar.total": "Усього {count}",
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Увімкнути автоматичне оновлення",
@@ -1751,6 +1756,7 @@ export const settingsDict = {
"settings.gitProviders.detectUrls.add": "Додати URL-адресу виявлення",
"settings.gitProviders.detectUrls.remove": "Видалити {host}",
"settings.gitProviders.detectUrls.invalid": "Введіть коректну URL-адресу SSH або HTTPS чи ім'я хоста.",
"settings.gitProviders.overridesLocked.description": "Підключіть обліковий запис {provider}, щоб налаштувати базову URL-адресу API та URL-адреси виявлення.",
"settings.notifications.page.delivery.title": "Доставка сповіщень",
"settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення",
"settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення",
@@ -1157,6 +1157,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git 提供商的 API 基础 URL',
'settings.projects.page.gitProviders.description': '为此项目覆盖全局 API 基础 URL。未设置时使用全局配置。',
'settings.projects.page.gitProviders.inheritsGlobal': '继承:{url}',
'settings.projects.page.gitProviders.provider.label': 'Git 提供方',
'settings.projects.page.gitProviders.provider.description': '强制此项目的仓库使用选定的托管平台。未设置时根据远程仓库自动检测。',
'settings.projects.page.gitProviders.provider.auto': '自动检测',
'settings.projects.page.gitProviders.provider.autoUnknown': '无法根据此项目的远程仓库识别提供方。请在上方选择提供方以设置 API 基础 URL。',
'settings.projects.page.gitProviders.provider.detectedAs': '已自动检测为 {provider}。继承:{url}',
'settings.usage.sidebar.title': '用量',
'settings.usage.sidebar.total': '总计 {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切换自动刷新',
@@ -1751,6 +1756,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '添加检测 URL',
'settings.gitProviders.detectUrls.remove': '移除 {host}',
'settings.gitProviders.detectUrls.invalid': '请输入有效的 SSH 或 HTTPS URL 或主机名。',
'settings.gitProviders.overridesLocked.description': '连接 {provider} 账户,以配置 API 基础 URL 和检测 URL。',
'settings.notifications.page.delivery.title': '通知投递',
'settings.notifications.page.delivery.enableAria': '启用通知',
'settings.notifications.page.delivery.enableLabel': '启用通知',
@@ -1064,6 +1064,11 @@ export const settingsDict = {
'settings.projects.page.gitProviders.title': 'Git 提供者的 API 基礎 URL',
'settings.projects.page.gitProviders.description': '為此專案覆寫全域 API 基礎 URL。未設定時使用全域設定。',
'settings.projects.page.gitProviders.inheritsGlobal': '繼承:{url}',
'settings.projects.page.gitProviders.provider.label': 'Git 提供者',
'settings.projects.page.gitProviders.provider.description': '強制此專案的儲存庫使用所選的程式碼代管平台。未設定時依遠端倉庫自動偵測。',
'settings.projects.page.gitProviders.provider.auto': '自動偵測',
'settings.projects.page.gitProviders.provider.autoUnknown': '無法依此專案的遠端倉庫識別提供者。請在上方選擇提供者以設定 API 基礎 URL。',
'settings.projects.page.gitProviders.provider.detectedAs': '已自動偵測為 {provider}。繼承:{url}',
'settings.usage.sidebar.title': '用量',
'settings.usage.sidebar.total': '總計 {count}',
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切換自動重新整理',
@@ -1658,6 +1663,7 @@ export const settingsDict = {
'settings.gitProviders.detectUrls.add': '新增偵測 URL',
'settings.gitProviders.detectUrls.remove': '移除 {host}',
'settings.gitProviders.detectUrls.invalid': '請輸入有效的 SSH 或 HTTPS URL 或主機名稱。',
'settings.gitProviders.overridesLocked.description': '連線 {provider} 帳號,以設定 API 基礎 URL 和偵測 URL。',
'settings.notifications.page.delivery.title': '通知傳遞',
'settings.notifications.page.delivery.enableAria': '啟用通知',
'settings.notifications.page.delivery.enableLabel': '啟用通知',
+16 -1
View File
@@ -28,6 +28,8 @@ interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext {
isLinux: boolean;
// Windows ARM64 — temporary workaround gate (see opencode#19130).
isWindowsArm64: boolean;
// Git provider override fields only render once an account is connected.
gitProvidersConnected: { github: boolean; gitlab: boolean; gitea: boolean };
}
const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
@@ -507,6 +509,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.github.page.apiBaseUrl.label',
descriptionKey: 'settings.github.page.apiBaseUrl.description',
keywords: ['github', 'api', 'base url', 'enterprise', 'self-hosted', 'server'],
isAvailable: (ctx) => ctx.gitProvidersConnected.github,
},
{
id: 'git.github-detect-urls',
@@ -514,6 +517,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.github.page.detectUrls.label',
descriptionKey: 'settings.github.page.detectUrls.description',
keywords: ['github', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'],
isAvailable: (ctx) => ctx.gitProvidersConnected.github,
},
{
id: 'git.gitlab-api-base-url',
@@ -521,6 +525,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitlab.page.apiBaseUrl.label',
descriptionKey: 'settings.gitlab.page.apiBaseUrl.description',
keywords: ['gitlab', 'api', 'base url', 'self-hosted', 'server', 'instance'],
isAvailable: (ctx) => ctx.gitProvidersConnected.gitlab,
},
{
id: 'git.gitlab-detect-urls',
@@ -528,6 +533,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitlab.page.detectUrls.label',
descriptionKey: 'settings.gitlab.page.detectUrls.description',
keywords: ['gitlab', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'],
isAvailable: (ctx) => ctx.gitProvidersConnected.gitlab,
},
{
id: 'git.gitea-api-base-url',
@@ -535,6 +541,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitea.page.apiBaseUrl.label',
descriptionKey: 'settings.gitea.page.apiBaseUrl.description',
keywords: ['gitea', 'forgejo', 'api', 'base url', 'self-hosted', 'server', 'instance'],
isAvailable: (ctx) => ctx.gitProvidersConnected.gitea,
},
{
id: 'git.gitea-detect-urls',
@@ -542,6 +549,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
titleKey: 'settings.gitea.page.detectUrls.label',
descriptionKey: 'settings.gitea.page.detectUrls.description',
keywords: ['gitea', 'forgejo', 'detect', 'remote', 'host', 'domain', 'ssh', 'url', 'self-hosted'],
isAvailable: (ctx) => ctx.gitProvidersConnected.gitea,
},
{
id: 'git.identities',
@@ -616,7 +624,14 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
page: 'projects',
titleKey: 'settings.projects.page.gitProviders.title',
descriptionKey: 'settings.projects.page.gitProviders.description',
keywords: ['github', 'gitlab', 'gitea', 'api base url', 'self-hosted', 'override', 'enterprise', 'server'],
keywords: ['github', 'gitlab', 'gitea', 'api base url', 'self-hosted', 'override', 'enterprise', 'server', 'provider', 'forge'],
},
{
id: 'projects.git-providers.provider',
page: 'projects',
titleKey: 'settings.projects.page.gitProviders.provider.label',
descriptionKey: 'settings.projects.page.gitProviders.provider.description',
keywords: ['github', 'gitlab', 'gitea', 'provider', 'forge', 'auto-detect', 'detection'],
},
{
id: 'remote-instances.client-auth',
+35 -9
View File
@@ -712,12 +712,28 @@ const toDirectoryKey = (directory: string | null | undefined): string => {
const fromDirectoryKey = (key: string): string | null => (key === DIRECTORY_KEY_GLOBAL ? null : key);
/**
* The directory store is part of this store's circular import cluster
* (useConfigStore persistence session-ui-store useConfigStore, with
* useDirectoryStore in the same strongly-connected component). In the bundled
* chunk its module body may not have run yet when this module evaluates, so the
* static import binding is in TDZ. Read it through the window registration that
* useDirectoryStore publishes as soon as it initializes; fall back to the
* client directory, which the directory store seeds at the same time.
*/
const getDirectoryStore = (): typeof useDirectoryStore | null => {
if (typeof window === 'undefined') {
return null;
}
return window.__zustand_directory_store__ ?? null;
};
const resolveInitialDirectoryKey = (): string => {
if (typeof window === 'undefined') {
return DIRECTORY_KEY_GLOBAL;
}
const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory;
const directory = opencodeClient.getDirectory() ?? getDirectoryStore()?.getState().currentDirectory;
return toConfigDirectoryKey(directory);
};
@@ -3429,14 +3445,24 @@ if (!unsubscribeConfigStoreSyncConfigChanges) {
}
if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) {
unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
const nextKey = toDirectoryKey(state.currentDirectory);
const prevKey = toDirectoryKey(prevState.currentDirectory);
if (nextKey === prevKey) {
return;
}
// useDirectoryStore's module body may not have run yet when this module
// evaluates (the two stores share a circular import cluster, and the
// bundled chunk can evaluate either body first). Defer subscription setup
// until after module evaluation completes so the import binding is no
// longer in TDZ. The subscription is registered before any user-driven
// directory change can occur; the initial directory is reconciled by
// initializeApp.
queueMicrotask(() => {
if (unsubscribeConfigStoreDirectoryChanges) return;
unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
const nextKey = toDirectoryKey(state.currentDirectory);
const prevKey = toDirectoryKey(prevState.currentDirectory);
if (nextKey === prevKey) {
return;
}
markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey });
void useConfigStore.getState().activateDirectory(state.currentDirectory);
markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey });
void useConfigStore.getState().activateDirectory(state.currentDirectory);
});
});
}
@@ -8,6 +8,12 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { getDeferredSafeStorage } from './utils/safeStorage';
declare global {
interface Window {
__zustand_directory_store__?: typeof useDirectoryStore;
}
}
interface DirectoryStore {
currentDirectory: string;
@@ -436,6 +442,11 @@ export const useDirectoryStore = create<DirectoryStore>()(
);
if (typeof window !== 'undefined') {
// Registered on window so store modules inside the circular import cluster
// (useConfigStore, useAgentsStore, ...) can reach this store lazily without
// a static import that would resolve in TDZ at module-evaluation time.
window.__zustand_directory_store__ = useDirectoryStore;
initializeHomeDirectory().then((home) => {
useDirectoryStore.getState().synchronizeHomeDirectory(home);
});
@@ -6,6 +6,7 @@ const resetDomains = () => {
domains: { github: [], gitlab: [], gitea: [] },
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
projectApiBaseUrls: {},
projectProviders: {},
});
};
@@ -217,4 +218,36 @@ describe('useGitProviderDomainsStore per-project overrides', () => {
gitea: '',
});
});
test('hydrateProjectFromServer stores a forced provider', () => {
resetDomains();
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
provider: 'gitlab',
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
});
expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({ path_proj: 'gitlab' });
});
test('hydrateProjectFromServer normalizes and drops unknown providers', () => {
resetDomains();
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_bad', { provider: 'Bitbucket' });
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_empty', { provider: '' });
expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({});
});
test('hydrateProjectFromServer clears the forced provider when removed', () => {
resetDomains();
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', { provider: 'gitea' });
expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({ path_proj: 'gitea' });
// A config without a provider removes it even when base urls stay empty.
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {});
expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({});
});
test('clearProjectGitProviders clears the forced provider too', () => {
resetDomains();
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', { provider: 'github' });
useGitProviderDomainsStore.getState().clearProjectGitProviders('path_proj');
expect(useGitProviderDomainsStore.getState().projectProviders).toEqual({});
});
});
@@ -79,25 +79,32 @@ export const normalizeApiBaseUrl = (raw: unknown): string => {
/**
* Normalize a server `gitProviders` config into per-provider `{ apiBaseUrl,
* detectUrls }` pairs. Unknown or malformed entries are dropped; provider keys
* outside the known set are ignored.
* detectUrls }` pairs plus an optional forced `provider`. Unknown or malformed
* entries are dropped; provider keys outside the known set are ignored.
*/
const normalizeGitProvidersConfig = (config: unknown): {
apiBaseUrls: GitProviderApiBaseUrls;
domains: GitProviderDomains;
provider: GitProviderName | null;
} => {
const apiBaseUrls: GitProviderApiBaseUrls = { ...EMPTY_API_BASE_URLS };
const domains: GitProviderDomains = { github: [], gitlab: [], gitea: [] };
if (!isRecord(config)) {
return { apiBaseUrls, domains };
let provider: GitProviderName | null = null;
if (isRecord(config)) {
if (typeof config.provider === 'string') {
const forced = config.provider.trim().toLowerCase();
if (GIT_PROVIDERS.includes(forced as GitProviderName)) {
provider = forced as GitProviderName;
}
}
for (const entryProvider of GIT_PROVIDERS) {
const entry = config[entryProvider];
if (!isRecord(entry)) continue;
apiBaseUrls[entryProvider] = normalizeApiBaseUrl(entry.apiBaseUrl);
domains[entryProvider] = normalizeDomainList(entry.detectUrls);
}
}
for (const provider of GIT_PROVIDERS) {
const entry = config[provider];
if (!isRecord(entry)) continue;
apiBaseUrls[provider] = normalizeApiBaseUrl(entry.apiBaseUrl);
domains[provider] = normalizeDomainList(entry.detectUrls);
}
return { apiBaseUrls, domains };
return { apiBaseUrls, domains, provider };
};
type GitProviderDomainsStore = {
@@ -109,6 +116,12 @@ type GitProviderDomainsStore = {
* on demand and cleared when the override is removed server-side.
*/
projectApiBaseUrls: Record<string, GitProviderApiBaseUrls>;
/**
* Per-project forced git provider (github|gitlab|gitea), keyed by project id.
* Overrides automatic provider detection for the project. Same
* server-authoritative, memory-only semantics as `projectApiBaseUrls`.
*/
projectProviders: Record<string, GitProviderName>;
setDomains: (provider: GitProviderName, domains: string[]) => void;
setApiBaseUrl: (provider: GitProviderName, url: string) => void;
/** Apply the server's `gitProviders` settings, keeping the server authoritative. */
@@ -125,6 +138,7 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
domains: EMPTY_DOMAINS,
apiBaseUrls: EMPTY_API_BASE_URLS,
projectApiBaseUrls: {},
projectProviders: {},
setDomains: (provider, domains) => {
set({
domains: {
@@ -159,16 +173,18 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
},
hydrateProjectFromServer: (projectId, config) => {
if (!projectId) return;
const { apiBaseUrls } = normalizeGitProvidersConfig(config);
const { apiBaseUrls, provider } = normalizeGitProvidersConfig(config);
const hasAny = Boolean(apiBaseUrls.github || apiBaseUrls.gitlab || apiBaseUrls.gitea);
const current = get().projectApiBaseUrls[projectId];
const unchanged = hasAny
const currentProvider = get().projectProviders[projectId];
const baseUrlsUnchanged = hasAny
? current !== undefined
&& current.github === apiBaseUrls.github
&& current.gitlab === apiBaseUrls.gitlab
&& current.gitea === apiBaseUrls.gitea
: current === undefined;
if (unchanged) return;
const providerUnchanged = provider ? currentProvider === provider : currentProvider === undefined;
if (baseUrlsUnchanged && providerUnchanged) return;
set((state) => {
const next = { ...state.projectApiBaseUrls };
if (hasAny) {
@@ -176,15 +192,24 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
} else {
delete next[projectId];
}
return { projectApiBaseUrls: next };
const nextProviders = { ...state.projectProviders };
if (provider) {
nextProviders[projectId] = provider;
} else {
delete nextProviders[projectId];
}
return { projectApiBaseUrls: next, projectProviders: nextProviders };
});
},
clearProjectGitProviders: (projectId) => {
if (!projectId || get().projectApiBaseUrls[projectId] === undefined) return;
if (!projectId
|| (get().projectApiBaseUrls[projectId] === undefined && get().projectProviders[projectId] === undefined)) return;
set((state) => {
const next = { ...state.projectApiBaseUrls };
delete next[projectId];
return { projectApiBaseUrls: next };
const nextProviders = { ...state.projectProviders };
delete nextProviders[projectId];
return { projectApiBaseUrls: next, projectProviders: nextProviders };
});
},
}),