fix: complete profile source tree review fixes

This commit is contained in:
Keisuke Hirata 2026-07-09 17:47:19 +09:00
parent 81abfa630a
commit 941b91261b
No known key found for this signature in database
12 changed files with 629 additions and 15 deletions

View File

@ -2,7 +2,7 @@
title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor'
state: 'inprogress' state: 'inprogress'
created_at: '2026-07-08T19:18:25Z' created_at: '2026-07-08T19:18:25Z'
updated_at: '2026-07-09T08:22:18Z' updated_at: '2026-07-09T08:46:58Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-07-09T07:56:07Z' queued_at: '2026-07-09T07:56:07Z'

View File

@ -148,4 +148,30 @@ Validation:
- `nix build .#yoi --no-link` - `nix build .#yoi --no-link`
---
<!-- event: implementation_report author: hare at: 2026-07-09T08:46:58Z -->
## Implementation report
Follow-up fixes for external review blockers.
Summary:
- Added required `decodal-codemirror@0.1.2` dependency and wired the `decodal()` CodeMirror 6 extension into the Decodal source editor.
- Added a profile source tree settings route plus create/save/delete editor operations using Backend virtual-path APIs.
- Changed project profile archive generation to build a selected-root/import-closure-only archive for the requested selector instead of snapshotting the whole tree.
- Added safe source/tree summary metadata for content type, content digest, and typed provenance without host paths.
- Hardened tree writes so symlinked intermediate directories fail before any outside filesystem mutation.
- Added focused Rust regression tests for closure-only archive content and symlink write rejection; added web route/path model smoke tests.
Validation:
- `git diff --check`
- `cargo test -p yoi-workspace-server`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo check -p yoi`
- `cd web/workspace && deno task check && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
--- ---

View File

@ -4,6 +4,7 @@ use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use worker_runtime::config_bundle::{ use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
}; };
@ -90,18 +91,30 @@ pub struct WorkspaceProfileSourceSummary {
pub profile_source_id: String, pub profile_source_id: String,
pub display_path: String, pub display_path: String,
pub kind: String, pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool, pub editable: bool,
pub revision: String, pub revision: String,
pub size_bytes: u64, pub size_bytes: u64,
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceProfileSourceProvenance {
ProjectProfileSourceTree,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeSummary { pub struct WorkspaceProfileSourceTreeSummary {
pub source_tree_id: String, pub source_tree_id: String,
pub label: String, pub label: String,
pub root_path: String, pub root_path: String,
pub kind: String, pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool, pub editable: bool,
pub revision: String, pub revision: String,
pub file_count: usize, pub file_count: usize,
@ -112,6 +125,9 @@ pub struct WorkspaceProfileSourceTreeSummary {
pub struct WorkspaceProfileSourceTreeFileSummary { pub struct WorkspaceProfileSourceTreeFileSummary {
pub path: String, pub path: String,
pub kind: String, pub kind: String,
pub content_type: String,
pub content_digest: String,
pub provenance: WorkspaceProfileSourceProvenance,
pub editable: bool, pub editable: bool,
pub revision: String, pub revision: String,
pub size_bytes: u64, pub size_bytes: u64,
@ -701,11 +717,7 @@ pub fn write_profile_tree_file(
)); ));
} }
let relative_path = relative_source_path_for_virtual_path(&request.path)?; let relative_path = relative_source_path_for_virtual_path(&request.path)?;
let full = workspace_root.join(&relative_path); let full = prepare_source_path_for_write(workspace_root, &relative_path)?;
if let Some(parent) = full.parent() {
fs::create_dir_all(parent)?;
}
let full = checked_source_path(workspace_root, &relative_path)?;
if let Some(expected) = request.revision.as_deref() { if let Some(expected) = request.revision.as_deref() {
ensure_revision(&full, expected, "profile_source_revision_conflict")?; ensure_revision(&full, expected, "profile_source_revision_conflict")?;
} else if full.exists() { } else if full.exists() {
@ -764,7 +776,7 @@ pub fn build_workspace_profile_archive(
} }
let registry = read_registry(workspace_root).map_err(profile_registry_error)?; let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let sources = read_profile_source_tree_contents(workspace_root)?; let sources = read_profile_source_tree_contents(workspace_root)?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?; let archive = build_profile_archive_for_selector(&registry, sources, selector)?;
archive archive
.verify() .verify()
.and_then(|verified| { .and_then(|verified| {
@ -1001,6 +1013,10 @@ fn profile_source_tree_summary_with_count(
label: "Project profile sources".to_string(), label: "Project profile sources".to_string(),
root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(), root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(),
kind: "decodal_source_tree".to_string(), kind: "decodal_source_tree".to_string(),
content_type: "application/vnd.yoi.profile-source-tree+json".to_string(),
content_digest: profile_source_tree_digest(workspace_root)
.unwrap_or_else(|_| "sha256:unavailable".to_string()),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: true, editable: true,
revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)), revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)),
file_count, file_count,
@ -1069,6 +1085,9 @@ fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSou
WorkspaceProfileSourceTreeFileSummary { WorkspaceProfileSourceTreeFileSummary {
path: virtual_path, path: virtual_path,
kind: "decodal".to_string(), kind: "decodal".to_string(),
content_type: "text/x-decodal".to_string(),
content_digest: file_content_digest(path),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: true, editable: true,
revision: file_revision(path), revision: file_revision(path),
size_bytes: source_metadata(path) size_bytes: source_metadata(path)
@ -1150,7 +1169,7 @@ fn read_profile_source_tree_contents(workspace_root: &Path) -> Result<BTreeMap<S
fn build_profile_archive_from_tree_sources( fn build_profile_archive_from_tree_sources(
registry: &ProfileRegistryDocument, registry: &ProfileRegistryDocument,
mut sources: BTreeMap<String, String>, sources: BTreeMap<String, String>,
) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> { ) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> {
let mut entrypoints = BTreeMap::new(); let mut entrypoints = BTreeMap::new();
for entry in project_entries(registry, &mut Vec::new()) { for entry in project_entries(registry, &mut Vec::new()) {
@ -1168,6 +1187,80 @@ fn build_profile_archive_from_tree_sources(
entrypoints.insert("default".to_string(), path); entrypoints.insert("default".to_string(), path);
} }
} }
build_profile_archive_from_source_set(entrypoints, sources)
}
fn build_profile_archive_for_selector(
registry: &ProfileRegistryDocument,
sources: BTreeMap<String, String>,
selector: &str,
) -> Result<ProfileSourceArchive> {
let Some(name) = selector.strip_prefix("project:") else {
return Err(profile_validation_error(
"profile_selector_invalid",
"Project profile archive selector must use project:*",
));
};
let entry = project_entries(registry, &mut Vec::new())
.into_iter()
.find(|entry| entry.name == name)
.ok_or_else(|| {
profile_validation_error(
"unknown_profile_selector",
"Selected project profile is not present in the workspace profile registry",
)
})?;
let root_path = display_source_path(&entry.relative_path);
if !sources.contains_key(&root_path) {
return Err(profile_validation_error(
"profile_source_missing",
"Selected project profile source is missing from the source tree",
));
}
let mut closure_sources = BTreeMap::new();
let mut imports = BTreeMap::new();
collect_profile_import_closure(&root_path, &sources, &mut closure_sources, &mut imports)?;
let mut entrypoints = BTreeMap::new();
entrypoints.insert(selector.to_string(), root_path);
build_profile_archive_from_source_set_with_imports(entrypoints, closure_sources, imports)
}
fn collect_profile_import_closure(
current_path: &str,
all_sources: &BTreeMap<String, String>,
closure_sources: &mut BTreeMap<String, String>,
imports: &mut BTreeMap<String, String>,
) -> Result<()> {
if closure_sources.contains_key(current_path) {
return Ok(());
}
let content = all_sources.get(current_path).ok_or_else(|| {
profile_validation_error(
"profile_source_import_missing",
&format!("Profile source import closure is missing {current_path}"),
)
})?;
closure_sources.insert(current_path.to_string(), content.clone());
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if !all_sources.contains_key(&target) {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
imports.insert(format!("{current_path}\0{specifier}"), target.clone());
collect_profile_import_closure(&target, all_sources, closure_sources, imports)?;
}
Ok(())
}
fn build_profile_archive_from_source_set(
entrypoints: BTreeMap<String, String>,
sources: BTreeMap<String, String>,
) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> {
let mut imports = BTreeMap::new(); let mut imports = BTreeMap::new();
let source_snapshot = sources.clone(); let source_snapshot = sources.clone();
for (current_path, content) in &source_snapshot { for (current_path, content) in &source_snapshot {
@ -1185,16 +1278,24 @@ fn build_profile_archive_from_tree_sources(
} }
} }
} }
build_profile_archive_from_source_set_with_imports(entrypoints, sources, imports.clone())
.map(|archive| (archive, imports))
}
fn build_profile_archive_from_source_set_with_imports(
entrypoints: BTreeMap<String, String>,
mut sources: BTreeMap<String, String>,
imports: BTreeMap<String, String>,
) -> Result<ProfileSourceArchive> {
if sources.is_empty() { if sources.is_empty() {
sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string()); sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string());
} }
ProfileSourceArchive::build(ProfileSourceArchiveInput { ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "workspace-project-decodal-profiles-v1".to_string(), id: "workspace-project-decodal-profiles-v1".to_string(),
entrypoints, entrypoints,
imports: imports.clone(), imports,
sources, sources,
}) })
.map(|archive| (archive, imports))
.map_err(|err| Error::RuntimeOperationFailed { .map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(), runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(), code: "profile_source_archive_invalid".to_string(),
@ -1369,6 +1470,11 @@ fn summarize_source(
profile_source_id: source_id.to_string(), profile_source_id: source_id.to_string(),
display_path, display_path,
kind: "decodal".to_string(), kind: "decodal".to_string(),
content_type: "text/x-decodal".to_string(),
content_digest: checked_source_path(workspace_root, relative_path)
.map(|path| file_content_digest(&path))
.unwrap_or_else(|_| "sha256:unavailable".to_string()),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: diagnostics editable: diagnostics
.iter() .iter()
.all(|d| d.severity != DiagnosticSeverity::Error), .all(|d| d.severity != DiagnosticSeverity::Error),
@ -1506,6 +1612,98 @@ fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String>
Ok(()) Ok(())
} }
fn profile_source_tree_digest(workspace_root: &Path) -> Result<String> {
let mut bytes = Vec::new();
for file in list_profile_tree_files(workspace_root)? {
bytes.extend_from_slice(file.path.as_bytes());
bytes.push(0);
bytes.extend_from_slice(file.content_digest.as_bytes());
bytes.push(0);
}
Ok(sha256_hex(&bytes))
}
fn file_content_digest(path: &Path) -> String {
fs::read(path)
.map(|bytes| sha256_hex(&bytes))
.unwrap_or_else(|_| "sha256:unavailable".to_string())
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::from("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> Result<PathBuf> {
validate_relative_source_path(relative_path).map_err(|message| {
Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_path_escape".to_string(),
message,
}
})?;
let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
fs::create_dir_all(&source_root)?;
let canonical_root = fs::canonicalize(&source_root)?;
let full = workspace_root.join(relative_path);
let parent = full.parent().ok_or_else(|| {
profile_validation_error(
"profile_source_path_invalid",
"Profile source path has no parent directory",
)
})?;
let parent_relative = parent.strip_prefix(&source_root).map_err(|_| {
profile_validation_error(
"profile_source_path_escape",
"Profile source parent must remain inside the source tree",
)
})?;
let mut current = source_root.clone();
for component in parent_relative.components() {
let Component::Normal(name) = component else {
return Err(profile_validation_error(
"profile_source_path_escape",
"Profile source parent must be a normalized relative path",
));
};
let next = current.join(name);
match fs::symlink_metadata(&next) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_symlink_escape".to_string(),
message: "Profile source parent contains a symlink".to_string(),
});
}
if !metadata.is_dir() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source parent component is not a directory",
));
}
let canonical_next = fs::canonicalize(&next)?;
if !canonical_next.starts_with(&canonical_root) {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_symlink_escape".to_string(),
message: "Profile source parent resolves outside the workspace profile source root".to_string(),
});
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&next)?,
Err(err) => return Err(err.into()),
}
current = next;
}
checked_source_path(workspace_root, relative_path)
}
fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result<PathBuf> { fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result<PathBuf> {
validate_relative_source_path(relative_path).map_err(|message| { validate_relative_source_path(relative_path).map_err(|message| {
Error::RuntimeOperationFailed { Error::RuntimeOperationFailed {
@ -1895,4 +2093,77 @@ mod tests {
assert!(rendered.contains("profile_source_symlink_escape")); assert!(rendered.contains("profile_source_symlink_escape"));
assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); assert!(!rendered.contains(dir.path().to_string_lossy().as_ref()));
} }
#[test]
fn selected_profile_archive_contains_only_import_closure() {
let mut registry = ProfileRegistryDocument::default();
registry.profile.insert(
"alpha".to_string(),
ProfileEntryFile::Table(ProfileEntryTable {
path: "profiles/alpha.dcdl".to_string(),
description: None,
}),
);
registry.profile.insert(
"beta".to_string(),
ProfileEntryFile::Table(ProfileEntryTable {
path: "profiles/beta.dcdl".to_string(),
description: None,
}),
);
let mut sources = BTreeMap::new();
sources.insert(
"profiles/alpha.dcdl".to_string(),
r#"{ extra = import "./shared.dcdl"; }"#.to_string(),
);
sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/beta.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/unregistered.dcdl".to_string(), "{}".to_string());
let archive =
build_profile_archive_for_selector(&registry, sources, "project:alpha").unwrap();
let verified = archive.verify().unwrap();
let manifest = verified.manifest();
let source_paths: Vec<_> = manifest
.sources
.iter()
.map(|source| source.path.as_str())
.collect();
assert_eq!(manifest.entrypoints.len(), 1);
assert_eq!(
manifest
.entrypoints
.get("project:alpha")
.map(String::as_str),
Some("profiles/alpha.dcdl")
);
assert!(source_paths.contains(&"profiles/alpha.dcdl"));
assert!(source_paths.contains(&"profiles/shared.dcdl"));
assert!(!source_paths.contains(&"profiles/beta.dcdl"));
assert!(!source_paths.contains(&"profiles/unregistered.dcdl"));
}
#[cfg(unix)]
#[test]
fn tree_write_rejects_symlink_parent_before_outside_side_effect() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join(".yoi/profiles/link")).unwrap();
let err = write_profile_tree_file(
"workspace-test",
dir.path(),
PROFILE_SOURCE_TREE_ID,
WriteWorkspaceProfileTreeFileRequest {
path: "profiles/link/nested/new.dcdl".to_string(),
content: valid_decodal("new"),
revision: None,
},
)
.unwrap_err();
assert!(err.to_string().contains("profile_source_symlink_escape"));
assert!(!outside.path().join("nested").exists());
}
} }

View File

@ -17,6 +17,7 @@
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
"@codemirror/state": "npm:@codemirror/state@6.5.2", "@codemirror/state": "npm:@codemirror/state@6.5.2",
"@codemirror/view": "npm:@codemirror/view@6.38.8", "@codemirror/view": "npm:@codemirror/view@6.38.8",
"decodal-codemirror": "npm:decodal-codemirror@0.1.2",
"clsx": "npm:clsx@2.1.1", "clsx": "npm:clsx@2.1.1",
"cookie": "npm:cookie@0.6.0", "cookie": "npm:cookie@0.6.0",
"devalue": "npm:devalue@5.6.4", "devalue": "npm:devalue@5.6.4",

View File

@ -8,6 +8,7 @@
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_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:clsx@2.1.1": "2.1.1",
"npm:cookie@0.6.0": "0.6.0", "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:devalue@5.6.4": "5.6.4",
"npm:set-cookie-parser@2.7.2": "2.7.2", "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", "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:vite@7.2.7": "7.2.7"
}, },
"npm": { "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": { "@codemirror/state@6.5.2": {
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"dependencies": [ "dependencies": [
@ -188,6 +200,21 @@
"@jridgewell/sourcemap-codec" "@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": { "@marijn/find-cluster-break@1.0.3": {
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==" "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="
}, },
@ -421,6 +448,14 @@
"ms" "ms"
] ]
}, },
"decodal-codemirror@0.1.2": {
"integrity": "sha512-VR+cFsBLPb9kZU3gJhElZck+IUVhOC1HoWgA8bxP1kxwn+eCZaeBHErHESlRZbsWvN2J5T8VKcDCtxLDYe9QyA==",
"dependencies": [
"@codemirror/language",
"@lezer/highlight",
"@lezer/lr"
]
},
"deepmerge@4.3.1": { "deepmerge@4.3.1": {
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
}, },
@ -680,6 +715,7 @@
"npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:@sveltejs/vite-plugin-svelte@6.2.1",
"npm:clsx@2.1.1", "npm:clsx@2.1.1",
"npm:cookie@0.6.0", "npm:cookie@0.6.0",
"npm:decodal-codemirror@0.1.2",
"npm:devalue@5.6.4", "npm:devalue@5.6.4",
"npm:set-cookie-parser@2.7.2", "npm:set-cookie-parser@2.7.2",
"npm:svelte-check@4.3.4", "npm:svelte-check@4.3.4",

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { decodal } from 'decodal-codemirror';
let { let {
value = '', value = '',
@ -42,6 +43,7 @@
lineNumbers(), lineNumbers(),
drawSelection(), drawSelection(),
highlightActiveLine(), highlightActiveLine(),
decodal(),
keymap.of([]), keymap.of([]),
EditorState.readOnly.of(readonly), EditorState.readOnly.of(readonly),
EditorView.editable.of(!readonly), EditorView.editable.of(!readonly),

View File

@ -6,6 +6,7 @@ import {
SETTINGS_SECTIONS, SETTINGS_SECTIONS,
settingsSectionHref, settingsSectionHref,
} from "./model.ts"; } from "./model.ts";
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from "./profile-routes.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => void): void; 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", "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");
});

View File

@ -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}`;
}

View File

@ -30,6 +30,9 @@ export type WorkspaceProfileSourceSummary = {
profile_source_id: string; profile_source_id: string;
display_path: string; display_path: string;
kind: "decodal" | string; kind: "decodal" | string;
content_type: string;
content_digest: string;
provenance: "project_profile_source_tree" | string;
editable: boolean; editable: boolean;
revision: string; revision: string;
size_bytes: number; size_bytes: number;
@ -65,6 +68,9 @@ export type WorkspaceProfileSourceTreeSummary = {
label: string; label: string;
root_path: string; root_path: string;
kind: "decodal_source_tree" | string; kind: "decodal_source_tree" | string;
content_type: string;
content_digest: string;
provenance: "project_profile_source_tree" | string;
editable: boolean; editable: boolean;
revision: string; revision: string;
file_count: number; file_count: number;
@ -74,6 +80,9 @@ export type WorkspaceProfileSourceTreeSummary = {
export type WorkspaceProfileSourceTreeFileSummary = { export type WorkspaceProfileSourceTreeFileSummary = {
path: string; path: string;
kind: "decodal" | string; kind: "decodal" | string;
content_type: string;
content_digest: string;
provenance: "project_profile_source_tree" | string;
editable: boolean; editable: boolean;
revision: string; revision: string;
size_bytes: number; size_bytes: number;

View File

@ -4,10 +4,12 @@
import { import {
fetchProfileSettings, fetchProfileSettings,
fetchProfileSourceTree, fetchProfileSourceTree,
deleteProfileTreeFile,
fetchProfileTreeFile, fetchProfileTreeFile,
writeProfileTreeFile, writeProfileTreeFile,
} from '$lib/workspace-settings/profile-api'; } from '$lib/workspace-settings/profile-api';
import type { Diagnostic } from '$lib/workspace-settings/model'; import type { Diagnostic } from '$lib/workspace-settings/model';
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
import type { import type {
ProfileSettingsResponse, ProfileSettingsResponse,
WorkspaceProfileSourceTreeFileResponse, WorkspaceProfileSourceTreeFileResponse,
@ -24,6 +26,9 @@
let draftContent = $state(''); let draftContent = $state('');
let loading = $state(true); let loading = $state(true);
let saving = $state(false); 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 message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]); let diagnostics = $state<Diagnostic[]>([]);
@ -54,6 +59,46 @@
diagnostics = selectedFile.diagnostics; 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() { async function saveSelectedFile() {
if (!selectedFile) return; if (!selectedFile) return;
saving = true; saving = true;
@ -128,14 +173,21 @@
<h3>Profile source tree</h3> <h3>Profile source tree</h3>
<p class="settings-note">Virtual Decodal paths exposed by the Backend-owned source tree.</p> <p class="settings-note">Virtual Decodal paths exposed by the Backend-owned source tree.</p>
{#if sourceTree} {#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"> <ul class="settings-profile-list">
{#each sourceTree.files as file (file.path)} {#each sourceTree.files as file (file.path)}
<li> <li>
<button class="link-button" type="button" onclick={() => selectTreeFile(sourceTree!.tree.source_tree_id, file.path)}> <button class="link-button" type="button" onclick={() => selectTreeFile(sourceTree!.tree.source_tree_id, file.path)}>
<strong>{file.path}</strong> <strong>{file.path}</strong>
</button> </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> <small>{file.editable ? 'editable' : 'read-only'} · rev {file.revision}</small>
<DiagnosticsList diagnostics={file.diagnostics} /> <DiagnosticsList diagnostics={file.diagnostics} />
</li> </li>
@ -154,9 +206,14 @@
<p class="eyebrow">{selectedFile.source_tree_id}</p> <p class="eyebrow">{selectedFile.source_tree_id}</p>
<h3>{selectedFile.file.path}</h3> <h3>{selectedFile.file.path}</h3>
</div> </div>
<div class="settings-editor-actions">
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}> <button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}>
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
</button> </button>
<button type="button" disabled={deleting} onclick={deleteSelectedFile}>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</header> </header>
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} /> <DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
</article> </article>

View File

@ -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>

View File

@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params }) => ({
sourceTreeId: params.sourceTreeId,
});