feat: add repository settings registration
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"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", () => {
|
||||
const section = SETTINGS_SECTIONS.find((entry) =>
|
||||
entry.id === "repository-access"
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Diagnostic = {
|
||||
export type SettingsSectionId =
|
||||
| "runtimes"
|
||||
| "configuration-sources"
|
||||
| "repositories"
|
||||
| "repository-access"
|
||||
| "profile-sources"
|
||||
| "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.",
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
label: "Repository Access",
|
||||
@@ -116,6 +129,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
return `${SETTINGS_ROUTE}/runtimes`;
|
||||
case "configuration-sources":
|
||||
return `${SETTINGS_ROUTE}/configuration`;
|
||||
case "repositories":
|
||||
return `${SETTINGS_ROUTE}/repositories`;
|
||||
case "repository-access":
|
||||
return `${SETTINGS_ROUTE}/repository-access`;
|
||||
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 {
|
||||
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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user