feat: add profile source tree editor
This commit is contained in:
parent
7da14f1a8d
commit
81abfa630a
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 を作成してから開始する。
|
||||
|
||||
---
|
||||
|
||||
<!-- 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`
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use decodal::{Engine, LoadedSource, SourceLoader};
|
|||
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use tar::{Archive, Builder, Header};
|
||||
|
|
@ -27,6 +27,10 @@ pub enum ProfileArchiveError {
|
|||
MissingManifest,
|
||||
#[error("profile source archive missing source {0}")]
|
||||
MissingSource(String),
|
||||
#[error("profile source archive contains duplicate source {0}")]
|
||||
DuplicateSource(String),
|
||||
#[error("profile source archive contains source not declared in manifest {0}")]
|
||||
UndeclaredSource(String),
|
||||
#[error(
|
||||
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
|
||||
)]
|
||||
|
|
@ -47,6 +51,14 @@ pub enum ProfileArchiveError {
|
|||
MissingEntrypoint(String),
|
||||
#[error("profile source archive import is not declared in manifest import map: {0}")]
|
||||
ImportNotDeclared(String),
|
||||
#[error(
|
||||
"profile source archive import map mismatch for {specifier}: expected {expected}, got {actual}"
|
||||
)]
|
||||
ImportMapMismatch {
|
||||
specifier: String,
|
||||
expected: String,
|
||||
actual: String,
|
||||
},
|
||||
#[error("failed to read profile source archive: {0}")]
|
||||
Io(String),
|
||||
#[error("failed to parse profile source archive manifest: {0}")]
|
||||
|
|
@ -94,7 +106,11 @@ pub struct ProfileSourceArchiveManifest {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ProfileSourceArchiveSource {
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub source_key: String,
|
||||
pub kind: String,
|
||||
#[serde(default = "default_decodal_content_type")]
|
||||
pub content_type: String,
|
||||
pub digest: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
|
@ -128,7 +144,9 @@ impl ProfileSourceArchive {
|
|||
}
|
||||
source_meta.push(ProfileSourceArchiveSource {
|
||||
path: path.clone(),
|
||||
source_key: path.clone(),
|
||||
kind: "decodal".to_string(),
|
||||
content_type: default_decodal_content_type(),
|
||||
digest: sha256_hex(source.as_bytes()),
|
||||
size_bytes: size,
|
||||
});
|
||||
|
|
@ -238,7 +256,9 @@ impl VerifiedProfileSourceArchive {
|
|||
} else {
|
||||
let source = String::from_utf8(data)
|
||||
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
||||
entries.insert(path, source);
|
||||
if entries.insert(path.clone(), source).is_some() {
|
||||
return Err(ProfileArchiveError::DuplicateSource(path));
|
||||
}
|
||||
}
|
||||
if entries.len() > MAX_SOURCES {
|
||||
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||
|
|
@ -258,8 +278,25 @@ impl VerifiedProfileSourceArchive {
|
|||
});
|
||||
}
|
||||
let mut total = 0u64;
|
||||
let mut manifest_paths = BTreeSet::new();
|
||||
for source in &manifest.sources {
|
||||
validate_archive_path(&source.path)?;
|
||||
if !source.source_key.is_empty() && source.source_key != source.path {
|
||||
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||
specifier: source.path.clone(),
|
||||
expected: source.path.clone(),
|
||||
actual: source.source_key.clone(),
|
||||
});
|
||||
}
|
||||
if source.content_type != default_decodal_content_type() {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.content_type.clone(),
|
||||
});
|
||||
}
|
||||
if !manifest_paths.insert(source.path.clone()) {
|
||||
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
|
||||
}
|
||||
if source.kind != "decodal" {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
|
|
@ -285,6 +322,11 @@ impl VerifiedProfileSourceArchive {
|
|||
}
|
||||
total += size;
|
||||
}
|
||||
for entry_path in entries.keys() {
|
||||
if !manifest_paths.contains(entry_path) {
|
||||
return Err(ProfileArchiveError::UndeclaredSource(entry_path.clone()));
|
||||
}
|
||||
}
|
||||
if total != reference.source_graph.total_source_bytes
|
||||
|| manifest.sources.len() != reference.source_graph.source_count
|
||||
{
|
||||
|
|
@ -298,10 +340,13 @@ impl VerifiedProfileSourceArchive {
|
|||
.chain(manifest.imports.values())
|
||||
{
|
||||
validate_archive_path(path)?;
|
||||
if !entries.contains_key(path) {
|
||||
if !manifest_paths.contains(path) || !entries.contains_key(path) {
|
||||
return Err(ProfileArchiveError::MissingSource(path.clone()));
|
||||
}
|
||||
}
|
||||
for (key, path) in &manifest.imports {
|
||||
validate_archive_import_map_entry(key, path, &manifest_paths)?;
|
||||
}
|
||||
Ok(Self {
|
||||
reference,
|
||||
manifest,
|
||||
|
|
@ -313,6 +358,10 @@ impl VerifiedProfileSourceArchive {
|
|||
&self.reference
|
||||
}
|
||||
|
||||
pub fn manifest(&self) -> &ProfileSourceArchiveManifest {
|
||||
&self.manifest
|
||||
}
|
||||
|
||||
pub fn resolve_profile(
|
||||
&self,
|
||||
selector: &str,
|
||||
|
|
@ -378,22 +427,26 @@ impl<'a> ArchiveSourceLoader<'a> {
|
|||
impl SourceLoader for ArchiveSourceLoader<'_> {
|
||||
fn load(
|
||||
&mut self,
|
||||
_current_key: Option<&str>,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> decodal::Result<LoadedSource> {
|
||||
let path = self
|
||||
.archive
|
||||
.manifest
|
||||
.imports
|
||||
.get(specifier)
|
||||
.ok_or_else(|| {
|
||||
decodal::Diagnostic::new(
|
||||
decodal::DiagnosticKind::Import,
|
||||
decodal::Span::default(),
|
||||
format!("archive import not declared: {specifier}"),
|
||||
)
|
||||
})?;
|
||||
let source = self.archive.sources.get(path).ok_or_else(|| {
|
||||
let path =
|
||||
archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
|
||||
.map_err(import_diagnostic)?;
|
||||
if let Some(current_key) = current_key {
|
||||
match resolve_archive_import_path(current_key, specifier) {
|
||||
Ok(expected) if expected == path => {}
|
||||
Ok(expected) => {
|
||||
return Err(import_diagnostic(ProfileArchiveError::ImportMapMismatch {
|
||||
specifier: format!("{current_key}\0{specifier}"),
|
||||
expected,
|
||||
actual: path.clone(),
|
||||
}));
|
||||
}
|
||||
Err(err) => return Err(import_diagnostic(err)),
|
||||
}
|
||||
}
|
||||
let source = self.archive.sources.get(&path).ok_or_else(|| {
|
||||
decodal::Diagnostic::new(
|
||||
decodal::DiagnosticKind::Import,
|
||||
decodal::Span::default(),
|
||||
|
|
@ -408,6 +461,123 @@ impl SourceLoader for ArchiveSourceLoader<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
fn default_decodal_content_type() -> String {
|
||||
"text/x-decodal".to_string()
|
||||
}
|
||||
|
||||
fn import_diagnostic(err: ProfileArchiveError) -> decodal::Diagnostic {
|
||||
decodal::Diagnostic::new(
|
||||
decodal::DiagnosticKind::Import,
|
||||
decodal::Span::default(),
|
||||
err.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn archive_import_map_lookup(
|
||||
imports: &BTreeMap<String, String>,
|
||||
current_key: Option<&str>,
|
||||
specifier: &str,
|
||||
) -> Result<String, ProfileArchiveError> {
|
||||
if let Some(current_key) = current_key {
|
||||
let scoped = format!("{current_key}\0{specifier}");
|
||||
if let Some(path) = imports.get(&scoped) {
|
||||
return Ok(path.clone());
|
||||
}
|
||||
}
|
||||
imports
|
||||
.get(specifier)
|
||||
.cloned()
|
||||
.ok_or_else(|| ProfileArchiveError::ImportNotDeclared(specifier.to_string()))
|
||||
}
|
||||
|
||||
fn validate_archive_import_map_entry(
|
||||
key: &str,
|
||||
path: &str,
|
||||
manifest_paths: &BTreeSet<String>,
|
||||
) -> Result<(), ProfileArchiveError> {
|
||||
if !manifest_paths.contains(path) {
|
||||
return Err(ProfileArchiveError::MissingSource(path.to_string()));
|
||||
}
|
||||
if let Some((current_key, specifier)) = key.split_once('\0') {
|
||||
validate_archive_path(current_key)?;
|
||||
let expected = resolve_archive_import_path(current_key, specifier)?;
|
||||
if expected != path {
|
||||
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||
specifier: key.to_string(),
|
||||
expected,
|
||||
actual: path.to_string(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
validate_archive_import_specifier(key)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_archive_import_path(
|
||||
current_key: &str,
|
||||
specifier: &str,
|
||||
) -> Result<String, ProfileArchiveError> {
|
||||
validate_archive_path(current_key)?;
|
||||
validate_archive_import_specifier(specifier)?;
|
||||
let raw = if let Some(rest) = specifier.strip_prefix("project:") {
|
||||
rest
|
||||
} else if let Some(rest) = specifier.strip_prefix("workspace:") {
|
||||
rest
|
||||
} else {
|
||||
specifier
|
||||
};
|
||||
let base = if raw.starts_with("./") || raw.starts_with("../") {
|
||||
Path::new(current_key)
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default()
|
||||
.join(raw)
|
||||
} else {
|
||||
PathBuf::from(raw)
|
||||
};
|
||||
normalize_archive_virtual_path(&base.to_string_lossy())
|
||||
}
|
||||
|
||||
fn validate_archive_import_specifier(specifier: &str) -> Result<(), ProfileArchiveError> {
|
||||
if specifier.is_empty()
|
||||
|| specifier.contains("://")
|
||||
|| specifier.starts_with('/')
|
||||
|| Path::new(specifier).is_absolute()
|
||||
{
|
||||
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||
}
|
||||
if let Some((namespace, rest)) = specifier.split_once(':') {
|
||||
if namespace != "project" && namespace != "workspace" {
|
||||
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||
}
|
||||
if rest.is_empty() || rest.starts_with('/') || rest.contains("://") {
|
||||
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_archive_virtual_path(path: &str) -> Result<String, ProfileArchiveError> {
|
||||
let path_ref = Path::new(path);
|
||||
if path_ref.is_absolute() {
|
||||
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||
}
|
||||
let mut out = PathBuf::new();
|
||||
for component in path_ref.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::Normal(value) => out.push(value),
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let normalized = out.to_string_lossy().replace('\\', "/");
|
||||
validate_archive_path(&normalized)?;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
|
||||
match data {
|
||||
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
|
||||
|
|
@ -551,4 +721,56 @@ mod tests {
|
|||
let mut loader = ArchiveSourceLoader::new(&verified);
|
||||
assert!(loader.load(None, "missing").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_loader_resolves_scoped_virtual_imports() {
|
||||
let mut entrypoints = BTreeMap::new();
|
||||
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
|
||||
let mut imports = BTreeMap::new();
|
||||
imports.insert(
|
||||
"profiles/main.dcdl\0./shared.dcdl".to_string(),
|
||||
"profiles/shared.dcdl".to_string(),
|
||||
);
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
|
||||
sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string());
|
||||
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||
id: "project".to_string(),
|
||||
entrypoints,
|
||||
imports,
|
||||
sources,
|
||||
})
|
||||
.unwrap();
|
||||
let verified = archive.verify().unwrap();
|
||||
let mut loader = ArchiveSourceLoader::new(&verified);
|
||||
let loaded = loader
|
||||
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
||||
.unwrap();
|
||||
assert_eq!(loaded.key, "profiles/shared.dcdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_verify_rejects_import_map_that_bypasses_virtual_resolver() {
|
||||
let mut entrypoints = BTreeMap::new();
|
||||
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
|
||||
let mut imports = BTreeMap::new();
|
||||
imports.insert(
|
||||
"profiles/main.dcdl\0./shared.dcdl".to_string(),
|
||||
"profiles/other.dcdl".to_string(),
|
||||
);
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
|
||||
sources.insert("profiles/other.dcdl".to_string(), "{}".to_string());
|
||||
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||
id: "project".to_string(),
|
||||
entrypoints,
|
||||
imports,
|
||||
sources,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::ImportMapMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub profiles: Vec<WorkspaceProfileSummary>,
|
||||
pub sources: Vec<WorkspaceProfileSourceSummary>,
|
||||
#[serde(default)]
|
||||
pub source_trees: Vec<WorkspaceProfileSourceTreeSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +96,45 @@ pub struct WorkspaceProfileSourceSummary {
|
|||
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)]
|
||||
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<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)]
|
||||
#[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<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(®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<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(®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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,9 @@ use crate::observation::{
|
|||
};
|
||||
use crate::profile_settings::{
|
||||
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
|
||||
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
|
||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
||||
UpdateWorkspaceProfileSourceRequest,
|
||||
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
||||
};
|
||||
use crate::records::{
|
||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||
|
|
@ -281,6 +282,16 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||
"/api/w/{workspace_id}/settings/profiles/registry",
|
||||
put(scoped_update_profile_registry),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}",
|
||||
get(scoped_get_profile_source_tree),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file",
|
||||
get(scoped_get_profile_tree_file)
|
||||
.put(scoped_write_profile_tree_file)
|
||||
.delete(scoped_delete_profile_tree_file),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
|
||||
get(scoped_get_profile_source)
|
||||
|
|
@ -907,6 +918,60 @@ async fn scoped_update_profile_registry(
|
|||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_source_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_source_tree(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::read_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
query,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_write_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::write_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_delete_profile_tree_file(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
|
||||
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
Ok(Json(crate::profile_settings::delete_profile_tree_file(
|
||||
&workspace_id,
|
||||
&api.config.workspace_root,
|
||||
&source_tree_id,
|
||||
request,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_source(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"npm:@codemirror/state@6.5.2": "6.5.2",
|
||||
"npm:@codemirror/view@6.38.8": "6.38.8",
|
||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
||||
|
|
@ -14,6 +16,21 @@
|
|||
"npm:vite@7.2.7": "7.2.7"
|
||||
},
|
||||
"npm": {
|
||||
"@codemirror/state@6.5.2": {
|
||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||
"dependencies": [
|
||||
"@marijn/find-cluster-break"
|
||||
]
|
||||
},
|
||||
"@codemirror/view@6.38.8": {
|
||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||
"dependencies": [
|
||||
"@codemirror/state",
|
||||
"crelt",
|
||||
"style-mod",
|
||||
"w3c-keyname"
|
||||
]
|
||||
},
|
||||
"@esbuild/aix-ppc64@0.25.12": {
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"os": ["aix"],
|
||||
|
|
@ -171,6 +188,9 @@
|
|||
"@jridgewell/sourcemap-codec"
|
||||
]
|
||||
},
|
||||
"@marijn/find-cluster-break@1.0.3": {
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="
|
||||
},
|
||||
"@polka/url@1.0.0-next.29": {
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
|
||||
},
|
||||
|
|
@ -392,6 +412,9 @@
|
|||
"cookie@0.6.0": {
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
|
||||
},
|
||||
"crelt@1.0.7": {
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="
|
||||
},
|
||||
"debug@4.4.3": {
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": [
|
||||
|
|
@ -567,6 +590,9 @@
|
|||
"source-map-js@1.2.1": {
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||
},
|
||||
"style-mod@4.1.3": {
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
|
||||
},
|
||||
"svelte-check@4.3.4_svelte@5.45.6_typescript@5.9.3": {
|
||||
"integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==",
|
||||
"dependencies": [
|
||||
|
|
@ -638,12 +664,17 @@
|
|||
"vite"
|
||||
]
|
||||
},
|
||||
"w3c-keyname@2.2.8": {
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||
},
|
||||
"zimmerframe@1.1.4": {
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"npm:@codemirror/state@6.5.2",
|
||||
"npm:@codemirror/view@6.38.8",
|
||||
"npm:@sveltejs/adapter-static@3.0.9",
|
||||
"npm:@sveltejs/kit@2.49.4",
|
||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||
|
||||
let {
|
||||
value = '',
|
||||
readonly = false,
|
||||
ariaLabel = 'Decodal source',
|
||||
onChange = (_value: string) => {},
|
||||
}: {
|
||||
value?: string;
|
||||
readonly?: boolean;
|
||||
ariaLabel?: string;
|
||||
onChange?: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let view = $state<EditorView | null>(null);
|
||||
|
||||
const theme = EditorView.theme({
|
||||
'&': {
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '0.75rem',
|
||||
minHeight: '24rem',
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.9rem',
|
||||
},
|
||||
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
|
||||
'.cm-content': { padding: '0.75rem 0' },
|
||||
'.cm-gutters': { background: 'var(--surface-2)', color: 'var(--text-muted)' },
|
||||
'.cm-activeLine': { backgroundColor: 'rgba(125, 211, 252, 0.08)' },
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!host || view) return;
|
||||
const editor = new EditorView({
|
||||
parent: host,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
keymap.of([]),
|
||||
EditorState.readOnly.of(readonly),
|
||||
EditorView.editable.of(!readonly),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChange(update.state.doc.toString());
|
||||
}),
|
||||
theme,
|
||||
],
|
||||
}),
|
||||
});
|
||||
view = editor;
|
||||
return () => {
|
||||
editor.destroy();
|
||||
view = null;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current !== value) {
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={host} role="textbox" aria-label={ariaLabel} aria-readonly={readonly}></div>
|
||||
|
|
@ -8,6 +8,8 @@ import type {
|
|||
WorkspaceMetadataMutationResponse,
|
||||
WorkspaceMetadataSettingsResponse,
|
||||
WorkspaceProfileSourceDetailResponse,
|
||||
WorkspaceProfileSourceTreeFileResponse,
|
||||
WorkspaceProfileSourceTreeResponse,
|
||||
} from "./profile-types";
|
||||
|
||||
export function fetchWorkspaceMetadataSettings(
|
||||
|
|
@ -116,3 +118,44 @@ export function deleteProfileSource(
|
|||
{ method: "DELETE", body: JSON.stringify(request) },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchProfileSourceTree(
|
||||
workspaceId: string,
|
||||
sourceTreeId: string,
|
||||
): Promise<WorkspaceProfileSourceTreeResponse> {
|
||||
return workspaceApiJson(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchProfileTreeFile(
|
||||
workspaceId: string,
|
||||
sourceTreeId: string,
|
||||
path: string,
|
||||
): Promise<WorkspaceProfileSourceTreeFileResponse> {
|
||||
return workspaceApiJson(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function writeProfileTreeFile(
|
||||
workspaceId: string,
|
||||
sourceTreeId: string,
|
||||
request: { path: string; content: string; revision?: string | null },
|
||||
): Promise<WorkspaceProfileSourceTreeFileResponse> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
|
||||
{ method: "PUT", body: JSON.stringify(request) },
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteProfileTreeFile(
|
||||
workspaceId: string,
|
||||
sourceTreeId: string,
|
||||
request: { path: string; revision: string },
|
||||
): Promise<WorkspaceProfileSourceTreeResponse> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
|
||||
{ method: "DELETE", body: JSON.stringify(request) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type ProfileSettingsResponse = {
|
|||
default_profile?: string | null;
|
||||
profiles: WorkspaceProfileSummary[];
|
||||
sources: WorkspaceProfileSourceSummary[];
|
||||
source_trees: WorkspaceProfileSourceTreeSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
|
|
@ -58,3 +59,38 @@ export type ProfileSettingsMutationResponse = {
|
|||
settings: ProfileSettingsResponse;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceTreeSummary = {
|
||||
source_tree_id: string;
|
||||
label: string;
|
||||
root_path: string;
|
||||
kind: "decodal_source_tree" | string;
|
||||
editable: boolean;
|
||||
revision: string;
|
||||
file_count: number;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceTreeFileSummary = {
|
||||
path: string;
|
||||
kind: "decodal" | string;
|
||||
editable: boolean;
|
||||
revision: string;
|
||||
size_bytes: number;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceTreeResponse = {
|
||||
workspace_id: string;
|
||||
tree: WorkspaceProfileSourceTreeSummary;
|
||||
files: WorkspaceProfileSourceTreeFileSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type WorkspaceProfileSourceTreeFileResponse = {
|
||||
workspace_id: string;
|
||||
source_tree_id: string;
|
||||
file: WorkspaceProfileSourceTreeFileSummary;
|
||||
content: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,40 +1,88 @@
|
|||
<script lang="ts">
|
||||
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
|
||||
import { fetchProfileSettings } from '$lib/workspace-settings/profile-api';
|
||||
import DecodalSourceEditor from '$lib/workspace-settings/DecodalSourceEditor.svelte';
|
||||
import {
|
||||
fetchProfileSettings,
|
||||
fetchProfileSourceTree,
|
||||
fetchProfileTreeFile,
|
||||
writeProfileTreeFile,
|
||||
} from '$lib/workspace-settings/profile-api';
|
||||
import type { Diagnostic } from '$lib/workspace-settings/model';
|
||||
import type { ProfileSettingsResponse } from '$lib/workspace-settings/profile-types';
|
||||
import type {
|
||||
ProfileSettingsResponse,
|
||||
WorkspaceProfileSourceTreeFileResponse,
|
||||
WorkspaceProfileSourceTreeResponse,
|
||||
} from '$lib/workspace-settings/profile-types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
|
||||
let profileSettings = $state<ProfileSettingsResponse | null>(null);
|
||||
let sourceTree = $state<WorkspaceProfileSourceTreeResponse | null>(null);
|
||||
let selectedFile = $state<WorkspaceProfileSourceTreeFileResponse | null>(null);
|
||||
let draftContent = $state('');
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let message = $state<string | null>(null);
|
||||
let diagnostics = $state<Diagnostic[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
async function loadSettings() {
|
||||
if (!workspaceId) return;
|
||||
loading = true;
|
||||
message = null;
|
||||
try {
|
||||
const response = await fetchProfileSettings(workspaceId);
|
||||
profileSettings = response;
|
||||
diagnostics = response.diagnostics;
|
||||
const treeId = response.source_trees[0]?.source_tree_id;
|
||||
if (treeId) {
|
||||
sourceTree = await fetchProfileSourceTree(workspaceId, treeId);
|
||||
const firstPath = sourceTree.files[0]?.path;
|
||||
if (firstPath) await selectTreeFile(treeId, firstPath);
|
||||
}
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : 'profile settings request failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTreeFile(sourceTreeId: string, path: string) {
|
||||
selectedFile = await fetchProfileTreeFile(workspaceId, sourceTreeId, path);
|
||||
draftContent = selectedFile.content;
|
||||
diagnostics = selectedFile.diagnostics;
|
||||
}
|
||||
|
||||
async function saveSelectedFile() {
|
||||
if (!selectedFile) return;
|
||||
saving = true;
|
||||
message = null;
|
||||
try {
|
||||
selectedFile = await writeProfileTreeFile(workspaceId, selectedFile.source_tree_id, {
|
||||
path: selectedFile.file.path,
|
||||
revision: selectedFile.file.revision,
|
||||
content: draftContent,
|
||||
});
|
||||
draftContent = selectedFile.content;
|
||||
sourceTree = await fetchProfileSourceTree(workspaceId, selectedFile.source_tree_id);
|
||||
diagnostics = selectedFile.diagnostics;
|
||||
message = 'Saved Decodal profile source.';
|
||||
} catch (err) {
|
||||
message = err instanceof Error ? err.message : 'profile source save failed';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
loading = true;
|
||||
message = null;
|
||||
try {
|
||||
const response = await fetchProfileSettings(workspaceId);
|
||||
if (!cancelled) {
|
||||
profileSettings = response;
|
||||
diagnostics = response.diagnostics;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) message = err instanceof Error ? err.message : 'profile settings request failed';
|
||||
} finally {
|
||||
if (!cancelled) loading = false;
|
||||
}
|
||||
await loadSettings();
|
||||
if (cancelled) return;
|
||||
}
|
||||
load();
|
||||
if (workspaceId) load();
|
||||
else loading = false;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
|
@ -48,13 +96,13 @@
|
|||
<section class="card settings-section" aria-labelledby="profile-sources-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">read-only</p>
|
||||
<p class="eyebrow">Backend-owned source tree</p>
|
||||
<h2 id="profile-sources-title">Profiles</h2>
|
||||
</div>
|
||||
<span class="badge warning">editing pending</span>
|
||||
<span class="badge">Decodal editor</span>
|
||||
</header>
|
||||
<p>
|
||||
Review the effective launch profiles and their source files. Editing is intentionally deferred until the Decodal profile source model is settled.
|
||||
Review effective launch profiles and edit Decodal source files through virtual profile-tree paths. The browser receives only safe relative paths and revision tokens.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
|
|
@ -77,24 +125,46 @@
|
|||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Profile source files</h3>
|
||||
<p class="settings-note">Decodal source files that define or contribute to launch profiles.</p>
|
||||
<ul class="settings-profile-list">
|
||||
{#each profileSettings.sources as source (source.profile_source_id)}
|
||||
<li>
|
||||
<strong>{source.display_path}</strong>
|
||||
<span>{source.kind} · {source.size_bytes} bytes</span>
|
||||
<small>{source.editable ? 'editable later' : 'read-only'} · rev {source.revision}</small>
|
||||
<DiagnosticsList diagnostics={source.diagnostics} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<h3>Profile source tree</h3>
|
||||
<p class="settings-note">Virtual Decodal paths exposed by the Backend-owned source tree.</p>
|
||||
{#if sourceTree}
|
||||
<small>{sourceTree.tree.root_path} · {sourceTree.tree.file_count} files · rev {sourceTree.tree.revision}</small>
|
||||
<ul class="settings-profile-list">
|
||||
{#each sourceTree.files as file (file.path)}
|
||||
<li>
|
||||
<button class="link-button" type="button" onclick={() => selectTreeFile(sourceTree!.tree.source_tree_id, file.path)}>
|
||||
<strong>{file.path}</strong>
|
||||
</button>
|
||||
<span>{file.kind} · {file.size_bytes} bytes</span>
|
||||
<small>{file.editable ? 'editable' : 'read-only'} · rev {file.revision}</small>
|
||||
<DiagnosticsList diagnostics={file.diagnostics} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="settings-note">No project profile source tree is available.</p>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{#if selectedFile}
|
||||
<article class="settings-editor-panel">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">{selectedFile.source_tree_id}</p>
|
||||
<h3>{selectedFile.file.path}</h3>
|
||||
</div>
|
||||
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</header>
|
||||
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
|
||||
</article>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if message}
|
||||
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
|
||||
<p class="status-message" class:error={message.includes('failed') || message.includes('error')}>{message}</p>
|
||||
{/if}
|
||||
<DiagnosticsList {diagnostics} />
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user