merge: orchestration

This commit is contained in:
2026-07-09 19:24:12 +09:00
17 changed files with 2281 additions and 137 deletions
+3
View File
@@ -15,6 +15,9 @@
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
"@codemirror/state": "npm:@codemirror/state@6.5.2",
"@codemirror/view": "npm:@codemirror/view@6.38.8",
"decodal-codemirror": "npm:decodal-codemirror@0.1.2",
"clsx": "npm:clsx@2.1.1",
"cookie": "npm:cookie@0.6.0",
"devalue": "npm:devalue@5.6.4",
+67
View File
@@ -1,11 +1,14 @@
{
"version": "5",
"specifiers": {
"npm:@codemirror/state@6.5.2": "6.5.2",
"npm:@codemirror/view@6.38.8": "6.38.8",
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
"npm:clsx@2.1.1": "2.1.1",
"npm:cookie@0.6.0": "0.6.0",
"npm:decodal-codemirror@0.1.2": "0.1.2",
"npm:devalue@5.6.4": "5.6.4",
"npm:set-cookie-parser@2.7.2": "2.7.2",
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
@@ -14,6 +17,32 @@
"npm:vite@7.2.7": "7.2.7"
},
"npm": {
"@codemirror/language@6.12.4": {
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dependencies": [
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
"style-mod"
]
},
"@codemirror/state@6.5.2": {
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"dependencies": [
"@marijn/find-cluster-break"
]
},
"@codemirror/view@6.38.8": {
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"dependencies": [
"@codemirror/state",
"crelt",
"style-mod",
"w3c-keyname"
]
},
"@esbuild/aix-ppc64@0.25.12": {
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"os": ["aix"],
@@ -171,6 +200,24 @@
"@jridgewell/sourcemap-codec"
]
},
"@lezer/common@1.5.2": {
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="
},
"@lezer/highlight@1.2.3": {
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"dependencies": [
"@lezer/common"
]
},
"@lezer/lr@1.4.10": {
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"dependencies": [
"@lezer/common"
]
},
"@marijn/find-cluster-break@1.0.3": {
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="
},
"@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
@@ -392,12 +439,23 @@
"cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
"crelt@1.0.7": {
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="
},
"debug@4.4.3": {
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": [
"ms"
]
},
"decodal-codemirror@0.1.2": {
"integrity": "sha512-VR+cFsBLPb9kZU3gJhElZck+IUVhOC1HoWgA8bxP1kxwn+eCZaeBHErHESlRZbsWvN2J5T8VKcDCtxLDYe9QyA==",
"dependencies": [
"@codemirror/language",
"@lezer/highlight",
"@lezer/lr"
]
},
"deepmerge@4.3.1": {
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
},
@@ -567,6 +625,9 @@
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"style-mod@4.1.3": {
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
},
"svelte-check@4.3.4_svelte@5.45.6_typescript@5.9.3": {
"integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==",
"dependencies": [
@@ -638,17 +699,23 @@
"vite"
]
},
"w3c-keyname@2.2.8": {
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
}
},
"workspace": {
"dependencies": [
"npm:@codemirror/state@6.5.2",
"npm:@codemirror/view@6.38.8",
"npm:@sveltejs/adapter-static@3.0.9",
"npm:@sveltejs/kit@2.49.4",
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
"npm:clsx@2.1.1",
"npm:cookie@0.6.0",
"npm:decodal-codemirror@0.1.2",
"npm:devalue@5.6.4",
"npm:set-cookie-parser@2.7.2",
"npm:svelte-check@4.3.4",
@@ -0,0 +1,73 @@
<script lang="ts">
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { decodal } from 'decodal-codemirror';
let {
value = '',
readonly = false,
ariaLabel = 'Decodal source',
onChange = (_value: string) => {},
}: {
value?: string;
readonly?: boolean;
ariaLabel?: string;
onChange?: (value: string) => void;
} = $props();
let host = $state<HTMLDivElement | null>(null);
let view = $state<EditorView | null>(null);
const theme = EditorView.theme({
'&': {
border: '1px solid var(--border-subtle)',
borderRadius: '0.75rem',
minHeight: '24rem',
background: 'var(--surface-2)',
color: 'var(--text-primary)',
fontSize: '0.9rem',
},
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
'.cm-content': { padding: '0.75rem 0' },
'.cm-gutters': { background: 'var(--surface-2)', color: 'var(--text-muted)' },
'.cm-activeLine': { backgroundColor: 'rgba(125, 211, 252, 0.08)' },
});
$effect(() => {
if (!host || view) return;
const editor = new EditorView({
parent: host,
state: EditorState.create({
doc: value,
extensions: [
lineNumbers(),
drawSelection(),
highlightActiveLine(),
decodal(),
keymap.of([]),
EditorState.readOnly.of(readonly),
EditorView.editable.of(!readonly),
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange(update.state.doc.toString());
}),
theme,
],
}),
});
view = editor;
return () => {
editor.destroy();
view = null;
};
});
$effect(() => {
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
}
});
</script>
<div bind:this={host} role="textbox" aria-label={ariaLabel} aria-readonly={readonly}></div>
@@ -6,6 +6,7 @@ import {
SETTINGS_SECTIONS,
settingsSectionHref,
} from "./model.ts";
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from "./profile-routes.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
@@ -102,3 +103,19 @@ Deno.test("diagnostic labels preserve severity and code", () => {
"diagnostic label should be bounded and stable",
);
});
Deno.test("profile source tree routes use encoded scoped ids", () => {
const href = profileSourceTreeSettingsHref("workspace a", "project/tree");
assert(
href === "/w/workspace%20a/settings/profiles/trees/project%2Ftree",
`unexpected href ${href}`,
);
assert(!href.includes("/home/"), "route must not contain host paths");
});
Deno.test("profile source create paths are normalized to virtual profile paths", () => {
assert(virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl", "bare file names are scoped");
assert(virtualProfilePathForCreate("profiles/alpha.dcdl") === "profiles/alpha.dcdl", "virtual paths are preserved");
assert(virtualProfilePathForCreate("project:profiles/alpha.dcdl") === "project:profiles/alpha.dcdl", "safe virtual namespaces are preserved");
});
@@ -8,6 +8,8 @@ import type {
WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse,
WorkspaceProfileSourceDetailResponse,
WorkspaceProfileSourceTreeFileResponse,
WorkspaceProfileSourceTreeResponse,
} from "./profile-types";
export function fetchWorkspaceMetadataSettings(
@@ -116,3 +118,44 @@ export function deleteProfileSource(
{ method: "DELETE", body: JSON.stringify(request) },
);
}
export function fetchProfileSourceTree(
workspaceId: string,
sourceTreeId: string,
): Promise<WorkspaceProfileSourceTreeResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}`,
);
}
export function fetchProfileTreeFile(
workspaceId: string,
sourceTreeId: string,
path: string,
): Promise<WorkspaceProfileSourceTreeFileResponse> {
return workspaceApiJson(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file?path=${encodeURIComponent(path)}`,
);
}
export function writeProfileTreeFile(
workspaceId: string,
sourceTreeId: string,
request: { path: string; content: string; revision?: string | null },
): Promise<WorkspaceProfileSourceTreeFileResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
{ method: "PUT", body: JSON.stringify(request) },
);
}
export function deleteProfileTreeFile(
workspaceId: string,
sourceTreeId: string,
request: { path: string; revision: string },
): Promise<WorkspaceProfileSourceTreeResponse> {
return workspaceApiJsonWithBody(
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
{ method: "DELETE", body: JSON.stringify(request) },
);
}
@@ -0,0 +1,15 @@
export function profileSettingsHref(workspaceId: string): string {
return `/w/${encodeURIComponent(workspaceId)}/settings/profiles`;
}
export function profileSourceTreeSettingsHref(workspaceId: string, sourceTreeId: string): string {
return `${profileSettingsHref(workspaceId)}/trees/${encodeURIComponent(sourceTreeId)}`;
}
export function virtualProfilePathForCreate(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) return trimmed;
if (trimmed.startsWith("profiles/")) return trimmed;
return `profiles/${trimmed}`;
}
@@ -30,6 +30,9 @@ export type WorkspaceProfileSourceSummary = {
profile_source_id: string;
display_path: string;
kind: "decodal" | string;
content_type: string;
content_digest: string;
provenance: "project_profile_source_tree" | string;
editable: boolean;
revision: string;
size_bytes: number;
@@ -42,6 +45,7 @@ export type ProfileSettingsResponse = {
default_profile?: string | null;
profiles: WorkspaceProfileSummary[];
sources: WorkspaceProfileSourceSummary[];
source_trees: WorkspaceProfileSourceTreeSummary[];
diagnostics: Diagnostic[];
};
@@ -58,3 +62,44 @@ export type ProfileSettingsMutationResponse = {
settings: ProfileSettingsResponse;
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSourceTreeSummary = {
source_tree_id: string;
label: string;
root_path: string;
kind: "decodal_source_tree" | string;
content_type: string;
content_digest: string;
provenance: "project_profile_source_tree" | string;
editable: boolean;
revision: string;
file_count: number;
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSourceTreeFileSummary = {
path: string;
kind: "decodal" | string;
content_type: string;
content_digest: string;
provenance: "project_profile_source_tree" | string;
editable: boolean;
revision: string;
size_bytes: number;
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSourceTreeResponse = {
workspace_id: string;
tree: WorkspaceProfileSourceTreeSummary;
files: WorkspaceProfileSourceTreeFileSummary[];
diagnostics: Diagnostic[];
};
export type WorkspaceProfileSourceTreeFileResponse = {
workspace_id: string;
source_tree_id: string;
file: WorkspaceProfileSourceTreeFileSummary;
content: string;
diagnostics: Diagnostic[];
};
@@ -1,40 +1,133 @@
<script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import { fetchProfileSettings } from '$lib/workspace-settings/profile-api';
import DecodalSourceEditor from '$lib/workspace-settings/DecodalSourceEditor.svelte';
import {
fetchProfileSettings,
fetchProfileSourceTree,
deleteProfileTreeFile,
fetchProfileTreeFile,
writeProfileTreeFile,
} from '$lib/workspace-settings/profile-api';
import type { Diagnostic } from '$lib/workspace-settings/model';
import type { ProfileSettingsResponse } from '$lib/workspace-settings/profile-types';
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
import type {
ProfileSettingsResponse,
WorkspaceProfileSourceTreeFileResponse,
WorkspaceProfileSourceTreeResponse,
} from '$lib/workspace-settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let profileSettings = $state<ProfileSettingsResponse | null>(null);
let sourceTree = $state<WorkspaceProfileSourceTreeResponse | null>(null);
let selectedFile = $state<WorkspaceProfileSourceTreeFileResponse | null>(null);
let draftContent = $state('');
let loading = $state(true);
let saving = $state(false);
let creating = $state(false);
let deleting = $state(false);
let newFilePath = $state('new-profile.dcdl');
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
$effect(() => {
if (!workspaceId) {
async function loadSettings() {
if (!workspaceId) return;
loading = true;
message = null;
try {
const response = await fetchProfileSettings(workspaceId);
profileSettings = response;
diagnostics = response.diagnostics;
const treeId = response.source_trees[0]?.source_tree_id;
if (treeId) {
sourceTree = await fetchProfileSourceTree(workspaceId, treeId);
const firstPath = sourceTree.files[0]?.path;
if (firstPath) await selectTreeFile(treeId, firstPath);
}
} catch (err) {
message = err instanceof Error ? err.message : 'profile settings request failed';
} finally {
loading = false;
return;
}
}
async function selectTreeFile(sourceTreeId: string, path: string) {
selectedFile = await fetchProfileTreeFile(workspaceId, sourceTreeId, path);
draftContent = selectedFile.content;
diagnostics = selectedFile.diagnostics;
}
async function createTreeFile(sourceTreeId: string) {
creating = true;
message = null;
try {
const path = virtualProfilePathForCreate(newFilePath);
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
path,
content: '{\n slug = "new-profile";\n description = "New profile";\n scope = "workspace_read";\n}',
});
draftContent = selectedFile.content;
sourceTree = await fetchProfileSourceTree(workspaceId, sourceTreeId);
diagnostics = selectedFile.diagnostics;
message = 'Created Decodal profile source.';
} catch (err) {
message = err instanceof Error ? err.message : 'profile source create failed';
} finally {
creating = false;
}
}
async function deleteSelectedFile() {
if (!selectedFile) return;
deleting = true;
message = null;
try {
sourceTree = await deleteProfileTreeFile(workspaceId, selectedFile.source_tree_id, {
path: selectedFile.file.path,
revision: selectedFile.file.revision,
});
selectedFile = null;
draftContent = '';
diagnostics = sourceTree.diagnostics;
message = 'Deleted Decodal profile source.';
} catch (err) {
message = err instanceof Error ? err.message : 'profile source delete failed';
} finally {
deleting = false;
}
}
async function saveSelectedFile() {
if (!selectedFile) return;
saving = true;
message = null;
try {
selectedFile = await writeProfileTreeFile(workspaceId, selectedFile.source_tree_id, {
path: selectedFile.file.path,
revision: selectedFile.file.revision,
content: draftContent,
});
draftContent = selectedFile.content;
sourceTree = await fetchProfileSourceTree(workspaceId, selectedFile.source_tree_id);
diagnostics = selectedFile.diagnostics;
message = 'Saved Decodal profile source.';
} catch (err) {
message = err instanceof Error ? err.message : 'profile source save failed';
} finally {
saving = false;
}
}
$effect(() => {
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchProfileSettings(workspaceId);
if (!cancelled) {
profileSettings = response;
diagnostics = response.diagnostics;
}
} catch (err) {
if (!cancelled) message = err instanceof Error ? err.message : 'profile settings request failed';
} finally {
if (!cancelled) loading = false;
}
await loadSettings();
if (cancelled) return;
}
load();
if (workspaceId) load();
else loading = false;
return () => {
cancelled = true;
};
@@ -48,13 +141,13 @@
<section class="card settings-section" aria-labelledby="profile-sources-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">read-only</p>
<p class="eyebrow">Backend-owned source tree</p>
<h2 id="profile-sources-title">Profiles</h2>
</div>
<span class="badge warning">editing pending</span>
<span class="badge">Decodal editor</span>
</header>
<p>
Review the effective launch profiles and their source files. Editing is intentionally deferred until the Decodal profile source model is settled.
Review effective launch profiles and edit Decodal source files through virtual profile-tree paths. The browser receives only safe relative paths and revision tokens.
</p>
{#if loading}
@@ -77,24 +170,58 @@
</ul>
</article>
<article>
<h3>Profile source files</h3>
<p class="settings-note">Decodal source files that define or contribute to launch profiles.</p>
<ul class="settings-profile-list">
{#each profileSettings.sources as source (source.profile_source_id)}
<li>
<strong>{source.display_path}</strong>
<span>{source.kind} · {source.size_bytes} bytes</span>
<small>{source.editable ? 'editable later' : 'read-only'} · rev {source.revision}</small>
<DiagnosticsList diagnostics={source.diagnostics} />
</li>
{/each}
</ul>
<h3>Profile source tree</h3>
<p class="settings-note">Virtual Decodal paths exposed by the Backend-owned source tree.</p>
{#if sourceTree}
<small>{sourceTree.tree.root_path} · {sourceTree.tree.file_count} files · {sourceTree.tree.content_type} · rev {sourceTree.tree.revision}</small>
<p><a href={profileSourceTreeSettingsHref(workspaceId, sourceTree.tree.source_tree_id)}>Open tree route</a></p>
<div class="settings-inline-form">
<input bind:value={newFilePath} aria-label="New profile source virtual path" placeholder="profiles/new-profile.dcdl" />
<button type="button" disabled={creating} onclick={() => createTreeFile(sourceTree!.tree.source_tree_id)}>
{creating ? 'Creating…' : 'Create source'}
</button>
</div>
<ul class="settings-profile-list">
{#each sourceTree.files as file (file.path)}
<li>
<button class="link-button" type="button" onclick={() => selectTreeFile(sourceTree!.tree.source_tree_id, file.path)}>
<strong>{file.path}</strong>
</button>
<span>{file.kind} · {file.content_type} · {file.size_bytes} bytes</span>
<small>{file.editable ? 'editable' : 'read-only'} · rev {file.revision}</small>
<DiagnosticsList diagnostics={file.diagnostics} />
</li>
{/each}
</ul>
{:else}
<p class="settings-note">No project profile source tree is available.</p>
{/if}
</article>
</div>
{#if selectedFile}
<article class="settings-editor-panel">
<header class="settings-section-header">
<div>
<p class="eyebrow">{selectedFile.source_tree_id}</p>
<h3>{selectedFile.file.path}</h3>
</div>
<div class="settings-editor-actions">
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}>
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" disabled={deleting} onclick={deleteSelectedFile}>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</header>
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
</article>
{/if}
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
<p class="status-message" class:error={message.includes('failed') || message.includes('error')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>
@@ -0,0 +1,175 @@
<script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
import DecodalSourceEditor from '$lib/workspace-settings/DecodalSourceEditor.svelte';
import {
deleteProfileTreeFile,
fetchProfileSourceTree,
fetchProfileTreeFile,
writeProfileTreeFile,
} from '$lib/workspace-settings/profile-api';
import { profileSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
import type { Diagnostic } from '$lib/workspace-settings/model';
import type {
WorkspaceProfileSourceTreeFileResponse,
WorkspaceProfileSourceTreeResponse,
} from '$lib/workspace-settings/profile-types';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let sourceTreeId = $derived(data.sourceTreeId);
let tree = $state<WorkspaceProfileSourceTreeResponse | null>(null);
let selectedFile = $state<WorkspaceProfileSourceTreeFileResponse | null>(null);
let draftContent = $state('');
let newFilePath = $state('new-profile.dcdl');
let loading = $state(true);
let saving = $state(false);
let creating = $state(false);
let deleting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
async function reloadTree() {
tree = await fetchProfileSourceTree(workspaceId, sourceTreeId);
diagnostics = tree.diagnostics;
}
async function selectFile(path: string) {
selectedFile = await fetchProfileTreeFile(workspaceId, sourceTreeId, path);
draftContent = selectedFile.content;
diagnostics = selectedFile.diagnostics;
}
async function createFile() {
creating = true;
message = null;
try {
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
path: virtualProfilePathForCreate(newFilePath),
content: '{\n slug = "new-profile";\n description = "New profile";\n scope = "workspace_read";\n}',
});
draftContent = selectedFile.content;
await reloadTree();
message = 'Created Decodal profile source.';
} catch (err) {
message = err instanceof Error ? err.message : 'profile source create failed';
} finally {
creating = false;
}
}
async function saveFile() {
if (!selectedFile) return;
saving = true;
message = null;
try {
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
path: selectedFile.file.path,
revision: selectedFile.file.revision,
content: draftContent,
});
draftContent = selectedFile.content;
await reloadTree();
message = 'Saved Decodal profile source.';
} catch (err) {
message = err instanceof Error ? err.message : 'profile source save failed';
} finally {
saving = false;
}
}
async function deleteFile() {
if (!selectedFile) return;
deleting = true;
message = null;
try {
tree = await deleteProfileTreeFile(workspaceId, sourceTreeId, {
path: selectedFile.file.path,
revision: selectedFile.file.revision,
});
selectedFile = null;
draftContent = '';
diagnostics = tree.diagnostics;
message = 'Deleted Decodal profile source.';
} catch (err) {
message = err instanceof Error ? err.message : 'profile source delete failed';
} finally {
deleting = false;
}
}
$effect(() => {
if (!workspaceId || !sourceTreeId) {
loading = false;
return;
}
loading = true;
reloadTree()
.catch((err) => {
message = err instanceof Error ? err.message : 'profile source tree load failed';
})
.finally(() => {
loading = false;
});
});
</script>
<svelte:head>
<title>Profile source tree · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="profile-tree-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">Profile source tree</p>
<h2 id="profile-tree-title">{sourceTreeId}</h2>
</div>
<a href={profileSettingsHref(workspaceId)}>Back to profiles</a>
</header>
{#if loading}
<p class="status-message">Loading source tree…</p>
{:else if tree}
<p class="settings-note">
{tree.tree.root_path} · {tree.tree.file_count} files · {tree.tree.content_type} · {tree.tree.content_digest}
</p>
<div class="settings-inline-form">
<input bind:value={newFilePath} aria-label="New profile source virtual path" placeholder="profiles/new-profile.dcdl" />
<button type="button" disabled={creating} onclick={createFile}>{creating ? 'Creating…' : 'Create source'}</button>
</div>
<div class="settings-profile-grid">
<article>
<h3>Files</h3>
<ul class="settings-profile-list">
{#each tree.files as file (file.path)}
<li>
<button class="link-button" type="button" onclick={() => selectFile(file.path)}><strong>{file.path}</strong></button>
<span>{file.content_type} · {file.content_digest}</span>
<small>{file.size_bytes} bytes · rev {file.revision}</small>
<DiagnosticsList diagnostics={file.diagnostics} />
</li>
{/each}
</ul>
</article>
{#if selectedFile}
<article>
<header class="settings-section-header">
<div>
<p class="eyebrow">{selectedFile.file.content_type}</p>
<h3>{selectedFile.file.path}</h3>
</div>
<div class="settings-editor-actions">
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveFile}>{saving ? 'Saving…' : 'Save'}</button>
<button type="button" disabled={deleting} onclick={deleteFile}>{deleting ? 'Deleting…' : 'Delete'}</button>
</div>
</header>
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
</article>
{/if}
</div>
{/if}
{#if message}<p class="status-message">{message}</p>{/if}
<DiagnosticsList {diagnostics} />
</section>
@@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => ({
sourceTreeId: params.sourceTreeId,
});