merge: profile source tree editor

# Conflicts:
#	.yoi/tickets/00001KX1JNJ2Y/item.md
#	.yoi/tickets/00001KX1JNJ2Y/thread.md
This commit is contained in:
Keisuke Hirata 2026-07-09 18:16:49 +09:00
commit 9a2e57fb0e
No known key found for this signature in database
13 changed files with 1841 additions and 135 deletions

View File

@ -2,7 +2,7 @@ use decodal::{Engine, LoadedSource, SourceLoader};
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write};
use std::path::{Component, Path, PathBuf};
use tar::{Archive, Builder, Header};
@ -27,6 +27,10 @@ pub enum ProfileArchiveError {
MissingManifest,
#[error("profile source archive missing source {0}")]
MissingSource(String),
#[error("profile source archive contains duplicate source {0}")]
DuplicateSource(String),
#[error("profile source archive contains source not declared in manifest {0}")]
UndeclaredSource(String),
#[error(
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
)]
@ -47,6 +51,14 @@ pub enum ProfileArchiveError {
MissingEntrypoint(String),
#[error("profile source archive import is not declared in manifest import map: {0}")]
ImportNotDeclared(String),
#[error(
"profile source archive import map mismatch for {specifier}: expected {expected}, got {actual}"
)]
ImportMapMismatch {
specifier: String,
expected: String,
actual: String,
},
#[error("failed to read profile source archive: {0}")]
Io(String),
#[error("failed to parse profile source archive manifest: {0}")]
@ -94,7 +106,11 @@ pub struct ProfileSourceArchiveManifest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveSource {
pub path: String,
#[serde(default)]
pub source_key: String,
pub kind: String,
#[serde(default = "default_decodal_content_type")]
pub content_type: String,
pub digest: String,
pub size_bytes: u64,
}
@ -128,7 +144,9 @@ impl ProfileSourceArchive {
}
source_meta.push(ProfileSourceArchiveSource {
path: path.clone(),
source_key: path.clone(),
kind: "decodal".to_string(),
content_type: default_decodal_content_type(),
digest: sha256_hex(source.as_bytes()),
size_bytes: size,
});
@ -238,7 +256,9 @@ impl VerifiedProfileSourceArchive {
} else {
let source = String::from_utf8(data)
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
entries.insert(path, source);
if entries.insert(path.clone(), source).is_some() {
return Err(ProfileArchiveError::DuplicateSource(path));
}
}
if entries.len() > MAX_SOURCES {
return Err(ProfileArchiveError::LimitExceeded("source count"));
@ -258,8 +278,25 @@ impl VerifiedProfileSourceArchive {
});
}
let mut total = 0u64;
let mut manifest_paths = BTreeSet::new();
for source in &manifest.sources {
validate_archive_path(&source.path)?;
if !source.source_key.is_empty() && source.source_key != source.path {
return Err(ProfileArchiveError::ImportMapMismatch {
specifier: source.path.clone(),
expected: source.path.clone(),
actual: source.source_key.clone(),
});
}
if source.content_type != default_decodal_content_type() {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.content_type.clone(),
});
}
if !manifest_paths.insert(source.path.clone()) {
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
}
if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
@ -285,6 +322,11 @@ impl VerifiedProfileSourceArchive {
}
total += size;
}
for entry_path in entries.keys() {
if !manifest_paths.contains(entry_path) {
return Err(ProfileArchiveError::UndeclaredSource(entry_path.clone()));
}
}
if total != reference.source_graph.total_source_bytes
|| manifest.sources.len() != reference.source_graph.source_count
{
@ -298,10 +340,13 @@ impl VerifiedProfileSourceArchive {
.chain(manifest.imports.values())
{
validate_archive_path(path)?;
if !entries.contains_key(path) {
if !manifest_paths.contains(path) || !entries.contains_key(path) {
return Err(ProfileArchiveError::MissingSource(path.clone()));
}
}
for (key, path) in &manifest.imports {
validate_archive_import_map_entry(key, path, &manifest_paths)?;
}
Ok(Self {
reference,
manifest,
@ -313,6 +358,10 @@ impl VerifiedProfileSourceArchive {
&self.reference
}
pub fn manifest(&self) -> &ProfileSourceArchiveManifest {
&self.manifest
}
pub fn resolve_profile(
&self,
selector: &str,
@ -378,22 +427,26 @@ impl<'a> ArchiveSourceLoader<'a> {
impl SourceLoader for ArchiveSourceLoader<'_> {
fn load(
&mut self,
_current_key: Option<&str>,
current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedSource> {
let path = self
.archive
.manifest
.imports
.get(specifier)
.ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("archive import not declared: {specifier}"),
)
})?;
let source = self.archive.sources.get(path).ok_or_else(|| {
let path =
archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
.map_err(import_diagnostic)?;
if let Some(current_key) = current_key {
match resolve_archive_import_path(current_key, specifier) {
Ok(expected) if expected == path => {}
Ok(expected) => {
return Err(import_diagnostic(ProfileArchiveError::ImportMapMismatch {
specifier: format!("{current_key}\0{specifier}"),
expected,
actual: path.clone(),
}));
}
Err(err) => return Err(import_diagnostic(err)),
}
}
let source = self.archive.sources.get(&path).ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
@ -408,6 +461,123 @@ impl SourceLoader for ArchiveSourceLoader<'_> {
}
}
fn default_decodal_content_type() -> String {
"text/x-decodal".to_string()
}
fn import_diagnostic(err: ProfileArchiveError) -> decodal::Diagnostic {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
err.to_string(),
)
}
fn archive_import_map_lookup(
imports: &BTreeMap<String, String>,
current_key: Option<&str>,
specifier: &str,
) -> Result<String, ProfileArchiveError> {
if let Some(current_key) = current_key {
let scoped = format!("{current_key}\0{specifier}");
if let Some(path) = imports.get(&scoped) {
return Ok(path.clone());
}
}
imports
.get(specifier)
.cloned()
.ok_or_else(|| ProfileArchiveError::ImportNotDeclared(specifier.to_string()))
}
fn validate_archive_import_map_entry(
key: &str,
path: &str,
manifest_paths: &BTreeSet<String>,
) -> Result<(), ProfileArchiveError> {
if !manifest_paths.contains(path) {
return Err(ProfileArchiveError::MissingSource(path.to_string()));
}
if let Some((current_key, specifier)) = key.split_once('\0') {
validate_archive_path(current_key)?;
let expected = resolve_archive_import_path(current_key, specifier)?;
if expected != path {
return Err(ProfileArchiveError::ImportMapMismatch {
specifier: key.to_string(),
expected,
actual: path.to_string(),
});
}
} else {
validate_archive_import_specifier(key)?;
}
Ok(())
}
fn resolve_archive_import_path(
current_key: &str,
specifier: &str,
) -> Result<String, ProfileArchiveError> {
validate_archive_path(current_key)?;
validate_archive_import_specifier(specifier)?;
let raw = if let Some(rest) = specifier.strip_prefix("project:") {
rest
} else if let Some(rest) = specifier.strip_prefix("workspace:") {
rest
} else {
specifier
};
let base = if raw.starts_with("./") || raw.starts_with("../") {
Path::new(current_key)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
} else {
PathBuf::from(raw)
};
normalize_archive_virtual_path(&base.to_string_lossy())
}
fn validate_archive_import_specifier(specifier: &str) -> Result<(), ProfileArchiveError> {
if specifier.is_empty()
|| specifier.contains("://")
|| specifier.starts_with('/')
|| Path::new(specifier).is_absolute()
{
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
}
if let Some((namespace, rest)) = specifier.split_once(':') {
if namespace != "project" && namespace != "workspace" {
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
}
if rest.is_empty() || rest.starts_with('/') || rest.contains("://") {
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
}
}
Ok(())
}
fn normalize_archive_virtual_path(path: &str) -> Result<String, ProfileArchiveError> {
let path_ref = Path::new(path);
if path_ref.is_absolute() {
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
}
let mut out = PathBuf::new();
for component in path_ref.components() {
match component {
Component::CurDir => {}
Component::Normal(value) => out.push(value),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
}
}
}
let normalized = out.to_string_lossy().replace('\\', "/");
validate_archive_path(&normalized)?;
Ok(normalized)
}
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
match data {
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
@ -551,4 +721,56 @@ mod tests {
let mut loader = ArchiveSourceLoader::new(&verified);
assert!(loader.load(None, "missing").is_err());
}
#[test]
fn archive_loader_resolves_scoped_virtual_imports() {
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
let mut imports = BTreeMap::new();
imports.insert(
"profiles/main.dcdl\0./shared.dcdl".to_string(),
"profiles/shared.dcdl".to_string(),
);
let mut sources = BTreeMap::new();
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string());
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "project".to_string(),
entrypoints,
imports,
sources,
})
.unwrap();
let verified = archive.verify().unwrap();
let mut loader = ArchiveSourceLoader::new(&verified);
let loaded = loader
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
.unwrap();
assert_eq!(loaded.key, "profiles/shared.dcdl");
}
#[test]
fn archive_verify_rejects_import_map_that_bypasses_virtual_resolver() {
let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
let mut imports = BTreeMap::new();
imports.insert(
"profiles/main.dcdl\0./shared.dcdl".to_string(),
"profiles/other.dcdl".to_string(),
);
let mut sources = BTreeMap::new();
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
sources.insert("profiles/other.dcdl".to_string(), "{}".to_string());
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "project".to_string(),
entrypoints,
imports,
sources,
})
.unwrap();
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::ImportMapMismatch { .. })
));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -38,8 +38,9 @@ use crate::observation::{
};
use crate::profile_settings::{
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
UpdateWorkspaceProfileSourceRequest,
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
};
use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
@ -281,6 +282,16 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/settings/profiles/registry",
put(scoped_update_profile_registry),
)
.route(
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}",
get(scoped_get_profile_source_tree),
)
.route(
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file",
get(scoped_get_profile_tree_file)
.put(scoped_write_profile_tree_file)
.delete(scoped_delete_profile_tree_file),
)
.route(
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
get(scoped_get_profile_source)
@ -907,6 +918,60 @@ async fn scoped_update_profile_registry(
)?))
}
async fn scoped_get_profile_source_tree(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::read_profile_source_tree(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
)?))
}
async fn scoped_get_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::read_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
query,
)?))
}
async fn scoped_write_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::write_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
request,
)?))
}
async fn scoped_delete_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::delete_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
request,
)?))
}
async fn scoped_get_profile_source(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,

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",

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",

View File

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

View File

@ -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");
});

View File

@ -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) },
);
}

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;
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[];
};

View File

@ -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) {
loading = false;
return;
}
let cancelled = false;
async function load() {
async function loadSettings() {
if (!workspaceId) return;
loading = true;
message = null;
try {
const response = await fetchProfileSettings(workspaceId);
if (!cancelled) {
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) {
if (!cancelled) message = err instanceof Error ? err.message : 'profile settings request failed';
message = err instanceof Error ? err.message : 'profile settings request failed';
} finally {
if (!cancelled) loading = false;
loading = false;
}
}
load();
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() {
await loadSettings();
if (cancelled) return;
}
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>
<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 profileSettings.sources as source (source.profile_source_id)}
{#each sourceTree.files as file (file.path)}
<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} />
<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>

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,
});