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": "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",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user