feat: add profile source tree editor

This commit is contained in:
Keisuke Hirata 2026-07-09 17:22:57 +09:00
parent 7da14f1a8d
commit 81abfa630a
No known key found for this signature in database
11 changed files with 1106 additions and 121 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-09T07:57:59Z' updated_at: '2026-07-09T08:22:18Z'
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

@ -123,4 +123,29 @@ Acceptance basis:
- Ticket scope is ProfileSourceTree virtual filesystem + Decodal editor follow-up on top of closed Decodal/ProfileSourceArchive and Workspace/Profile settings work。 - Ticket scope is ProfileSourceTree virtual filesystem + Decodal editor follow-up on top of closed Decodal/ProfileSourceArchive and Workspace/Profile settings work。
- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。
---
<!-- event: implementation_report author: hare at: 2026-07-09T08:22:18Z -->
## Implementation report
Implemented ProfileSourceTree virtual source-tree support for project Decodal profiles.
Summary:
- Added Runtime archive manifest validation for virtual source metadata, duplicate/undeclared tar entries, scoped import-map keys, namespace/path safety, and current-source relative import resolution without filesystem reads.
- Added Backend-owned project `ProfileSourceTree` API over virtual `profiles/...` paths, revision-aware read/write/delete file operations, path validation, tree scanning, import closure/archive building from tree contents, and project-profile config bundle generation from archives.
- Extended workspace server routes and browser API/types for scoped profile source tree access without exposing host absolute paths or resource/archive internals.
- Added a Profiles settings Decodal CodeMirror editor backed by virtual paths/revision tokens only.
- Added worker-runtime tests for scoped virtual imports and import-map mismatch rejection.
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

@ -2,7 +2,7 @@ use decodal::{Engine, LoadedSource, SourceLoader};
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value}; use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write}; use std::io::{Cursor, Read, Write};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use tar::{Archive, Builder, Header}; use tar::{Archive, Builder, Header};
@ -27,6 +27,10 @@ pub enum ProfileArchiveError {
MissingManifest, MissingManifest,
#[error("profile source archive missing source {0}")] #[error("profile source archive missing source {0}")]
MissingSource(String), 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( #[error(
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}" "profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
)] )]
@ -47,6 +51,14 @@ pub enum ProfileArchiveError {
MissingEntrypoint(String), MissingEntrypoint(String),
#[error("profile source archive import is not declared in manifest import map: {0}")] #[error("profile source archive import is not declared in manifest import map: {0}")]
ImportNotDeclared(String), 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}")] #[error("failed to read profile source archive: {0}")]
Io(String), Io(String),
#[error("failed to parse profile source archive manifest: {0}")] #[error("failed to parse profile source archive manifest: {0}")]
@ -94,7 +106,11 @@ pub struct ProfileSourceArchiveManifest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveSource { pub struct ProfileSourceArchiveSource {
pub path: String, pub path: String,
#[serde(default)]
pub source_key: String,
pub kind: String, pub kind: String,
#[serde(default = "default_decodal_content_type")]
pub content_type: String,
pub digest: String, pub digest: String,
pub size_bytes: u64, pub size_bytes: u64,
} }
@ -128,7 +144,9 @@ impl ProfileSourceArchive {
} }
source_meta.push(ProfileSourceArchiveSource { source_meta.push(ProfileSourceArchiveSource {
path: path.clone(), path: path.clone(),
source_key: path.clone(),
kind: "decodal".to_string(), kind: "decodal".to_string(),
content_type: default_decodal_content_type(),
digest: sha256_hex(source.as_bytes()), digest: sha256_hex(source.as_bytes()),
size_bytes: size, size_bytes: size,
}); });
@ -238,7 +256,9 @@ impl VerifiedProfileSourceArchive {
} else { } else {
let source = String::from_utf8(data) let source = String::from_utf8(data)
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?; .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 { if entries.len() > MAX_SOURCES {
return Err(ProfileArchiveError::LimitExceeded("source count")); return Err(ProfileArchiveError::LimitExceeded("source count"));
@ -258,8 +278,25 @@ impl VerifiedProfileSourceArchive {
}); });
} }
let mut total = 0u64; let mut total = 0u64;
let mut manifest_paths = BTreeSet::new();
for source in &manifest.sources { for source in &manifest.sources {
validate_archive_path(&source.path)?; 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" { if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource { return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(), path: source.path.clone(),
@ -285,6 +322,11 @@ impl VerifiedProfileSourceArchive {
} }
total += size; 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 if total != reference.source_graph.total_source_bytes
|| manifest.sources.len() != reference.source_graph.source_count || manifest.sources.len() != reference.source_graph.source_count
{ {
@ -298,10 +340,13 @@ impl VerifiedProfileSourceArchive {
.chain(manifest.imports.values()) .chain(manifest.imports.values())
{ {
validate_archive_path(path)?; validate_archive_path(path)?;
if !entries.contains_key(path) { if !manifest_paths.contains(path) || !entries.contains_key(path) {
return Err(ProfileArchiveError::MissingSource(path.clone())); return Err(ProfileArchiveError::MissingSource(path.clone()));
} }
} }
for (key, path) in &manifest.imports {
validate_archive_import_map_entry(key, path, &manifest_paths)?;
}
Ok(Self { Ok(Self {
reference, reference,
manifest, manifest,
@ -313,6 +358,10 @@ impl VerifiedProfileSourceArchive {
&self.reference &self.reference
} }
pub fn manifest(&self) -> &ProfileSourceArchiveManifest {
&self.manifest
}
pub fn resolve_profile( pub fn resolve_profile(
&self, &self,
selector: &str, selector: &str,
@ -378,22 +427,26 @@ impl<'a> ArchiveSourceLoader<'a> {
impl SourceLoader for ArchiveSourceLoader<'_> { impl SourceLoader for ArchiveSourceLoader<'_> {
fn load( fn load(
&mut self, &mut self,
_current_key: Option<&str>, current_key: Option<&str>,
specifier: &str, specifier: &str,
) -> decodal::Result<LoadedSource> { ) -> decodal::Result<LoadedSource> {
let path = self let path =
.archive archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
.manifest .map_err(import_diagnostic)?;
.imports if let Some(current_key) = current_key {
.get(specifier) match resolve_archive_import_path(current_key, specifier) {
.ok_or_else(|| { Ok(expected) if expected == path => {}
decodal::Diagnostic::new( Ok(expected) => {
decodal::DiagnosticKind::Import, return Err(import_diagnostic(ProfileArchiveError::ImportMapMismatch {
decodal::Span::default(), specifier: format!("{current_key}\0{specifier}"),
format!("archive import not declared: {specifier}"), expected,
) actual: path.clone(),
})?; }));
let source = self.archive.sources.get(path).ok_or_else(|| { }
Err(err) => return Err(import_diagnostic(err)),
}
}
let source = self.archive.sources.get(&path).ok_or_else(|| {
decodal::Diagnostic::new( decodal::Diagnostic::new(
decodal::DiagnosticKind::Import, decodal::DiagnosticKind::Import,
decodal::Span::default(), 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 { fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
match data { match data {
decodal::Data::Bool(value) => serde_json::Value::Bool(*value), decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
@ -551,4 +721,56 @@ mod tests {
let mut loader = ArchiveSourceLoader::new(&verified); let mut loader = ArchiveSourceLoader::new(&verified);
assert!(loader.load(None, "missing").is_err()); 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 { .. })
));
}
} }

View File

@ -14,6 +14,8 @@ use crate::{Error, Result};
const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml"; const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml";
const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles"; const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles";
const PROFILE_SOURCE_TREE_ID: &str = "project";
const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles";
const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024;
const BUILTIN_PROFILE_IDS: &[&str] = &[ const BUILTIN_PROFILE_IDS: &[&str] = &[
"builtin:default", "builtin:default",
@ -63,6 +65,8 @@ pub struct ProfileSettingsResponse {
pub default_profile: Option<String>, pub default_profile: Option<String>,
pub profiles: Vec<WorkspaceProfileSummary>, pub profiles: Vec<WorkspaceProfileSummary>,
pub sources: Vec<WorkspaceProfileSourceSummary>, pub sources: Vec<WorkspaceProfileSourceSummary>,
#[serde(default)]
pub source_trees: Vec<WorkspaceProfileSourceTreeSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
@ -92,6 +96,45 @@ pub struct WorkspaceProfileSourceSummary {
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeSummary {
pub source_tree_id: String,
pub label: String,
pub root_path: String,
pub kind: String,
pub editable: bool,
pub revision: String,
pub file_count: usize,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeFileSummary {
pub path: String,
pub kind: String,
pub editable: bool,
pub revision: String,
pub size_bytes: u64,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeResponse {
pub workspace_id: String,
pub tree: WorkspaceProfileSourceTreeSummary,
pub files: Vec<WorkspaceProfileSourceTreeFileSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeFileResponse {
pub workspace_id: String,
pub source_tree_id: String,
pub file: WorkspaceProfileSourceTreeFileSummary,
pub content: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceDetailResponse { pub struct WorkspaceProfileSourceDetailResponse {
pub workspace_id: String, pub workspace_id: String,
@ -137,6 +180,28 @@ pub struct UpdateWorkspaceProfileSourceRequest {
pub revision: String, pub revision: String,
} }
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WriteWorkspaceProfileTreeFileRequest {
pub path: String,
pub content: String,
#[serde(default)]
pub revision: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeleteWorkspaceProfileTreeFileRequest {
pub path: String,
pub revision: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadWorkspaceProfileTreeFileQuery {
pub path: String,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct DeleteWorkspaceProfileSourceRequest { pub struct DeleteWorkspaceProfileSourceRequest {
@ -355,6 +420,7 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi
default_profile: registry.default, default_profile: registry.default,
profiles, profiles,
sources, sources,
source_trees: vec![profile_source_tree_summary(workspace_root)],
diagnostics, diagnostics,
} }
} }
@ -580,6 +646,115 @@ pub fn delete_profile_source(
}) })
} }
pub fn read_profile_source_tree(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
) -> Result<WorkspaceProfileSourceTreeResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let files = list_profile_tree_files(workspace_root)?;
Ok(WorkspaceProfileSourceTreeResponse {
workspace_id: workspace_id.to_string(),
tree: profile_source_tree_summary_with_count(workspace_root, files.len()),
files,
diagnostics: Vec::new(),
})
}
pub fn read_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
query: ReadWorkspaceProfileTreeFileQuery,
) -> Result<WorkspaceProfileSourceTreeFileResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let relative_path = relative_source_path_for_virtual_path(&query.path)?;
let full = checked_source_path(workspace_root, &relative_path)?;
let metadata = source_metadata(&full)?;
if metadata.len() > MAX_PROFILE_SOURCE_BYTES {
return Err(profile_validation_error(
"profile_source_too_large",
"Profile source is too large for browser editing",
));
}
let content = fs::read_to_string(&full)?;
Ok(WorkspaceProfileSourceTreeFileResponse {
workspace_id: workspace_id.to_string(),
source_tree_id: source_tree_id.to_string(),
file: summarize_tree_file(&full, display_source_path(&relative_path)),
content,
diagnostics: Vec::new(),
})
}
pub fn write_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
request: WriteWorkspaceProfileTreeFileRequest,
) -> Result<WorkspaceProfileSourceTreeFileResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
if request.content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES {
return Err(profile_validation_error(
"profile_source_too_large",
"Profile source exceeds the browser editing size limit",
));
}
let relative_path = relative_source_path_for_virtual_path(&request.path)?;
let full = workspace_root.join(&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() {
ensure_revision(&full, expected, "profile_source_revision_conflict")?;
} else if full.exists() {
return Err(profile_validation_error(
"profile_source_revision_required",
"Existing profile source edits require a revision token",
));
}
validate_tree_content(workspace_root, &relative_path, &request.content)?;
fs::write(&full, &request.content)?;
Ok(WorkspaceProfileSourceTreeFileResponse {
workspace_id: workspace_id.to_string(),
source_tree_id: source_tree_id.to_string(),
file: summarize_tree_file(&full, display_source_path(&relative_path)),
content: request.content,
diagnostics: vec![diagnostic(
"profile_source_file_written",
DiagnosticSeverity::Info,
"Profile source file was written through the source tree API.",
)],
})
}
pub fn delete_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
request: DeleteWorkspaceProfileTreeFileRequest,
) -> Result<WorkspaceProfileSourceTreeResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let relative_path = relative_source_path_for_virtual_path(&request.path)?;
let full = checked_source_path(workspace_root, &relative_path)?;
ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?;
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
if project_entries(&registry, &mut Vec::new())
.iter()
.any(|entry| entry.relative_path == relative_path)
{
return Err(profile_validation_error(
"profile_source_registered",
"Registered profile entry sources must be deleted through the profile registry API",
));
}
if full.exists() {
fs::remove_file(full)?;
}
read_profile_source_tree(workspace_id, workspace_root, source_tree_id)
}
pub fn build_workspace_profile_archive( pub fn build_workspace_profile_archive(
workspace_root: &Path, workspace_root: &Path,
selector: &str, selector: &str,
@ -588,43 +763,8 @@ pub fn build_workspace_profile_archive(
return Ok(None); return Ok(None);
} }
let registry = read_registry(workspace_root).map_err(profile_registry_error)?; let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let mut entrypoints = BTreeMap::new(); let sources = read_profile_source_tree_contents(workspace_root)?;
let mut sources = BTreeMap::new(); let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
for entry in project_entries(&registry, &mut Vec::new()) {
let path = archive_path_for_entry(&entry.name);
let full = checked_source_path(workspace_root, &entry.relative_path)?;
let content = fs::read_to_string(&full)?;
sources.insert(path.clone(), content);
entrypoints.insert(project_selector(&entry.name), path);
}
if !entrypoints.contains_key(selector) {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "unknown_profile_selector".to_string(),
message: "Selected project profile is not present in the workspace profile registry"
.to_string(),
});
}
if let Some(default) = registry
.default
.as_deref()
.filter(|value| value.starts_with("project:"))
{
if let Some(path) = entrypoints.get(default).cloned() {
entrypoints.insert("default".to_string(), path);
}
}
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "workspace-project-decodal-profiles-v1".to_string(),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
archive archive
.verify() .verify()
.and_then(|verified| { .and_then(|verified| {
@ -845,6 +985,305 @@ fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> {
)) ))
} }
fn profile_source_tree_summary(workspace_root: &Path) -> WorkspaceProfileSourceTreeSummary {
let file_count = list_profile_tree_files(workspace_root)
.map(|files| files.len())
.unwrap_or(0);
profile_source_tree_summary_with_count(workspace_root, file_count)
}
fn profile_source_tree_summary_with_count(
workspace_root: &Path,
file_count: usize,
) -> WorkspaceProfileSourceTreeSummary {
WorkspaceProfileSourceTreeSummary {
source_tree_id: PROFILE_SOURCE_TREE_ID.to_string(),
label: "Project profile sources".to_string(),
root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(),
kind: "decodal_source_tree".to_string(),
editable: true,
revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)),
file_count,
diagnostics: Vec::new(),
}
}
fn ensure_profile_source_tree_id(source_tree_id: &str) -> Result<()> {
if source_tree_id == PROFILE_SOURCE_TREE_ID {
Ok(())
} else {
Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "unknown_profile_source_tree".to_string(),
message: "Unknown profile source tree".to_string(),
})
}
}
fn list_profile_tree_files(
workspace_root: &Path,
) -> Result<Vec<WorkspaceProfileSourceTreeFileSummary>> {
let mut files = Vec::new();
collect_profile_tree_files(
workspace_root,
&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH),
&mut files,
)?;
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(files)
}
fn collect_profile_tree_files(
workspace_root: &Path,
dir: &Path,
files: &mut Vec<WorkspaceProfileSourceTreeFileSummary>,
) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_profile_tree_files(workspace_root, &path, files)?;
} else if file_type.is_file()
&& path.extension().and_then(|value| value.to_str()) == Some("dcdl")
{
let relative = path
.strip_prefix(workspace_root)
.map_err(|_| {
profile_validation_error(
"profile_source_path_invalid",
"Profile source is outside workspace",
)
})?
.to_path_buf();
files.push(summarize_tree_file(&path, display_source_path(&relative)));
}
}
Ok(())
}
fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSourceTreeFileSummary {
WorkspaceProfileSourceTreeFileSummary {
path: virtual_path,
kind: "decodal".to_string(),
editable: true,
revision: file_revision(path),
size_bytes: source_metadata(path)
.map(|metadata| metadata.len())
.unwrap_or(0),
diagnostics: Vec::new(),
}
}
fn relative_source_path_for_virtual_path(virtual_path: &str) -> Result<PathBuf> {
let normalized = normalize_virtual_profile_source_path(virtual_path)?;
let Some(rest) = normalized.strip_prefix("profiles/") else {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source paths must be under profiles/",
));
};
if rest.is_empty() || !rest.ends_with(".dcdl") {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source files must use the .dcdl extension",
));
}
Ok(Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH).join(rest))
}
fn display_source_path(relative_path: &Path) -> String {
let profile_root = Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
let relative = relative_path
.strip_prefix(profile_root)
.unwrap_or(relative_path);
let rest = relative.to_string_lossy().replace('\\', "/");
format!("{PROFILE_SOURCE_TREE_DISPLAY_ROOT}/{rest}")
}
fn normalize_virtual_profile_source_path(path: &str) -> Result<String> {
let path = path
.strip_prefix("project:")
.or_else(|| path.strip_prefix("workspace:"))
.unwrap_or(path);
if path.is_empty() || path.contains("://") || Path::new(path).is_absolute() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must be a virtual relative path",
));
}
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must not escape the virtual source tree",
));
}
}
}
let normalized = normalized.to_string_lossy().replace('\\', "/");
if normalized.is_empty() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must not be empty",
));
}
Ok(normalized)
}
fn read_profile_source_tree_contents(workspace_root: &Path) -> Result<BTreeMap<String, String>> {
let mut sources = BTreeMap::new();
for file in list_profile_tree_files(workspace_root)? {
let relative = relative_source_path_for_virtual_path(&file.path)?;
let full = checked_source_path(workspace_root, &relative)?;
sources.insert(file.path, fs::read_to_string(full)?);
}
Ok(sources)
}
fn build_profile_archive_from_tree_sources(
registry: &ProfileRegistryDocument,
mut sources: BTreeMap<String, String>,
) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> {
let mut entrypoints = BTreeMap::new();
for entry in project_entries(registry, &mut Vec::new()) {
let path = display_source_path(&entry.relative_path);
if sources.contains_key(&path) {
entrypoints.insert(project_selector(&entry.name), path);
}
}
if let Some(default) = registry
.default
.as_deref()
.filter(|value| value.starts_with("project:"))
{
if let Some(path) = entrypoints.get(default).cloned() {
entrypoints.insert("default".to_string(), path);
}
}
let mut imports = BTreeMap::new();
let source_snapshot = sources.clone();
for (current_path, content) in &source_snapshot {
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if source_snapshot.contains_key(&target) {
imports.insert(format!("{current_path}\0{specifier}"), target);
} else {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
}
}
if sources.is_empty() {
sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string());
}
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "workspace-project-decodal-profiles-v1".to_string(),
entrypoints,
imports: imports.clone(),
sources,
})
.map(|archive| (archive, imports))
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})
}
fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result<String> {
if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be a virtual relative path",
));
}
let raw = specifier
.strip_prefix("project:")
.or_else(|| specifier.strip_prefix("workspace:"))
.unwrap_or(specifier);
if specifier.contains(':') && raw == specifier {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Unsupported profile source import namespace",
));
}
let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") {
PathBuf::from(raw)
} else {
Path::new(current_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
};
let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?;
if !normalized.starts_with("profiles/") {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must resolve inside the profiles/ tree",
));
}
Ok(normalized)
}
fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
let mut specifiers = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') {
continue;
}
let Some(index) = trimmed.find("import") else {
continue;
};
let after = trimmed[index + "import".len()..].trim_start();
let Some(first) = after.chars().next() else {
continue;
};
if first == '"' || first == '\'' {
if let Some(end) = after[1..].find(first) {
specifiers.push(after[1..1 + end].to_string());
}
} else {
let ident: String = after
.chars()
.take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{')
.collect();
if !ident.is_empty() {
specifiers.push(ident);
}
}
}
specifiers
}
fn validate_tree_content(workspace_root: &Path, relative_path: &Path, content: &str) -> Result<()> {
let mut sources = read_profile_source_tree_contents(workspace_root)?;
sources.insert(display_source_path(relative_path), content.to_string());
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
archive
.verify()
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_invalid".to_string(),
message: err.to_string(),
})?;
Ok(())
}
fn validate_source_content( fn validate_source_content(
workspace_root: &Path, workspace_root: &Path,
name: &str, name: &str,
@ -858,24 +1297,18 @@ fn validate_source_content(
message: "Profile source exceeds the browser editing size limit".to_string(), message: "Profile source exceeds the browser editing size limit".to_string(),
}); });
} }
let archive_path = archive_path_for_entry(name); checked_source_path(workspace_root, relative_path)?;
let mut sources = read_profile_source_tree_contents(workspace_root)?;
sources.insert(display_source_path(relative_path), content.to_string());
let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?;
registry.profile.entry(name.to_string()).or_insert_with(|| {
ProfileEntryFile::Table(ProfileEntryTable {
path: display_source_path(relative_path),
description: None,
})
});
let selector = project_selector(name); let selector = project_selector(name);
let mut entrypoints = BTreeMap::new(); let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
entrypoints.insert(selector.clone(), archive_path.clone());
entrypoints.insert("default".to_string(), archive_path.clone());
let mut sources = BTreeMap::new();
sources.insert(archive_path, content.to_string());
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: format!("workspace-profile-validation-{name}"),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
let verified = archive let verified = archive
.verify() .verify()
.map_err(|err| Error::RuntimeOperationFailed { .map_err(|err| Error::RuntimeOperationFailed {
@ -883,7 +1316,6 @@ fn validate_source_content(
code: "profile_source_archive_invalid".to_string(), code: "profile_source_archive_invalid".to_string(),
message: err.to_string(), message: err.to_string(),
})?; })?;
checked_source_path(workspace_root, relative_path)?;
verified verified
.resolve_profile(&selector, workspace_root, "workspace-settings-validation") .resolve_profile(&selector, workspace_root, "workspace-settings-validation")
.map_err(|err| Error::RuntimeOperationFailed { .map_err(|err| Error::RuntimeOperationFailed {
@ -1179,18 +1611,6 @@ pub fn selector_for_builtin_candidate(
} }
} }
fn archive_path_for_entry(name: &str) -> String {
format!("profiles/{name}.dcdl")
}
fn display_source_path(relative_path: &Path) -> String {
relative_path
.strip_prefix(".yoi")
.unwrap_or(relative_path)
.to_string_lossy()
.replace('\\', "/")
}
fn registry_path(workspace_root: &Path) -> PathBuf { fn registry_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH) workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH)
} }

View File

@ -38,8 +38,9 @@ use crate::observation::{
}; };
use crate::profile_settings::{ use crate::profile_settings::{
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
UpdateWorkspaceProfileSourceRequest, UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
}; };
use crate::records::{ use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
@ -281,6 +282,16 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/settings/profiles/registry", "/api/w/{workspace_id}/settings/profiles/registry",
put(scoped_update_profile_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( .route(
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}", "/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
get(scoped_get_profile_source) 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( async fn scoped_get_profile_source(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,

View File

@ -15,6 +15,8 @@
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
"@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/view": "npm:@codemirror/view@6.38.8",
"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

@ -1,6 +1,8 @@
{ {
"version": "5", "version": "5",
"specifiers": { "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/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/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:@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:vite@7.2.7": "7.2.7"
}, },
"npm": { "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": { "@esbuild/aix-ppc64@0.25.12": {
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"os": ["aix"], "os": ["aix"],
@ -171,6 +188,9 @@
"@jridgewell/sourcemap-codec" "@jridgewell/sourcemap-codec"
] ]
}, },
"@marijn/find-cluster-break@1.0.3": {
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="
},
"@polka/url@1.0.0-next.29": { "@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
}, },
@ -392,6 +412,9 @@
"cookie@0.6.0": { "cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
}, },
"crelt@1.0.7": {
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="
},
"debug@4.4.3": { "debug@4.4.3": {
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": [ "dependencies": [
@ -567,6 +590,9 @@
"source-map-js@1.2.1": { "source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" "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": { "svelte-check@4.3.4_svelte@5.45.6_typescript@5.9.3": {
"integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==", "integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==",
"dependencies": [ "dependencies": [
@ -638,12 +664,17 @@
"vite" "vite"
] ]
}, },
"w3c-keyname@2.2.8": {
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"zimmerframe@1.1.4": { "zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
} }
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"npm:@codemirror/state@6.5.2",
"npm:@codemirror/view@6.38.8",
"npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/adapter-static@3.0.9",
"npm:@sveltejs/kit@2.49.4", "npm:@sveltejs/kit@2.49.4",
"npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:@sveltejs/vite-plugin-svelte@6.2.1",

View File

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

View File

@ -8,6 +8,8 @@ import type {
WorkspaceMetadataMutationResponse, WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse, WorkspaceMetadataSettingsResponse,
WorkspaceProfileSourceDetailResponse, WorkspaceProfileSourceDetailResponse,
WorkspaceProfileSourceTreeFileResponse,
WorkspaceProfileSourceTreeResponse,
} from "./profile-types"; } from "./profile-types";
export function fetchWorkspaceMetadataSettings( export function fetchWorkspaceMetadataSettings(
@ -116,3 +118,44 @@ export function deleteProfileSource(
{ method: "DELETE", body: JSON.stringify(request) }, { 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

@ -42,6 +42,7 @@ export type ProfileSettingsResponse = {
default_profile?: string | null; default_profile?: string | null;
profiles: WorkspaceProfileSummary[]; profiles: WorkspaceProfileSummary[];
sources: WorkspaceProfileSourceSummary[]; sources: WorkspaceProfileSourceSummary[];
source_trees: WorkspaceProfileSourceTreeSummary[];
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
}; };
@ -58,3 +59,38 @@ export type ProfileSettingsMutationResponse = {
settings: ProfileSettingsResponse; settings: ProfileSettingsResponse;
diagnostics: Diagnostic[]; 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[];
};

View File

@ -1,40 +1,88 @@
<script lang="ts"> <script lang="ts">
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte'; 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 { 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'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? ''); let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let profileSettings = $state<ProfileSettingsResponse | null>(null); 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 loading = $state(true);
let saving = $state(false);
let message = $state<string | null>(null); let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]); let diagnostics = $state<Diagnostic[]>([]);
$effect(() => { async function loadSettings() {
if (!workspaceId) { 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; 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; let cancelled = false;
async function load() { async function load() {
loading = true; await loadSettings();
message = null; if (cancelled) return;
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;
}
} }
load(); if (workspaceId) load();
else loading = false;
return () => { return () => {
cancelled = true; cancelled = true;
}; };
@ -48,13 +96,13 @@
<section class="card settings-section" aria-labelledby="profile-sources-title"> <section class="card settings-section" aria-labelledby="profile-sources-title">
<header class="settings-section-header"> <header class="settings-section-header">
<div> <div>
<p class="eyebrow">read-only</p> <p class="eyebrow">Backend-owned source tree</p>
<h2 id="profile-sources-title">Profiles</h2> <h2 id="profile-sources-title">Profiles</h2>
</div> </div>
<span class="badge warning">editing pending</span> <span class="badge">Decodal editor</span>
</header> </header>
<p> <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> </p>
{#if loading} {#if loading}
@ -77,24 +125,46 @@
</ul> </ul>
</article> </article>
<article> <article>
<h3>Profile source files</h3> <h3>Profile source tree</h3>
<p class="settings-note">Decodal source files that define or contribute to launch profiles.</p> <p class="settings-note">Virtual Decodal paths exposed by the Backend-owned source tree.</p>
<ul class="settings-profile-list"> {#if sourceTree}
{#each profileSettings.sources as source (source.profile_source_id)} <small>{sourceTree.tree.root_path} · {sourceTree.tree.file_count} files · rev {sourceTree.tree.revision}</small>
<li> <ul class="settings-profile-list">
<strong>{source.display_path}</strong> {#each sourceTree.files as file (file.path)}
<span>{source.kind} · {source.size_bytes} bytes</span> <li>
<small>{source.editable ? 'editable later' : 'read-only'} · rev {source.revision}</small> <button class="link-button" type="button" onclick={() => selectTreeFile(sourceTree!.tree.source_tree_id, file.path)}>
<DiagnosticsList diagnostics={source.diagnostics} /> <strong>{file.path}</strong>
</li> </button>
{/each} <span>{file.kind} · {file.size_bytes} bytes</span>
</ul> <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> </article>
</div> </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}
{#if message} {#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} {/if}
<DiagnosticsList {diagnostics} /> <DiagnosticsList {diagnostics} />
</section> </section>