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:
@@ -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.')) {
|
||||
|
||||
Reference in New Issue
Block a user