fix: complete profile source tree review fixes
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"@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",
|
||||
|
||||
Generated
+36
@@ -8,6 +8,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",
|
||||
@@ -16,6 +17,17 @@
|
||||
"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": [
|
||||
@@ -188,6 +200,21 @@
|
||||
"@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=="
|
||||
},
|
||||
@@ -421,6 +448,14 @@
|
||||
"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=="
|
||||
},
|
||||
@@ -680,6 +715,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+175
@@ -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>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = ({ params }) => ({
|
||||
sourceTreeId: params.sourceTreeId,
|
||||
});
|
||||
Reference in New Issue
Block a user