feat: add profile source tree editor
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
"@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",
|
||||
"clsx": "npm:clsx@2.1.1",
|
||||
"cookie": "npm:cookie@0.6.0",
|
||||
"devalue": "npm:devalue@5.6.4",
|
||||
|
||||
Generated
+31
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"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",
|
||||
@@ -14,6 +16,21 @@
|
||||
"npm:vite@7.2.7": "7.2.7"
|
||||
},
|
||||
"npm": {
|
||||
"@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 +188,9 @@
|
||||
"@jridgewell/sourcemap-codec"
|
||||
]
|
||||
},
|
||||
"@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,6 +412,9 @@
|
||||
"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": [
|
||||
@@ -567,6 +590,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,12 +664,17 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||
|
||||
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(),
|
||||
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>
|
||||
@@ -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) },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export type ProfileSettingsResponse = {
|
||||
default_profile?: string | null;
|
||||
profiles: WorkspaceProfileSummary[];
|
||||
sources: WorkspaceProfileSourceSummary[];
|
||||
source_trees: WorkspaceProfileSourceTreeSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
@@ -58,3 +59,38 @@ export type ProfileSettingsMutationResponse = {
|
||||
settings: ProfileSettingsResponse;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceTreeSummary = {
|
||||
source_tree_id: string;
|
||||
label: string;
|
||||
root_path: string;
|
||||
kind: "decodal_source_tree" | string;
|
||||
editable: boolean;
|
||||
revision: string;
|
||||
file_count: number;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceTreeFileSummary = {
|
||||
path: string;
|
||||
kind: "decodal" | 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,88 @@
|
||||
<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,
|
||||
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 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 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 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 +96,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 +125,46 @@
|
||||
</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 · rev {sourceTree.tree.revision}</small>
|
||||
<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>
|
||||
<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>
|
||||
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user