feat: show worker workdirs as repo branches
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,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.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 test/sidebar/worker-actions.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.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-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||||
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.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",
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||||||
const workersNav = await Deno.readTextFile(
|
const workersNav = await Deno.readTextFile(
|
||||||
new URL("../sidebar/WorkersNavSection.svelte", import.meta.url),
|
new URL("../sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
|
const workerWorkdirMeta = await Deno.readTextFile(
|
||||||
|
new URL("../sidebar/worker-workdir-meta.ts", import.meta.url),
|
||||||
|
);
|
||||||
const sidebar = await Deno.readTextFile(
|
const sidebar = await Deno.readTextFile(
|
||||||
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
@@ -256,12 +259,18 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||||||
workersNav.includes("worker.display_name || worker.label") &&
|
workersNav.includes("worker.display_name || worker.label") &&
|
||||||
workersNav.includes("worker-status-dot") &&
|
workersNav.includes("worker-status-dot") &&
|
||||||
workersNav.includes("worker-status-spinner") &&
|
workersNav.includes("worker-status-spinner") &&
|
||||||
workersNav.includes("workdirMeta(worker)") &&
|
workersNav.includes("sidebarWorkdirMeta(worker.workdir_attachments)") &&
|
||||||
workersNav.includes("attachment.alias") &&
|
workersNav.includes("title={workdir.details}") &&
|
||||||
workersNav.includes("attachment.repository_key ?? '—'") &&
|
!workersNav.includes("attachment.alias") &&
|
||||||
workersNav.includes("attachment.working_directory_id") &&
|
workerWorkdirMeta.includes("workdir.current_selector") &&
|
||||||
|
workerWorkdirMeta.includes("workdir.creation_selector") &&
|
||||||
|
workerWorkdirMeta.includes("explicitReferenceFallback(") &&
|
||||||
|
workerWorkdirMeta.includes('"detached"') &&
|
||||||
|
sidebarCss.includes(".worker-nav-meta") &&
|
||||||
|
sidebarCss.includes("text-overflow: ellipsis") &&
|
||||||
|
sidebarCss.includes("white-space: nowrap") &&
|
||||||
!workersNav.includes('aria-disabled="true"'),
|
!workersNav.includes('aria-disabled="true"'),
|
||||||
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata",
|
"Workers sidebar should show truncated repo:branch Workdir metadata with detached fallback and detail-only attachment identifiers",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workersNav.includes("COLLAPSED_WORKER_COUNT = 6") &&
|
workersNav.includes("COLLAPSED_WORKER_COUNT = 6") &&
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
type SidebarWorker,
|
type SidebarWorker,
|
||||||
} from './worker-subscription';
|
} from './worker-subscription';
|
||||||
import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers';
|
import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers';
|
||||||
|
import { sidebarWorkdirMeta } from './worker-workdir-meta';
|
||||||
|
|
||||||
const COLLAPSED_WORKER_COUNT = 6;
|
const COLLAPSED_WORKER_COUNT = 6;
|
||||||
type WorkerActionKind = 'stop' | 'delete';
|
type WorkerActionKind = 'stop' | 'delete';
|
||||||
@@ -37,14 +38,6 @@
|
|||||||
Math.max(0, workers.length - COLLAPSED_WORKER_COUNT),
|
Math.max(0, workers.length - COLLAPSED_WORKER_COUNT),
|
||||||
);
|
);
|
||||||
|
|
||||||
function workdirMeta(worker: SidebarWorker): string {
|
|
||||||
const attachments = worker.workdir_attachments;
|
|
||||||
if (attachments.length === 0) return '—';
|
|
||||||
return attachments
|
|
||||||
.map((attachment) => `${attachment.alias}: ${attachment.repository_key ?? '—'}・${attachment.working_directory_id}`)
|
|
||||||
.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function workerKey(worker: SidebarWorker): string {
|
function workerKey(worker: SidebarWorker): string {
|
||||||
return `${worker.runtime_id}:${worker.worker_id}`;
|
return `${worker.runtime_id}:${worker.worker_id}`;
|
||||||
}
|
}
|
||||||
@@ -180,6 +173,7 @@
|
|||||||
{@const activity = sidebarWorkerActivity(worker)}
|
{@const activity = sidebarWorkerActivity(worker)}
|
||||||
{@const key = workerKey(worker)}
|
{@const key = workerKey(worker)}
|
||||||
{@const label = worker.display_name || worker.label}
|
{@const label = worker.display_name || worker.label}
|
||||||
|
{@const workdir = sidebarWorkdirMeta(worker.workdir_attachments)}
|
||||||
<li class="worker-nav-item" data-worker-actions={key}>
|
<li class="worker-nav-item" data-worker-actions={key}>
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -197,8 +191,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="worker-nav-label">{label}</span>
|
<span class="worker-nav-label">{label}</span>
|
||||||
<small class="worker-nav-meta">
|
<small class="worker-nav-meta" title={workdir.details} aria-label={workdir.details}>
|
||||||
{workdirMeta(worker)}
|
{workdir.text}
|
||||||
</small>
|
</small>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { readable, type Readable } from 'svelte/store';
|
import { type Readable, readable } from "svelte/store";
|
||||||
import type {
|
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api";
|
||||||
SubscriptionWorker,
|
import type { SubscriptionWorker } from "$lib/generated/protocol";
|
||||||
SubscriptionWorkerWorkdirAttachment,
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
} from '$lib/generated/protocol';
|
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs";
|
||||||
import { workspaceMultiplexer } from '$lib/workspace/multiplexer';
|
import { workspaceMultiplexer } from "$lib/workspace/multiplexer";
|
||||||
import {
|
import {
|
||||||
applyWorkspaceWorkersFrame,
|
applyWorkspaceWorkersFrame,
|
||||||
createWorkspaceWorkersProjection,
|
createWorkspaceWorkersProjection,
|
||||||
} from './worker-subscription-model';
|
} from "./worker-subscription-model";
|
||||||
import { liveWorkerState } from './worker-state';
|
import { liveWorkerState } from "./worker-state";
|
||||||
import { compareWorkersForSidebar } from './workers';
|
import type { SidebarWorkdirAttachment } from "./worker-workdir-meta";
|
||||||
import type { Worker } from './types';
|
import { compareWorkersForSidebar } from "./workers";
|
||||||
|
import type { Worker } from "./types";
|
||||||
|
|
||||||
export type SidebarWorker = Omit<Worker, 'workdir_attachments'> & {
|
export type SidebarWorker = Omit<Worker, "workdir_attachments"> & {
|
||||||
workdir_attachments: SubscriptionWorkerWorkdirAttachment[];
|
workdir_attachments: SidebarWorkdirAttachment[];
|
||||||
has_running_internal_workers: boolean;
|
has_running_internal_workers: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +30,9 @@ export function disposeWorkspaceWorkersStore(workspaceId: string): void {
|
|||||||
stores.delete(workspaceId);
|
stores.delete(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWorkersState> {
|
export function workspaceWorkersStore(
|
||||||
|
workspaceId: string,
|
||||||
|
): Readable<WorkspaceWorkersState> {
|
||||||
const cached = stores.get(workspaceId);
|
const cached = stores.get(workspaceId);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const store = readable<WorkspaceWorkersState>(
|
const store = readable<WorkspaceWorkersState>(
|
||||||
@@ -40,48 +43,113 @@ export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projection = createWorkspaceWorkersProjection();
|
const projection = createWorkspaceWorkersProjection();
|
||||||
const publish = (loading = false, error: string | null = null) => {
|
let workdirs = new Map<string, WorkingDirectorySummary>();
|
||||||
|
let loading = true;
|
||||||
|
let error: string | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
let workdirRequest = 0;
|
||||||
|
const publish = () => {
|
||||||
const workers = [...projection.workers.values()]
|
const workers = [...projection.workers.values()]
|
||||||
.map(projectWorker)
|
.map((worker) => projectWorker(worker, workdirs))
|
||||||
.sort(compareWorkersForSidebar);
|
.sort(compareWorkersForSidebar);
|
||||||
set({ loading, error, workers });
|
set({ loading, error, workers });
|
||||||
};
|
};
|
||||||
const subscription = workspaceMultiplexer(workspaceId).subscribe(
|
const refreshWorkdirs = async () => {
|
||||||
{ topic: 'workspace_workers' },
|
const request = ++workdirRequest;
|
||||||
|
const result = await loadJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(workspaceId, "/working-directories"),
|
||||||
|
undefined,
|
||||||
|
parseWorkingDirectoryListResponse,
|
||||||
|
);
|
||||||
|
if (disposed || request !== workdirRequest || !result.data) return;
|
||||||
|
workdirs = new Map(
|
||||||
|
result.data.items.map((
|
||||||
|
workdir,
|
||||||
|
) => [workdir.working_directory_id, workdir]),
|
||||||
|
);
|
||||||
|
publish();
|
||||||
|
};
|
||||||
|
const multiplexer = workspaceMultiplexer(workspaceId);
|
||||||
|
const subscription = multiplexer.subscribe(
|
||||||
|
{ topic: "workspace_workers" },
|
||||||
{
|
{
|
||||||
onFrame: (frame) => {
|
onFrame: (frame) => {
|
||||||
try {
|
try {
|
||||||
if (frame.frame === 'event' && frame.message.event === 'subscription_closed') {
|
if (
|
||||||
|
frame.frame === "event" &&
|
||||||
|
frame.message.event === "subscription_closed"
|
||||||
|
) {
|
||||||
throw new Error(frame.message.data.message);
|
throw new Error(frame.message.data.message);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
frame.frame === 'response' &&
|
frame.frame === "response" &&
|
||||||
frame.message.result === 'subscription_rejected'
|
frame.message.result === "subscription_rejected"
|
||||||
) {
|
) {
|
||||||
throw new Error(frame.message.payload.message);
|
throw new Error(frame.message.payload.message);
|
||||||
}
|
}
|
||||||
applyWorkspaceWorkersFrame(projection, frame);
|
applyWorkspaceWorkersFrame(projection, frame);
|
||||||
publish(false, null);
|
loading = false;
|
||||||
} catch (error) {
|
error = null;
|
||||||
publish(false, error instanceof Error ? error.message : 'invalid Worker subscription frame');
|
publish();
|
||||||
|
} catch (cause) {
|
||||||
|
loading = false;
|
||||||
|
error = cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "invalid Worker subscription frame";
|
||||||
|
publish();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStatus: (status, message) => {
|
onStatus: (status, message) => {
|
||||||
if (status === 'connecting') publish(projection.workers.size === 0, null);
|
if (status === "connecting") {
|
||||||
if (status === 'closed') publish(projection.workers.size === 0, message ?? null);
|
loading = projection.workers.size === 0;
|
||||||
|
error = null;
|
||||||
|
publish();
|
||||||
|
}
|
||||||
|
if (status === "closed") {
|
||||||
|
loading = projection.workers.size === 0;
|
||||||
|
error = message ?? null;
|
||||||
|
publish();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return () => subscription.close();
|
const workdirSubscription = multiplexer.subscribe(
|
||||||
|
{ topic: "workspace_workdirs" },
|
||||||
|
{
|
||||||
|
onFrame: (frame) => {
|
||||||
|
const isSnapshot = frame.frame === "response" &&
|
||||||
|
frame.message.result === "subscribed";
|
||||||
|
const isUpdate = frame.frame === "event" &&
|
||||||
|
frame.message.event === "event" &&
|
||||||
|
(frame.message.data.payload.event === "workdir_upserted" ||
|
||||||
|
frame.message.data.payload.event === "workdir_removed");
|
||||||
|
if (isSnapshot || isUpdate) void refreshWorkdirs();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
void refreshWorkdirs();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
subscription.close();
|
||||||
|
workdirSubscription.close();
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
stores.set(workspaceId, store);
|
stores.set(workspaceId, store);
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
function projectWorker(
|
||||||
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
|
worker: SubscriptionWorker,
|
||||||
if (!worker.resource_key) throw new Error('Workspace Worker projection is missing resource_key');
|
workdirs: ReadonlyMap<string, WorkingDirectorySummary>,
|
||||||
|
): SidebarWorker {
|
||||||
|
if (!worker.runtime_id) {
|
||||||
|
throw new Error("Workspace Worker projection is missing runtime_id");
|
||||||
|
}
|
||||||
|
if (!worker.resource_key) {
|
||||||
|
throw new Error("Workspace Worker projection is missing resource_key");
|
||||||
|
}
|
||||||
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
|
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
|
||||||
return {
|
return {
|
||||||
runtime_id: worker.runtime_id,
|
runtime_id: worker.runtime_id,
|
||||||
@@ -92,20 +160,29 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
|||||||
label: displayName,
|
label: displayName,
|
||||||
profile: worker.profile ?? null,
|
profile: worker.profile ?? null,
|
||||||
tags: [],
|
tags: [],
|
||||||
workspace: { visibility: 'workspace', identity: 'runtime_subscription_worker' },
|
workspace: {
|
||||||
|
visibility: "workspace",
|
||||||
|
identity: "runtime_subscription_worker",
|
||||||
|
},
|
||||||
state: liveWorkerState(worker),
|
state: liveWorkerState(worker),
|
||||||
worker_state: worker.worker_state,
|
worker_state: worker.worker_state,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
retention_state: 'transient',
|
retention_state: "transient",
|
||||||
implementation: {
|
implementation: {
|
||||||
kind: 'runtime_subscription_worker',
|
kind: "runtime_subscription_worker",
|
||||||
display_hint: 'Workspace-authorized Runtime Worker',
|
display_hint: "Workspace-authorized Runtime Worker",
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
can_stop: worker.availability !== 'unavailable' && worker.state !== 'stopped',
|
can_stop: worker.availability !== "unavailable" &&
|
||||||
|
worker.state !== "stopped",
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
workdir_attachments: worker.workdir_attachments ?? [],
|
workdir_attachments: (worker.workdir_attachments ?? []).map((
|
||||||
|
attachment,
|
||||||
|
) => ({
|
||||||
|
...attachment,
|
||||||
|
working_directory: workdirs.get(attachment.working_directory_id),
|
||||||
|
})),
|
||||||
has_running_internal_workers: worker.has_running_internal_workers,
|
has_running_internal_workers: worker.has_running_internal_workers,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api";
|
||||||
|
import {
|
||||||
|
type SidebarWorkdirAttachment,
|
||||||
|
sidebarWorkdirMeta,
|
||||||
|
} from "./worker-workdir-meta.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition: boolean, message: string): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function workdir(
|
||||||
|
overrides: Partial<WorkingDirectorySummary> = {},
|
||||||
|
): WorkingDirectorySummary {
|
||||||
|
return {
|
||||||
|
working_directory_id: "workdir-1234567890abcdef",
|
||||||
|
repository_key: "main",
|
||||||
|
materializer_kind: "runtime_git_clone",
|
||||||
|
status: "active",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachment(
|
||||||
|
alias: string,
|
||||||
|
overrides: Partial<WorkingDirectorySummary> = {},
|
||||||
|
): SidebarWorkdirAttachment {
|
||||||
|
const workingDirectory = workdir(overrides);
|
||||||
|
return {
|
||||||
|
alias,
|
||||||
|
repository_key: workingDirectory.repository_key,
|
||||||
|
working_directory_id: workingDirectory.working_directory_id,
|
||||||
|
working_directory: workingDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Worker sidebar Workdir label prefers the current branch selector", () => {
|
||||||
|
const meta = sidebarWorkdirMeta([
|
||||||
|
attachment("checkout", {
|
||||||
|
creation_selector: "develop",
|
||||||
|
current_selector: "refs/heads/work/T-642-sidebar",
|
||||||
|
current_ref: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(meta.text, "main:work/T-642-sidebar");
|
||||||
|
assert(
|
||||||
|
meta.details.includes("checkout — main:work/T-642-sidebar"),
|
||||||
|
"attachment alias should remain available in details",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
meta.details.includes("Workdir workdir-1234567890abcdef"),
|
||||||
|
"opaque Workdir id should remain available in details",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker sidebar Workdir label uses the creation branch before observation", () => {
|
||||||
|
const meta = sidebarWorkdirMeta([
|
||||||
|
attachment("checkout", { creation_selector: "develop" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(meta.text, "main:develop");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker sidebar Workdir label marks detached HEAD instead of reusing creation branch", () => {
|
||||||
|
const meta = sidebarWorkdirMeta([
|
||||||
|
attachment("checkout", {
|
||||||
|
creation_selector: "develop",
|
||||||
|
current_ref: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(meta.text, "main:detached@0123456789ab");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker sidebar Workdir label has an explicit unknown fallback", () => {
|
||||||
|
const meta = sidebarWorkdirMeta([{
|
||||||
|
alias: "checkout",
|
||||||
|
repository_key: "main",
|
||||||
|
working_directory_id: "workdir-1234567890abcdef",
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assertEquals(meta.text, "main:unknown@workdir-1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker sidebar Workdir metadata keeps multiple attachments distinguishable", () => {
|
||||||
|
const meta = sidebarWorkdirMeta([
|
||||||
|
attachment("frontend", { current_selector: "develop" }),
|
||||||
|
attachment("backend", {
|
||||||
|
working_directory_id: "workdir-fedcba0987654321",
|
||||||
|
repository_key: "server",
|
||||||
|
current_selector: "release",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(meta.text, "main:develop · server:release");
|
||||||
|
assert(
|
||||||
|
meta.details.includes("frontend — main:develop"),
|
||||||
|
"first alias should be in details",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
meta.details.includes("backend — server:release"),
|
||||||
|
"second alias should be in details",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api";
|
||||||
|
import type { SubscriptionWorkerWorkdirAttachment } from "$lib/generated/protocol";
|
||||||
|
|
||||||
|
const SHORT_REFERENCE_LENGTH = 12;
|
||||||
|
|
||||||
|
export type SidebarWorkdirAttachment = SubscriptionWorkerWorkdirAttachment & {
|
||||||
|
working_directory?: WorkingDirectorySummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SidebarWorkdirMeta = {
|
||||||
|
text: string;
|
||||||
|
details: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sidebarWorkdirMeta(
|
||||||
|
attachments: readonly SidebarWorkdirAttachment[],
|
||||||
|
): SidebarWorkdirMeta {
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
return { text: "—", details: "No Workdir attachments" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: attachments.map(attachmentLabel).join(" · "),
|
||||||
|
details: attachments.map(attachmentDetails).join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentLabel(attachment: SidebarWorkdirAttachment): string {
|
||||||
|
const workdir = attachment.working_directory;
|
||||||
|
const repository = clean(workdir?.repository_key) ??
|
||||||
|
clean(attachment.repository_key) ?? "unknown-repo";
|
||||||
|
return `${repository}:${revisionLabel(attachment)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisionLabel(attachment: SidebarWorkdirAttachment): string {
|
||||||
|
const workdir = attachment.working_directory;
|
||||||
|
if (!workdir) {
|
||||||
|
return `unknown@${shortReference(attachment.working_directory_id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSelector = clean(workdir.current_selector);
|
||||||
|
const currentBranch = branchName(currentSelector);
|
||||||
|
if (currentBranch) return currentBranch;
|
||||||
|
|
||||||
|
const currentReference = clean(workdir.current_ref);
|
||||||
|
if (currentSelector || currentReference) {
|
||||||
|
return explicitReferenceFallback(
|
||||||
|
"detached",
|
||||||
|
currentReference ?? currentSelector,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creationSelector = clean(workdir.creation_selector);
|
||||||
|
const creationBranch = branchName(creationSelector);
|
||||||
|
if (creationBranch) return creationBranch;
|
||||||
|
|
||||||
|
const creationReference = clean(workdir.creation_ref);
|
||||||
|
if (creationSelector || creationReference) {
|
||||||
|
return explicitReferenceFallback(
|
||||||
|
"revision",
|
||||||
|
creationReference ?? creationSelector,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `unknown@${shortReference(attachment.working_directory_id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchName(selector: string | null): string | null {
|
||||||
|
if (!selector || selector === "HEAD" || isHash(selector)) return null;
|
||||||
|
if (selector.startsWith("refs/heads/")) {
|
||||||
|
return clean(selector.slice("refs/heads/".length));
|
||||||
|
}
|
||||||
|
if (selector.startsWith("refs/")) return null;
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
function explicitReferenceFallback(
|
||||||
|
kind: "detached" | "revision",
|
||||||
|
reference: string | null,
|
||||||
|
): string {
|
||||||
|
return reference ? `${kind}@${shortReference(reference)}` : kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentDetails(attachment: SidebarWorkdirAttachment): string {
|
||||||
|
const workdir = attachment.working_directory;
|
||||||
|
const details = [
|
||||||
|
`${attachment.alias} — ${attachmentLabel(attachment)}`,
|
||||||
|
`Workdir ${attachment.working_directory_id}`,
|
||||||
|
];
|
||||||
|
if (clean(workdir?.current_selector)) {
|
||||||
|
details.push(`Current selector ${workdir?.current_selector?.trim()}`);
|
||||||
|
}
|
||||||
|
if (clean(workdir?.current_ref)) {
|
||||||
|
details.push(`Current ref ${workdir?.current_ref?.trim()}`);
|
||||||
|
}
|
||||||
|
if (clean(workdir?.creation_selector)) {
|
||||||
|
details.push(`Creation selector ${workdir?.creation_selector?.trim()}`);
|
||||||
|
}
|
||||||
|
return details.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value: string | null | undefined): string | null {
|
||||||
|
const cleaned = value?.trim();
|
||||||
|
return cleaned ? cleaned : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortReference(reference: string): string {
|
||||||
|
return reference.length > SHORT_REFERENCE_LENGTH
|
||||||
|
? reference.slice(0, SHORT_REFERENCE_LENGTH)
|
||||||
|
: reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHash(value: string): boolean {
|
||||||
|
return /^[0-9a-f]{7,64}$/i.test(value);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user