feat: add repository settings registration

This commit is contained in:
2026-08-31 18:20:17 +09:00
parent 7d09b20445
commit 310801a29b
7 changed files with 365 additions and 1 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repository-access/ui.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -53,6 +53,19 @@ Deno.test("settings shell advertises scoped account authority", () => {
); );
}); });
Deno.test("Repository settings expose the canonical list and Add route", () => {
const section = SETTINGS_SECTIONS.find((entry) => entry.id === "repositories");
assert(section?.status === "editable", "Repositories should be editable");
assert(
settingsSectionHref("repositories") === "/settings/repositories",
"Repositories should have a dedicated settings route",
);
assert(
section?.bullets.join("\n").includes("without network access"),
"Repository registration copy should preserve the no-network boundary",
);
});
Deno.test("Repository access settings are editable and canonically routed", () => { Deno.test("Repository access settings are editable and canonically routed", () => {
const section = SETTINGS_SECTIONS.find((entry) => const section = SETTINGS_SECTIONS.find((entry) =>
entry.id === "repository-access" entry.id === "repository-access"
@@ -7,6 +7,7 @@ export type Diagnostic = {
export type SettingsSectionId = export type SettingsSectionId =
| "runtimes" | "runtimes"
| "configuration-sources" | "configuration-sources"
| "repositories"
| "repository-access" | "repository-access"
| "profile-sources" | "profile-sources"
| "workspace-identity"; | "workspace-identity";
@@ -54,6 +55,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.", "Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
], ],
}, },
{
id: "repositories",
label: "Repositories",
status: "editable",
summary:
"Register the local and remote Git repositories available to this Workspace.",
bullets: [
"Repository identity and source registration are Workspace-scoped Server authority.",
"Adding a Repository performs validation and persistence without network access.",
"SSH credentials and pinned host trust remain separate Repository Access resources.",
],
},
{ {
id: "repository-access", id: "repository-access",
label: "Repository Access", label: "Repository Access",
@@ -116,6 +129,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}/runtimes`; return `${SETTINGS_ROUTE}/runtimes`;
case "configuration-sources": case "configuration-sources":
return `${SETTINGS_ROUTE}/configuration`; return `${SETTINGS_ROUTE}/configuration`;
case "repositories":
return `${SETTINGS_ROUTE}/repositories`;
case "repository-access": case "repository-access":
return `${SETTINGS_ROUTE}/repository-access`; return `${SETTINGS_ROUTE}/repository-access`;
case "profile-sources": case "profile-sources":
@@ -680,6 +680,96 @@
} }
} }
.repositories-page {
display: grid;
gap: var(--space-5);
}
.settings-repository-form {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-form h2 {
margin: 0;
}
.settings-repository-form label {
display: grid;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
}
.settings-repository-form input {
min-width: 0;
border: 1px solid var(--line);
border-radius: 0.45rem;
background: var(--bg);
color: var(--text-strong);
padding: 0.65rem 0.75rem;
font: inherit;
font-family: var(--font-mono);
}
.settings-repository-form small,
.settings-repository-table small {
color: var(--text-muted);
font-weight: 400;
}
.settings-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.settings-form-field-wide {
grid-column: 1 / -1;
}
.settings-repository-table-wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-repository-table {
width: 100%;
border-collapse: collapse;
}
.settings-repository-table th,
.settings-repository-table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.settings-repository-table th {
color: var(--text-muted);
font-size: 0.72rem;
text-transform: uppercase;
}
.settings-repository-table tbody tr:last-child td {
border-bottom: 0;
}
.settings-repository-table td:first-child,
.settings-repository-table td:nth-child(2) {
display: grid;
gap: var(--space-1);
}
.settings-repository-table code {
overflow-wrap: anywhere;
}
.settings-muted-action {
color: var(--text-muted);
}
@media (max-width: 48rem) {
.settings-form-grid {
grid-template-columns: 1fr;
}
.settings-form-field-wide {
grid-column: auto;
}
}
.status-message.error { .status-message.error {
color: var(--danger); color: var(--danger);
} }
@@ -0,0 +1,152 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { workspaceApiPath, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let showAddRepository = $state(false);
let repositoryId = $state('');
let displayName = $state('');
let source = $state('');
let defaultRef = $state('');
let pending = $state(false);
let requestError = $state<string | null>(null);
async function responseError(response: Response): Promise<string> {
const payload = await response.json().catch(() => null) as
| { message?: string; error?: string }
| null;
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
}
async function addRepository(event: SubmitEvent): Promise<void> {
event.preventDefault();
pending = true;
requestError = null;
try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/repositories'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repository_id: repositoryId,
display_name: displayName,
source,
default_ref: defaultRef || null,
}),
});
if (!response.ok) throw new Error(await responseError(response));
repositoryId = '';
displayName = '';
source = '';
defaultRef = '';
showAddRepository = false;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
pending = false;
}
}
</script>
<svelte:head>
<title>Repositories · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Repository resources" />
</svelte:head>
<section class="repositories-page" aria-labelledby="repositories-heading">
<header class="page-header-row">
<div>
<p class="eyebrow">owner only</p>
<h1 id="repositories-heading">Repositories</h1>
<p>Register the local and remote Git repositories available to this Workspace.</p>
</div>
<button type="button" onclick={() => showAddRepository = !showAddRepository}>
{showAddRepository ? 'Close' : 'Add Repository'}
</button>
</header>
{#if showAddRepository}
<form class="settings-repository-form" onsubmit={addRepository}>
<h2>Add Repository</h2>
<div class="settings-form-grid">
<label>
Repository ID
<input bind:value={repositoryId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" autocomplete="off" />
</label>
<label>
Display name
<input bind:value={displayName} required maxlength="256" autocomplete="off" />
</label>
<label class="settings-form-field-wide">
Source
<input bind:value={source} required autocomplete="off" placeholder="/absolute/path or ssh://git@example.test/org/repository.git" />
<small>Registration validates the source without accessing the filesystem or network.</small>
</label>
<label>
Default ref
<input bind:value={defaultRef} maxlength="512" autocomplete="off" placeholder="main" />
</label>
</div>
<p class="status-message">
SSH credentials and pinned host keys are managed separately in
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Repository Access</a>.
</p>
<div class="settings-action-row">
<button type="submit" disabled={pending}>{pending ? 'Adding…' : 'Add Repository'}</button>
<button type="button" disabled={pending} onclick={() => showAddRepository = false}>Cancel</button>
</div>
</form>
{/if}
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if data.repositoriesError}
<p class="section-state error">{data.repositoriesError}</p>
{:else if !data.repositories}
<p class="section-state">Loading Repositories…</p>
{:else if data.repositories.items.length === 0}
<p class="section-state">No Repositories are registered.</p>
{:else}
<div class="settings-repository-table-wrap">
<table class="settings-repository-table">
<thead>
<tr>
<th>Repository</th>
<th>Source</th>
<th>Default ref</th>
<th>Status</th>
<th>Access</th>
</tr>
</thead>
<tbody>
{#each data.repositories.items as repository (repository.repository_id)}
<tr>
<td>
<a class="inline-link" href={workspaceRoute(data.workspaceId, `/repositories/${encodeURIComponent(repository.repository_id)}`)}>
<strong>{repository.display_name}</strong>
</a>
<small><code>{repository.repository_id}</code></small>
</td>
<td>
<span>{repository.source.kind === 'local_path' ? 'Local' : 'Remote Git'}</span>
<small><code>{repository.source.uri}</code></small>
</td>
<td>{repository.default_selector ?? '—'}</td>
<td>{repository.observed.status}</td>
<td>
{#if repository.source.kind === 'remote_git'}
<a class="inline-link" href={workspaceRoute(data.workspaceId, '/settings/repository-access')}>Configure access</a>
{:else}
<span class="settings-muted-action">Not required</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
@@ -0,0 +1,38 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { PageLoad } from "./$types";
export type RepositorySource = {
kind: "local_path" | "remote_git";
uri: string;
};
export type RepositorySummary = {
repository_id: string;
display_name: string;
kind: string;
provider: string;
source: RepositorySource;
default_selector?: string | null;
observed: {
status: string;
observed_at?: string | null;
};
diagnostics: Array<{ code: string; message: string }>;
};
type RepositoryListResponse = {
items: RepositorySummary[];
diagnostics: Array<{ code: string; message: string }>;
};
export const load: PageLoad = async ({ fetch, params }) => {
const repositories = await loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
);
return {
workspaceId: params.workspaceId,
repositories: repositories.data,
repositoriesError: repositories.error,
};
};
@@ -0,0 +1,56 @@
type TestRegistrar = (name: string, fn: () => void | Promise<void>) => void;
const test =
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const pageSource = await Deno.readTextFile(
new URL(
"../../src/routes/w/[workspaceId]/settings/repositories/+page.svelte",
import.meta.url,
),
);
const loadSource = await Deno.readTextFile(
new URL(
"../../src/routes/w/[workspaceId]/settings/repositories/+page.ts",
import.meta.url,
),
);
test("Repository settings use the scoped list and typed create collection", () => {
for (const token of [
"workspaceApiPath(params.workspaceId, \"/repositories\")",
"workspaceApiPath(data.workspaceId, '/repositories')",
"method: 'POST'",
"repository_id: repositoryId",
"display_name: displayName",
"default_ref: defaultRef || null",
"await invalidateAll()",
]) {
assert(
pageSource.includes(token) || loadSource.includes(token),
`Repository settings should include ${token}`,
);
}
});
test("Repository Add form keeps access secrets outside registration input", () => {
for (const forbidden of [
"private_key",
"passphrase",
"credential_id",
"host_trust_id",
]) {
assert(
!pageSource.includes(forbidden),
`Repository registration must not accept ${forbidden}`,
);
}
assert(
pageSource.includes("/settings/repository-access"),
"remote Repository rows should link to Repository Access",
);
});