merge: profile source tree editor
# Conflicts: # .yoi/tickets/00001KX1JNJ2Y/item.md # .yoi/tickets/00001KX1JNJ2Y/thread.md
This commit is contained in:
commit
9a2e57fb0e
|
|
@ -2,7 +2,7 @@ use decodal::{Engine, LoadedSource, SourceLoader};
|
||||||
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::io::{Cursor, Read, Write};
|
use std::io::{Cursor, Read, Write};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use tar::{Archive, Builder, Header};
|
use tar::{Archive, Builder, Header};
|
||||||
|
|
@ -27,6 +27,10 @@ pub enum ProfileArchiveError {
|
||||||
MissingManifest,
|
MissingManifest,
|
||||||
#[error("profile source archive missing source {0}")]
|
#[error("profile source archive missing source {0}")]
|
||||||
MissingSource(String),
|
MissingSource(String),
|
||||||
|
#[error("profile source archive contains duplicate source {0}")]
|
||||||
|
DuplicateSource(String),
|
||||||
|
#[error("profile source archive contains source not declared in manifest {0}")]
|
||||||
|
UndeclaredSource(String),
|
||||||
#[error(
|
#[error(
|
||||||
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
|
"profile source archive source digest mismatch for {path}: expected {expected}, got {actual}"
|
||||||
)]
|
)]
|
||||||
|
|
@ -47,6 +51,14 @@ pub enum ProfileArchiveError {
|
||||||
MissingEntrypoint(String),
|
MissingEntrypoint(String),
|
||||||
#[error("profile source archive import is not declared in manifest import map: {0}")]
|
#[error("profile source archive import is not declared in manifest import map: {0}")]
|
||||||
ImportNotDeclared(String),
|
ImportNotDeclared(String),
|
||||||
|
#[error(
|
||||||
|
"profile source archive import map mismatch for {specifier}: expected {expected}, got {actual}"
|
||||||
|
)]
|
||||||
|
ImportMapMismatch {
|
||||||
|
specifier: String,
|
||||||
|
expected: String,
|
||||||
|
actual: String,
|
||||||
|
},
|
||||||
#[error("failed to read profile source archive: {0}")]
|
#[error("failed to read profile source archive: {0}")]
|
||||||
Io(String),
|
Io(String),
|
||||||
#[error("failed to parse profile source archive manifest: {0}")]
|
#[error("failed to parse profile source archive manifest: {0}")]
|
||||||
|
|
@ -94,7 +106,11 @@ pub struct ProfileSourceArchiveManifest {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ProfileSourceArchiveSource {
|
pub struct ProfileSourceArchiveSource {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_key: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
#[serde(default = "default_decodal_content_type")]
|
||||||
|
pub content_type: String,
|
||||||
pub digest: String,
|
pub digest: String,
|
||||||
pub size_bytes: u64,
|
pub size_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +144,9 @@ impl ProfileSourceArchive {
|
||||||
}
|
}
|
||||||
source_meta.push(ProfileSourceArchiveSource {
|
source_meta.push(ProfileSourceArchiveSource {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
source_key: path.clone(),
|
||||||
kind: "decodal".to_string(),
|
kind: "decodal".to_string(),
|
||||||
|
content_type: default_decodal_content_type(),
|
||||||
digest: sha256_hex(source.as_bytes()),
|
digest: sha256_hex(source.as_bytes()),
|
||||||
size_bytes: size,
|
size_bytes: size,
|
||||||
});
|
});
|
||||||
|
|
@ -238,7 +256,9 @@ impl VerifiedProfileSourceArchive {
|
||||||
} else {
|
} else {
|
||||||
let source = String::from_utf8(data)
|
let source = String::from_utf8(data)
|
||||||
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
.map_err(|err| ProfileArchiveError::Io(err.to_string()))?;
|
||||||
entries.insert(path, source);
|
if entries.insert(path.clone(), source).is_some() {
|
||||||
|
return Err(ProfileArchiveError::DuplicateSource(path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if entries.len() > MAX_SOURCES {
|
if entries.len() > MAX_SOURCES {
|
||||||
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||||
|
|
@ -258,8 +278,25 @@ impl VerifiedProfileSourceArchive {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
|
let mut manifest_paths = BTreeSet::new();
|
||||||
for source in &manifest.sources {
|
for source in &manifest.sources {
|
||||||
validate_archive_path(&source.path)?;
|
validate_archive_path(&source.path)?;
|
||||||
|
if !source.source_key.is_empty() && source.source_key != source.path {
|
||||||
|
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||||
|
specifier: source.path.clone(),
|
||||||
|
expected: source.path.clone(),
|
||||||
|
actual: source.source_key.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if source.content_type != default_decodal_content_type() {
|
||||||
|
return Err(ProfileArchiveError::UnsupportedSource {
|
||||||
|
path: source.path.clone(),
|
||||||
|
kind: source.content_type.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !manifest_paths.insert(source.path.clone()) {
|
||||||
|
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
|
||||||
|
}
|
||||||
if source.kind != "decodal" {
|
if source.kind != "decodal" {
|
||||||
return Err(ProfileArchiveError::UnsupportedSource {
|
return Err(ProfileArchiveError::UnsupportedSource {
|
||||||
path: source.path.clone(),
|
path: source.path.clone(),
|
||||||
|
|
@ -285,6 +322,11 @@ impl VerifiedProfileSourceArchive {
|
||||||
}
|
}
|
||||||
total += size;
|
total += size;
|
||||||
}
|
}
|
||||||
|
for entry_path in entries.keys() {
|
||||||
|
if !manifest_paths.contains(entry_path) {
|
||||||
|
return Err(ProfileArchiveError::UndeclaredSource(entry_path.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
if total != reference.source_graph.total_source_bytes
|
if total != reference.source_graph.total_source_bytes
|
||||||
|| manifest.sources.len() != reference.source_graph.source_count
|
|| manifest.sources.len() != reference.source_graph.source_count
|
||||||
{
|
{
|
||||||
|
|
@ -298,10 +340,13 @@ impl VerifiedProfileSourceArchive {
|
||||||
.chain(manifest.imports.values())
|
.chain(manifest.imports.values())
|
||||||
{
|
{
|
||||||
validate_archive_path(path)?;
|
validate_archive_path(path)?;
|
||||||
if !entries.contains_key(path) {
|
if !manifest_paths.contains(path) || !entries.contains_key(path) {
|
||||||
return Err(ProfileArchiveError::MissingSource(path.clone()));
|
return Err(ProfileArchiveError::MissingSource(path.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (key, path) in &manifest.imports {
|
||||||
|
validate_archive_import_map_entry(key, path, &manifest_paths)?;
|
||||||
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
reference,
|
reference,
|
||||||
manifest,
|
manifest,
|
||||||
|
|
@ -313,6 +358,10 @@ impl VerifiedProfileSourceArchive {
|
||||||
&self.reference
|
&self.reference
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn manifest(&self) -> &ProfileSourceArchiveManifest {
|
||||||
|
&self.manifest
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_profile(
|
pub fn resolve_profile(
|
||||||
&self,
|
&self,
|
||||||
selector: &str,
|
selector: &str,
|
||||||
|
|
@ -378,22 +427,26 @@ impl<'a> ArchiveSourceLoader<'a> {
|
||||||
impl SourceLoader for ArchiveSourceLoader<'_> {
|
impl SourceLoader for ArchiveSourceLoader<'_> {
|
||||||
fn load(
|
fn load(
|
||||||
&mut self,
|
&mut self,
|
||||||
_current_key: Option<&str>,
|
current_key: Option<&str>,
|
||||||
specifier: &str,
|
specifier: &str,
|
||||||
) -> decodal::Result<LoadedSource> {
|
) -> decodal::Result<LoadedSource> {
|
||||||
let path = self
|
let path =
|
||||||
.archive
|
archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
|
||||||
.manifest
|
.map_err(import_diagnostic)?;
|
||||||
.imports
|
if let Some(current_key) = current_key {
|
||||||
.get(specifier)
|
match resolve_archive_import_path(current_key, specifier) {
|
||||||
.ok_or_else(|| {
|
Ok(expected) if expected == path => {}
|
||||||
decodal::Diagnostic::new(
|
Ok(expected) => {
|
||||||
decodal::DiagnosticKind::Import,
|
return Err(import_diagnostic(ProfileArchiveError::ImportMapMismatch {
|
||||||
decodal::Span::default(),
|
specifier: format!("{current_key}\0{specifier}"),
|
||||||
format!("archive import not declared: {specifier}"),
|
expected,
|
||||||
)
|
actual: path.clone(),
|
||||||
})?;
|
}));
|
||||||
let source = self.archive.sources.get(path).ok_or_else(|| {
|
}
|
||||||
|
Err(err) => return Err(import_diagnostic(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let source = self.archive.sources.get(&path).ok_or_else(|| {
|
||||||
decodal::Diagnostic::new(
|
decodal::Diagnostic::new(
|
||||||
decodal::DiagnosticKind::Import,
|
decodal::DiagnosticKind::Import,
|
||||||
decodal::Span::default(),
|
decodal::Span::default(),
|
||||||
|
|
@ -408,6 +461,123 @@ impl SourceLoader for ArchiveSourceLoader<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_decodal_content_type() -> String {
|
||||||
|
"text/x-decodal".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_diagnostic(err: ProfileArchiveError) -> decodal::Diagnostic {
|
||||||
|
decodal::Diagnostic::new(
|
||||||
|
decodal::DiagnosticKind::Import,
|
||||||
|
decodal::Span::default(),
|
||||||
|
err.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_import_map_lookup(
|
||||||
|
imports: &BTreeMap<String, String>,
|
||||||
|
current_key: Option<&str>,
|
||||||
|
specifier: &str,
|
||||||
|
) -> Result<String, ProfileArchiveError> {
|
||||||
|
if let Some(current_key) = current_key {
|
||||||
|
let scoped = format!("{current_key}\0{specifier}");
|
||||||
|
if let Some(path) = imports.get(&scoped) {
|
||||||
|
return Ok(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imports
|
||||||
|
.get(specifier)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ProfileArchiveError::ImportNotDeclared(specifier.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_archive_import_map_entry(
|
||||||
|
key: &str,
|
||||||
|
path: &str,
|
||||||
|
manifest_paths: &BTreeSet<String>,
|
||||||
|
) -> Result<(), ProfileArchiveError> {
|
||||||
|
if !manifest_paths.contains(path) {
|
||||||
|
return Err(ProfileArchiveError::MissingSource(path.to_string()));
|
||||||
|
}
|
||||||
|
if let Some((current_key, specifier)) = key.split_once('\0') {
|
||||||
|
validate_archive_path(current_key)?;
|
||||||
|
let expected = resolve_archive_import_path(current_key, specifier)?;
|
||||||
|
if expected != path {
|
||||||
|
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||||
|
specifier: key.to_string(),
|
||||||
|
expected,
|
||||||
|
actual: path.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
validate_archive_import_specifier(key)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_archive_import_path(
|
||||||
|
current_key: &str,
|
||||||
|
specifier: &str,
|
||||||
|
) -> Result<String, ProfileArchiveError> {
|
||||||
|
validate_archive_path(current_key)?;
|
||||||
|
validate_archive_import_specifier(specifier)?;
|
||||||
|
let raw = if let Some(rest) = specifier.strip_prefix("project:") {
|
||||||
|
rest
|
||||||
|
} else if let Some(rest) = specifier.strip_prefix("workspace:") {
|
||||||
|
rest
|
||||||
|
} else {
|
||||||
|
specifier
|
||||||
|
};
|
||||||
|
let base = if raw.starts_with("./") || raw.starts_with("../") {
|
||||||
|
Path::new(current_key)
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(raw)
|
||||||
|
} else {
|
||||||
|
PathBuf::from(raw)
|
||||||
|
};
|
||||||
|
normalize_archive_virtual_path(&base.to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_archive_import_specifier(specifier: &str) -> Result<(), ProfileArchiveError> {
|
||||||
|
if specifier.is_empty()
|
||||||
|
|| specifier.contains("://")
|
||||||
|
|| specifier.starts_with('/')
|
||||||
|
|| Path::new(specifier).is_absolute()
|
||||||
|
{
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||||
|
}
|
||||||
|
if let Some((namespace, rest)) = specifier.split_once(':') {
|
||||||
|
if namespace != "project" && namespace != "workspace" {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||||
|
}
|
||||||
|
if rest.is_empty() || rest.starts_with('/') || rest.contains("://") {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(specifier.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_archive_virtual_path(path: &str) -> Result<String, ProfileArchiveError> {
|
||||||
|
let path_ref = Path::new(path);
|
||||||
|
if path_ref.is_absolute() {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||||
|
}
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for component in path_ref.components() {
|
||||||
|
match component {
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::Normal(value) => out.push(value),
|
||||||
|
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||||
|
return Err(ProfileArchiveError::UnsafePath(path.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let normalized = out.to_string_lossy().replace('\\', "/");
|
||||||
|
validate_archive_path(&normalized)?;
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
|
fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value {
|
||||||
match data {
|
match data {
|
||||||
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
|
decodal::Data::Bool(value) => serde_json::Value::Bool(*value),
|
||||||
|
|
@ -551,4 +721,56 @@ mod tests {
|
||||||
let mut loader = ArchiveSourceLoader::new(&verified);
|
let mut loader = ArchiveSourceLoader::new(&verified);
|
||||||
assert!(loader.load(None, "missing").is_err());
|
assert!(loader.load(None, "missing").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_loader_resolves_scoped_virtual_imports() {
|
||||||
|
let mut entrypoints = BTreeMap::new();
|
||||||
|
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
|
||||||
|
let mut imports = BTreeMap::new();
|
||||||
|
imports.insert(
|
||||||
|
"profiles/main.dcdl\0./shared.dcdl".to_string(),
|
||||||
|
"profiles/shared.dcdl".to_string(),
|
||||||
|
);
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
|
||||||
|
sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string());
|
||||||
|
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||||
|
id: "project".to_string(),
|
||||||
|
entrypoints,
|
||||||
|
imports,
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let verified = archive.verify().unwrap();
|
||||||
|
let mut loader = ArchiveSourceLoader::new(&verified);
|
||||||
|
let loaded = loader
|
||||||
|
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loaded.key, "profiles/shared.dcdl");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_verify_rejects_import_map_that_bypasses_virtual_resolver() {
|
||||||
|
let mut entrypoints = BTreeMap::new();
|
||||||
|
entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string());
|
||||||
|
let mut imports = BTreeMap::new();
|
||||||
|
imports.insert(
|
||||||
|
"profiles/main.dcdl\0./shared.dcdl".to_string(),
|
||||||
|
"profiles/other.dcdl".to_string(),
|
||||||
|
);
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert("profiles/main.dcdl".to_string(), "{}".to_string());
|
||||||
|
sources.insert("profiles/other.dcdl".to_string(), "{}".to_string());
|
||||||
|
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||||
|
id: "project".to_string(),
|
||||||
|
entrypoints,
|
||||||
|
imports,
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
archive.verify(),
|
||||||
|
Err(ProfileArchiveError::ImportMapMismatch { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -38,8 +38,9 @@ use crate::observation::{
|
||||||
};
|
};
|
||||||
use crate::profile_settings::{
|
use crate::profile_settings::{
|
||||||
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
|
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
|
||||||
|
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
|
||||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
||||||
UpdateWorkspaceProfileSourceRequest,
|
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
||||||
};
|
};
|
||||||
use crate::records::{
|
use crate::records::{
|
||||||
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
|
||||||
|
|
@ -281,6 +282,16 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
"/api/w/{workspace_id}/settings/profiles/registry",
|
"/api/w/{workspace_id}/settings/profiles/registry",
|
||||||
put(scoped_update_profile_registry),
|
put(scoped_update_profile_registry),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}",
|
||||||
|
get(scoped_get_profile_source_tree),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file",
|
||||||
|
get(scoped_get_profile_tree_file)
|
||||||
|
.put(scoped_write_profile_tree_file)
|
||||||
|
.delete(scoped_delete_profile_tree_file),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
|
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
|
||||||
get(scoped_get_profile_source)
|
get(scoped_get_profile_source)
|
||||||
|
|
@ -907,6 +918,60 @@ async fn scoped_update_profile_registry(
|
||||||
)?))
|
)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_profile_source_tree(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||||
|
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||||
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
|
Ok(Json(crate::profile_settings::read_profile_source_tree(
|
||||||
|
&workspace_id,
|
||||||
|
&api.config.workspace_root,
|
||||||
|
&source_tree_id,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_get_profile_tree_file(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||||
|
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
|
||||||
|
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||||
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
|
Ok(Json(crate::profile_settings::read_profile_tree_file(
|
||||||
|
&workspace_id,
|
||||||
|
&api.config.workspace_root,
|
||||||
|
&source_tree_id,
|
||||||
|
query,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_write_profile_tree_file(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||||
|
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
|
||||||
|
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
|
||||||
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
|
Ok(Json(crate::profile_settings::write_profile_tree_file(
|
||||||
|
&workspace_id,
|
||||||
|
&api.config.workspace_root,
|
||||||
|
&source_tree_id,
|
||||||
|
request,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_delete_profile_tree_file(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
|
||||||
|
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
|
||||||
|
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
|
||||||
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
|
Ok(Json(crate::profile_settings::delete_profile_tree_file(
|
||||||
|
&workspace_id,
|
||||||
|
&api.config.workspace_root,
|
||||||
|
&source_tree_id,
|
||||||
|
request,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_get_profile_source(
|
async fn scoped_get_profile_source(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@
|
||||||
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
|
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
|
||||||
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
|
"@codemirror/state": "npm:@codemirror/state@6.5.2",
|
||||||
|
"@codemirror/view": "npm:@codemirror/view@6.38.8",
|
||||||
|
"decodal-codemirror": "npm:decodal-codemirror@0.1.2",
|
||||||
"clsx": "npm:clsx@2.1.1",
|
"clsx": "npm:clsx@2.1.1",
|
||||||
"cookie": "npm:cookie@0.6.0",
|
"cookie": "npm:cookie@0.6.0",
|
||||||
"devalue": "npm:devalue@5.6.4",
|
"devalue": "npm:devalue@5.6.4",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
|
"npm:@codemirror/state@6.5.2": "6.5.2",
|
||||||
|
"npm:@codemirror/view@6.38.8": "6.38.8",
|
||||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
||||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
||||||
"npm:clsx@2.1.1": "2.1.1",
|
"npm:clsx@2.1.1": "2.1.1",
|
||||||
"npm:cookie@0.6.0": "0.6.0",
|
"npm:cookie@0.6.0": "0.6.0",
|
||||||
|
"npm:decodal-codemirror@0.1.2": "0.1.2",
|
||||||
"npm:devalue@5.6.4": "5.6.4",
|
"npm:devalue@5.6.4": "5.6.4",
|
||||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||||
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
||||||
|
|
@ -14,6 +17,32 @@
|
||||||
"npm:vite@7.2.7": "7.2.7"
|
"npm:vite@7.2.7": "7.2.7"
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
|
"@codemirror/language@6.12.4": {
|
||||||
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
|
"dependencies": [
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common",
|
||||||
|
"@lezer/highlight",
|
||||||
|
"@lezer/lr",
|
||||||
|
"style-mod"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@codemirror/state@6.5.2": {
|
||||||
|
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@marijn/find-cluster-break"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@codemirror/view@6.38.8": {
|
||||||
|
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||||
|
"dependencies": [
|
||||||
|
"@codemirror/state",
|
||||||
|
"crelt",
|
||||||
|
"style-mod",
|
||||||
|
"w3c-keyname"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@esbuild/aix-ppc64@0.25.12": {
|
"@esbuild/aix-ppc64@0.25.12": {
|
||||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
"os": ["aix"],
|
"os": ["aix"],
|
||||||
|
|
@ -171,6 +200,24 @@
|
||||||
"@jridgewell/sourcemap-codec"
|
"@jridgewell/sourcemap-codec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@lezer/common@1.5.2": {
|
||||||
|
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="
|
||||||
|
},
|
||||||
|
"@lezer/highlight@1.2.3": {
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"dependencies": [
|
||||||
|
"@lezer/common"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@lezer/lr@1.4.10": {
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"dependencies": [
|
||||||
|
"@lezer/common"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@marijn/find-cluster-break@1.0.3": {
|
||||||
|
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="
|
||||||
|
},
|
||||||
"@polka/url@1.0.0-next.29": {
|
"@polka/url@1.0.0-next.29": {
|
||||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
|
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
|
||||||
},
|
},
|
||||||
|
|
@ -392,12 +439,23 @@
|
||||||
"cookie@0.6.0": {
|
"cookie@0.6.0": {
|
||||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
|
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
|
||||||
},
|
},
|
||||||
|
"crelt@1.0.7": {
|
||||||
|
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="
|
||||||
|
},
|
||||||
"debug@4.4.3": {
|
"debug@4.4.3": {
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"ms"
|
"ms"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"decodal-codemirror@0.1.2": {
|
||||||
|
"integrity": "sha512-VR+cFsBLPb9kZU3gJhElZck+IUVhOC1HoWgA8bxP1kxwn+eCZaeBHErHESlRZbsWvN2J5T8VKcDCtxLDYe9QyA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@codemirror/language",
|
||||||
|
"@lezer/highlight",
|
||||||
|
"@lezer/lr"
|
||||||
|
]
|
||||||
|
},
|
||||||
"deepmerge@4.3.1": {
|
"deepmerge@4.3.1": {
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
||||||
},
|
},
|
||||||
|
|
@ -567,6 +625,9 @@
|
||||||
"source-map-js@1.2.1": {
|
"source-map-js@1.2.1": {
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||||
},
|
},
|
||||||
|
"style-mod@4.1.3": {
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
|
||||||
|
},
|
||||||
"svelte-check@4.3.4_svelte@5.45.6_typescript@5.9.3": {
|
"svelte-check@4.3.4_svelte@5.45.6_typescript@5.9.3": {
|
||||||
"integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==",
|
"integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -638,17 +699,23 @@
|
||||||
"vite"
|
"vite"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"w3c-keyname@2.2.8": {
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||||
|
},
|
||||||
"zimmerframe@1.1.4": {
|
"zimmerframe@1.1.4": {
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
"npm:@codemirror/state@6.5.2",
|
||||||
|
"npm:@codemirror/view@6.38.8",
|
||||||
"npm:@sveltejs/adapter-static@3.0.9",
|
"npm:@sveltejs/adapter-static@3.0.9",
|
||||||
"npm:@sveltejs/kit@2.49.4",
|
"npm:@sveltejs/kit@2.49.4",
|
||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
"npm:clsx@2.1.1",
|
"npm:clsx@2.1.1",
|
||||||
"npm:cookie@0.6.0",
|
"npm:cookie@0.6.0",
|
||||||
|
"npm:decodal-codemirror@0.1.2",
|
||||||
"npm:devalue@5.6.4",
|
"npm:devalue@5.6.4",
|
||||||
"npm:set-cookie-parser@2.7.2",
|
"npm:set-cookie-parser@2.7.2",
|
||||||
"npm:svelte-check@4.3.4",
|
"npm:svelte-check@4.3.4",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
|
import { decodal } from 'decodal-codemirror';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = '',
|
||||||
|
readonly = false,
|
||||||
|
ariaLabel = 'Decodal source',
|
||||||
|
onChange = (_value: string) => {},
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let host = $state<HTMLDivElement | null>(null);
|
||||||
|
let view = $state<EditorView | null>(null);
|
||||||
|
|
||||||
|
const theme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
minHeight: '24rem',
|
||||||
|
background: 'var(--surface-2)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
},
|
||||||
|
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
|
||||||
|
'.cm-content': { padding: '0.75rem 0' },
|
||||||
|
'.cm-gutters': { background: 'var(--surface-2)', color: 'var(--text-muted)' },
|
||||||
|
'.cm-activeLine': { backgroundColor: 'rgba(125, 211, 252, 0.08)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!host || view) return;
|
||||||
|
const editor = new EditorView({
|
||||||
|
parent: host,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: [
|
||||||
|
lineNumbers(),
|
||||||
|
drawSelection(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
decodal(),
|
||||||
|
keymap.of([]),
|
||||||
|
EditorState.readOnly.of(readonly),
|
||||||
|
EditorView.editable.of(!readonly),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) onChange(update.state.doc.toString());
|
||||||
|
}),
|
||||||
|
theme,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
view = editor;
|
||||||
|
return () => {
|
||||||
|
editor.destroy();
|
||||||
|
view = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!view) return;
|
||||||
|
const current = view.state.doc.toString();
|
||||||
|
if (current !== value) {
|
||||||
|
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={host} role="textbox" aria-label={ariaLabel} aria-readonly={readonly}></div>
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
SETTINGS_SECTIONS,
|
SETTINGS_SECTIONS,
|
||||||
settingsSectionHref,
|
settingsSectionHref,
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
|
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from "./profile-routes.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void): void;
|
test(name: string, fn: () => void): void;
|
||||||
|
|
@ -102,3 +103,19 @@ Deno.test("diagnostic labels preserve severity and code", () => {
|
||||||
"diagnostic label should be bounded and stable",
|
"diagnostic label should be bounded and stable",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Deno.test("profile source tree routes use encoded scoped ids", () => {
|
||||||
|
const href = profileSourceTreeSettingsHref("workspace a", "project/tree");
|
||||||
|
assert(
|
||||||
|
href === "/w/workspace%20a/settings/profiles/trees/project%2Ftree",
|
||||||
|
`unexpected href ${href}`,
|
||||||
|
);
|
||||||
|
assert(!href.includes("/home/"), "route must not contain host paths");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("profile source create paths are normalized to virtual profile paths", () => {
|
||||||
|
assert(virtualProfilePathForCreate("alpha.dcdl") === "profiles/alpha.dcdl", "bare file names are scoped");
|
||||||
|
assert(virtualProfilePathForCreate("profiles/alpha.dcdl") === "profiles/alpha.dcdl", "virtual paths are preserved");
|
||||||
|
assert(virtualProfilePathForCreate("project:profiles/alpha.dcdl") === "project:profiles/alpha.dcdl", "safe virtual namespaces are preserved");
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import type {
|
||||||
WorkspaceMetadataMutationResponse,
|
WorkspaceMetadataMutationResponse,
|
||||||
WorkspaceMetadataSettingsResponse,
|
WorkspaceMetadataSettingsResponse,
|
||||||
WorkspaceProfileSourceDetailResponse,
|
WorkspaceProfileSourceDetailResponse,
|
||||||
|
WorkspaceProfileSourceTreeFileResponse,
|
||||||
|
WorkspaceProfileSourceTreeResponse,
|
||||||
} from "./profile-types";
|
} from "./profile-types";
|
||||||
|
|
||||||
export function fetchWorkspaceMetadataSettings(
|
export function fetchWorkspaceMetadataSettings(
|
||||||
|
|
@ -116,3 +118,44 @@ export function deleteProfileSource(
|
||||||
{ method: "DELETE", body: JSON.stringify(request) },
|
{ method: "DELETE", body: JSON.stringify(request) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchProfileSourceTree(
|
||||||
|
workspaceId: string,
|
||||||
|
sourceTreeId: string,
|
||||||
|
): Promise<WorkspaceProfileSourceTreeResponse> {
|
||||||
|
return workspaceApiJson(
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchProfileTreeFile(
|
||||||
|
workspaceId: string,
|
||||||
|
sourceTreeId: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<WorkspaceProfileSourceTreeFileResponse> {
|
||||||
|
return workspaceApiJson(
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file?path=${encodeURIComponent(path)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeProfileTreeFile(
|
||||||
|
workspaceId: string,
|
||||||
|
sourceTreeId: string,
|
||||||
|
request: { path: string; content: string; revision?: string | null },
|
||||||
|
): Promise<WorkspaceProfileSourceTreeFileResponse> {
|
||||||
|
return workspaceApiJsonWithBody(
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
|
||||||
|
{ method: "PUT", body: JSON.stringify(request) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProfileTreeFile(
|
||||||
|
workspaceId: string,
|
||||||
|
sourceTreeId: string,
|
||||||
|
request: { path: string; revision: string },
|
||||||
|
): Promise<WorkspaceProfileSourceTreeResponse> {
|
||||||
|
return workspaceApiJsonWithBody(
|
||||||
|
`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`,
|
||||||
|
{ method: "DELETE", body: JSON.stringify(request) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
15
web/workspace/src/lib/workspace-settings/profile-routes.ts
Normal file
15
web/workspace/src/lib/workspace-settings/profile-routes.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export function profileSettingsHref(workspaceId: string): string {
|
||||||
|
return `/w/${encodeURIComponent(workspaceId)}/settings/profiles`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileSourceTreeSettingsHref(workspaceId: string, sourceTreeId: string): string {
|
||||||
|
return `${profileSettingsHref(workspaceId)}/trees/${encodeURIComponent(sourceTreeId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function virtualProfilePathForCreate(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.startsWith("project:") || trimmed.startsWith("workspace:")) return trimmed;
|
||||||
|
if (trimmed.startsWith("profiles/")) return trimmed;
|
||||||
|
return `profiles/${trimmed}`;
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,9 @@ export type WorkspaceProfileSourceSummary = {
|
||||||
profile_source_id: string;
|
profile_source_id: string;
|
||||||
display_path: string;
|
display_path: string;
|
||||||
kind: "decodal" | string;
|
kind: "decodal" | string;
|
||||||
|
content_type: string;
|
||||||
|
content_digest: string;
|
||||||
|
provenance: "project_profile_source_tree" | string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
revision: string;
|
revision: string;
|
||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
|
|
@ -42,6 +45,7 @@ export type ProfileSettingsResponse = {
|
||||||
default_profile?: string | null;
|
default_profile?: string | null;
|
||||||
profiles: WorkspaceProfileSummary[];
|
profiles: WorkspaceProfileSummary[];
|
||||||
sources: WorkspaceProfileSourceSummary[];
|
sources: WorkspaceProfileSourceSummary[];
|
||||||
|
source_trees: WorkspaceProfileSourceTreeSummary[];
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -58,3 +62,44 @@ export type ProfileSettingsMutationResponse = {
|
||||||
settings: ProfileSettingsResponse;
|
settings: ProfileSettingsResponse;
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspaceProfileSourceTreeSummary = {
|
||||||
|
source_tree_id: string;
|
||||||
|
label: string;
|
||||||
|
root_path: string;
|
||||||
|
kind: "decodal_source_tree" | string;
|
||||||
|
content_type: string;
|
||||||
|
content_digest: string;
|
||||||
|
provenance: "project_profile_source_tree" | string;
|
||||||
|
editable: boolean;
|
||||||
|
revision: string;
|
||||||
|
file_count: number;
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceProfileSourceTreeFileSummary = {
|
||||||
|
path: string;
|
||||||
|
kind: "decodal" | string;
|
||||||
|
content_type: string;
|
||||||
|
content_digest: string;
|
||||||
|
provenance: "project_profile_source_tree" | string;
|
||||||
|
editable: boolean;
|
||||||
|
revision: string;
|
||||||
|
size_bytes: number;
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceProfileSourceTreeResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
tree: WorkspaceProfileSourceTreeSummary;
|
||||||
|
files: WorkspaceProfileSourceTreeFileSummary[];
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceProfileSourceTreeFileResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
source_tree_id: string;
|
||||||
|
file: WorkspaceProfileSourceTreeFileSummary;
|
||||||
|
content: string;
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,133 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
|
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
|
||||||
import { fetchProfileSettings } from '$lib/workspace-settings/profile-api';
|
import DecodalSourceEditor from '$lib/workspace-settings/DecodalSourceEditor.svelte';
|
||||||
|
import {
|
||||||
|
fetchProfileSettings,
|
||||||
|
fetchProfileSourceTree,
|
||||||
|
deleteProfileTreeFile,
|
||||||
|
fetchProfileTreeFile,
|
||||||
|
writeProfileTreeFile,
|
||||||
|
} from '$lib/workspace-settings/profile-api';
|
||||||
import type { Diagnostic } from '$lib/workspace-settings/model';
|
import type { Diagnostic } from '$lib/workspace-settings/model';
|
||||||
import type { ProfileSettingsResponse } from '$lib/workspace-settings/profile-types';
|
import { profileSourceTreeSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
|
||||||
|
import type {
|
||||||
|
ProfileSettingsResponse,
|
||||||
|
WorkspaceProfileSourceTreeFileResponse,
|
||||||
|
WorkspaceProfileSourceTreeResponse,
|
||||||
|
} from '$lib/workspace-settings/profile-types';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||||
|
|
||||||
let profileSettings = $state<ProfileSettingsResponse | null>(null);
|
let profileSettings = $state<ProfileSettingsResponse | null>(null);
|
||||||
|
let sourceTree = $state<WorkspaceProfileSourceTreeResponse | null>(null);
|
||||||
|
let selectedFile = $state<WorkspaceProfileSourceTreeFileResponse | null>(null);
|
||||||
|
let draftContent = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let newFilePath = $state('new-profile.dcdl');
|
||||||
let message = $state<string | null>(null);
|
let message = $state<string | null>(null);
|
||||||
let diagnostics = $state<Diagnostic[]>([]);
|
let diagnostics = $state<Diagnostic[]>([]);
|
||||||
|
|
||||||
$effect(() => {
|
async function loadSettings() {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) return;
|
||||||
|
loading = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
const response = await fetchProfileSettings(workspaceId);
|
||||||
|
profileSettings = response;
|
||||||
|
diagnostics = response.diagnostics;
|
||||||
|
const treeId = response.source_trees[0]?.source_tree_id;
|
||||||
|
if (treeId) {
|
||||||
|
sourceTree = await fetchProfileSourceTree(workspaceId, treeId);
|
||||||
|
const firstPath = sourceTree.files[0]?.path;
|
||||||
|
if (firstPath) await selectTreeFile(treeId, firstPath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'profile settings request failed';
|
||||||
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTreeFile(sourceTreeId: string, path: string) {
|
||||||
|
selectedFile = await fetchProfileTreeFile(workspaceId, sourceTreeId, path);
|
||||||
|
draftContent = selectedFile.content;
|
||||||
|
diagnostics = selectedFile.diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTreeFile(sourceTreeId: string) {
|
||||||
|
creating = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
const path = virtualProfilePathForCreate(newFilePath);
|
||||||
|
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
|
||||||
|
path,
|
||||||
|
content: '{\n slug = "new-profile";\n description = "New profile";\n scope = "workspace_read";\n}',
|
||||||
|
});
|
||||||
|
draftContent = selectedFile.content;
|
||||||
|
sourceTree = await fetchProfileSourceTree(workspaceId, sourceTreeId);
|
||||||
|
diagnostics = selectedFile.diagnostics;
|
||||||
|
message = 'Created Decodal profile source.';
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'profile source create failed';
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedFile() {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
deleting = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
sourceTree = await deleteProfileTreeFile(workspaceId, selectedFile.source_tree_id, {
|
||||||
|
path: selectedFile.file.path,
|
||||||
|
revision: selectedFile.file.revision,
|
||||||
|
});
|
||||||
|
selectedFile = null;
|
||||||
|
draftContent = '';
|
||||||
|
diagnostics = sourceTree.diagnostics;
|
||||||
|
message = 'Deleted Decodal profile source.';
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'profile source delete failed';
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectedFile() {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
saving = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
selectedFile = await writeProfileTreeFile(workspaceId, selectedFile.source_tree_id, {
|
||||||
|
path: selectedFile.file.path,
|
||||||
|
revision: selectedFile.file.revision,
|
||||||
|
content: draftContent,
|
||||||
|
});
|
||||||
|
draftContent = selectedFile.content;
|
||||||
|
sourceTree = await fetchProfileSourceTree(workspaceId, selectedFile.source_tree_id);
|
||||||
|
diagnostics = selectedFile.diagnostics;
|
||||||
|
message = 'Saved Decodal profile source.';
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'profile source save failed';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
await loadSettings();
|
||||||
message = null;
|
if (cancelled) return;
|
||||||
try {
|
|
||||||
const response = await fetchProfileSettings(workspaceId);
|
|
||||||
if (!cancelled) {
|
|
||||||
profileSettings = response;
|
|
||||||
diagnostics = response.diagnostics;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) message = err instanceof Error ? err.message : 'profile settings request failed';
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
load();
|
if (workspaceId) load();
|
||||||
|
else loading = false;
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
|
@ -48,13 +141,13 @@
|
||||||
<section class="card settings-section" aria-labelledby="profile-sources-title">
|
<section class="card settings-section" aria-labelledby="profile-sources-title">
|
||||||
<header class="settings-section-header">
|
<header class="settings-section-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">read-only</p>
|
<p class="eyebrow">Backend-owned source tree</p>
|
||||||
<h2 id="profile-sources-title">Profiles</h2>
|
<h2 id="profile-sources-title">Profiles</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge warning">editing pending</span>
|
<span class="badge">Decodal editor</span>
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<p>
|
||||||
Review the effective launch profiles and their source files. Editing is intentionally deferred until the Decodal profile source model is settled.
|
Review effective launch profiles and edit Decodal source files through virtual profile-tree paths. The browser receives only safe relative paths and revision tokens.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -77,24 +170,58 @@
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<h3>Profile source files</h3>
|
<h3>Profile source tree</h3>
|
||||||
<p class="settings-note">Decodal source files that define or contribute to launch profiles.</p>
|
<p class="settings-note">Virtual Decodal paths exposed by the Backend-owned source tree.</p>
|
||||||
<ul class="settings-profile-list">
|
{#if sourceTree}
|
||||||
{#each profileSettings.sources as source (source.profile_source_id)}
|
<small>{sourceTree.tree.root_path} · {sourceTree.tree.file_count} files · {sourceTree.tree.content_type} · rev {sourceTree.tree.revision}</small>
|
||||||
<li>
|
<p><a href={profileSourceTreeSettingsHref(workspaceId, sourceTree.tree.source_tree_id)}>Open tree route</a></p>
|
||||||
<strong>{source.display_path}</strong>
|
<div class="settings-inline-form">
|
||||||
<span>{source.kind} · {source.size_bytes} bytes</span>
|
<input bind:value={newFilePath} aria-label="New profile source virtual path" placeholder="profiles/new-profile.dcdl" />
|
||||||
<small>{source.editable ? 'editable later' : 'read-only'} · rev {source.revision}</small>
|
<button type="button" disabled={creating} onclick={() => createTreeFile(sourceTree!.tree.source_tree_id)}>
|
||||||
<DiagnosticsList diagnostics={source.diagnostics} />
|
{creating ? 'Creating…' : 'Create source'}
|
||||||
</li>
|
</button>
|
||||||
{/each}
|
</div>
|
||||||
</ul>
|
<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.content_type} · {file.size_bytes} bytes</span>
|
||||||
|
<small>{file.editable ? 'editable' : 'read-only'} · rev {file.revision}</small>
|
||||||
|
<DiagnosticsList diagnostics={file.diagnostics} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="settings-note">No project profile source tree is available.</p>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if selectedFile}
|
||||||
|
<article class="settings-editor-panel">
|
||||||
|
<header class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{selectedFile.source_tree_id}</p>
|
||||||
|
<h3>{selectedFile.file.path}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="settings-editor-actions">
|
||||||
|
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveSelectedFile}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={deleting} onclick={deleteSelectedFile}>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if message}
|
{#if message}
|
||||||
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
|
<p class="status-message" class:error={message.includes('failed') || message.includes('error')}>{message}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<DiagnosticsList {diagnostics} />
|
<DiagnosticsList {diagnostics} />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import DiagnosticsList from '$lib/workspace-settings/DiagnosticsList.svelte';
|
||||||
|
import DecodalSourceEditor from '$lib/workspace-settings/DecodalSourceEditor.svelte';
|
||||||
|
import {
|
||||||
|
deleteProfileTreeFile,
|
||||||
|
fetchProfileSourceTree,
|
||||||
|
fetchProfileTreeFile,
|
||||||
|
writeProfileTreeFile,
|
||||||
|
} from '$lib/workspace-settings/profile-api';
|
||||||
|
import { profileSettingsHref, virtualProfilePathForCreate } from '$lib/workspace-settings/profile-routes';
|
||||||
|
import type { Diagnostic } from '$lib/workspace-settings/model';
|
||||||
|
import type {
|
||||||
|
WorkspaceProfileSourceTreeFileResponse,
|
||||||
|
WorkspaceProfileSourceTreeResponse,
|
||||||
|
} from '$lib/workspace-settings/profile-types';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||||
|
let sourceTreeId = $derived(data.sourceTreeId);
|
||||||
|
|
||||||
|
let tree = $state<WorkspaceProfileSourceTreeResponse | null>(null);
|
||||||
|
let selectedFile = $state<WorkspaceProfileSourceTreeFileResponse | null>(null);
|
||||||
|
let draftContent = $state('');
|
||||||
|
let newFilePath = $state('new-profile.dcdl');
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let message = $state<string | null>(null);
|
||||||
|
let diagnostics = $state<Diagnostic[]>([]);
|
||||||
|
|
||||||
|
async function reloadTree() {
|
||||||
|
tree = await fetchProfileSourceTree(workspaceId, sourceTreeId);
|
||||||
|
diagnostics = tree.diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFile(path: string) {
|
||||||
|
selectedFile = await fetchProfileTreeFile(workspaceId, sourceTreeId, path);
|
||||||
|
draftContent = selectedFile.content;
|
||||||
|
diagnostics = selectedFile.diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFile() {
|
||||||
|
creating = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
|
||||||
|
path: virtualProfilePathForCreate(newFilePath),
|
||||||
|
content: '{\n slug = "new-profile";\n description = "New profile";\n scope = "workspace_read";\n}',
|
||||||
|
});
|
||||||
|
draftContent = selectedFile.content;
|
||||||
|
await reloadTree();
|
||||||
|
message = 'Created Decodal profile source.';
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'profile source create failed';
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFile() {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
saving = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
selectedFile = await writeProfileTreeFile(workspaceId, sourceTreeId, {
|
||||||
|
path: selectedFile.file.path,
|
||||||
|
revision: selectedFile.file.revision,
|
||||||
|
content: draftContent,
|
||||||
|
});
|
||||||
|
draftContent = selectedFile.content;
|
||||||
|
await reloadTree();
|
||||||
|
message = 'Saved Decodal profile source.';
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'profile source save failed';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile() {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
deleting = true;
|
||||||
|
message = null;
|
||||||
|
try {
|
||||||
|
tree = await deleteProfileTreeFile(workspaceId, sourceTreeId, {
|
||||||
|
path: selectedFile.file.path,
|
||||||
|
revision: selectedFile.file.revision,
|
||||||
|
});
|
||||||
|
selectedFile = null;
|
||||||
|
draftContent = '';
|
||||||
|
diagnostics = tree.diagnostics;
|
||||||
|
message = 'Deleted Decodal profile source.';
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : 'profile source delete failed';
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!workspaceId || !sourceTreeId) {
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
reloadTree()
|
||||||
|
.catch((err) => {
|
||||||
|
message = err instanceof Error ? err.message : 'profile source tree load failed';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Profile source tree · Yoi Workspace</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card settings-section" aria-labelledby="profile-tree-title">
|
||||||
|
<header class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Profile source tree</p>
|
||||||
|
<h2 id="profile-tree-title">{sourceTreeId}</h2>
|
||||||
|
</div>
|
||||||
|
<a href={profileSettingsHref(workspaceId)}>Back to profiles</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="status-message">Loading source tree…</p>
|
||||||
|
{:else if tree}
|
||||||
|
<p class="settings-note">
|
||||||
|
{tree.tree.root_path} · {tree.tree.file_count} files · {tree.tree.content_type} · {tree.tree.content_digest}
|
||||||
|
</p>
|
||||||
|
<div class="settings-inline-form">
|
||||||
|
<input bind:value={newFilePath} aria-label="New profile source virtual path" placeholder="profiles/new-profile.dcdl" />
|
||||||
|
<button type="button" disabled={creating} onclick={createFile}>{creating ? 'Creating…' : 'Create source'}</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-profile-grid">
|
||||||
|
<article>
|
||||||
|
<h3>Files</h3>
|
||||||
|
<ul class="settings-profile-list">
|
||||||
|
{#each tree.files as file (file.path)}
|
||||||
|
<li>
|
||||||
|
<button class="link-button" type="button" onclick={() => selectFile(file.path)}><strong>{file.path}</strong></button>
|
||||||
|
<span>{file.content_type} · {file.content_digest}</span>
|
||||||
|
<small>{file.size_bytes} bytes · rev {file.revision}</small>
|
||||||
|
<DiagnosticsList diagnostics={file.diagnostics} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
{#if selectedFile}
|
||||||
|
<article>
|
||||||
|
<header class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{selectedFile.file.content_type}</p>
|
||||||
|
<h3>{selectedFile.file.path}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="settings-editor-actions">
|
||||||
|
<button type="button" disabled={saving || draftContent === selectedFile.content} onclick={saveFile}>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
<button type="button" disabled={deleting} onclick={deleteFile}>{deleting ? 'Deleting…' : 'Delete'}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<DecodalSourceEditor value={draftContent} onChange={(value) => (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} />
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if message}<p class="status-message">{message}</p>{/if}
|
||||||
|
<DiagnosticsList {diagnostics} />
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = ({ params }) => ({
|
||||||
|
sourceTreeId: params.sourceTreeId,
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user