fix: complete profile source tree review fixes

This commit is contained in:
2026-07-09 17:47:19 +09:00
parent 81abfa630a
commit 941b91261b
12 changed files with 629 additions and 15 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { decodal } from 'decodal-codemirror';
let {
value = '',
@@ -42,6 +43,7 @@
lineNumbers(),
drawSelection(),
highlightActiveLine(),
decodal(),
keymap.of([]),
EditorState.readOnly.of(readonly),
EditorView.editable.of(!readonly),
@@ -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");
});
@@ -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;
@@ -65,6 +68,9 @@ export type WorkspaceProfileSourceTreeSummary = {
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;
@@ -74,6 +80,9 @@ export type WorkspaceProfileSourceTreeSummary = {
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;
@@ -4,10 +4,12 @@
import {
fetchProfileSettings,
fetchProfileSourceTree,
deleteProfileTreeFile,
fetchProfileTreeFile,
writeProfileTreeFile,
} from '$lib/workspace-settings/profile-api';
import type { Diagnostic } from '$lib/workspace-settings/model';
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
import type {
ProfileSettingsResponse,
WorkspaceProfileSourceTreeFileResponse,
@@ -24,6 +26,9 @@
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[]>([]);
@@ -54,6 +59,46 @@
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;
@@ -128,14 +173,21 @@
<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 · rev {sourceTree.tree.revision}</small>
<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.size_bytes} bytes</span>
<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>
@@ -154,9 +206,14 @@
<p class="eyebrow">{selectedFile.source_tree_id}</p>
<h3>{selectedFile.file.path}</h3>
</div>
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}>
{saving ? 'Saving…' : 'Save'}
</button>
<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>
@@ -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,
});