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:
2026-08-17 09:57:54 +00:00
parent 697925ee0d
commit 66edc74fac
42 changed files with 1765 additions and 87 deletions
@@ -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>
+126
View File
@@ -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');
});
});
+32 -4
View File
@@ -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': '切換自動重新整理',
+7 -3
View File
@@ -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,
+28
View File
@@ -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) {
+156
View File
@@ -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,
});
+7
View File
@@ -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) => {