feat(web,ui): per-project git provider API base URL overrides
Per provider (github|gitlab|gitea) a project override stored in ~/.config/openchamber/projects/<projectId>.json under gitProviders wins over the global settings.json value (precedence: project override > global > built-in default). Server forge routes resolve the override per request directory (worktree-aware via git-common-dir + containment + path fallback, 60s TTL cache); the override host is also accepted for remote parsing and client detection. New GET/PUT /api/projects/:projectId/git-providers route; client openchamberConfig preserves the server-owned gitProviders key; Projects page gains a Git provider API base URLs section; detection store hydrates per-project overrides (memory-only, server-authoritative).
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { reportSettingsSaveState } from '@/lib/persistence';
|
||||
import { ProjectSettingsSubsection } from '@/components/sections/projects/ProjectSettingsSubsection';
|
||||
import { SettingsStackedField } from '@/components/sections/shared/SettingsSection';
|
||||
import { getProjectGitProviders, saveProjectGitProviders } from '@/lib/projectGitProviders';
|
||||
import {
|
||||
useGitProviderDomainsStore,
|
||||
type GitProviderApiBaseUrls,
|
||||
type GitProviderName,
|
||||
} from '@/stores/useGitProviderDomainsStore';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
|
||||
const GIT_PROVIDERS: GitProviderName[] = ['github', 'gitlab', 'gitea'];
|
||||
|
||||
const EMPTY_API_BASE_URLS: GitProviderApiBaseUrls = { github: '', gitlab: '', gitea: '' };
|
||||
|
||||
/**
|
||||
* Read the per-provider `apiBaseUrl` overrides out of an untyped server
|
||||
* `gitProviders` payload. Unknown or malformed entries collapse to ''.
|
||||
*/
|
||||
const readProjectApiBaseUrls = (gitProviders: unknown): GitProviderApiBaseUrls => {
|
||||
const result: GitProviderApiBaseUrls = { ...EMPTY_API_BASE_URLS };
|
||||
if (!gitProviders || typeof gitProviders !== 'object' || Array.isArray(gitProviders)) {
|
||||
return result;
|
||||
}
|
||||
const config = gitProviders as Record<string, unknown>;
|
||||
for (const provider of GIT_PROVIDERS) {
|
||||
const entry = config[provider];
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
const apiBaseUrl = (entry as Record<string, unknown>).apiBaseUrl;
|
||||
result[provider] = typeof apiBaseUrl === 'string' ? apiBaseUrl.trim() : '';
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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();
|
||||
if (url) {
|
||||
payload[provider] = { apiBaseUrl: url };
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
type ProjectGitProvidersSectionProps = {
|
||||
projectRef: ProjectRef;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 [isLoading, setIsLoading] = React.useState(true);
|
||||
const hasEditedRef = React.useRef(false);
|
||||
const committedSnapshotRef = React.useRef('');
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
hasEditedRef.current = false;
|
||||
setIsLoading(true);
|
||||
void (async () => {
|
||||
const { gitProviders } = await getProjectGitProviders(projectRef.id);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const loaded = readProjectApiBaseUrls(gitProviders);
|
||||
// Never clobber an edit the user started before the read resolved.
|
||||
if (!hasEditedRef.current) {
|
||||
setDrafts(loaded);
|
||||
committedSnapshotRef.current = JSON.stringify(buildGitProvidersPayload(loaded));
|
||||
}
|
||||
setIsLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectRef.id]);
|
||||
|
||||
const commit = React.useCallback(() => {
|
||||
const payload = buildGitProvidersPayload(drafts);
|
||||
const snapshot = JSON.stringify(payload);
|
||||
if (snapshot === committedSnapshotRef.current) {
|
||||
// Blur with no real change: drop incidental whitespace from the drafts.
|
||||
setDrafts({
|
||||
github: drafts.github.trim(),
|
||||
gitlab: drafts.gitlab.trim(),
|
||||
gitea: drafts.gitea.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const previousSnapshot = committedSnapshotRef.current;
|
||||
reportSettingsSaveState('saving');
|
||||
void saveProjectGitProviders(projectRef.id, payload).then((ok) => {
|
||||
if (ok) {
|
||||
committedSnapshotRef.current = snapshot;
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer(projectRef.id, payload);
|
||||
reportSettingsSaveState('saved');
|
||||
} else {
|
||||
// Keep the pre-failure snapshot so an unchanged blur can retry.
|
||||
committedSnapshotRef.current = previousSnapshot;
|
||||
reportSettingsSaveState('error');
|
||||
}
|
||||
});
|
||||
}, [drafts, projectRef.id]);
|
||||
|
||||
return (
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.gitProviders.title')}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</ProjectSettingsSubsection>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
|
||||
import { ProjectGitProvidersSection } from '@/components/sections/projects/ProjectGitProvidersSection';
|
||||
import { ProjectIdentityFields } from '@/components/sections/projects/ProjectIdentityFields';
|
||||
import {
|
||||
useProjectIdentityForm,
|
||||
@@ -47,6 +48,7 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<ProjectIdentityFields form={form} />
|
||||
<ProjectGitProvidersSection projectRef={projectRef} />
|
||||
<ProjectActionsSection projectRef={projectRef} />
|
||||
{showWorktrees ? <WorktreeSectionContent projectRef={projectRef} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGitProviderHosts, detectGitProvider, type GitProviderHosts } from './gitProvider';
|
||||
import {
|
||||
mergeGitProviderApiBaseUrls,
|
||||
resolveProjectApiBaseUrls,
|
||||
resolveProjectIdForDirectory,
|
||||
} from './projectGitProviders';
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
|
||||
const EMPTY_HOSTS: GitProviderHosts = { github: [], gitlab: [], gitea: [] };
|
||||
|
||||
@@ -206,3 +212,123 @@ describe('buildGitProviderHosts', () => {
|
||||
expect(hosts.github).toEqual(['github.example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProjectIdForDirectory', () => {
|
||||
const projects = [
|
||||
{ id: 'parent', path: '/workspace' },
|
||||
{ id: 'child', path: '/workspace/app' },
|
||||
];
|
||||
|
||||
test('matches the longest containing project path', () => {
|
||||
expect(resolveProjectIdForDirectory('/workspace/app/sub', projects)).toBe('child');
|
||||
expect(resolveProjectIdForDirectory('/workspace/app', projects)).toBe('child');
|
||||
expect(resolveProjectIdForDirectory('/workspace', projects)).toBe('parent');
|
||||
expect(resolveProjectIdForDirectory('/workspace/other', projects)).toBe('parent');
|
||||
});
|
||||
|
||||
test('matches a root project for any directory', () => {
|
||||
expect(resolveProjectIdForDirectory('/workspace/app', [{ id: 'root', path: '/' }])).toBe('root');
|
||||
});
|
||||
|
||||
test('normalizes windows drive casing before matching', () => {
|
||||
expect(resolveProjectIdForDirectory('C:\\repo\\sub', [{ id: 'proj', path: 'c:\\repo' }])).toBe('proj');
|
||||
});
|
||||
|
||||
test('falls back to a path-derived project id when nothing matches', () => {
|
||||
const directory = '/some/unregistered/repo';
|
||||
expect(resolveProjectIdForDirectory(directory, [])).toBe(createProjectIdFromPath(directory));
|
||||
expect(resolveProjectIdForDirectory(directory, projects)).toBe(createProjectIdFromPath(directory));
|
||||
});
|
||||
|
||||
test('an external worktree resolves to its owning project via the worktree map', () => {
|
||||
const worktreesByProject = new Map([
|
||||
['/workspace', [{ path: '/other/repo-worktrees/slug' }]],
|
||||
]);
|
||||
// Worktree lives outside the project path and nothing contains it, but the
|
||||
// map still ties it to the owning project.
|
||||
expect(resolveProjectIdForDirectory('/other/repo-worktrees/slug', projects, worktreesByProject)).toBe('parent');
|
||||
});
|
||||
|
||||
test('the worktree map lookup wins over containment when both would match', () => {
|
||||
const worktreesByProject = new Map([
|
||||
// Directory is inside the `child` project but declared as a worktree of
|
||||
// the shallower `parent` project — the map is authoritative.
|
||||
['/workspace', [{ path: '/workspace/app/sub' }]],
|
||||
]);
|
||||
expect(resolveProjectIdForDirectory('/workspace/app/sub', projects, worktreesByProject)).toBe('parent');
|
||||
});
|
||||
|
||||
test('a worktree whose owning project is not registered falls through to path fallback', () => {
|
||||
const worktreesByProject = new Map([
|
||||
['/unknown/owner', [{ path: '/other/worktrees/slug' }]],
|
||||
]);
|
||||
expect(resolveProjectIdForDirectory('/other/worktrees/slug', projects, worktreesByProject))
|
||||
.toBe(createProjectIdFromPath('/other/worktrees/slug'));
|
||||
});
|
||||
|
||||
test('resolution is unchanged when worktreesByProject is omitted', () => {
|
||||
expect(resolveProjectIdForDirectory('/workspace/app/sub', projects)).toBe('child');
|
||||
expect(resolveProjectIdForDirectory('/some/unregistered/repo', projects)).toBe(createProjectIdFromPath('/some/unregistered/repo'));
|
||||
expect(resolveProjectIdForDirectory(null, projects)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for empty or sibling-only directories without a matching project', () => {
|
||||
expect(resolveProjectIdForDirectory('', projects)).toBeNull();
|
||||
expect(resolveProjectIdForDirectory(null, projects)).toBeNull();
|
||||
expect(resolveProjectIdForDirectory(' ', projects)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('project override detection overlay', () => {
|
||||
const projects = [{ id: 'proj', path: '/repo' }];
|
||||
const overrides = { proj: { github: '', gitlab: 'https://git.self.example.com', gitea: '' } };
|
||||
|
||||
test('resolveProjectApiBaseUrls returns the owning project override', () => {
|
||||
expect(resolveProjectApiBaseUrls('/repo/sub', projects, overrides)).toEqual(overrides.proj);
|
||||
expect(resolveProjectApiBaseUrls('/repo', projects, overrides)).toEqual(overrides.proj);
|
||||
});
|
||||
|
||||
test('resolveProjectApiBaseUrls returns undefined without a resolvable project', () => {
|
||||
expect(resolveProjectApiBaseUrls('/unknown', [], overrides)).toBe(undefined);
|
||||
expect(resolveProjectApiBaseUrls('/unknown', projects, {})).toBe(undefined);
|
||||
expect(resolveProjectApiBaseUrls(null, projects, overrides)).toBe(undefined);
|
||||
});
|
||||
|
||||
test('resolveProjectApiBaseUrls forwards the worktree map to project resolution', () => {
|
||||
const worktreesByProject = new Map([
|
||||
['/repo', [{ path: '/elsewhere/worktrees/pr-42' }]],
|
||||
]);
|
||||
expect(resolveProjectApiBaseUrls('/elsewhere/worktrees/pr-42', projects, overrides, worktreesByProject)).toEqual(overrides.proj);
|
||||
});
|
||||
|
||||
test('mergeGitProviderApiBaseUrls: project values win, empty slots fall back to global', () => {
|
||||
const global = { github: 'https://github.example.com/api/v3', gitlab: '', gitea: 'https://gitea.example.com' };
|
||||
const override = { github: '', gitlab: 'https://git.self.example.com', gitea: '' };
|
||||
expect(mergeGitProviderApiBaseUrls(override, global)).toEqual({
|
||||
github: 'https://github.example.com/api/v3',
|
||||
gitlab: 'https://git.self.example.com',
|
||||
gitea: 'https://gitea.example.com',
|
||||
});
|
||||
expect(mergeGitProviderApiBaseUrls(undefined, global)).toEqual(global);
|
||||
});
|
||||
|
||||
test('a project override host that is not global classifies the remote as the provider', () => {
|
||||
const override = resolveProjectApiBaseUrls('/repo/sub', projects, overrides);
|
||||
const effective = mergeGitProviderApiBaseUrls(override, emptyInput.apiBaseUrls);
|
||||
const hosts = buildGitProviderHosts({ ...emptyInput, apiBaseUrls: effective });
|
||||
// The override host is added to detection even though nothing global or
|
||||
// account-derived knows it.
|
||||
expect(hosts.gitlab).toEqual(['git.self.example.com']);
|
||||
expect(hosts.github).toEqual([]);
|
||||
expect(detectGitProvider(['git@git.self.example.com:group/repo.git'], hosts)).toBe('gitlab');
|
||||
expect(detectGitProvider(['https://git.self.example.com/group/repo.git'], hosts)).toBe('gitlab');
|
||||
});
|
||||
|
||||
test('global api base urls keep working when the project has no override', () => {
|
||||
const globalApi = { github: '', gitlab: 'https://gitlab.example.com', gitea: '' };
|
||||
const override = resolveProjectApiBaseUrls('/repo/sub', projects, {});
|
||||
const effective = mergeGitProviderApiBaseUrls(override, globalApi);
|
||||
const hosts = buildGitProviderHosts({ ...emptyInput, apiBaseUrls: effective });
|
||||
expect(detectGitProvider(['git@gitlab.example.com:group/repo.git'], hosts)).toBe('gitlab');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { parseGitHost } from '@/lib/gitHost';
|
||||
import { getRemotes } from '@/lib/gitApi';
|
||||
import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore';
|
||||
import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import {
|
||||
mergeGitProviderApiBaseUrls,
|
||||
resolveProjectApiBaseUrls,
|
||||
} from '@/lib/projectGitProviders';
|
||||
import {
|
||||
useGitProviderDomainsStore,
|
||||
normalizeProviderDomain,
|
||||
@@ -134,8 +140,20 @@ export const detectGitProvider = (fetchUrls: string[], hosts: GitProviderHosts):
|
||||
const RESOLVE_CACHE_TTL_MS = 60_000;
|
||||
const resolveCache = new Map<string, { at: number; provider: GitProvider | null }>();
|
||||
|
||||
/**
|
||||
* Per-directory detection is memoized for RESOLVE_CACHE_TTL_MS. The cache key
|
||||
* includes the effective detection host sets, so a project override (or any
|
||||
* api base/domain/account change) that alters the hosts invalidates the cached
|
||||
* classification immediately instead of serving a stale provider for up to a
|
||||
* minute. The serialized key is stable across renders because
|
||||
* `buildGitProviderHosts` emits deterministic, deduped host lists.
|
||||
*/
|
||||
const resolveCacheKey = (directory: string, hosts: GitProviderHosts): string =>
|
||||
`${directory}|${JSON.stringify(hosts)}`;
|
||||
|
||||
export const resolveGitProvider = async (directory: string, hosts: GitProviderHosts): Promise<GitProvider | null> => {
|
||||
const cached = resolveCache.get(directory);
|
||||
const cacheKey = resolveCacheKey(directory, hosts);
|
||||
const cached = resolveCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < RESOLVE_CACHE_TTL_MS) {
|
||||
return cached.provider;
|
||||
}
|
||||
@@ -146,7 +164,7 @@ export const resolveGitProvider = async (directory: string, hosts: GitProviderHo
|
||||
} catch {
|
||||
provider = null;
|
||||
}
|
||||
resolveCache.set(directory, { at: Date.now(), provider });
|
||||
resolveCache.set(cacheKey, { at: Date.now(), provider });
|
||||
return provider;
|
||||
};
|
||||
|
||||
@@ -162,9 +180,19 @@ export const useGitProvider = (directory: string | null | undefined): GitProvide
|
||||
const giteaAccounts = useGiteaAuthStore((state) => state.status?.accounts);
|
||||
const domains = useGitProviderDomainsStore((state) => state.domains);
|
||||
const apiBaseUrls = useGitProviderDomainsStore((state) => state.apiBaseUrls);
|
||||
const projectApiBaseUrls = useGitProviderDomainsStore((state) => state.projectApiBaseUrls);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const hosts = useMemo<GitProviderHosts>(
|
||||
() => buildGitProviderHosts({ domains, apiBaseUrls, gitlabAccounts, giteaAccounts }),
|
||||
[domains, apiBaseUrls, gitlabAccounts, giteaAccounts],
|
||||
() => {
|
||||
// Precedence per provider: project override > global server settings.
|
||||
// The merged api base urls flow into buildGitProviderHosts, whose
|
||||
// apiBaseHost handling then auto-adds the override host to detection.
|
||||
const projectOverride = resolveProjectApiBaseUrls(directory, projects, projectApiBaseUrls, worktreesByProject);
|
||||
const effectiveApiBaseUrls = mergeGitProviderApiBaseUrls(projectOverride, apiBaseUrls);
|
||||
return buildGitProviderHosts({ domains, apiBaseUrls: effectiveApiBaseUrls, gitlabAccounts, giteaAccounts });
|
||||
},
|
||||
[directory, projects, projectApiBaseUrls, worktreesByProject, domains, apiBaseUrls, gitlabAccounts, giteaAccounts],
|
||||
);
|
||||
const [provider, setProvider] = useState<GitProvider | null>(null);
|
||||
|
||||
|
||||
@@ -1124,6 +1124,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'Benutzerdefiniertes Symbol bereits für dieses Projekt festgelegt',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Projekt-Symbol gefunden',
|
||||
'settings.projects.page.toast.saveFailed': 'Fehler beim Speichern der Projekteinstellungen',
|
||||
'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.usage.sidebar.title': 'Nutzung',
|
||||
'settings.usage.sidebar.total': 'Gesamt {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Automatisches Aktualisieren umschalten',
|
||||
|
||||
@@ -1186,6 +1186,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'Custom icon already set for this project',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Project icon discovered',
|
||||
'settings.projects.page.toast.saveFailed': 'Failed to save project settings',
|
||||
'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.usage.sidebar.title': 'Usage',
|
||||
'settings.usage.sidebar.total': 'Total {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Toggle auto refresh',
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
"settings.projects.page.toast.customIconAlreadySet": "Icono personalizado ya establecido para este proyecto",
|
||||
"settings.projects.page.toast.iconDiscovered": "Icono del proyecto descubierto",
|
||||
"settings.projects.page.toast.saveFailed": "Error al guardar la configuración del proyecto",
|
||||
"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.usage.sidebar.title": "Uso",
|
||||
"settings.usage.sidebar.total": "Total {count}",
|
||||
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar refresco automático",
|
||||
|
||||
@@ -1072,6 +1072,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'Icône personnalisée déjà définie pour ce projet',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Icône de projet découverte',
|
||||
'settings.projects.page.toast.saveFailed': 'Échec de l\'enregistrement des paramètres du projet',
|
||||
'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.usage.sidebar.title': 'Usage',
|
||||
'settings.usage.sidebar.total': 'Total {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Activer l\'actualisation automatique',
|
||||
|
||||
@@ -1187,6 +1187,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': 'このプロジェクトには既にカスタムアイコンが設定されています',
|
||||
'settings.projects.page.toast.iconDiscovered': 'プロジェクトアイコンを検出しました',
|
||||
'settings.projects.page.toast.saveFailed': 'プロジェクト設定の保存に失敗しました',
|
||||
'settings.projects.page.gitProviders.title': 'Git プロバイダーの API ベース URL',
|
||||
'settings.projects.page.gitProviders.description': 'このプロジェクトの API ベース URL をグローバル設定で上書きします。未設定の場合はグローバル設定が使用されます。',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '継承: {url}',
|
||||
'settings.usage.sidebar.title': '使用量',
|
||||
'settings.usage.sidebar.total': '合計 {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '自動更新の切替',
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': '이 프로젝트에는 이미 사용자 정의 아이콘이 설정되어 있습니다',
|
||||
'settings.projects.page.toast.iconDiscovered': '프로젝트 아이콘을 찾았습니다',
|
||||
'settings.projects.page.toast.saveFailed': '프로젝트 설정 저장에 실패했습니다',
|
||||
'settings.projects.page.gitProviders.title': 'Git 공급자 API 기본 URL',
|
||||
'settings.projects.page.gitProviders.description': '이 프로젝트의 API 기본 URL을 전역 설정으로 재정의합니다. 설정하지 않으면 전역 설정이 사용됩니다.',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '상속: {url}',
|
||||
'settings.usage.sidebar.title': '사용량',
|
||||
'settings.usage.sidebar.total': '총 {count}개',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '자동 새로고침 토글',
|
||||
|
||||
@@ -1448,6 +1448,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.discoverIconFailed': 'Nie udało się wykryć ikony projektu',
|
||||
'settings.projects.page.toast.iconDiscovered': 'Wykryto ikonę projektu',
|
||||
'settings.projects.page.toast.saveFailed': 'Nie udało się zapisać ustawień projektu',
|
||||
'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.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',
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
"settings.projects.page.toast.customIconAlreadySet": "Ícone personalizado já definido para este projeto",
|
||||
"settings.projects.page.toast.iconDiscovered": "Ícone do projeto descoberto",
|
||||
"settings.projects.page.toast.saveFailed": "Falha ao salvar as configurações do projeto",
|
||||
"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.usage.sidebar.title": "Uso",
|
||||
"settings.usage.sidebar.total": "Total {count}",
|
||||
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Alternar atualização automática",
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
"settings.projects.page.toast.customIconAlreadySet": "Спеціальна піктограма вже встановлена для цього проєкту",
|
||||
"settings.projects.page.toast.iconDiscovered": "Виявлено значок проєкту",
|
||||
"settings.projects.page.toast.saveFailed": "Не вдалося зберегти налаштування проєкту",
|
||||
"settings.projects.page.gitProviders.title": "Базові URL-адреси API постачальників Git",
|
||||
"settings.projects.page.gitProviders.description": "Перевизначає глобальну базову URL-адресу API для цього проєкту. Якщо не задано, використовується глобальне значення.",
|
||||
"settings.projects.page.gitProviders.inheritsGlobal": "Успадковує: {url}",
|
||||
"settings.usage.sidebar.title": "Використання",
|
||||
"settings.usage.sidebar.total": "Усього {count}",
|
||||
"settings.usage.sidebar.actions.toggleAutoRefreshAria": "Увімкнути автоматичне оновлення",
|
||||
|
||||
@@ -1154,6 +1154,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': '该项目已设置自定义图标',
|
||||
'settings.projects.page.toast.iconDiscovered': '项目图标已发现',
|
||||
'settings.projects.page.toast.saveFailed': '保存项目设置失败',
|
||||
'settings.projects.page.gitProviders.title': 'Git 提供商的 API 基础 URL',
|
||||
'settings.projects.page.gitProviders.description': '为此项目覆盖全局 API 基础 URL。未设置时使用全局配置。',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '继承:{url}',
|
||||
'settings.usage.sidebar.title': '用量',
|
||||
'settings.usage.sidebar.total': '总计 {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切换自动刷新',
|
||||
|
||||
@@ -1061,6 +1061,9 @@ export const settingsDict = {
|
||||
'settings.projects.page.toast.customIconAlreadySet': '該專案已設定自訂圖示',
|
||||
'settings.projects.page.toast.iconDiscovered': '專案圖示已發現',
|
||||
'settings.projects.page.toast.saveFailed': '儲存專案設定失敗',
|
||||
'settings.projects.page.gitProviders.title': 'Git 提供者的 API 基礎 URL',
|
||||
'settings.projects.page.gitProviders.description': '為此專案覆寫全域 API 基礎 URL。未設定時使用全域設定。',
|
||||
'settings.projects.page.gitProviders.inheritsGlobal': '繼承:{url}',
|
||||
'settings.usage.sidebar.title': '用量',
|
||||
'settings.usage.sidebar.total': '總計 {count}',
|
||||
'settings.usage.sidebar.actions.toggleAutoRefreshAria': '切換自動重新整理',
|
||||
|
||||
@@ -40,6 +40,8 @@ interface OpenChamberConfig {
|
||||
projectActions?: OpenChamberProjectAction[];
|
||||
projectActionsPrimaryId?: string;
|
||||
draftStarters?: DraftStarterRef[];
|
||||
/** Written by the server via the git-providers route; the client must preserve it. */
|
||||
gitProviders?: unknown;
|
||||
}
|
||||
|
||||
type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
|
||||
@@ -620,9 +622,10 @@ async function readOpenChamberConfig(project: ProjectRef): Promise<OpenChamberCo
|
||||
/**
|
||||
* Write the per-user config for a project.
|
||||
*
|
||||
* Server owns `version` and `scheduledTasks` keys; client reads them via their
|
||||
* dedicated route and never round-trips them through this config write path to
|
||||
* avoid a read-then-write race clobbering a concurrent server update.
|
||||
* Server owns `version`, `scheduledTasks`, and `gitProviders` keys; client
|
||||
* reads them via their dedicated routes and never round-trips them through
|
||||
* this config write path to avoid a read-then-write race clobbering a
|
||||
* concurrent server update.
|
||||
*/
|
||||
async function writeOpenChamberConfig(
|
||||
project: ProjectRef,
|
||||
@@ -661,6 +664,7 @@ async function writeOpenChamberConfig(
|
||||
const serverOwned: Record<string, unknown> = {};
|
||||
if (existing.version !== undefined) serverOwned.version = existing.version;
|
||||
if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks;
|
||||
if (existing.gitProviders !== undefined) serverOwned.gitProviders = existing.gitProviders;
|
||||
|
||||
const content = JSON.stringify({
|
||||
...existing,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
normalizeApiBaseUrl,
|
||||
normalizeDomainList,
|
||||
} from '@/stores/useGitProviderDomainsStore';
|
||||
import { getProjectGitProviders, resolveProjectIdForDirectory } from '@/lib/projectGitProviders';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import {
|
||||
DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
isFollowUpBehavior,
|
||||
@@ -1847,6 +1851,30 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
} catch (error) {
|
||||
console.warn('applyGitProviderSettings failed:', error);
|
||||
}
|
||||
try {
|
||||
// Per-project git provider api base url overrides are also
|
||||
// server-authoritative. Resolve the active project with the same
|
||||
// directory resolution used by git provider detection (so hydration and
|
||||
// detection key by the same project id) and fetch its override
|
||||
// best-effort; failures are ignored and prior state is preserved.
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const activeProjectId = projectsState.getActiveProject()?.id ?? null;
|
||||
const projectId = activeProjectId
|
||||
?? resolveProjectIdForDirectory(
|
||||
useDirectoryStore.getState().currentDirectory,
|
||||
projectsState.projects,
|
||||
useSessionUIStore.getState().availableWorktreesByProject,
|
||||
);
|
||||
if (projectId) {
|
||||
void getProjectGitProviders(projectId)
|
||||
.then(({ gitProviders }) => {
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer(projectId, gitProviders);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('applyProjectGitProviderSettings failed:', error);
|
||||
}
|
||||
const migrationPatch: Partial<DesktopSettings> = {};
|
||||
if (shouldPersistCraftGoalMigration) {
|
||||
if (authoritativeSettings.draftStarters) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import type { GitProviderApiBaseUrls } from '@/stores/useGitProviderDomainsStore';
|
||||
|
||||
const gitProvidersRoute = (projectId: string): string =>
|
||||
`/api/projects/${encodeURIComponent(projectId)}/git-providers`;
|
||||
|
||||
/**
|
||||
* Read the server-authoritative per-project git provider config for a project.
|
||||
* Fails closed: any non-OK, malformed, or unparseable response collapses to
|
||||
* `{ gitProviders: {} }` and this never throws. Callers treat the empty object
|
||||
* as "no project override", which is the conservative default — an unreachable
|
||||
* server must never widen provider detection.
|
||||
*/
|
||||
export const getProjectGitProviders = async (projectId: string): Promise<{ gitProviders: unknown }> => {
|
||||
if (!projectId) {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch(gitProvidersRoute(projectId), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as { gitProviders?: unknown } | null;
|
||||
if (!payload || typeof payload !== 'object' || !('gitProviders' in payload)) {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
return { gitProviders: payload.gitProviders };
|
||||
} catch {
|
||||
return { gitProviders: {} };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the full per-project git provider config. The server replaces the
|
||||
* whole `gitProviders` key on PUT, so every provider must be sent together.
|
||||
* Returns true only on an OK response; never throws.
|
||||
*/
|
||||
export const saveProjectGitProviders = async (projectId: string, gitProviders: unknown): Promise<boolean> => {
|
||||
if (!projectId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch(gitProvidersRoute(projectId), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ gitProviders }),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type ResolvableProject = { id: string; path: string };
|
||||
|
||||
/** Same shape as `useSessionUIStore`'s `availableWorktreesByProject`. */
|
||||
export type WorktreesByProject = ReadonlyMap<string, ReadonlyArray<{ path?: string }>>;
|
||||
|
||||
const isNormalizedPathWithinProject = (directory: string, projectPath: string): boolean => {
|
||||
if (directory === projectPath) return true;
|
||||
if (projectPath === '/') return directory.startsWith('/');
|
||||
return directory.startsWith(`${projectPath}/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* A directory that is a known worktree resolves to its owning project, no
|
||||
* matter where the worktree lives (git worktrees can be created outside the
|
||||
* project root). Mirrors the sidebar's `worktreeInfoByPath` lookup: exact
|
||||
* normalized path match, then exact normalized owning-project match.
|
||||
*/
|
||||
const resolveWorktreeOwner = (
|
||||
normalizedDirectory: string,
|
||||
worktreesByProject: WorktreesByProject,
|
||||
projects: ResolvableProject[],
|
||||
): ResolvableProject | null => {
|
||||
for (const [projectPath, worktrees] of worktreesByProject) {
|
||||
const normalizedProjectPath = normalizePath(projectPath);
|
||||
if (!normalizedProjectPath) continue;
|
||||
for (const worktree of worktrees) {
|
||||
if (normalizePath(worktree.path) !== normalizedDirectory) continue;
|
||||
const exact = projects.find((project) => normalizePath(project.path) === normalizedProjectPath);
|
||||
if (exact) return exact;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which project owns a directory, mirroring the sidebar's
|
||||
* `findProjectForDirectory` precedence: a known worktree directory resolves to
|
||||
* its owning project first (worktrees may live outside the project root),
|
||||
* then the longest normalized project path that contains the directory wins.
|
||||
* When no registered project matches, fall back to the deterministic
|
||||
* path-derived project id (`createProjectIdFromPath`) so overrides still key
|
||||
* correctly for unregistered/not-yet-indexed directories. Returns null for an
|
||||
* empty directory.
|
||||
*/
|
||||
export const resolveProjectIdForDirectory = (
|
||||
directory: string | null | undefined,
|
||||
projects: ResolvableProject[],
|
||||
worktreesByProject?: WorktreesByProject,
|
||||
): string | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!normalizedDirectory) return null;
|
||||
if (worktreesByProject) {
|
||||
const worktreeOwner = resolveWorktreeOwner(normalizedDirectory, worktreesByProject, projects);
|
||||
if (worktreeOwner) return worktreeOwner.id;
|
||||
}
|
||||
let best: { id: string; normalizedPath: string } | null = null;
|
||||
for (const project of projects) {
|
||||
const normalizedPath = normalizePath(project.path);
|
||||
if (!normalizedPath) continue;
|
||||
if (!isNormalizedPathWithinProject(normalizedDirectory, normalizedPath)) continue;
|
||||
if (!best || normalizedPath.length > best.normalizedPath.length) {
|
||||
best = { id: project.id, normalizedPath };
|
||||
}
|
||||
}
|
||||
if (best) return best.id;
|
||||
const fallbackId = createProjectIdFromPath(directory ?? '');
|
||||
return fallbackId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-project api base url override for the directory's owning project, or
|
||||
* undefined when the directory has no resolvable project id.
|
||||
*/
|
||||
export const resolveProjectApiBaseUrls = (
|
||||
directory: string | null | undefined,
|
||||
projects: ResolvableProject[],
|
||||
projectApiBaseUrls: Record<string, GitProviderApiBaseUrls>,
|
||||
worktreesByProject?: WorktreesByProject,
|
||||
): GitProviderApiBaseUrls | undefined => {
|
||||
const projectId = resolveProjectIdForDirectory(directory, projects, worktreesByProject);
|
||||
return projectId ? projectApiBaseUrls[projectId] : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge a project-scoped override over the global settings api base urls.
|
||||
* Non-empty project values win; empty override slots fall back to the global
|
||||
* settings value (precedence: project override > global server settings).
|
||||
*/
|
||||
export const mergeGitProviderApiBaseUrls = (
|
||||
projectOverride: GitProviderApiBaseUrls | undefined,
|
||||
globalApiBaseUrls: GitProviderApiBaseUrls,
|
||||
): GitProviderApiBaseUrls => ({
|
||||
github: projectOverride?.github || globalApiBaseUrls.github,
|
||||
gitlab: projectOverride?.gitlab || globalApiBaseUrls.gitlab,
|
||||
gitea: projectOverride?.gitea || globalApiBaseUrls.gitea,
|
||||
});
|
||||
@@ -611,6 +611,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
titleKey: 'settings.openchamber.worktrees.setup.waitForCommands',
|
||||
keywords: ['worktree', 'setup commands', 'bootstrap', 'wait'],
|
||||
},
|
||||
{
|
||||
id: 'projects.git-providers',
|
||||
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'],
|
||||
},
|
||||
{
|
||||
id: 'remote-instances.client-auth',
|
||||
page: 'remote-instances',
|
||||
|
||||
@@ -5,6 +5,7 @@ const resetDomains = () => {
|
||||
useGitProviderDomainsStore.setState({
|
||||
domains: { github: [], gitlab: [], gitea: [] },
|
||||
apiBaseUrls: { github: '', gitlab: '', gitea: '' },
|
||||
projectApiBaseUrls: {},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -135,3 +136,85 @@ describe('useGitProviderDomainsStore', () => {
|
||||
expect(domains.gitea).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGitProviderDomainsStore per-project overrides', () => {
|
||||
test('hydrateProjectFromServer stores a normalized per-project override', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
|
||||
github: { apiBaseUrl: 'https://git.self.example.com/api/v3/', detectUrls: ['git.self.example.com'] },
|
||||
});
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({
|
||||
path_proj: { github: 'https://git.self.example.com/api/v3', gitlab: '', gitea: '' },
|
||||
});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer drops the key when all api base urls are empty', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
|
||||
github: { apiBaseUrl: 'https://git.self.example.com/api/v3' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', {
|
||||
github: { apiBaseUrl: '' },
|
||||
});
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer with malformed config stores nothing', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', { github: 'not an object' });
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_proj', undefined);
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer keeps unrelated projects and global slices untouched', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().setDomains('github', ['github.example.com']);
|
||||
useGitProviderDomainsStore.getState().setApiBaseUrl('github', 'https://github.example.com/api/v3');
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_a', {
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_b', {
|
||||
gitea: { apiBaseUrl: 'https://gitea.example.com' },
|
||||
});
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({
|
||||
path_a: { github: '', gitlab: 'https://gitlab.example.com', gitea: '' },
|
||||
path_b: { github: '', gitlab: '', gitea: 'https://gitea.example.com' },
|
||||
});
|
||||
// Global slices are untouched by per-project hydration.
|
||||
const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState();
|
||||
expect(domains.github).toEqual(['github.example.com']);
|
||||
expect(apiBaseUrls.github).toBe('https://github.example.com/api/v3');
|
||||
});
|
||||
|
||||
test('clearProjectGitProviders removes only the given project key', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_a', {
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_b', {
|
||||
gitea: { apiBaseUrl: 'https://gitea.example.com' },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().clearProjectGitProviders('path_a');
|
||||
expect(useGitProviderDomainsStore.getState().projectApiBaseUrls).toEqual({
|
||||
path_b: { github: '', gitlab: '', gitea: 'https://gitea.example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
test('hydrateProjectFromServer does not disturb global hydrateFromServer behavior', () => {
|
||||
resetDomains();
|
||||
useGitProviderDomainsStore.getState().hydrateFromServer({
|
||||
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
|
||||
});
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer('path_a', {
|
||||
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
||||
});
|
||||
const state = useGitProviderDomainsStore.getState();
|
||||
expect(state.apiBaseUrls.github).toBe('https://github.example.com/api/v3');
|
||||
expect(state.domains.github).toEqual(['github.example.com']);
|
||||
expect(state.projectApiBaseUrls.path_a).toEqual({
|
||||
github: '',
|
||||
gitlab: 'https://gitlab.example.com',
|
||||
gitea: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,10 +103,20 @@ const normalizeGitProvidersConfig = (config: unknown): {
|
||||
type GitProviderDomainsStore = {
|
||||
domains: GitProviderDomains;
|
||||
apiBaseUrls: GitProviderApiBaseUrls;
|
||||
/**
|
||||
* Per-project api base url overrides, keyed by project id. Server-authoritative
|
||||
* and intentionally NOT persisted (the server owns the config file); hydrated
|
||||
* on demand and cleared when the override is removed server-side.
|
||||
*/
|
||||
projectApiBaseUrls: Record<string, GitProviderApiBaseUrls>;
|
||||
setDomains: (provider: GitProviderName, domains: string[]) => void;
|
||||
setApiBaseUrl: (provider: GitProviderName, url: string) => void;
|
||||
/** Apply the server's `gitProviders` settings, keeping the server authoritative. */
|
||||
hydrateFromServer: (gitProvidersConfig?: unknown) => void;
|
||||
/** Store one project's server-authoritative git provider override; drops the key when empty. */
|
||||
hydrateProjectFromServer: (projectId: string, config?: unknown) => void;
|
||||
/** Remove a project's override entry. */
|
||||
clearProjectGitProviders: (projectId: string) => void;
|
||||
};
|
||||
|
||||
export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||
@@ -114,6 +124,7 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||
(set, get) => ({
|
||||
domains: EMPTY_DOMAINS,
|
||||
apiBaseUrls: EMPTY_API_BASE_URLS,
|
||||
projectApiBaseUrls: {},
|
||||
setDomains: (provider, domains) => {
|
||||
set({
|
||||
domains: {
|
||||
@@ -146,6 +157,36 @@ export const useGitProviderDomainsStore = create<GitProviderDomainsStore>()(
|
||||
}
|
||||
set({ domains, apiBaseUrls } as Partial<GitProviderDomainsStore>);
|
||||
},
|
||||
hydrateProjectFromServer: (projectId, config) => {
|
||||
if (!projectId) return;
|
||||
const { apiBaseUrls } = normalizeGitProvidersConfig(config);
|
||||
const hasAny = Boolean(apiBaseUrls.github || apiBaseUrls.gitlab || apiBaseUrls.gitea);
|
||||
const current = get().projectApiBaseUrls[projectId];
|
||||
const unchanged = hasAny
|
||||
? current !== undefined
|
||||
&& current.github === apiBaseUrls.github
|
||||
&& current.gitlab === apiBaseUrls.gitlab
|
||||
&& current.gitea === apiBaseUrls.gitea
|
||||
: current === undefined;
|
||||
if (unchanged) return;
|
||||
set((state) => {
|
||||
const next = { ...state.projectApiBaseUrls };
|
||||
if (hasAny) {
|
||||
next[projectId] = apiBaseUrls;
|
||||
} else {
|
||||
delete next[projectId];
|
||||
}
|
||||
return { projectApiBaseUrls: next };
|
||||
});
|
||||
},
|
||||
clearProjectGitProviders: (projectId) => {
|
||||
if (!projectId || get().projectApiBaseUrls[projectId] === undefined) return;
|
||||
set((state) => {
|
||||
const next = { ...state.projectApiBaseUrls };
|
||||
delete next[projectId];
|
||||
return { projectApiBaseUrls: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: DOMAINS_STORAGE_KEY,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getVSCodeBootstrapConfig, isVSCodeRuntime } from './utils/vscodeRuntime';
|
||||
import { getProjectGitProviders } from '@/lib/projectGitProviders';
|
||||
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
|
||||
|
||||
/** Pick a color key that's least used among existing projects */
|
||||
const pickAutoColor = (projects: ProjectEntry[]): string => {
|
||||
@@ -677,6 +679,14 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
|
||||
opencodeClient.setDirectory(target.path);
|
||||
useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false });
|
||||
|
||||
// Best-effort: hydrate the newly active project's git provider override
|
||||
// so detection picks up its self-hosted api base host right away.
|
||||
void getProjectGitProviders(id)
|
||||
.then(({ gitProviders }) => {
|
||||
useGitProviderDomainsStore.getState().hydrateProjectFromServer(id, gitProviders);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
|
||||
setActiveProjectIdOnly: (id: string) => {
|
||||
|
||||
Reference in New Issue
Block a user