fix: derive repository UI from registry

This commit is contained in:
Keisuke Hirata 2026-07-05 05:10:23 +09:00
parent 2f0d1cee19
commit 14e63ca5b6
No known key found for this signature in database
9 changed files with 242 additions and 35 deletions

View File

@ -1,4 +1,8 @@
use std::{collections::BTreeSet, path::PathBuf, process::Command}; use std::{
collections::BTreeSet,
path::{Path, PathBuf},
process::Command,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -336,15 +340,35 @@ fn parse_git_log(raw: &str) -> Vec<GitCommitSummary> {
} }
fn sanitize_remote_url(url: &str) -> String { fn sanitize_remote_url(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else { let trimmed = url.trim();
return url.to_string(); if is_local_path_like(trimmed) {
return "<redacted-local-path>".to_string();
}
let Some((scheme, rest)) = trimmed.split_once("://") else {
return trimmed.to_string();
}; };
if scheme.eq_ignore_ascii_case("file") {
return "file://<redacted-local-path>".to_string();
}
let Some((_credentials, host_path)) = rest.split_once('@') else { let Some((_credentials, host_path)) = rest.split_once('@') else {
return url.to_string(); return trimmed.to_string();
}; };
format!("{scheme}://<redacted>@{host_path}") format!("{scheme}://<redacted>@{host_path}")
} }
fn is_local_path_like(value: &str) -> bool {
Path::new(value).is_absolute() || is_windows_absolute_path_like(value)
}
fn is_windows_absolute_path_like(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/')
}
fn non_empty_string(value: &str) -> Option<String> { fn non_empty_string(value: &str) -> Option<String> {
if value.is_empty() { if value.is_empty() {
None None
@ -358,7 +382,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn sanitizes_remote_credentials() { fn sanitizes_remote_credentials_and_local_paths() {
assert_eq!( assert_eq!(
sanitize_remote_url("https://user:token@example.com/org/repo.git"), sanitize_remote_url("https://user:token@example.com/org/repo.git"),
"https://<redacted>@example.com/org/repo.git" "https://<redacted>@example.com/org/repo.git"
@ -367,6 +391,26 @@ mod tests {
sanitize_remote_url("git@example.com:org/repo.git"), sanitize_remote_url("git@example.com:org/repo.git"),
"git@example.com:org/repo.git" "git@example.com:org/repo.git"
); );
assert_eq!(
sanitize_remote_url("/home/alice/private/repo.git"),
"<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("/Users/alice/private/repo.git"),
"<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("C:\\Users\\alice\\private\\repo.git"),
"<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("file:///home/alice/private/repo.git"),
"file://<redacted-local-path>"
);
assert_eq!(
sanitize_remote_url("file://localhost/home/alice/private/repo.git"),
"file://<redacted-local-path>"
);
} }
#[test] #[test]

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 -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --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 src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts", "test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.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"
}, },

View File

@ -8,6 +8,7 @@
ObjectiveDetail, ObjectiveDetail,
ObjectiveListResponse, ObjectiveListResponse,
RepositoryDetailResponse, RepositoryDetailResponse,
RepositoryListResponse,
RepositorySummary, RepositorySummary,
RepositoryTicketsResponse, RepositoryTicketsResponse,
Worker, Worker,
@ -25,12 +26,13 @@
let { let {
view = 'overview', view = 'overview',
objectiveId = null, objectiveId = null,
repositoryId = 'main' repositoryId = null
}: { view?: WorkspaceView; repositoryId?: string; objectiveId?: string | null } = $props(); }: { view?: WorkspaceView; repositoryId?: string | null; objectiveId?: string | null } = $props();
let workspace = $state<WorkspaceResponse | null>(null); let workspace = $state<WorkspaceResponse | null>(null);
let hosts = $state<ListResponse<Host> | null>(null); let hosts = $state<ListResponse<Host> | null>(null);
let workers = $state<ListResponse<Worker> | null>(null); let workers = $state<ListResponse<Worker> | null>(null);
let repositories = $state<RepositoryListResponse | null>(null);
let repository = $state<RepositorySummary | null>(null); let repository = $state<RepositorySummary | null>(null);
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null); let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
let objectives = $state<ObjectiveListResponse | null>(null); let objectives = $state<ObjectiveListResponse | null>(null);
@ -39,6 +41,7 @@
let workspaceError = $state<string | null>(null); let workspaceError = $state<string | null>(null);
let hostsError = $state<string | null>(null); let hostsError = $state<string | null>(null);
let workersError = $state<string | null>(null); let workersError = $state<string | null>(null);
let repositoriesError = $state<string | null>(null);
let repositoryError = $state<string | null>(null); let repositoryError = $state<string | null>(null);
let repositoryTicketsError = $state<string | null>(null); let repositoryTicketsError = $state<string | null>(null);
let objectivesError = $state<string | null>(null); let objectivesError = $state<string | null>(null);
@ -86,12 +89,21 @@
} }
} }
async function loadRepository() { async function loadRepositories() {
repositoriesError = null;
try {
repositories = await getJson<RepositoryListResponse>('/api/repositories');
} catch (error) {
repositoriesError = error instanceof Error ? error.message : String(error);
repositories = null;
}
}
async function loadRepository(id: string) {
repositoryError = null; repositoryError = null;
const selectedRepositoryId = route.page === 'repository' ? route.repositoryId : repositoryId;
try { try {
const detail = await getJson<RepositoryDetailResponse>( const detail = await getJson<RepositoryDetailResponse>(
`/api/repositories/${encodeURIComponent(selectedRepositoryId)}` `/api/repositories/${encodeURIComponent(id)}`
); );
repository = detail.item; repository = detail.item;
} catch (error) { } catch (error) {
@ -100,12 +112,11 @@
} }
} }
async function loadRepositoryTickets() { async function loadRepositoryTickets(id: string) {
repositoryTicketsError = null; repositoryTicketsError = null;
const selectedRepositoryId = route.page === 'repository' ? route.repositoryId : repositoryId;
try { try {
repositoryTickets = await getJson<RepositoryTicketsResponse>( repositoryTickets = await getJson<RepositoryTicketsResponse>(
`/api/repositories/${encodeURIComponent(selectedRepositoryId)}/tickets` `/api/repositories/${encodeURIComponent(id)}/tickets`
); );
} catch (error) { } catch (error) {
repositoryTicketsError = error instanceof Error ? error.message : String(error); repositoryTicketsError = error instanceof Error ? error.message : String(error);
@ -147,9 +158,9 @@
function routeFromView( function routeFromView(
view: WorkspaceView, view: WorkspaceView,
objectiveId: string | null, objectiveId: string | null,
repositoryId: string repositoryId: string | null
): RouteState { ): RouteState {
if (view === 'repository') { if (view === 'repository' && repositoryId) {
return { page: 'repository', repositoryId }; return { page: 'repository', repositoryId };
} }
if (view === 'objective' && objectiveId) { if (view === 'objective' && objectiveId) {
@ -182,11 +193,22 @@
void loadWorkspace(); void loadWorkspace();
void loadHosts(); void loadHosts();
void loadWorkers(); void loadWorkers();
void loadRepository(); void loadRepositories();
void loadRepositoryTickets();
void loadObjectives(); void loadObjectives();
}); });
$effect(() => {
if (route.page === 'repository') {
void loadRepository(route.repositoryId);
void loadRepositoryTickets(route.repositoryId);
} else {
repository = null;
repositoryTickets = null;
repositoryError = null;
repositoryTicketsError = null;
}
});
$effect(() => { $effect(() => {
const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null; const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null;
if (selectedObjectiveId) { if (selectedObjectiveId) {
@ -210,7 +232,7 @@
</svelte:head> </svelte:head>
<div class="workspace-layout"> <div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} /> <WorkspaceSidebar {workspace} {workspaceError} {repositories} {repositoriesError} {currentPath} />
<main class="shell"> <main class="shell">
{#if route.page === 'repository'} {#if route.page === 'repository'}

View File

@ -1,26 +1,46 @@
<script lang="ts"> <script lang="ts">
import type { WorkspaceResponse } from './types'; import { projectRepositoryNav } from './repository-nav';
import type { RepositoryListResponse } from './types';
type Props = { type Props = {
workspace: WorkspaceResponse | null; repositories: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string; currentPath?: string;
}; };
let { workspace, currentPath = '/' }: Props = $props(); let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
</script> </script>
<section class="nav-section" aria-labelledby="repositories-heading"> <section class="nav-section" aria-labelledby="repositories-heading">
<div class="section-heading-row"> <div class="section-heading-row">
<h2 id="repositories-heading">repositories</h2> <h2 id="repositories-heading">repositories</h2>
<span class="section-count">1</span> <span class="section-count">{navigation.count}</span>
</div> </div>
{#if repositoriesError}
<p class="nav-empty error">Repository registry unavailable.</p>
{:else if !repositories}
<p class="nav-empty">Loading repositories…</p>
{:else if navigation.items.length === 0}
<p class="nav-empty">No repositories configured.</p>
{#if navigation.diagnostics.length > 0}
<ul class="diagnostics" aria-label="Repository diagnostics">
{#each navigation.diagnostics as diagnostic}
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
{/each}
</ul>
{/if}
{:else}
<ul class="nav-list" aria-label="Repositories"> <ul class="nav-list" aria-label="Repositories">
{#each navigation.items as item (item.id)}
<li> <li>
<a class="nav-item" class:active={currentPath.startsWith('/repositories')} href="/repositories/main"> <a class="nav-item" class:active={item.active} href={item.href} aria-current={item.active ? 'page' : undefined}>
<span class="item-title">main</span> <span class="item-title">{item.title}</span>
<span class="item-meta">configured repository · read-only</span> <span class="item-meta">{item.meta}</span>
</a> </a>
</li> </li>
{/each}
</ul> </ul>
{/if}
</section> </section>

View File

@ -2,15 +2,23 @@
import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte'; import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte';
import type { WorkspaceResponse } from './types'; import type { RepositoryListResponse, WorkspaceResponse } from './types';
type Props = { type Props = {
workspace: WorkspaceResponse | null; workspace: WorkspaceResponse | null;
workspaceError?: string | null; workspaceError?: string | null;
repositories?: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string; currentPath?: string;
}; };
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props(); let {
workspace,
workspaceError = null,
repositories = null,
repositoriesError = null,
currentPath = '/'
}: Props = $props();
let settingsActive = $derived(currentPath.startsWith("/settings")); let settingsActive = $derived(currentPath.startsWith("/settings"));
</script> </script>
@ -43,7 +51,7 @@
</header> </header>
<nav class="sidebar-sections" aria-label="Workspace sections"> <nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {workspace} {currentPath} /> <RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
<ObjectivesNavSection {currentPath} /> <ObjectivesNavSection {currentPath} />
<WorkersNavSection {currentPath} /> <WorkersNavSection {currentPath} />

View File

@ -0,0 +1,70 @@
import { projectRepositoryNav } from "./repository-nav.ts";
import type { RepositoryListResponse } from "./types.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assertEquals<T>(actual: T, expected: T): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
}
}
function repositories(
items: RepositoryListResponse["items"],
): RepositoryListResponse {
return {
workspace_id: "workspace-1",
items,
source: "workspace_backend_config",
diagnostics: [],
};
}
Deno.test("repository nav does not invent main for an empty registry", () => {
const projection = projectRepositoryNav({
workspace_id: "workspace-1",
items: [],
source: "workspace_backend_config",
diagnostics: [
{
code: "repository_config_empty",
severity: "warning",
message: "No repositories configured",
},
],
});
assertEquals(projection.count, 0);
assertEquals(projection.items, []);
assertEquals(projection.diagnostics[0].code, "repository_config_empty");
});
Deno.test("repository nav links configured non-main repository ids", () => {
const projection = projectRepositoryNav(
repositories([
{
id: "infra",
display_name: "Infrastructure",
kind: "git",
provider: "git",
record_authority: "workspace-backend-config",
git: null,
diagnostics: [],
},
]),
"/repositories/infra",
);
assertEquals(projection.count, 1);
assertEquals(projection.items[0], {
id: "infra",
title: "Infrastructure",
href: "/repositories/infra",
meta: "git repository · read-only",
active: true,
});
});

View File

@ -0,0 +1,36 @@
import type { Diagnostic, RepositoryListResponse } from "./types";
export type RepositoryNavItem = {
id: string;
title: string;
href: string;
meta: string;
active: boolean;
};
export type RepositoryNavProjection = {
count: number;
items: RepositoryNavItem[];
diagnostics: Diagnostic[];
};
export function projectRepositoryNav(
repositories: RepositoryListResponse | null,
currentPath = "/",
): RepositoryNavProjection {
const summaries = repositories?.items ?? [];
return {
count: summaries.length,
diagnostics: repositories?.diagnostics ?? [],
items: summaries.map((repository) => {
const href = `/repositories/${encodeURIComponent(repository.id)}`;
return {
id: repository.id,
title: repository.display_name || repository.id,
href,
meta: `${repository.provider} repository · read-only`,
active: currentPath === href || currentPath.startsWith(`${href}/`),
};
}),
};
}

View File

@ -212,6 +212,13 @@ export type GitCommitSummary = {
refs: string[]; refs: string[];
}; };
export type RepositoryListResponse = {
workspace_id: string;
items: RepositorySummary[];
source: string;
diagnostics: Diagnostic[];
};
export type RepositoryDetailResponse = { export type RepositoryDetailResponse = {
workspace_id: string; workspace_id: string;
item: RepositorySummary; item: RepositorySummary;

View File

@ -3,4 +3,4 @@
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte'; import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
</script> </script>
<WorkspacePage view="repository" repositoryId={page.params.repositoryId ?? 'main'} /> <WorkspacePage view="repository" repositoryId={page.params.repositoryId} />