From 81abfa630ab834c1a791ee21aba7d7e7883fe622 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 17:22:57 +0900 Subject: [PATCH] feat: add profile source tree editor --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 25 + crates/worker-runtime/src/profile_archive.rs | 256 +++++++- .../workspace-server/src/profile_settings.rs | 554 +++++++++++++++--- crates/workspace-server/src/server.rs | 67 ++- web/workspace/deno.json | 2 + web/workspace/deno.lock | 31 + .../DecodalSourceEditor.svelte | 71 +++ .../src/lib/workspace-settings/profile-api.ts | 43 ++ .../lib/workspace-settings/profile-types.ts | 36 ++ .../settings/profiles/+page.svelte | 140 +++-- 11 files changed, 1106 insertions(+), 121 deletions(-) create mode 100644 web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index b6b65dbb..37f47bf6 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T07:57:59Z' +updated_at: '2026-07-09T08:22:18Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 1d662966..2fffc4de 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -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。 - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 +--- + + + +## 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` + + --- diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index b759c9d6..a610fe20 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -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 { - 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, + current_key: Option<&str>, + specifier: &str, +) -> Result { + 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, +) -> 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 { + 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 { + 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 { .. }) + )); + } } diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index f1113064..2475e0b5 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -14,6 +14,8 @@ use crate::{Error, Result}; const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml"; 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 BUILTIN_PROFILE_IDS: &[&str] = &[ "builtin:default", @@ -63,6 +65,8 @@ pub struct ProfileSettingsResponse { pub default_profile: Option, pub profiles: Vec, pub sources: Vec, + #[serde(default)] + pub source_trees: Vec, pub diagnostics: Vec, } @@ -92,6 +96,45 @@ pub struct WorkspaceProfileSourceSummary { pub diagnostics: Vec, } +#[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, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceTreeResponse { + pub workspace_id: String, + pub tree: WorkspaceProfileSourceTreeSummary, + pub files: Vec, + pub diagnostics: Vec, +} + +#[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, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceProfileSourceDetailResponse { pub workspace_id: String, @@ -137,6 +180,28 @@ pub struct UpdateWorkspaceProfileSourceRequest { 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, +} + +#[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)] #[serde(deny_unknown_fields)] pub struct DeleteWorkspaceProfileSourceRequest { @@ -355,6 +420,7 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi default_profile: registry.default, profiles, sources, + source_trees: vec![profile_source_tree_summary(workspace_root)], 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 { + 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 { + 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 { + 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 { + 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(®istry, &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( workspace_root: &Path, selector: &str, @@ -588,43 +763,8 @@ pub fn build_workspace_profile_archive( return Ok(None); } let registry = read_registry(workspace_root).map_err(profile_registry_error)?; - let mut entrypoints = BTreeMap::new(); - let mut sources = BTreeMap::new(); - for entry in project_entries(®istry, &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(), - })?; + let sources = read_profile_source_tree_contents(workspace_root)?; + let (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; archive .verify() .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> { + 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, +) -> 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 { + 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 { + 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> { + 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, +) -> Result<(ProfileSourceArchive, BTreeMap)> { + 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 { + 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 { + 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(®istry, 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( workspace_root: &Path, name: &str, @@ -858,24 +1297,18 @@ fn validate_source_content( 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 mut entrypoints = BTreeMap::new(); - 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 (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; let verified = archive .verify() .map_err(|err| Error::RuntimeOperationFailed { @@ -883,7 +1316,6 @@ fn validate_source_content( code: "profile_source_archive_invalid".to_string(), message: err.to_string(), })?; - checked_source_path(workspace_root, relative_path)?; verified .resolve_profile(&selector, workspace_root, "workspace-settings-validation") .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 { workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ffa67b8a..a4d33e96 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, +) -> ApiResult> { + 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, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, + Query(query): Query, +) -> ApiResult> { + 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, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + 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, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + 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, AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 0511dfa3..74c4df10 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -15,6 +15,8 @@ "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", + "@codemirror/state": "npm:@codemirror/state@6.5.2", + "@codemirror/view": "npm:@codemirror/view@6.38.8", "clsx": "npm:clsx@2.1.1", "cookie": "npm:cookie@0.6.0", "devalue": "npm:devalue@5.6.4", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index aa3b468c..8778ced0 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -1,6 +1,8 @@ { "version": "5", "specifiers": { + "npm:@codemirror/state@6.5.2": "6.5.2", + "npm:@codemirror/view@6.38.8": "6.38.8", "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", @@ -14,6 +16,21 @@ "npm:vite@7.2.7": "7.2.7" }, "npm": { + "@codemirror/state@6.5.2": { + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "dependencies": [ + "@marijn/find-cluster-break" + ] + }, + "@codemirror/view@6.38.8": { + "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", + "dependencies": [ + "@codemirror/state", + "crelt", + "style-mod", + "w3c-keyname" + ] + }, "@esbuild/aix-ppc64@0.25.12": { "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "os": ["aix"], @@ -171,6 +188,9 @@ "@jridgewell/sourcemap-codec" ] }, + "@marijn/find-cluster-break@1.0.3": { + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==" + }, "@polka/url@1.0.0-next.29": { "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" }, @@ -392,6 +412,9 @@ "cookie@0.6.0": { "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, + "crelt@1.0.7": { + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==" + }, "debug@4.4.3": { "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": [ @@ -567,6 +590,9 @@ "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "style-mod@4.1.3": { + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" + }, "svelte-check@4.3.4_svelte@5.45.6_typescript@5.9.3": { "integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==", "dependencies": [ @@ -638,12 +664,17 @@ "vite" ] }, + "w3c-keyname@2.2.8": { + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "zimmerframe@1.1.4": { "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" } }, "workspace": { "dependencies": [ + "npm:@codemirror/state@6.5.2", + "npm:@codemirror/view@6.38.8", "npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/kit@2.49.4", "npm:@sveltejs/vite-plugin-svelte@6.2.1", diff --git a/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte new file mode 100644 index 00000000..bd300a60 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte @@ -0,0 +1,71 @@ + + +
diff --git a/web/workspace/src/lib/workspace-settings/profile-api.ts b/web/workspace/src/lib/workspace-settings/profile-api.ts index 3b3df0c3..f03dbe75 100644 --- a/web/workspace/src/lib/workspace-settings/profile-api.ts +++ b/web/workspace/src/lib/workspace-settings/profile-api.ts @@ -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 { + return workspaceApiJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}`, + ); +} + +export function fetchProfileTreeFile( + workspaceId: string, + sourceTreeId: string, + path: string, +): Promise { + 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 { + 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 { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`, + { method: "DELETE", body: JSON.stringify(request) }, + ); +} diff --git a/web/workspace/src/lib/workspace-settings/profile-types.ts b/web/workspace/src/lib/workspace-settings/profile-types.ts index d421f2f6..5508e81f 100644 --- a/web/workspace/src/lib/workspace-settings/profile-types.ts +++ b/web/workspace/src/lib/workspace-settings/profile-types.ts @@ -42,6 +42,7 @@ export type ProfileSettingsResponse = { default_profile?: string | null; profiles: WorkspaceProfileSummary[]; sources: WorkspaceProfileSourceSummary[]; + source_trees: WorkspaceProfileSourceTreeSummary[]; diagnostics: Diagnostic[]; }; @@ -58,3 +59,38 @@ export type ProfileSettingsMutationResponse = { settings: ProfileSettingsResponse; diagnostics: Diagnostic[]; }; + +export type WorkspaceProfileSourceTreeSummary = { + source_tree_id: string; + label: string; + root_path: string; + kind: "decodal_source_tree" | string; + editable: boolean; + revision: string; + file_count: number; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceTreeFileSummary = { + path: string; + kind: "decodal" | string; + editable: boolean; + revision: string; + size_bytes: number; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceTreeResponse = { + workspace_id: string; + tree: WorkspaceProfileSourceTreeSummary; + files: WorkspaceProfileSourceTreeFileSummary[]; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceTreeFileResponse = { + workspace_id: string; + source_tree_id: string; + file: WorkspaceProfileSourceTreeFileSummary; + content: string; + diagnostics: Diagnostic[]; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte index 2cd14807..89e84377 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte @@ -1,40 +1,88 @@