Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3753b4fd05 | ||
|
|
b4b860f277 | ||
|
|
2df7f580aa | ||
|
|
6a244fb21c | ||
|
|
cbc49a7802 | ||
|
|
4686d05003 | ||
|
|
dfb6102ba5 | ||
|
|
be7a8cd8d7 | ||
|
|
f5e1c734da | ||
|
|
9613cf71aa | ||
|
|
08350bb3f4 | ||
|
|
36c105f3cd | ||
|
|
c5ea82ed50 | ||
|
|
8cf64cedb2 | ||
|
|
33c6d83e9b | ||
|
|
1e63dd69ed | ||
|
|
00a21d3322 | ||
|
|
eeeaa12627 | ||
|
|
f4e426ae2a | ||
|
|
fb16c4200f | ||
|
|
2dedde29e9 | ||
|
|
8e00d3ce4f | ||
|
|
36db9356cc | ||
|
|
fb24def2f5 | ||
|
|
b70bf1df45 | ||
|
|
b2c3e54001 | ||
|
|
ca53f574e5 | ||
|
|
c4bad4b8de | ||
|
|
d95b1ab178 | ||
|
|
df6f595626 | ||
|
|
07eb3cd12b | ||
|
|
27df4a3945 | ||
|
|
6a610616e7 | ||
|
|
01b76a1e89 |
+53
-9
@@ -31,17 +31,61 @@ crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-reopen-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-native-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-reboot-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-layout-reboot-20260911/run-gates2.ps1 whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-local-20260911/**/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
harness/experiments/wsl-capacity-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/wsl-postboot-9e8r6t-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-postboot-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-verified-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-layout-full-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
|
||||
# Preserve exact inputs of the frozen D4D9 authoring milestone build.
|
||||
crates/oa-desktop/src/semantic_review/artifacts_view.rs -text
|
||||
crates/oa-desktop/src/semantic_review/attachments_view.rs -text
|
||||
crates/oa-desktop/src/semantic_review/keyed_rows.rs -text
|
||||
crates/oa-desktop/src/semantic_review/keyed_tests.rs -text
|
||||
crates/oa-desktop/src/semantic_review/keyed.rs -text
|
||||
crates/oa-desktop/src/semantic_review/ordered.rs -text
|
||||
docs/openapi.json -text
|
||||
harness/experiments/contextual-source-recovery-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-machine-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-lifeline-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-lifeline-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-machine-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-source-reopen-20260911/launch-method.ps1 whitespace=-blank-at-eof,cr-at-eol
|
||||
# Raw compact.exe output includes CR-CR-LF and incidental trailing spaces.
|
||||
harness/experiments/wsl-capacity-20260911/*.log -whitespace
|
||||
harness/experiments/contextual-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-prerelease-publication-20260911/** -text whitespace=cr-at-eol
|
||||
harness/history/2026-09-11-state-before-gitea-migration/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/native/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/producer/package*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/contextual-prerelease-20260911/producer/source-push.log -whitespace
|
||||
harness/experiments/source-alias-rename-native-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-completed-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-refresh-20260912/** -text whitespace=cr-at-eol
|
||||
harness/experiments/source-alias-rename-native-closure-20260912/** -text whitespace=cr-at-eol
|
||||
# Preserve Text-workspace input, native and review evidence at its recorded bytes.
|
||||
harness/experiments/text-workspace-initial-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-initial-review-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-find-selection-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-native2-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-followup-review-20260913/** -text whitespace=cr-at-eol
|
||||
harness/experiments/text-workspace-native3-20260913/** -text whitespace=cr-at-eol
|
||||
# These checksum-bound baseline/expected fixtures deliberately end with blank lines.
|
||||
harness/experiments/text-workspace-native2-20260913/fixtures/**/*.arcadia whitespace=-blank-at-eof,cr-at-eol
|
||||
# Captured process streams retain intentional blank lines and diagnostic spacing.
|
||||
harness/experiments/text-find-selection-20260913/**/*.log -whitespace
|
||||
harness/experiments/text-workspace-initial-20260913/**/*.log -whitespace
|
||||
harness/experiments/text-workspace-native2-20260913/**/*.log -whitespace
|
||||
harness/experiments/text-workspace-native3-20260913/**/*.log -whitespace
|
||||
|
||||
Generated
+9
@@ -2139,6 +2139,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-cli"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"oa-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oa-core"
|
||||
version = "0.2.0"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -18,7 +18,7 @@ From the repository root:
|
||||
cargo run --locked -p oa-desktop --bin arcadia-studio
|
||||
```
|
||||
|
||||
Build both desktop and server release executables:
|
||||
Build the desktop, server and source CLI release executables:
|
||||
|
||||
```sh
|
||||
cargo build --release --workspace --locked
|
||||
@@ -51,6 +51,21 @@ The earlier V2 package record describes `dist/ArcadiaStudio-Windows.zip` and `di
|
||||
|
||||
The current [Windows packaging instructions](docs/windows-packaging.md) stage both executables, verify their runtime DLL dependencies, and produce an archive with checksums and provenance. Native acceptance fixtures and independent expected results are retained in [the release fixture directory](harness/fixtures/release1-native/README.md).
|
||||
|
||||
## Work with source files from the command line
|
||||
|
||||
The `arcadia` CLI checks an explicit `.arcadia` entry file and its includes without
|
||||
starting the desktop or server:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p oa-cli --bin arcadia -- check examples/text-first/project.arcadia
|
||||
```
|
||||
|
||||
`stabilize ROOT` previews the identities needed for persistent editing;
|
||||
`rename-alias ROOT OLD NEW` previews an alias change and its references. Add
|
||||
`--write` to save that invocation's candidate, or `--json` for structured
|
||||
diagnostics and changed-file text/diffs. Preview leaves files untouched.
|
||||
See [source CLI commands](docs/source-cli.md) for examples and exit codes.
|
||||
|
||||
## Model operational needs and system responsibilities
|
||||
|
||||
The desktop starts at **Home**, where you can create a model, open a local project, continue recent files or recover retained work. Missing recent files stay visible with **Locate**; **Remove** only forgets their history entry. Choose **Explore rescue example** for a model combining people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "oa-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "arcadia"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
oa-core = { path = "../oa-core" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Source-only authoring. Preparation captures bytes; only core owns disk writes.
|
||||
use oa_core::{
|
||||
Command, language,
|
||||
persistence::MigrationReport,
|
||||
prepare_command,
|
||||
source_files::{self, DiskProject, SourceFileError},
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LIMITS: ProjectLimits = ProjectLimits {
|
||||
max_files: 256,
|
||||
max_include_depth: 32,
|
||||
max_include_edges: 1024,
|
||||
model: language::ParseLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_graph_objects: 100_000,
|
||||
max_records: 250_000,
|
||||
max_step_references: 500_000,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Operation {
|
||||
Check,
|
||||
Stabilize,
|
||||
RenameAlias { old: String, new: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Location {
|
||||
pub path: String,
|
||||
/// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars.
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub line: Option<usize>,
|
||||
pub column: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Diagnostic {
|
||||
pub severity: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub coordinate_space: String,
|
||||
pub location: Option<Location>,
|
||||
pub related: Vec<Location>,
|
||||
pub object_id: Option<String>,
|
||||
pub field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
/// Exact captured and proposed UTF-8 text, including line endings.
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Report {
|
||||
pub format_version: u32,
|
||||
pub entry: String,
|
||||
pub operation: String,
|
||||
pub status: String,
|
||||
pub exit_code: u8,
|
||||
/// True only after a successful changed save. False on failure does not
|
||||
/// promise rollback: consult the specific error/recovery outcome.
|
||||
pub written: bool,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub changes: Vec<Change>,
|
||||
pub identities_to_stabilize: usize,
|
||||
pub migration: Option<MigrationReport>,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn failure(
|
||||
entry: &Path,
|
||||
operation: &str,
|
||||
exit_code: u8,
|
||||
code: &str,
|
||||
message: String,
|
||||
) -> Self {
|
||||
let mut report = Self::empty(entry, operation);
|
||||
report.fail(exit_code, code, message);
|
||||
report
|
||||
}
|
||||
|
||||
fn empty(entry: &Path, operation: &str) -> Self {
|
||||
Self {
|
||||
format_version: 1,
|
||||
entry: entry.to_string_lossy().into_owned(),
|
||||
operation: operation.into(),
|
||||
status: "checked".into(),
|
||||
exit_code: 0,
|
||||
written: false,
|
||||
diagnostics: vec![],
|
||||
changes: vec![],
|
||||
identities_to_stabilize: 0,
|
||||
migration: None,
|
||||
notes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&mut self, exit_code: u8, code: &str, message: String) {
|
||||
self.status = "rejected".into();
|
||||
self.exit_code = exit_code;
|
||||
self.diagnostics.push(Diagnostic {
|
||||
severity: "error".into(),
|
||||
code: code.into(),
|
||||
message,
|
||||
coordinate_space: "operation".into(),
|
||||
location: None,
|
||||
related: vec![],
|
||||
object_id: None,
|
||||
field: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic {
|
||||
let d = issue.diagnostic;
|
||||
Diagnostic {
|
||||
severity: match d.severity {
|
||||
Severity::Error => "error",
|
||||
Severity::Warning => "warning",
|
||||
}
|
||||
.into(),
|
||||
code: d.code,
|
||||
message: d.message,
|
||||
coordinate_space: space.into(),
|
||||
location: Some(Location {
|
||||
path: issue.path,
|
||||
start: d.span.start,
|
||||
end: d.span.end,
|
||||
line: Some(d.line),
|
||||
column: Some(d.column),
|
||||
}),
|
||||
related: issue
|
||||
.related
|
||||
.into_iter()
|
||||
.map(|at| Location {
|
||||
path: at.path,
|
||||
start: at.span.start,
|
||||
end: at.span.end,
|
||||
line: None,
|
||||
column: None,
|
||||
})
|
||||
.collect(),
|
||||
object_id: d.object_id.map(|id| id.to_string()),
|
||||
field: d.field,
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_error(report: &mut Report, error: SourceFileError) {
|
||||
let code = match &error {
|
||||
SourceFileError::Conflict(_) => "external_change",
|
||||
SourceFileError::RecoveryRequired(_) => "recovery_required",
|
||||
SourceFileError::RecoveryFailed { .. } => "recovery_failed",
|
||||
SourceFileError::Path(_) => "unsafe_path",
|
||||
SourceFileError::Limit(_) => "source_limit",
|
||||
SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation",
|
||||
SourceFileError::Invalid(_) => "invalid_save_candidate",
|
||||
SourceFileError::Io { .. } => "io_error",
|
||||
};
|
||||
report.fail(3, code, error.to_string());
|
||||
}
|
||||
|
||||
/// A candidate owns its original expectations and is consumed by saving.
|
||||
/// Callers cannot replace its source/model/expectations after preparation.
|
||||
pub struct Prepared {
|
||||
report: Report,
|
||||
snapshot: DiskProject,
|
||||
compiled: CompiledSourceProject,
|
||||
mutable: bool,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn report(&self) -> &Report {
|
||||
&self.report
|
||||
}
|
||||
|
||||
pub fn into_report(self) -> Report {
|
||||
self.report
|
||||
}
|
||||
|
||||
pub fn write(mut self) -> Report {
|
||||
if !self.mutable {
|
||||
self.report
|
||||
.fail(2, "usage", "check/lint does not accept --write".into());
|
||||
return self.report;
|
||||
}
|
||||
match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) {
|
||||
Ok(_) => {
|
||||
self.report.written = !self.report.changes.is_empty();
|
||||
self.report.status = if self.report.written {
|
||||
"written"
|
||||
} else {
|
||||
"unchanged"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Err(error) => disk_error(&mut self.report, error),
|
||||
}
|
||||
self.report
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Report>> {
|
||||
let name = match operation {
|
||||
Operation::Check => "check",
|
||||
Operation::Stabilize => "stabilize",
|
||||
Operation::RenameAlias { .. } => "rename-alias",
|
||||
};
|
||||
let mut report = Report::empty(entry, name);
|
||||
let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| {
|
||||
disk_error(&mut report, error);
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
let raw = snapshot.reachable_bundle();
|
||||
// The loader may include warnings from stabilized compilation. Only its
|
||||
// discovery/IO diagnostics are authoritative on the original bytes.
|
||||
let mut diagnostics: Vec<_> = snapshot
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save"
|
||||
| "source_encoding"
|
||||
| "source_limit"
|
||||
| "invalid_path"
|
||||
| "missing_include"
|
||||
| "project_limit"
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for diagnostic in source_project::lint_bundle(&raw, LIMITS) {
|
||||
if !diagnostics.contains(&diagnostic) {
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
let disk_refusal = snapshot.diagnostics.iter().any(|d| {
|
||||
matches!(
|
||||
d.diagnostic.code.as_str(),
|
||||
"pending_save" | "missing_include" | "invalid_path"
|
||||
)
|
||||
});
|
||||
let invalid = diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == Severity::Error);
|
||||
report.diagnostics = diagnostics
|
||||
.into_iter()
|
||||
.map(|d| located(d, "input"))
|
||||
.collect();
|
||||
if invalid {
|
||||
// Do not run declaration analysis or any updater on invalid raw text.
|
||||
// Failed lint may also have allocated provisional object identities.
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = if disk_refusal { 3 } else { 1 };
|
||||
return Err(Box::new(report));
|
||||
}
|
||||
let explicit_ids: std::collections::BTreeSet<_> = raw
|
||||
.files
|
||||
.values()
|
||||
.flat_map(|text| language::analyze(text).declarations)
|
||||
.filter_map(|d| d.id.map(|id| id.to_string()))
|
||||
.collect();
|
||||
for diagnostic in &mut report.diagnostics {
|
||||
if diagnostic
|
||||
.object_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !explicit_ids.contains(id))
|
||||
{
|
||||
diagnostic.object_id = None;
|
||||
}
|
||||
}
|
||||
let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
report.identities_to_stabilize = raw
|
||||
.files
|
||||
.values()
|
||||
.map(|text| {
|
||||
language::analyze(text)
|
||||
.declarations
|
||||
.into_iter()
|
||||
.filter(|d| d.id.is_none())
|
||||
.count()
|
||||
})
|
||||
.sum();
|
||||
report.migration = compiled.decoded.migration.clone();
|
||||
let mutable = !matches!(operation, Operation::Check);
|
||||
if let Operation::RenameAlias { old, new } = operation {
|
||||
let project = &compiled.decoded.project;
|
||||
let Some((id, _)) = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == old)
|
||||
else {
|
||||
report.fail(
|
||||
1,
|
||||
"unknown_alias",
|
||||
format!("No declaration has alias '{old}'."),
|
||||
);
|
||||
return Err(Box::new(report));
|
||||
};
|
||||
let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| {
|
||||
report.fail(1, "semantic_command_rejected", error.to_string());
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
compiled = source_project::update_bundle_with_identity_map(
|
||||
&compiled,
|
||||
&command.project,
|
||||
&command.identity_map,
|
||||
LIMITS,
|
||||
)
|
||||
.map_err(|diagnostics| {
|
||||
report.status = "invalid".into();
|
||||
report.exit_code = 1;
|
||||
report
|
||||
.diagnostics
|
||||
.extend(diagnostics.into_iter().map(|d| located(d, "candidate")));
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
}
|
||||
if mutable {
|
||||
for (path, after) in &compiled.bundle.files {
|
||||
let before = raw.files.get(path).cloned().unwrap_or_default();
|
||||
if &before != after {
|
||||
report.changes.push(Change {
|
||||
path: path.clone(),
|
||||
diff: unified_diff(path, &before, after),
|
||||
before,
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
report.status = "preview".into();
|
||||
report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into());
|
||||
}
|
||||
report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into());
|
||||
report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into());
|
||||
Ok(Prepared {
|
||||
report,
|
||||
snapshot,
|
||||
compiled,
|
||||
mutable,
|
||||
})
|
||||
}
|
||||
|
||||
/// One bounded hunk per file: common prefix/suffix plus three context lines.
|
||||
/// Linear in input size; no quadratic LCS allocation for large modules.
|
||||
fn unified_diff(path: &str, before: &str, after: &str) -> String {
|
||||
let a: Vec<_> = before.split_inclusive('\n').collect();
|
||||
let b: Vec<_> = after.split_inclusive('\n').collect();
|
||||
let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count();
|
||||
let suffix = a[prefix..]
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(b[prefix..].iter().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let start = prefix.saturating_sub(3);
|
||||
let a_end = (a.len() - suffix + 3).min(a.len());
|
||||
let b_end = (b.len() - suffix + 3).min(b.len());
|
||||
let a_start = if a_end == start { start } else { start + 1 };
|
||||
let b_start = if b_end == start { start } else { start + 1 };
|
||||
let mut out = format!(
|
||||
"--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n",
|
||||
a_end - start,
|
||||
b_end - start
|
||||
);
|
||||
let mut emit = |prefix: char, line: &str| {
|
||||
out.push(prefix);
|
||||
out.push_str(line);
|
||||
if !line.ends_with('\n') {
|
||||
out.push_str("\n\\ No newline at end of file\n");
|
||||
}
|
||||
};
|
||||
for line in &a[start..prefix] {
|
||||
emit(' ', line);
|
||||
}
|
||||
for line in &a[prefix..a.len() - suffix] {
|
||||
emit('-', line);
|
||||
}
|
||||
for line in &b[prefix..b.len() - suffix] {
|
||||
emit('+', line);
|
||||
}
|
||||
for line in &a[a.len() - suffix..a_end] {
|
||||
emit(' ', line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct Arguments {
|
||||
pub entry: PathBuf,
|
||||
pub operation: Operation,
|
||||
pub write: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n";
|
||||
|
||||
pub fn arguments(args: &[std::ffi::OsString]) -> Result<Arguments, String> {
|
||||
let mut positional = Vec::new();
|
||||
let (mut write, mut json) = (false, false);
|
||||
for arg in args {
|
||||
if arg == "--write" && !write {
|
||||
write = true;
|
||||
} else if arg == "--json" && !json {
|
||||
json = true;
|
||||
} else if arg.to_string_lossy().starts_with('-') {
|
||||
return Err(format!(
|
||||
"Unknown or repeated option: {}",
|
||||
arg.to_string_lossy()
|
||||
));
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
let command = positional.first().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let operation = match (command, positional.len()) {
|
||||
("check" | "lint", 2) if !write => Operation::Check,
|
||||
("stabilize", 2) => Operation::Stabilize,
|
||||
("rename-alias", 4) => Operation::RenameAlias {
|
||||
old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(),
|
||||
},
|
||||
_ => return Err(HELP.into()),
|
||||
};
|
||||
Ok(Arguments {
|
||||
entry: PathBuf::from(positional[1]),
|
||||
operation,
|
||||
write,
|
||||
json,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use oa_cli::{Report, arguments, prepare};
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
path::Path,
|
||||
process::ExitCode,
|
||||
};
|
||||
|
||||
fn human_report(report: &Report, show_diff: bool) -> io::Result<()> {
|
||||
let mut err = io::stderr().lock();
|
||||
for d in &report.diagnostics {
|
||||
let at = d
|
||||
.location
|
||||
.as_ref()
|
||||
.map(|l| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
l.path,
|
||||
l.line.unwrap_or(0),
|
||||
l.column.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| report.entry.clone());
|
||||
writeln!(
|
||||
err,
|
||||
"{at}: {}[{}] ({}): {}",
|
||||
d.severity, d.code, d.coordinate_space, d.message
|
||||
)?;
|
||||
}
|
||||
writeln!(
|
||||
err,
|
||||
"{}: {} file changes; {} identities to stabilize",
|
||||
report.status,
|
||||
report.changes.len(),
|
||||
report.identities_to_stabilize
|
||||
)?;
|
||||
if let Some(migration) = &report.migration {
|
||||
writeln!(
|
||||
err,
|
||||
"Source migration: schema {} → {} (separate from identity stabilization)",
|
||||
migration.from_schema, migration.to_schema
|
||||
)?;
|
||||
}
|
||||
for note in &report.notes {
|
||||
writeln!(err, "{note}")?;
|
||||
}
|
||||
if show_diff {
|
||||
let mut out = io::stdout().lock();
|
||||
for change in &report.changes {
|
||||
write!(out, "{}", change.diff)?;
|
||||
}
|
||||
out.flush()?;
|
||||
}
|
||||
err.flush()
|
||||
}
|
||||
|
||||
fn run() -> io::Result<u8> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
|
||||
io::stdout().write_all(oa_cli::HELP.as_bytes())?;
|
||||
return Ok(0);
|
||||
}
|
||||
if args.len() == 1 && args[0] == "--version" {
|
||||
writeln!(io::stdout(), "arcadia {}", env!("CARGO_PKG_VERSION"))?;
|
||||
return Ok(0);
|
||||
}
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let mut displayed = false;
|
||||
let report = match arguments(&args) {
|
||||
Err(error) => Report::failure(Path::new(""), "usage", 2, "usage", error),
|
||||
Ok(args) => match prepare(&args.entry, args.operation) {
|
||||
Err(report) => *report,
|
||||
Ok(prepared) => {
|
||||
if args.write {
|
||||
if !args.json {
|
||||
human_report(prepared.report(), true)?;
|
||||
displayed = true;
|
||||
}
|
||||
prepared.write()
|
||||
} else {
|
||||
prepared.into_report()
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if json {
|
||||
serde_json::to_writer_pretty(io::stdout().lock(), &report)?;
|
||||
writeln!(io::stdout())?;
|
||||
} else {
|
||||
human_report(&report, !displayed)?;
|
||||
}
|
||||
Ok(report.exit_code)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
let _ = writeln!(io::stderr(), "Output failed: {error}");
|
||||
ExitCode::from(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
use oa_cli::{LIMITS, Operation, Prepared, prepare};
|
||||
use oa_core::{Id, Project, source_files, source_project};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn put(&self, name: &str, text: &str) {
|
||||
let path = self.0.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
fn entry(&self) -> PathBuf {
|
||||
self.0.join("project.arcadia")
|
||||
}
|
||||
fn read(&self, name: &str) -> String {
|
||||
fs::read_to_string(self.0.join(name)).unwrap()
|
||||
}
|
||||
fn prepare(&self, op: Operation) -> Prepared {
|
||||
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
|
||||
}
|
||||
fn model(&self) -> Project {
|
||||
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
|
||||
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project
|
||||
}
|
||||
fn cli(&self, args: &[&str]) -> (i32, Value) {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
|
||||
command
|
||||
.arg(args[0])
|
||||
.arg(self.entry())
|
||||
.args(&args[1..])
|
||||
.arg("--json");
|
||||
let output = command.output().unwrap();
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
(
|
||||
output.status.code().unwrap(),
|
||||
serde_json::from_slice(&output.stdout).unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
|
||||
|
||||
#[test]
|
||||
fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
||||
let fixture = Fixture::new();
|
||||
let source = include_str!(
|
||||
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
|
||||
);
|
||||
fixture.put("project.arcadia", source);
|
||||
let (code, check) = fixture.cli(&["check"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(check["diagnostics"], serde_json::json!([]));
|
||||
let (code, preview) = fixture.cli(&["stabilize"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(preview["identities_to_stabilize"], 40);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert!(
|
||||
preview["changes"][0]["diff"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("+project alarm_response")
|
||||
);
|
||||
let (code, written) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let saved = fixture.read("project.arcadia");
|
||||
assert_eq!(written["changes"][0]["after"], saved);
|
||||
let project = fixture.model();
|
||||
assert_eq!(project.identities().len(), 40);
|
||||
let mut stripped = saved.clone();
|
||||
for (id, _) in project.identities() {
|
||||
stripped = stripped.replace(&format!(" id \"{id}\""), "");
|
||||
}
|
||||
assert_eq!(stripped, source, "only identity clauses may be inserted");
|
||||
let (code, again) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(again["changes"], serde_json::json!([]));
|
||||
assert_eq!(again["status"], "unchanged");
|
||||
assert_eq!(fixture.model(), project);
|
||||
assert_eq!(fixture.read("project.arcadia"), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let candidate = fixture.prepare(Operation::Stabilize);
|
||||
let expected = candidate.report().changes[0].after.clone();
|
||||
assert_eq!(candidate.report().identities_to_stabilize, 2);
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
assert_eq!(fixture.read("project.arcadia"), expected);
|
||||
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
|
||||
assert!(!expected.contains("description"));
|
||||
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
|
||||
let report = fixture.prepare(Operation::Stabilize).write();
|
||||
assert_eq!(report.status, "unchanged");
|
||||
assert!(!report.written);
|
||||
assert_eq!(
|
||||
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
|
||||
fixture.put(
|
||||
"participants.arcadia",
|
||||
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
|
||||
);
|
||||
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
|
||||
.into_iter()
|
||||
.map(|p| (p, fixture.read(p)))
|
||||
.collect();
|
||||
let team = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "team")
|
||||
.unwrap()
|
||||
.0;
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "rescue_team".into(),
|
||||
});
|
||||
assert_eq!(candidate.report().changes.len(), 2);
|
||||
assert_eq!(fixture.model(), before, "preview cannot mutate");
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
let mut expected = before;
|
||||
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
|
||||
assert_eq!(
|
||||
fixture.read("participants.arcadia"),
|
||||
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("work.arcadia"),
|
||||
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
for command in [
|
||||
vec!["check"],
|
||||
vec!["stabilize", "--write"],
|
||||
vec!["rename-alias", "a", "b", "--write"],
|
||||
] {
|
||||
let (code, report) = fixture.cli(&command);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
let diagnostics = report["diagnostics"].as_array().unwrap();
|
||||
assert!(!diagnostics.is_empty());
|
||||
for d in diagnostics {
|
||||
assert_eq!(d["coordinate_space"], "input");
|
||||
let at = &d["location"];
|
||||
let start = at["start"].as_u64().unwrap() as usize;
|
||||
assert!(raw.is_char_boundary(start));
|
||||
assert_eq!(
|
||||
at["line"].as_u64().unwrap() as usize,
|
||||
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
|
||||
);
|
||||
let line = raw[..start]
|
||||
.rsplit_once('\n')
|
||||
.map_or(&raw[..start], |(_, tail)| tail);
|
||||
assert_eq!(
|
||||
at["column"].as_u64().unwrap() as usize,
|
||||
line.chars().count() + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
|
||||
let fixture = Fixture::new();
|
||||
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, result) = fixture.cli(&["lint"]);
|
||||
assert_eq!(code, 0, "engineering warnings do not reject admission");
|
||||
let warning = result["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["code"] == "work_without_behavior_context")
|
||||
.unwrap();
|
||||
assert_eq!(warning["severity"], "warning");
|
||||
assert_eq!(
|
||||
warning["location"]["start"],
|
||||
raw.find("oa_activity").unwrap()
|
||||
);
|
||||
assert_eq!(warning["location"]["line"], 3);
|
||||
assert_eq!(warning["location"]["column"], 5);
|
||||
assert!(warning["object_id"].is_null());
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let pending = fixture.prepare(Operation::Stabilize);
|
||||
let external = format!("{SIMPLE}// editor changed this\n");
|
||||
fixture.put("project.arcadia", &external);
|
||||
let result = pending.write();
|
||||
assert_eq!(result.exit_code, 3);
|
||||
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
|
||||
assert_eq!(fixture.read("project.arcadia"), external);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
assert!(noop.report().changes.is_empty());
|
||||
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
|
||||
fixture.put("project.arcadia", &latest);
|
||||
assert_eq!(noop.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let noop = fixture.prepare(Operation::Stabilize);
|
||||
let source = fixture.read("project.arcadia");
|
||||
// An opaque incomplete journal must never be interpreted/recovered by CLI.
|
||||
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
|
||||
fs::create_dir(&journal).unwrap();
|
||||
fs::write(journal.join("sentinel"), b"retain").unwrap();
|
||||
for op in [Operation::Check, Operation::Stabilize] {
|
||||
let report = prepare(&fixture.entry(), op).err().unwrap();
|
||||
assert_eq!(report.exit_code, 3);
|
||||
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
|
||||
}
|
||||
assert_eq!(
|
||||
noop.write().exit_code,
|
||||
3,
|
||||
"saver must recheck after preview"
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_alias_and_same_alias_preserve_source() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let source = fixture.read("project.arcadia");
|
||||
for (old, new) in [
|
||||
("team", "rescue"),
|
||||
("team", "invalid alias"),
|
||||
("absent", "valid"),
|
||||
] {
|
||||
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(result["status"], "rejected");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(result["status"], "unchanged");
|
||||
assert_eq!(fixture.read("project.arcadia"), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_shows_missing_identity_and_separate_source_migration() {
|
||||
let fixture = Fixture::new();
|
||||
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
|
||||
fixture.put("project.arcadia", old);
|
||||
let candidate = fixture.prepare(Operation::RenameAlias {
|
||||
old: "team".into(),
|
||||
new: "crew".into(),
|
||||
});
|
||||
let report = candidate.report();
|
||||
assert_eq!(report.identities_to_stabilize, 2);
|
||||
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
|
||||
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
|
||||
assert!(report.changes[0].diff.contains("-arcadia 5;"));
|
||||
assert!(report.changes[0].diff.contains("+arcadia 7;"));
|
||||
assert!(report.changes[0].after.contains("oa_entity crew"));
|
||||
assert_eq!(fixture.read("project.arcadia"), old);
|
||||
assert_eq!(candidate.write().exit_code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", SIMPLE);
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
|
||||
.current_dir(&fixture.0)
|
||||
.args(["check", "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(error["diagnostics"][0]["code"], "usage");
|
||||
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
||||
let (code, _) = fixture.cli(&["check", "--write"]);
|
||||
assert_eq!(code, 2);
|
||||
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
|
||||
let fixture = Fixture::new();
|
||||
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
|
||||
fixture.put("project.arcadia", entry);
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 3, "a disk dependency could not be read");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "missing_include")
|
||||
);
|
||||
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
|
||||
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1, "invalid encoding is invalid raw source");
|
||||
assert!(
|
||||
report["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["code"] == "source_encoding")
|
||||
);
|
||||
assert_eq!(fixture.read("project.arcadia"), entry);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("part.arcadia")).unwrap(),
|
||||
[0xff, 0xfe]
|
||||
);
|
||||
for raw in [
|
||||
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
|
||||
"arcadia 2; project p \"P\" {}",
|
||||
] {
|
||||
fixture.put("project.arcadia", raw);
|
||||
let (code, _) = fixture.cli(&["stabilize", "--write"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(fixture.read("project.arcadia"), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
|
||||
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
|
||||
fixture.put(
|
||||
"detached.arcadia",
|
||||
"invalid detached draft must stay exact 雨",
|
||||
);
|
||||
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
||||
let before = fixture.model();
|
||||
let relation = before
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "responsible")
|
||||
.unwrap()
|
||||
.0;
|
||||
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
|
||||
assert_eq!(code, 0);
|
||||
let mut expected = before;
|
||||
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
|
||||
assert_eq!(fixture.model(), expected);
|
||||
assert!(
|
||||
fixture
|
||||
.read("project.arcadia")
|
||||
.contains("@ // keep edge comment\nteam")
|
||||
);
|
||||
let captured = fixture.prepare(Operation::RenameAlias {
|
||||
old: "p".into(),
|
||||
new: "rescue".into(),
|
||||
});
|
||||
assert!(
|
||||
captured
|
||||
.report()
|
||||
.changes
|
||||
.iter()
|
||||
.all(|c| c.path != "part.arcadia")
|
||||
);
|
||||
let root_before = fixture.read("project.arcadia");
|
||||
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
|
||||
assert_eq!(captured.write().exit_code, 3);
|
||||
assert_eq!(fixture.read("project.arcadia"), root_before);
|
||||
assert_eq!(
|
||||
fixture.read("part.arcadia"),
|
||||
"// external unchanged-dependency edit\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.read("detached.arcadia"),
|
||||
"invalid detached draft must stay exact 雨"
|
||||
);
|
||||
}
|
||||
+160
-27
@@ -142,12 +142,87 @@ pub struct Analysis {
|
||||
pub declarations: Vec<DeclarationSpan>,
|
||||
}
|
||||
|
||||
/// Check the decoded spelling required by declaration-header UUID clauses.
|
||||
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
|
||||
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
|
||||
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
|
||||
pub fn has_uuid_header_shape(decoded: &str) -> bool {
|
||||
decoded.len() == 36
|
||||
&& [8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| decoded.as_bytes()[index] == b'-')
|
||||
}
|
||||
|
||||
/// Budgets for one original source buffer. Policy is caller-owned; these
|
||||
/// limits do not change the grammar or perform model admission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimits {
|
||||
pub max_source_bytes: usize,
|
||||
pub max_tokens: usize,
|
||||
pub max_declarations: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutlineResource {
|
||||
SourceBytes,
|
||||
Tokens,
|
||||
Declarations,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OutlineLimit {
|
||||
pub resource: OutlineResource,
|
||||
pub limit: usize,
|
||||
pub observed_at_least: usize,
|
||||
/// Original byte offset where admission stopped; never a rewritten offset.
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
/// Roles discovered by the existing tolerant declaration walk. Keeping the
|
||||
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OutlineDeclaration {
|
||||
pub declaration: DeclarationSpan,
|
||||
pub kind_span: Span,
|
||||
pub alias_span: Span,
|
||||
/// Whole quoted header ID token, even if it is not a valid UUID. In that
|
||||
/// case declaration.id is None. Missing/non-string ID values have no span.
|
||||
pub id_literal_span: Option<Span>,
|
||||
}
|
||||
|
||||
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
|
||||
/// text remains available. Lexical errors and unmatched braces are facts only:
|
||||
/// their absence does not establish syntactic or semantic validity.
|
||||
///
|
||||
/// A budget failure returns Err and no partial Outline. Include reachability,
|
||||
/// project completeness and editor revision are intentionally not inferred here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outline<'a> {
|
||||
pub source: &'a str,
|
||||
pub tokens: Vec<TokenSpan>,
|
||||
pub declarations: Vec<OutlineDeclaration>,
|
||||
pub has_lexical_errors: bool,
|
||||
pub has_unmatched_braces: bool,
|
||||
}
|
||||
|
||||
/// Tolerant highlighting tokens cover the whole input, including incomplete
|
||||
/// strings and unexpected characters. No model is created or modified.
|
||||
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
|
||||
}
|
||||
|
||||
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < source.len() {
|
||||
if tokens.len() == max_tokens {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Tokens,
|
||||
limit: max_tokens,
|
||||
observed_at_least: tokens.len().saturating_add(1),
|
||||
offset: cursor,
|
||||
});
|
||||
}
|
||||
let start = cursor;
|
||||
let ch = source[cursor..].chars().next().expect("in bounds");
|
||||
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
|
||||
@@ -224,7 +299,7 @@ pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
span: Span { start, end: cursor },
|
||||
});
|
||||
}
|
||||
tokens
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
||||
@@ -1230,11 +1305,7 @@ impl Parser<'_> {
|
||||
self.cursor += 1;
|
||||
let offset = self.current().span.start;
|
||||
let value = self.quoted()?;
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
if !has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -1962,8 +2033,49 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
|
||||
|
||||
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
|
||||
/// spans remain available even when a later declaration contains errors.
|
||||
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
|
||||
/// the declaration parser rather than the UUID crate's broader accepted forms.
|
||||
pub fn analyze(source: &str) -> Analysis {
|
||||
let tokens = tokenize(source);
|
||||
let (declarations, _) =
|
||||
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read exact current text without lint, parsing a model, stabilization or I/O.
|
||||
/// Source bytes are admitted before token allocation; token/declaration limits
|
||||
/// are checked before lexing the next token or materializing the next record.
|
||||
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
|
||||
if source.len() > limits.max_source_bytes {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::SourceBytes,
|
||||
limit: limits.max_source_bytes,
|
||||
observed_at_least: source.len(),
|
||||
offset: 0,
|
||||
});
|
||||
}
|
||||
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
|
||||
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
|
||||
let (declarations, has_unmatched_braces) =
|
||||
declaration_outline(source, &tokens, limits.max_declarations)?;
|
||||
Ok(Outline {
|
||||
source,
|
||||
tokens,
|
||||
declarations,
|
||||
has_lexical_errors,
|
||||
has_unmatched_braces,
|
||||
})
|
||||
}
|
||||
|
||||
fn declaration_outline(
|
||||
source: &str,
|
||||
tokens: &[TokenSpan],
|
||||
max_declarations: usize,
|
||||
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
|
||||
let significant = tokens
|
||||
.iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
@@ -1972,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
// again would make tolerant navigation quadratic on deeply nested drafts.
|
||||
let mut body_ends = vec![None; significant.len()];
|
||||
let mut bodies = Vec::new();
|
||||
let mut unmatched_close = false;
|
||||
for (index, token) in significant.iter().enumerate() {
|
||||
match &source[token.span.start..token.span.end] {
|
||||
"{" => bodies.push(index),
|
||||
"}" => {
|
||||
if let Some(open) = bodies.pop() {
|
||||
body_ends[open] = Some(token.span.end);
|
||||
} else {
|
||||
unmatched_close = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
|
||||
let mut declarations = Vec::new();
|
||||
for (i, t) in significant.iter().enumerate() {
|
||||
let word = &source[t.span.start..t.span.end];
|
||||
@@ -2007,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if declarations.len() == max_declarations {
|
||||
return Err(OutlineLimit {
|
||||
resource: OutlineResource::Declarations,
|
||||
limit: max_declarations,
|
||||
observed_at_least: declarations.len().saturating_add(1),
|
||||
offset: t.span.start,
|
||||
});
|
||||
}
|
||||
let mut id = None;
|
||||
let mut id_literal_span = None;
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
|
||||
@@ -2015,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
if let Some(value) = significant.get(j + 1)
|
||||
&& value.class == TokenClass::String
|
||||
{
|
||||
id_literal_span = Some(value.span);
|
||||
let text =
|
||||
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
|
||||
.unwrap();
|
||||
id = Id::parse_str(&text).ok();
|
||||
if has_uuid_header_shape(&text) {
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
let mut end = significant
|
||||
.get(j)
|
||||
.map_or(alias_token.span.end, |t| t.span.end);
|
||||
// A recognized header ID at EOF must remain inside the whole span.
|
||||
// Keep the old next-token/body behavior; only the EOF fallback changes.
|
||||
let header_end = significant
|
||||
.get(j.saturating_sub(1))
|
||||
.or_else(|| significant.last())
|
||||
.map_or(alias_token.span.end, |token| token.span.end);
|
||||
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
|
||||
if significant
|
||||
.get(j)
|
||||
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
|
||||
{
|
||||
end = body_ends[j].unwrap_or(source.len());
|
||||
}
|
||||
declarations.push(DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
declarations.push(OutlineDeclaration {
|
||||
declaration: DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
},
|
||||
},
|
||||
kind_span: t.span,
|
||||
alias_span: alias_token.span,
|
||||
id_literal_span,
|
||||
});
|
||||
}
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations,
|
||||
}
|
||||
Ok((declarations, has_unmatched_braces))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_outline_tests.rs"]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_nesting_tests.rs"]
|
||||
mod nesting_tests;
|
||||
@@ -2442,11 +2579,7 @@ pub(crate) mod legacy {
|
||||
let value = self.quoted()?;
|
||||
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
|
||||
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| value.as_bytes()[index] == b'-')
|
||||
{
|
||||
if !super::has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Prepared outline tests. Explicit compatibility tests additionally exercise
|
||||
//! the existing parsers/analyze; the outline entry itself never calls them.
|
||||
use super::*;
|
||||
|
||||
fn limits() -> OutlineLimits {
|
||||
OutlineLimits {
|
||||
max_source_bytes: 1024 * 1024,
|
||||
max_tokens: 100_000,
|
||||
max_declarations: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
|
||||
result
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.declaration.alias == alias)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn text(source: &str, span: Span) -> &str {
|
||||
&source[span.start..span.end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
|
||||
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
|
||||
let before = source.as_bytes().to_vec();
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source.as_bytes(), before);
|
||||
assert_eq!(result.source.as_ptr(), source.as_ptr());
|
||||
let d = declaration(&result, "responder");
|
||||
assert_eq!(text(source, d.kind_span), "oa_actor");
|
||||
assert_eq!(text(source, d.alias_span), "responder");
|
||||
assert_eq!(
|
||||
text(source, d.id_literal_span.unwrap()),
|
||||
"\"\\u00300000000-0000-0000-0000-000000000001\""
|
||||
);
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
|
||||
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
|
||||
let mut end = 0;
|
||||
for token in &result.tokens {
|
||||
assert_eq!(token.span.start, end);
|
||||
assert!(token.span.start < token.span.end);
|
||||
assert!(source.is_char_boundary(token.span.start));
|
||||
assert!(source.is_char_boundary(token.span.end));
|
||||
end = token.span.end;
|
||||
}
|
||||
assert_eq!(end, source.len());
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_id_at_eof_is_inside_the_declaration_extent() {
|
||||
for source in [
|
||||
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
|
||||
"node placed id \"00000000-0000-0000-0000-000000000001\"",
|
||||
] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
let literal = d.id_literal_span.unwrap();
|
||||
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert!(d.declaration.span.start <= literal.start);
|
||||
assert!(literal.end <= d.declaration.span.end);
|
||||
}
|
||||
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
|
||||
let result = outline(source, limits()).unwrap();
|
||||
let d = &result.declarations[0];
|
||||
assert_eq!(d.declaration.span.end, source.len());
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(d.id_literal_span, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
|
||||
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
|
||||
let first = outline(source, limits()).unwrap();
|
||||
let second = outline(source, limits()).unwrap();
|
||||
assert_eq!(first, second);
|
||||
assert!(
|
||||
first
|
||||
.declarations
|
||||
.iter()
|
||||
.all(|d| d.declaration.id.is_none())
|
||||
);
|
||||
assert_eq!(declaration(&first, "missing").id_literal_span, None);
|
||||
assert_eq!(declaration(&first, "historical").id_literal_span, None);
|
||||
assert_eq!(
|
||||
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
|
||||
"\"not-a-uuid\""
|
||||
);
|
||||
// Balanced tokens are not a successful syntax/semantic validation claim.
|
||||
assert!(!first.has_lexical_errors);
|
||||
assert!(!first.has_unmatched_braces);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
|
||||
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.source, source);
|
||||
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
|
||||
assert!(result.has_lexical_errors);
|
||||
assert!(result.has_unmatched_braces);
|
||||
let ready = declaration(&result, "ready");
|
||||
assert_eq!(
|
||||
text(source, ready.declaration.span),
|
||||
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "outer").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
assert_eq!(
|
||||
declaration(&result, "pending").declaration.span.end,
|
||||
source.len()
|
||||
);
|
||||
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
|
||||
assert!(unmatched_close.has_unmatched_braces);
|
||||
assert!(!unmatched_close.has_lexical_errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
|
||||
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 1);
|
||||
assert!(!result.has_lexical_errors);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
// There is deliberately no `valid` flag: the known-complete fact is scanning
|
||||
// these bytes, not the missing body becoming legal or the draft being applied.
|
||||
assert_eq!(result.source, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
|
||||
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
|
||||
let result = outline(source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 5);
|
||||
let duplicates: Vec<_> = result
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|d| d.declaration.alias == "oa_activity")
|
||||
.collect();
|
||||
assert_eq!(duplicates.len(), 2);
|
||||
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
|
||||
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
|
||||
assert!(
|
||||
result
|
||||
.tokens
|
||||
.iter()
|
||||
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
|
||||
);
|
||||
assert_eq!(
|
||||
text(source, declaration(&result, "placement").kind_span),
|
||||
"node"
|
||||
);
|
||||
assert_eq!(declaration(&result, "placement").declaration.id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_refuse_before_returning_a_partial_outline() {
|
||||
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
|
||||
let admitted = outline(source, limits()).unwrap();
|
||||
let exact = OutlineLimits {
|
||||
max_source_bytes: source.len(),
|
||||
max_tokens: admitted.tokens.len(),
|
||||
max_declarations: 2,
|
||||
};
|
||||
assert_eq!(outline(source, exact).unwrap(), admitted);
|
||||
for resource in [
|
||||
OutlineResource::SourceBytes,
|
||||
OutlineResource::Tokens,
|
||||
OutlineResource::Declarations,
|
||||
] {
|
||||
let mut lower = exact;
|
||||
match resource {
|
||||
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
|
||||
OutlineResource::Tokens => lower.max_tokens -= 1,
|
||||
OutlineResource::Declarations => lower.max_declarations -= 1,
|
||||
}
|
||||
let error = outline(source, lower).unwrap_err();
|
||||
assert_eq!(error.resource, resource);
|
||||
assert!(error.observed_at_least > error.limit);
|
||||
assert!(source.is_char_boundary(error.offset));
|
||||
if resource == OutlineResource::Declarations {
|
||||
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
|
||||
}
|
||||
}
|
||||
let empty = outline(
|
||||
"",
|
||||
OutlineLimits {
|
||||
max_source_bytes: 0,
|
||||
max_tokens: 0,
|
||||
max_declarations: 0,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
|
||||
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_source_bytes: 4,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::SourceBytes);
|
||||
assert_eq!(error.offset, 0);
|
||||
let error = outline(
|
||||
unicode,
|
||||
OutlineLimits {
|
||||
max_tokens: 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Tokens);
|
||||
assert_eq!(error.offset, unicode.find('\n').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
|
||||
let old = "oa_actor a \"A\" {}\nsource @a;";
|
||||
let current = format!("// 航海 🚒\r\n{old}");
|
||||
let old_outline = outline(old, limits()).unwrap();
|
||||
let current_outline = outline(¤t, limits()).unwrap();
|
||||
let shift = current.len() - old.len();
|
||||
let old_role = &old_outline.declarations[0];
|
||||
let current_role = ¤t_outline.declarations[0];
|
||||
assert_eq!(
|
||||
current_role.alias_span.start,
|
||||
old_role.alias_span.start + shift
|
||||
);
|
||||
assert_eq!(
|
||||
current_role.kind_span.start,
|
||||
old_role.kind_span.start + shift
|
||||
);
|
||||
assert_eq!(
|
||||
current_role.declaration.span.end,
|
||||
old_role.declaration.span.end + shift
|
||||
);
|
||||
assert_eq!(current_outline.source, current);
|
||||
assert_eq!(old_outline.source, old);
|
||||
assert_eq!(current_role.declaration.id, None);
|
||||
assert!(!current_outline.source.contains(" id "));
|
||||
// Workspace revision/stale-handle rejection belongs to the query/adapter,
|
||||
// not a claim that an old owned span can be applied to any other string.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
|
||||
let mut source = String::new();
|
||||
for index in 0..600 {
|
||||
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
|
||||
}
|
||||
source.push_str(&"}\n".repeat(600));
|
||||
let result = outline(&source, limits()).unwrap();
|
||||
assert_eq!(result.declarations.len(), 600);
|
||||
assert!(!result.has_unmatched_braces);
|
||||
assert_eq!(
|
||||
result.declarations[0].declaration.span.end,
|
||||
source.len() - 1
|
||||
);
|
||||
let error = outline(
|
||||
&source,
|
||||
OutlineLimits {
|
||||
max_declarations: 599,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.resource, OutlineResource::Declarations);
|
||||
assert_eq!(error.observed_at_least, 600);
|
||||
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
|
||||
for raw in [
|
||||
"00000000000000000000000000000001",
|
||||
"{00000000-0000-0000-0000-000000000001}",
|
||||
"urn:uuid:00000000-0000-0000-0000-000000000001",
|
||||
] {
|
||||
assert!(
|
||||
Id::parse_str(raw).is_ok(),
|
||||
"The UUID crate alone is too permissive"
|
||||
);
|
||||
assert!(!has_uuid_header_shape(raw));
|
||||
let literal = format!("\"{raw}\"");
|
||||
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let before = modern.clone();
|
||||
let result = outline(&modern, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
|
||||
assert_eq!(modern, before);
|
||||
let errors = parse(&modern).unwrap_err();
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12")
|
||||
&& error.span.start == modern.find(&literal).unwrap())
|
||||
);
|
||||
// Intentional compatibility correction: old analyze yielded Some(Id(1))
|
||||
// here despite lint rejecting the header. It now shares outline's None.
|
||||
let analysis = analyze(&modern);
|
||||
assert_eq!(
|
||||
analysis
|
||||
.declarations
|
||||
.iter()
|
||||
.find(|d| d.alias == "model")
|
||||
.unwrap()
|
||||
.id,
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12"))
|
||||
);
|
||||
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
|
||||
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
|
||||
assert!(
|
||||
legacy_errors
|
||||
.iter()
|
||||
.any(|error| error.message.contains("8-4-4-4-12"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
|
||||
let expected = Id::from_u128(0xabcdef);
|
||||
let canonical = expected.to_string();
|
||||
let uppercase = canonical.to_uppercase();
|
||||
assert_ne!(uppercase, canonical);
|
||||
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
|
||||
let literal = format!("\"{raw}\"");
|
||||
let decoded: String = serde_json::from_str(&literal).unwrap();
|
||||
assert!(has_uuid_header_shape(&decoded));
|
||||
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let result = outline(&modern, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, Some(expected));
|
||||
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
|
||||
assert_eq!(result.source.as_bytes(), modern.as_bytes());
|
||||
assert_eq!(parse(&modern).unwrap().id, expected);
|
||||
let analysis = analyze(&modern);
|
||||
assert_eq!(analysis.declarations[0].id, Some(expected));
|
||||
assert!(
|
||||
!analysis
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|error| error.severity == crate::validation::Severity::Error)
|
||||
);
|
||||
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
|
||||
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
|
||||
let raw = "00000000-0000-0000-0000-00000000000Z";
|
||||
assert!(has_uuid_header_shape(raw));
|
||||
assert!(Id::parse_str(raw).is_err());
|
||||
let literal = format!("\"{raw}\"");
|
||||
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
|
||||
let result = outline(&source, limits()).unwrap();
|
||||
let d = declaration(&result, "model");
|
||||
assert_eq!(d.declaration.id, None);
|
||||
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
|
||||
assert!(
|
||||
parse(&source)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|error| error.message == "Invalid UUID identity.")
|
||||
);
|
||||
assert_eq!(analyze(&source).declarations[0].id, None);
|
||||
}
|
||||
+404
-79
@@ -7,6 +7,9 @@ mod diagram_selection_tests;
|
||||
mod hosted;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
pub(crate) mod text_workspace;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
@@ -413,11 +416,18 @@ pub enum Message {
|
||||
IncludesLoaded(u64, Result<DiskProject, String>),
|
||||
Lint(u64),
|
||||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||||
SourceDiagnostic(usize),
|
||||
SourceRelated(SourceLocation),
|
||||
SourceViewport,
|
||||
TextWorkspace(text_workspace::Event),
|
||||
SourceViewport(Option<Id>),
|
||||
SourceSymbols(
|
||||
crate::source_navigation::SymbolContext,
|
||||
crate::source_navigation::SymbolEvent,
|
||||
),
|
||||
SourceSymbolsReady(crate::source_navigation::Completion),
|
||||
SourceRename(source_rename::Event),
|
||||
SourceRenameReady(source_rename::Completion),
|
||||
SourceForSelection,
|
||||
SourceNavigation(crate::source_navigation::Event),
|
||||
SourceFindClose(crate::source_navigation::SymbolContext, Id),
|
||||
Save(bool),
|
||||
SavePath(Option<PathBuf>),
|
||||
Saved(Result<Option<FileSnapshot>, SaveFailure>),
|
||||
@@ -491,6 +501,10 @@ pub struct App {
|
||||
pub source_jump: Option<text_editor::Cursor>,
|
||||
source_select_line: bool,
|
||||
pub source_navigation: crate::source_navigation::State,
|
||||
source_find_instance: Id,
|
||||
pub source_symbols: crate::source_navigation::SymbolState,
|
||||
pub text_workspace: text_workspace::State,
|
||||
pub source_rename: source_rename::State,
|
||||
pub model_issues: Vec<validation::Issue>,
|
||||
pub migration: Option<persistence::MigrationReport>,
|
||||
source_version: u64,
|
||||
@@ -596,6 +610,10 @@ impl App {
|
||||
source_jump: None,
|
||||
source_select_line: false,
|
||||
source_navigation: crate::source_navigation::State::default(),
|
||||
source_find_instance: Id::new_v4(),
|
||||
source_symbols: crate::source_navigation::SymbolState::default(),
|
||||
text_workspace: text_workspace::State::default(),
|
||||
source_rename: source_rename::State::default(),
|
||||
model_issues,
|
||||
migration: None,
|
||||
source_version: 0,
|
||||
@@ -642,18 +660,62 @@ impl App {
|
||||
|| self.behavior.has_pending()
|
||||
}
|
||||
fn sync_source(&mut self) {
|
||||
self.sync_source_with_alias_editor(None);
|
||||
}
|
||||
fn publish_source_diagnostics(&mut self, diagnostics: Vec<SourceDiagnostic>) {
|
||||
self.source_diagnostics = diagnostics;
|
||||
self.text_workspace.publish_diagnostics(self.source_key());
|
||||
}
|
||||
/// A different source destination owns a new navigation/presentation session.
|
||||
/// Only diagnostics proven current before this identity-only change may be
|
||||
/// republished for it; stale source locations must not become fresh again.
|
||||
fn reset_source_navigation_session(&mut self) {
|
||||
let diagnostics_current = self
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.is_some_and(|publication| publication.source() == self.source_key());
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.clear_source_presentation();
|
||||
if diagnostics_current {
|
||||
self.publish_source_diagnostics(self.source_diagnostics.clone());
|
||||
}
|
||||
}
|
||||
fn clear_source_presentation(&mut self) {
|
||||
let in_flight = self.source_symbols.in_flight;
|
||||
self.source_symbols = crate::source_navigation::SymbolState::default();
|
||||
self.source_symbols.in_flight = in_flight;
|
||||
self.source_jump = None;
|
||||
self.text_workspace.reset();
|
||||
}
|
||||
fn sync_source_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.source_workspace.accept(self.document.bundle());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.show_source_file_with_alias_editor(false, hint);
|
||||
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
|
||||
self.source_version += 1;
|
||||
self.lint_requested = None;
|
||||
}
|
||||
fn show_source_file(&mut self, restore_cursor: bool) {
|
||||
self.show_source_file_with_alias_editor(restore_cursor, None);
|
||||
}
|
||||
fn show_source_file_with_alias_editor(
|
||||
&mut self,
|
||||
restore_cursor: bool,
|
||||
hint: Option<source_rename::EditorRefresh<'_>>,
|
||||
) {
|
||||
self.source_symbols.reveal = None;
|
||||
self.source_tree.reveal(&self.source_workspace.active);
|
||||
self.source_select_line = false;
|
||||
let source = self.source_workspace.text().to_owned();
|
||||
self.source_navigation.refresh(&source);
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
if !hint.is_some_and(|hint| {
|
||||
hint.apply(
|
||||
&mut self.source_editor,
|
||||
&self.source_workspace.active,
|
||||
&source,
|
||||
)
|
||||
}) {
|
||||
self.source_editor = text_editor::Content::with_text(&source);
|
||||
}
|
||||
self.source_snapshot = Arc::from(source);
|
||||
self.source_jump = restore_cursor
|
||||
.then(|| {
|
||||
@@ -663,6 +725,9 @@ impl App {
|
||||
.map(|b| b.cursor)
|
||||
})
|
||||
.flatten();
|
||||
if let Some(cursor) = self.source_jump {
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
}
|
||||
fn selected_architecture_placement(&self) -> Option<Id> {
|
||||
self.active_diagram
|
||||
@@ -695,6 +760,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
fn sync(&mut self) {
|
||||
self.sync_with_alias_editor(None);
|
||||
}
|
||||
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.model_issues = validate(self.project());
|
||||
self.normalize_creation();
|
||||
@@ -724,7 +792,7 @@ impl App {
|
||||
.map(|d| d.id);
|
||||
}
|
||||
self.refresh_draft();
|
||||
self.sync_source();
|
||||
self.sync_source_with_alias_editor(hint);
|
||||
}
|
||||
fn normalize_creation(&mut self) {
|
||||
if !self.create_kind.supports(self.layer) {
|
||||
@@ -763,7 +831,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
Err(issues) => {
|
||||
self.source_diagnostics = issues;
|
||||
self.publish_source_diagnostics(issues);
|
||||
self.status = "Source contains errors. Your draft is preserved.".into();
|
||||
self.page = Page::Text;
|
||||
false
|
||||
@@ -854,8 +922,12 @@ impl App {
|
||||
let scenario_selection = self
|
||||
.behavior
|
||||
.scenario
|
||||
.created_selection(&command)
|
||||
.or_else(|| self.behavior.topology.created_selection(&command));
|
||||
.created_selection(self.project(), &command)
|
||||
.or_else(|| {
|
||||
self.behavior
|
||||
.topology
|
||||
.created_selection(self.project(), &command)
|
||||
});
|
||||
match self.document.execute(command) {
|
||||
Ok(selected) => {
|
||||
if let Some(created) = created {
|
||||
@@ -912,6 +984,9 @@ impl App {
|
||||
let canvas_size = self.canvas_size;
|
||||
let source_version = self.source_version + 1;
|
||||
let lint_running = self.lint_running;
|
||||
let navigation_in_flight = self.source_symbols.in_flight;
|
||||
let rename_in_flight = self.source_rename.in_flight;
|
||||
let menu_sequence = self.menu_sequence;
|
||||
let guide = std::mem::take(&mut self.guide);
|
||||
let mut hosted = std::mem::take(&mut self.hosted);
|
||||
hosted.leave_document();
|
||||
@@ -923,6 +998,9 @@ impl App {
|
||||
self.canvas_size = canvas_size;
|
||||
self.source_version = source_version;
|
||||
self.lint_running = lint_running;
|
||||
self.source_symbols.in_flight = navigation_in_flight;
|
||||
self.source_rename.in_flight = rename_in_flight;
|
||||
self.menu_sequence = menu_sequence;
|
||||
self.guide = guide;
|
||||
self.path = path;
|
||||
self.status = "Project ready".into();
|
||||
@@ -1020,10 +1098,13 @@ impl App {
|
||||
pub fn view(&self) -> Element<'_, Message> {
|
||||
let base = crate::context_menu::Popup::new(
|
||||
crate::views::view(self),
|
||||
crate::model_menu::popup(self),
|
||||
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, base)
|
||||
crate::source_navigation::interaction::observe(
|
||||
crate::hosted::view::overlay(self, base),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -1031,8 +1112,19 @@ impl App {
|
||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||
architecture_authoring::subscription(self),
|
||||
behavior::creation::subscription(self.behavior.creation.has_pending())
|
||||
.map(|()| Message::Escape),
|
||||
// Use one captured-Escape bridge: a focused Find input consumes
|
||||
// Escape, while modal priority remains in the ordinary handler.
|
||||
behavior::creation::subscription(
|
||||
self.text_workspace.menu().is_none()
|
||||
&& (self.behavior.creation.has_pending()
|
||||
|| self.behavior.source_active()
|
||||
|| (self.page == Page::Text
|
||||
&& (self.text_workspace.drawer().is_some()
|
||||
|| self.source_navigation.open
|
||||
|| self.source_rename.open())
|
||||
&& self.architecture.is_none())),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
@@ -1077,6 +1169,110 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Captured Text commands validate their origin before they can
|
||||
// invalidate that same origin or pass into existing command handlers.
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
return self.text_workspace_event(event);
|
||||
}
|
||||
if let Message::SourceRename(event) = &message
|
||||
&& !self.source_rename_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let message = match message {
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
if context != self.source_symbol_context()
|
||||
|| instance != self.source_find_instance
|
||||
|| !self.source_navigation.open
|
||||
|| !self.source_navigation_available()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Only the checked envelope may enter the legacy Close handler.
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close)
|
||||
}
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
|
||||
return Task::none();
|
||||
}
|
||||
message => message,
|
||||
};
|
||||
if let Message::SourceSymbols(context, _) = &message
|
||||
&& (context.session != self.source_workspace.session()
|
||||
|| context.path != self.source_workspace.active)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Retaining result data does not retain authority for an old row click.
|
||||
if let Message::SourceSymbols(
|
||||
_,
|
||||
crate::source_navigation::SymbolEvent::Choose { stamp, visit, .. },
|
||||
) = &message
|
||||
&& (self.text_workspace.drawer() != Some(text_workspace::DrawerTab::References)
|
||||
|| !self.source_navigation_available()
|
||||
|| stamp.snapshot != self.source_key()
|
||||
|| *visit != self.text_workspace.drawer_visit()
|
||||
|| self
|
||||
.source_symbols
|
||||
.results
|
||||
.as_ref()
|
||||
.is_none_or(|results| results.stamp != *stamp))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Internal results and geometry do not constitute a new user intent.
|
||||
// Process query completion even behind a modal to release its worker slot;
|
||||
// its handler independently refuses navigation outside the live editor.
|
||||
if let Message::SourceSymbolsReady(completion) = message {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
if let Message::SourceRenameReady(completion) = message {
|
||||
return self.source_rename_ready(completion);
|
||||
}
|
||||
if self.text_workspace.menu().is_some() {
|
||||
if matches!(
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.text_workspace.dismiss_menu();
|
||||
self.source_interaction();
|
||||
return Task::none();
|
||||
}
|
||||
// Global subscriptions can receive captured keyboard input.
|
||||
// A Text menu owns those commands while passive work can finish.
|
||||
if !matches!(
|
||||
message,
|
||||
Message::CheckpointWritten(..)
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::CheckpointTick
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::Saved(_)
|
||||
| Message::Hosted(crate::hosted::Event::Completed(..))
|
||||
| Message::Loaded(_)
|
||||
| Message::IncludesLoaded(..)
|
||||
| Message::SavePath(_)
|
||||
| Message::SourceRecovered(_)
|
||||
| Message::Exported(_)
|
||||
) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
&message,
|
||||
Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
) {
|
||||
self.source_interaction();
|
||||
}
|
||||
if matches!(&message, Message::Connection(_))
|
||||
&& self
|
||||
.architecture
|
||||
@@ -1141,7 +1337,7 @@ impl App {
|
||||
| Message::CanvasResized(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Request(Pending::Close(_))
|
||||
)
|
||||
{
|
||||
@@ -1187,7 +1383,7 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
@@ -1225,7 +1421,7 @@ impl App {
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
@@ -1255,6 +1451,18 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.source_rename.open() {
|
||||
if matches!(
|
||||
message,
|
||||
Message::Escape | Message::Request(Pending::Close(_))
|
||||
) {
|
||||
self.close_source_rename();
|
||||
return Task::none();
|
||||
}
|
||||
if !modal_event && !matches!(message, Message::SourceRename(_)) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
message,
|
||||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||||
@@ -1299,6 +1507,7 @@ impl App {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
let mut task = self.handle(message);
|
||||
self.refresh_source_rename_context();
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page != Page::Home {
|
||||
self.last_workspace_page = self.page;
|
||||
@@ -1519,6 +1728,37 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::First(_, tool) => {
|
||||
let empty = match tool {
|
||||
scenario::Tool::Lifeline => !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline),
|
||||
scenario::Tool::Message | scenario::Tool::Action => {
|
||||
self.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Lifeline)
|
||||
&& !self
|
||||
.project()
|
||||
.children(context.scenario)
|
||||
.any(|e| e.kind() == Kind::Occurrence)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !empty {
|
||||
self.status="This scenario already has content. Use its current authoring tools.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.update(
|
||||
self.project(),
|
||||
scenario::Event::Start(context.scenario, tool),
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
if let Err(error) = result {
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
CanvasEvent::BeginOccurrence {
|
||||
tool,
|
||||
slot,
|
||||
@@ -1697,11 +1937,11 @@ impl App {
|
||||
// Validate the originating canvas before advancing its input
|
||||
// epoch. Actions are then generated with the refreshed context,
|
||||
// while pointer history hidden behind the menu becomes stale.
|
||||
let Some(opened) = crate::model_menu::Opened::new(
|
||||
self,
|
||||
request,
|
||||
self.menu_sequence.wrapping_add(1),
|
||||
) else {
|
||||
let Some(token) = self.menu_sequence.checked_add(1) else {
|
||||
self.status = "Reopen the application to start a new menu session.".into();
|
||||
return Task::none();
|
||||
};
|
||||
let Some(opened) = crate::model_menu::Opened::new(self, request, token) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.connection = None;
|
||||
@@ -1754,7 +1994,18 @@ impl App {
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.behavior.creation.has_pending() {
|
||||
let restore_source_focus = self.source_navigation_available()
|
||||
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
|
||||
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
|
||||
if self.behavior.source_back() {
|
||||
self.status =
|
||||
"Returned to the unchanged parent form; no source was created.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.source_active() {
|
||||
self.behavior.acknowledge_commit();
|
||||
self.status = "Behavior form cancelled; no staged source was created.".into();
|
||||
} else if self.behavior.creation.has_pending() {
|
||||
self.behavior.creation.acknowledge_commit();
|
||||
self.status = "Behavior creation cancelled".into();
|
||||
}
|
||||
@@ -1762,6 +2013,14 @@ impl App {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
}
|
||||
self.source_navigation.open = false;
|
||||
// Popup/modal priority was handled above. In the available Text
|
||||
// workspace, one Escape dismisses Find before the drawer beneath it.
|
||||
if !dismiss_find_only {
|
||||
self.text_workspace.show_drawer(None);
|
||||
}
|
||||
if restore_source_focus {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
@@ -2321,10 +2580,13 @@ impl App {
|
||||
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
|
||||
if (event.starts_form() || (event.is_source() && !event.is_source_back()))
|
||||
&& (self.source_dirty() || self.draft_dirty())
|
||||
{
|
||||
self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let source_was_open = self.behavior.source_open();
|
||||
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
|
||||
self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
@@ -2342,6 +2604,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if !source_was_open && self.behavior.source_open() {
|
||||
return iced::widget::operation::focus(
|
||||
behavior::source_draft::NAME_INPUT,
|
||||
);
|
||||
}
|
||||
if focus_name {
|
||||
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
|
||||
}
|
||||
@@ -2537,13 +2804,19 @@ impl App {
|
||||
Message::Undo | Message::Redo => {
|
||||
let redo = matches!(message, Message::Redo);
|
||||
if self.apply() {
|
||||
if redo {
|
||||
self.document.redo();
|
||||
let changed = if redo {
|
||||
self.document.redo()
|
||||
} else {
|
||||
self.document.undo();
|
||||
self.document.undo()
|
||||
};
|
||||
if changed {
|
||||
self.sync();
|
||||
}
|
||||
self.sync();
|
||||
self.status = if redo {
|
||||
self.status = if !changed && redo {
|
||||
"Nothing to redo"
|
||||
} else if !changed {
|
||||
"Nothing to undo"
|
||||
} else if redo {
|
||||
"Model change redone"
|
||||
} else {
|
||||
"Model change undone"
|
||||
@@ -2722,7 +2995,7 @@ impl App {
|
||||
.edit(after.clone(), self.source_editor.cursor());
|
||||
self.source_snapshot = Arc::from(after);
|
||||
self.source_version += 1;
|
||||
self.source_diagnostics.clear();
|
||||
self.publish_source_diagnostics(Vec::new());
|
||||
let version = self.source_version;
|
||||
return Task::perform(
|
||||
async move {
|
||||
@@ -2734,10 +3007,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::SourceFile(path) => {
|
||||
if self
|
||||
.source_workspace
|
||||
.switch(&path, self.source_editor.cursor())
|
||||
if path != self.source_workspace.active
|
||||
&& self.source_workspace.files.contains_key(&path)
|
||||
{
|
||||
let departure =
|
||||
crate::source_navigation::selection::capture(&mut self.source_editor);
|
||||
self.source_workspace.switch(&path, departure);
|
||||
self.show_source_file(true);
|
||||
}
|
||||
}
|
||||
@@ -2783,9 +3058,15 @@ impl App {
|
||||
if self.pending_recovery.as_ref() != Some(&receipt.target) {
|
||||
return Task::none();
|
||||
}
|
||||
self.invalidate_source_rename("Disk recovery changed this source context. Your replacement is retained; close and invoke Rename again.");
|
||||
self.pending_recovery = None;
|
||||
self.source_diagnostics
|
||||
.retain(|issue| issue.diagnostic.code != "pending_save");
|
||||
let diagnostics = self
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.diagnostic.code != "pending_save")
|
||||
.cloned()
|
||||
.collect();
|
||||
self.publish_source_diagnostics(diagnostics);
|
||||
match receipt.disk {
|
||||
Ok(disk) if !self.source_workspace.applied => {
|
||||
self.open_source_project(disk)
|
||||
@@ -2796,12 +3077,21 @@ impl App {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
if self.path.as_ref() != Some(&receipt.target) {
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.path = Some(receipt.target);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.disk_project = Some(disk);
|
||||
}
|
||||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||||
}
|
||||
Err(error) => {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.document.invalidate_saved_baseline();
|
||||
}
|
||||
self.status = format!(
|
||||
"Disk recovery completed; files could not be reloaded: {error}"
|
||||
)
|
||||
@@ -2822,7 +3112,7 @@ impl App {
|
||||
self.source_workspace
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.source_version += 1;
|
||||
self.source_diagnostics = disk.diagnostics.clone();
|
||||
self.publish_source_diagnostics(disk.diagnostics.clone());
|
||||
self.disk_project = Some(disk);
|
||||
self.status = "Included files loaded; existing drafts retained.".into();
|
||||
return self.request_lint();
|
||||
@@ -2843,29 +3133,20 @@ impl App {
|
||||
self.lint_running = None;
|
||||
if version == self.source_version {
|
||||
match result {
|
||||
Ok(diagnostics) => self.source_diagnostics = diagnostics,
|
||||
Ok(diagnostics) => self.publish_source_diagnostics(diagnostics),
|
||||
Err(error) => self.status = format!("Source checks failed: {error}"),
|
||||
}
|
||||
}
|
||||
return self.start_pending_lint();
|
||||
}
|
||||
Message::SourceDiagnostic(index) => {
|
||||
if let Some(issue) = self.source_diagnostics.get(index) {
|
||||
self.jump_to_source(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
});
|
||||
}
|
||||
}
|
||||
Message::SourceRelated(location) => self.jump_to_source(location),
|
||||
Message::SourceViewport => {
|
||||
if let Some(cursor) = self.source_jump.take() {
|
||||
self.source_editor.move_to(cursor);
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
return iced::widget::operation::focus("source-editor");
|
||||
}
|
||||
Message::TextWorkspace(_) => return Task::none(), // checked before generic input
|
||||
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
|
||||
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
|
||||
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
|
||||
Message::SourceRename(event) => return self.source_rename_event(event),
|
||||
Message::SourceRenameReady(completion) => return self.source_rename_ready(completion),
|
||||
Message::SourceSymbolsReady(completion) => {
|
||||
return self.source_symbols_ready(completion);
|
||||
}
|
||||
Message::ApplySource => {
|
||||
self.apply_source();
|
||||
@@ -2882,7 +3163,7 @@ impl App {
|
||||
if self.source_workspace.undo(redo) {
|
||||
self.show_source_file(true);
|
||||
self.source_version += 1;
|
||||
self.source_diagnostics.clear();
|
||||
self.publish_source_diagnostics(Vec::new());
|
||||
return self.request_lint();
|
||||
}
|
||||
}
|
||||
@@ -2911,6 +3192,7 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
self.page = Page::Text;
|
||||
self.source_find_instance = Id::new_v4();
|
||||
self.source_navigation.open = true;
|
||||
self.source_navigation.refresh(&self.source_editor.text());
|
||||
return iced::widget::operation::focus(if go_to_line {
|
||||
@@ -2919,13 +3201,22 @@ impl App {
|
||||
"source-find"
|
||||
});
|
||||
}
|
||||
Event::Close => self.source_navigation.open = false,
|
||||
Event::Close => {
|
||||
let was_open = self.source_navigation.open;
|
||||
self.source_navigation.open = false;
|
||||
if was_open {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
}
|
||||
Event::Query(query) => {
|
||||
self.source_navigation.query = query;
|
||||
self.source_navigation.refresh(&self.source_editor.text());
|
||||
}
|
||||
Event::Next(backwards) if self.page == Page::Text => {
|
||||
self.source_jump = self.source_navigation.next(backwards);
|
||||
if let Some(cursor) = self.source_jump {
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
if self.source_jump.is_none() {
|
||||
self.status = "No matching source text.".into();
|
||||
}
|
||||
@@ -2936,8 +3227,7 @@ impl App {
|
||||
.destination(self.source_editor.line_count())
|
||||
{
|
||||
Ok(cursor) => {
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = true;
|
||||
self.queue_source_reveal(cursor, true);
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
},
|
||||
@@ -3020,6 +3310,9 @@ impl App {
|
||||
}
|
||||
self.sync_source();
|
||||
}
|
||||
if self.path.as_ref() != Some(&receipt.path) {
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.disk_project = Some(disk);
|
||||
self.path = Some(receipt.path);
|
||||
self.hosted.session = None;
|
||||
@@ -3560,7 +3853,7 @@ impl App {
|
||||
self.source_workspace
|
||||
.discovered(&disk.bundle, disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = self.document.compiled().diagnostics.clone();
|
||||
self.publish_source_diagnostics(self.document.compiled().diagnostics.clone());
|
||||
self.migration = self.document.compiled().decoded.migration.clone();
|
||||
if self.migration.is_some() {
|
||||
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
|
||||
@@ -3578,10 +3871,12 @@ impl App {
|
||||
self.canvas_document = Id::new_v4();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.source_workspace.reachable = disk.reachable.clone();
|
||||
self.clear_source_presentation();
|
||||
self.source_workspace.set_reachable(disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = error.diagnostics().to_vec();
|
||||
self.publish_source_diagnostics(error.diagnostics().to_vec());
|
||||
self.source_version += 1;
|
||||
self.lint_requested = None;
|
||||
self.page = Page::Text;
|
||||
@@ -3611,17 +3906,14 @@ impl App {
|
||||
}
|
||||
|
||||
fn jump_to_source(&mut self, location: SourceLocation) {
|
||||
self.source_workspace
|
||||
.switch(&location.path, self.source_editor.cursor());
|
||||
if self.source_workspace.active != location.path {
|
||||
let Some(target) = self.source_workspace.files.get(&location.path) else {
|
||||
self.status = format!(
|
||||
"{} has not been loaded. Use Load includes or create the missing file.",
|
||||
location.path
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.show_source_file(false);
|
||||
let source = self.source_workspace.text();
|
||||
};
|
||||
let source = &target.text;
|
||||
let mut offset = location.span.start.min(source.len());
|
||||
while !source.is_char_boundary(offset) {
|
||||
offset -= 1;
|
||||
@@ -3631,11 +3923,16 @@ impl App {
|
||||
let column = prefix
|
||||
.rfind('\n')
|
||||
.map_or(offset, |start| offset - start - 1);
|
||||
self.source_jump = Some(text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
});
|
||||
self.source_select_line = true;
|
||||
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
|
||||
self.source_workspace.switch(&location.path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(
|
||||
text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn create_source_file(&mut self) -> Result<(), String> {
|
||||
@@ -3655,6 +3952,7 @@ impl App {
|
||||
.is_ok_and(|includes| includes.iter().any(|include| include.target == path))
|
||||
});
|
||||
let mut changed = bundle.clone();
|
||||
let mut changed_entry = None;
|
||||
if !already_included {
|
||||
if self.source_dirty() {
|
||||
return Err(
|
||||
@@ -3674,16 +3972,30 @@ impl App {
|
||||
serde_json::to_string(&path).map_err(|e| e.to_string())?
|
||||
),
|
||||
);
|
||||
self.source_workspace
|
||||
.switch(&bundle.entry, self.source_editor.cursor());
|
||||
self.source_workspace
|
||||
.edit(entry.clone(), self.source_editor.cursor());
|
||||
changed_entry = Some(entry.clone());
|
||||
}
|
||||
// All fallible preparation is complete. The intermediate entry buffer
|
||||
// is not displayed, so it must not receive the outgoing editor's cursor.
|
||||
let original_path = self.source_workspace.active.clone();
|
||||
let departure = crate::source_navigation::selection::capture(&mut self.source_editor);
|
||||
if let Some(entry) = changed_entry {
|
||||
self.source_workspace.switch(&bundle.entry, departure);
|
||||
let cursor = if self.source_workspace.active == original_path {
|
||||
departure
|
||||
} else {
|
||||
self.source_workspace.files[&self.source_workspace.active].cursor
|
||||
};
|
||||
self.source_workspace.edit(entry, cursor);
|
||||
}
|
||||
changed.files.insert(path.clone(), String::new());
|
||||
self.source_workspace
|
||||
.discovered(&changed, changed.files.keys().cloned().collect());
|
||||
self.source_workspace
|
||||
.switch(&path, self.source_editor.cursor());
|
||||
let cursor = if self.source_workspace.active == original_path {
|
||||
departure
|
||||
} else {
|
||||
self.source_workspace.files[&self.source_workspace.active].cursor
|
||||
};
|
||||
self.source_workspace.switch(&path, cursor);
|
||||
self.source_workspace.new_path.clear();
|
||||
self.show_source_file(true);
|
||||
self.source_version += 1;
|
||||
@@ -4245,6 +4557,10 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
#[path = "app/save_as_tests.rs"]
|
||||
mod save_as_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "app/repair_save_state_tests.rs"]
|
||||
mod repair_save_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_project_tests {
|
||||
use super::*;
|
||||
@@ -4625,7 +4941,16 @@ mod source_project_tests {
|
||||
assert_eq!(app.project(), &project);
|
||||
assert!(!app.apply_source());
|
||||
assert_eq!(app.source_diagnostics[0].path, fragment);
|
||||
send(&mut app, Message::SourceDiagnostic(0));
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
let _ = app.show_text_drawer(Some(text_workspace::DrawerTab::Problems));
|
||||
let issue = &app.source_diagnostics[0];
|
||||
let message = app
|
||||
.text_diagnostic_message(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
})
|
||||
.unwrap();
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_workspace.active, fragment);
|
||||
assert_eq!(app.source_editor.text(), "? invalid source");
|
||||
send(&mut app, Message::RevertSource);
|
||||
|
||||
@@ -214,6 +214,11 @@ impl App {
|
||||
.is_some_and(architecture_authoring::State::inline)
|
||||
{
|
||||
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
||||
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
||||
Task::batch([
|
||||
lint,
|
||||
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
||||
])
|
||||
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
||||
Task::batch([
|
||||
lint,
|
||||
|
||||
@@ -213,6 +213,14 @@ impl App {
|
||||
self.pending = None;
|
||||
return Task::none();
|
||||
}
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
resource_id: remote.resource_id,
|
||||
@@ -345,6 +353,14 @@ impl App {
|
||||
match self.document.apply_compiled((*remote.compiled).clone()) {
|
||||
Ok(_) => {
|
||||
self.document.mark_saved(remote.compiled.clone());
|
||||
if self.path.is_some()
|
||||
|| self.hosted.session.as_ref().is_none_or(|session| {
|
||||
session.resource_id != remote.resource_id
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
resource_id: remote.resource_id,
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn open(app: &mut App, path: &std::path::Path) {
|
||||
let _ = app.update(Message::Loaded(Ok(Some(
|
||||
read_snapshot(path.to_owned()).unwrap(),
|
||||
))));
|
||||
}
|
||||
|
||||
fn edit(app: &mut App, text: &str) {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text.into())),
|
||||
)));
|
||||
}
|
||||
|
||||
fn save(app: &mut App, path: &std::path::Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
|
||||
for extra in [
|
||||
" include \"missing.arcadia\";\r\n",
|
||||
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
|
||||
] {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, &invalid).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
assert!(!app.dirty());
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
open(&mut app, &second_path);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
edit(&mut app, VALID);
|
||||
assert!(app.apply_source(), "{}", app.status);
|
||||
assert!(app.source_workspace.applied);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Equal content creates no history step"
|
||||
);
|
||||
assert!(app.document.dirty());
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
let close = window::Id::unique();
|
||||
let task = app.update(Message::Request(Pending::Close(close)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
|
||||
let _ = app.update(Message::Cancel);
|
||||
save(&mut app, &second_path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let before = app.document.snapshot();
|
||||
let invalid = format!("{VALID} unfinished {{");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
open(&mut app, &path);
|
||||
let repaired = format!("{VALID}// Repaired rationale\r\n");
|
||||
edit(&mut app, &repaired);
|
||||
assert!(app.apply_source());
|
||||
assert!(app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"Old saved bytes are not saved at the repair destination"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
|
||||
|
||||
std::fs::write(&path, "external edit").unwrap();
|
||||
let failed = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let _ = app.update(Message::Saved(Err(failed)));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_equal_content_at_distinct_destinations_stays_clean() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let before = app.document.bundle().clone();
|
||||
open(&mut app, &second_path);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.path.as_ref(), Some(&second_path));
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let root = second.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let before = VALID.replace("Repair 救援", "Before 救援");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), &before).unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let accepted = app.document.snapshot();
|
||||
open(&mut app, &path);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(app.apply_source());
|
||||
assert!(
|
||||
!app.dirty(),
|
||||
"Current bytes still match disk before rollback"
|
||||
);
|
||||
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_dirty());
|
||||
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
|
||||
for (current_destination, failed_reload) in
|
||||
[(true, false), (false, false), (true, true), (false, true)]
|
||||
{
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let accepted = app.document.snapshot();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
open(&mut app, &path);
|
||||
assert!(app.apply_source());
|
||||
assert!(!app.dirty());
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let saved_elsewhere = destination
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
if !current_destination {
|
||||
save(&mut app, &saved_elsewhere);
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
}
|
||||
let mut receipt = recover_snapshot(path.clone()).unwrap();
|
||||
assert!(
|
||||
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
|
||||
"Missing entry reloads as an empty repair workspace"
|
||||
);
|
||||
if failed_reload {
|
||||
// Separately exercise the adapter's successful-recovery/failed-read
|
||||
// boundary; first-save rollback itself does not cause a read error.
|
||||
receipt.disk = Err("Injected post-recovery reload failure".into());
|
||||
}
|
||||
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!path.exists());
|
||||
assert!(!journal.exists());
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.dirty(), current_destination);
|
||||
if current_destination {
|
||||
let _ = app.update(Message::Request(Pending::New));
|
||||
assert!(matches!(app.pending, Some(Pending::New)));
|
||||
} else {
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
//! Keep Iced's single-line input from silently filtering pasted control bytes.
|
||||
//! Only the alias field uses this adapter. The child retains editing and focus.
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
|
||||
};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(AliasInput { input, rejected })
|
||||
}
|
||||
|
||||
struct AliasInput<'a, Message, Theme, R> {
|
||||
input: Element<'a, Message, Theme, R>,
|
||||
rejected: Option<Message>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
modifiers: keyboard::Modifiers,
|
||||
}
|
||||
|
||||
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
|
||||
// previously admitted paste while V is held, until KeyReleased clears that cache.
|
||||
struct CapturedClipboard<'a> {
|
||||
content: Option<String>,
|
||||
original: &'a mut dyn Clipboard,
|
||||
}
|
||||
impl Clipboard for CapturedClipboard<'_> {
|
||||
fn read(&self, kind: clipboard::Kind) -> Option<String> {
|
||||
if kind == clipboard::Kind::Standard {
|
||||
self.content.clone()
|
||||
} else {
|
||||
self.original.read(kind)
|
||||
}
|
||||
}
|
||||
fn write(&mut self, kind: clipboard::Kind, contents: String) {
|
||||
self.original.write(kind, contents);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
|
||||
for AliasInput<'_, Message, Theme, R>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.input.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.input.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(self.input.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.input));
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &R,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.input.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.input
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let focused = tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused();
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
|
||||
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
|
||||
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
}
|
||||
let paste = matches!(event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
|
||||
if key.to_latin(*physical_key) == Some('v')
|
||||
&& state.modifiers.command() && !state.modifiers.alt());
|
||||
if focused
|
||||
&& paste
|
||||
&& let Some(rejected) = &self.rejected
|
||||
{
|
||||
let content = clipboard.read(clipboard::Kind::Standard);
|
||||
if content
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.chars().any(char::is_control))
|
||||
{
|
||||
// Do not invoke the child: even an empty replacement would erase
|
||||
// its selection. Approval invalidation is an explicit App event.
|
||||
shell.publish(rejected.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
&mut CapturedClipboard {
|
||||
content,
|
||||
original: clipboard,
|
||||
},
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.input.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
|
||||
// Escape branch, which also resets stored modifiers. Observe that branch
|
||||
// after its text/shortcut early returns and effective-key conversion.
|
||||
if focused
|
||||
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
|
||||
&& !tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
|
||||
.is_focused()
|
||||
{
|
||||
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
|
||||
}
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.input.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, R>> {
|
||||
self.input.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,220 @@
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::keyboard::{
|
||||
Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Signal {
|
||||
Input(String),
|
||||
Rejected,
|
||||
}
|
||||
|
||||
struct ClipboardText {
|
||||
text: String,
|
||||
reads: Cell<usize>,
|
||||
}
|
||||
impl Clipboard for ClipboardText {
|
||||
fn read(&self, _: clipboard::Kind) -> Option<String> {
|
||||
self.reads.set(self.reads.get() + 1);
|
||||
Some(self.text.clone())
|
||||
}
|
||||
fn write(&mut self, _: clipboard::Kind, _: String) {}
|
||||
}
|
||||
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
|
||||
|
||||
struct Mounted {
|
||||
input: Element<'static, Signal, iced::Theme, FontLayout>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
clipboard: ClipboardText,
|
||||
messages: Vec<Signal>,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
|
||||
let mut child = iced::widget::text_input("Alias", "send_water");
|
||||
if enabled {
|
||||
child = child.on_input(Signal::Input);
|
||||
}
|
||||
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let node = input.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
|
||||
);
|
||||
if focused {
|
||||
let state = tree.children[0].state.downcast_mut::<InputState>();
|
||||
state.focus();
|
||||
state.select_range(0, 10);
|
||||
}
|
||||
let mut mounted = Self {
|
||||
input,
|
||||
tree,
|
||||
node,
|
||||
clipboard: ClipboardText {
|
||||
text: "foo\nbar".into(),
|
||||
reads: Cell::new(0),
|
||||
},
|
||||
messages: vec![],
|
||||
};
|
||||
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
modifiers,
|
||||
)));
|
||||
mounted
|
||||
}
|
||||
fn state(&self) -> &InputState {
|
||||
self.tree.children[0].state.downcast_ref::<InputState>()
|
||||
}
|
||||
fn event(&mut self, event: Event) -> bool {
|
||||
let mut shell = Shell::new(&mut self.messages);
|
||||
self.input.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut self.clipboard,
|
||||
&mut shell,
|
||||
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
|
||||
);
|
||||
shell.is_event_captured()
|
||||
}
|
||||
}
|
||||
fn key(key: Key, modified_key: Key, code: Code) -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key,
|
||||
modified_key,
|
||||
physical_key: Physical::Code(code),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn paste() -> Event {
|
||||
key(
|
||||
Key::Character("v".into()),
|
||||
Key::Character("v".into()),
|
||||
Code::KeyV,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
|
||||
for focused in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::COMMAND,
|
||||
Modifiers::COMMAND | Modifiers::ALT,
|
||||
Modifiers::COMMAND | Modifiers::SHIFT,
|
||||
] {
|
||||
let mut mounted = Mounted::new(focused, enabled, modifiers);
|
||||
let before = mounted.state().cursor();
|
||||
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
|
||||
let captured = mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
if expected {
|
||||
vec![Signal::Rejected]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
if expected {
|
||||
assert!(captured);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_escape_reset_follows_child_after_early_returns() {
|
||||
// Raw Escape mapped to Home must not clear only the guard's Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Escape),
|
||||
Key::Named(Named::Home),
|
||||
Code::Escape,
|
||||
));
|
||||
assert!(mounted.state().is_focused());
|
||||
assert!(mounted.event(paste()));
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
|
||||
// Effective Escape clears both states; a later focus operation does not
|
||||
// invent a new ModifiersChanged event or revive the previous Command state.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.event(key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
));
|
||||
assert!(!mounted.state().is_focused());
|
||||
mounted.tree.children[0]
|
||||
.state
|
||||
.downcast_mut::<InputState>()
|
||||
.focus();
|
||||
mounted.event(paste());
|
||||
assert!(mounted.messages.is_empty());
|
||||
assert_eq!(mounted.clipboard.reads.get(), 0);
|
||||
|
||||
// Printable text returns before effective Escape and leaves modifiers live.
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
let mut event = key(
|
||||
Key::Named(Named::Home),
|
||||
Key::Named(Named::Escape),
|
||||
Code::Home,
|
||||
);
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
|
||||
*text = Some("x".into());
|
||||
}
|
||||
mounted.event(event);
|
||||
assert!(mounted.state().is_focused());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_paste_cache_contains_only_previously_admitted_text() {
|
||||
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
|
||||
mounted.clipboard.text = "water".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
|
||||
mounted.messages.clear();
|
||||
mounted.clipboard.text = "other".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwater".into())],
|
||||
"Iced reuses its first admitted paste while the key is held"
|
||||
);
|
||||
mounted.messages.clear();
|
||||
let before = mounted.state().cursor();
|
||||
mounted.clipboard.text = "bad\tvalue".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(mounted.messages, vec![Signal::Rejected]);
|
||||
assert_eq!(mounted.state().cursor(), before);
|
||||
mounted.messages.clear();
|
||||
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
|
||||
key: Key::Character("v".into()),
|
||||
modified_key: Key::Character("v".into()),
|
||||
physical_key: Physical::Code(Code::KeyV),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
}));
|
||||
mounted.clipboard.text = "later".into();
|
||||
mounted.event(paste());
|
||||
assert_eq!(
|
||||
mounted.messages,
|
||||
vec![Signal::Input("waterwaterlater".into())]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Display-only reuse after a guarded alias rename. This is not model authority.
|
||||
use iced::{advanced::text::Renderer, widget::text_editor};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Replace exact, unchanged-line-count source in an existing editor.
|
||||
///
|
||||
/// The caller must first authorize/commit the model change and must reconstruct
|
||||
/// from accepted source on false. Preflight failures do not mutate the editor;
|
||||
/// a failed final readback can leave it changed. On changing success the cursor
|
||||
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
|
||||
/// Direct Content actions deliberately bypass App draft edits and Undo history.
|
||||
pub(super) fn replace<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
before: &str,
|
||||
after: &str,
|
||||
) -> bool {
|
||||
if editor.text() != before {
|
||||
return false;
|
||||
}
|
||||
let Some(old_lines) = lines(before) else {
|
||||
return false;
|
||||
};
|
||||
let Some(new_lines) = lines(after) else {
|
||||
return false;
|
||||
};
|
||||
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
|
||||
return false;
|
||||
}
|
||||
let mut patches = Vec::new();
|
||||
for (line, ((old, old_ending), (new, new_ending))) in
|
||||
old_lines.into_iter().zip(new_lines).enumerate()
|
||||
{
|
||||
let Some(actual) = editor.line(line) else {
|
||||
return false;
|
||||
};
|
||||
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
|
||||
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
|
||||
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
|
||||
return false;
|
||||
}
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
let mut start = old
|
||||
.bytes()
|
||||
.zip(new.bytes())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
let mut suffix = old[start..]
|
||||
.bytes()
|
||||
.rev()
|
||||
.zip(new[start..].bytes().rev())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
|
||||
{
|
||||
suffix -= 1;
|
||||
}
|
||||
// All positions and owned payloads are prepared before the first edit.
|
||||
patches.push((
|
||||
line,
|
||||
start,
|
||||
old.len() - suffix,
|
||||
Arc::new(new[start..new.len() - suffix].to_owned()),
|
||||
));
|
||||
}
|
||||
if patches.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let empty = Arc::new(String::new());
|
||||
for (line, start, end, replacement) in patches.into_iter().rev() {
|
||||
select(editor, line, start, end);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
replacement,
|
||||
)));
|
||||
}
|
||||
// Iced overwrites its earliest-change marker on each Edit. An explicit
|
||||
// zero-width selection + empty Paste changes no bytes, but marks line zero.
|
||||
// This also refreshes previously unhighlighted lines before the first patch.
|
||||
// It resets only line zero's text cache; other unchanged lines retain theirs.
|
||||
select(editor, 0, 0, 0);
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
|
||||
editor.text() == after
|
||||
}
|
||||
|
||||
fn select<R: Renderer>(
|
||||
editor: &mut text_editor::Content<R>,
|
||||
line: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) {
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line,
|
||||
column: start,
|
||||
},
|
||||
// move_to(None) does not clear a previous selection in this Iced version.
|
||||
selection: Some(text_editor::Position { line, column: end }),
|
||||
});
|
||||
}
|
||||
|
||||
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
|
||||
use text_editor::LineEnding;
|
||||
let mut result = Vec::new();
|
||||
let mut parts = source.split('\n').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
let (text, ending) = if parts.peek().is_none() {
|
||||
(part, LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, LineEnding::CrLf)
|
||||
} else {
|
||||
(part, LineEnding::Lf)
|
||||
};
|
||||
if text.contains('\r')
|
||||
|| (text.is_empty()
|
||||
&& ending == LineEnding::CrLf
|
||||
&& result
|
||||
.last()
|
||||
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
result.push((text, ending));
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{layout, text::Highlighter, widget::Tree};
|
||||
use iced::{Element, Font, Length, Size};
|
||||
use std::{cell::RefCell, ops::Range};
|
||||
|
||||
#[test]
|
||||
fn exact_text_matrix_and_reverse_preserve_endings() {
|
||||
for (before, after) in [
|
||||
("", ""),
|
||||
("", "alias"),
|
||||
("alias", ""),
|
||||
("same", "same"),
|
||||
("a\n", "alias\n"),
|
||||
("a\r\n", "alias\r\n"),
|
||||
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
|
||||
("\na\n\n", "\nlong_alias\n\n"),
|
||||
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
|
||||
(
|
||||
"雨🙂 e\u{301} @a @a // a",
|
||||
"雨🙂 e\u{301} @a_long @a_long // a",
|
||||
),
|
||||
("@abc", "@abcdef"),
|
||||
("@abcdef", "@abc"),
|
||||
("@abc_suffix", "@def_suffix"),
|
||||
("@none", "@identifier"),
|
||||
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
|
||||
// not be used as String split positions, even outside alias scope.
|
||||
("雨xé", "雪xê"),
|
||||
("é", "©"),
|
||||
(
|
||||
"unchanged\n@a\nunchanged\n@a\n",
|
||||
"unchanged\n@long\nunchanged\n@long\n",
|
||||
),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
assert!(
|
||||
replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), after);
|
||||
let expected = text_editor::Content::<FontLayout>::with_text(after);
|
||||
assert_eq!(editor.line_count(), expected.line_count());
|
||||
for (actual, expected) in editor.lines().zip(expected.lines()) {
|
||||
assert_eq!(actual.text, expected.text);
|
||||
assert_eq!(actual.ending, expected.ending);
|
||||
}
|
||||
if before != after {
|
||||
assert_eq!(
|
||||
editor.cursor().position,
|
||||
text_editor::Position { line: 0, column: 0 }
|
||||
);
|
||||
assert_eq!(editor.cursor().selection, None);
|
||||
}
|
||||
assert!(replace(&mut editor, after, before));
|
||||
assert_eq!(editor.text(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
|
||||
for (actual, before, after) in [
|
||||
("live", "stale", "changed"),
|
||||
("a\nb", "a\nb", "long\nb\nc"),
|
||||
("a\nb", "a\nb", "long\r\nb"),
|
||||
// Refusal on a later line must not apply the earlier planned edit.
|
||||
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
|
||||
("a\rb", "a\rb", "long\rb"),
|
||||
("a\n\rb", "a\n\rb", "long\n\rb"),
|
||||
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
|
||||
// The replacement must not introduce a greedy LFCR boundary either.
|
||||
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
|
||||
("a", "a", "long\r"),
|
||||
] {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
|
||||
select(&mut editor, 0, 0, 1);
|
||||
let cursor = editor.cursor();
|
||||
let selection = editor.selection();
|
||||
assert!(
|
||||
!replace(&mut editor, before, after),
|
||||
"{before:?} -> {after:?}"
|
||||
);
|
||||
assert_eq!(editor.text(), actual);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert_eq!(editor.selection(), selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
|
||||
select(&mut editor, 0, 1, 2);
|
||||
let cursor = editor.cursor();
|
||||
assert!(replace(&mut editor, "@a", "@a"));
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
assert!(replace(&mut editor, "@a", "@long"));
|
||||
editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
|
||||
assert_eq!(editor.text(), "@long!");
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
struct Tracked(crate::highlight::ArcadiaHighlighter);
|
||||
impl Highlighter for Tracked {
|
||||
type Settings = Arc<str>;
|
||||
type Highlight = oa_core::language::TokenClass;
|
||||
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
|
||||
fn new(settings: &Self::Settings) -> Self {
|
||||
Self(crate::highlight::ArcadiaHighlighter::new(settings))
|
||||
}
|
||||
fn update(&mut self, settings: &Self::Settings) {
|
||||
self.0.update(settings);
|
||||
}
|
||||
fn change_line(&mut self, line: usize) {
|
||||
CHANGES.with(|changes| changes.borrow_mut().push(line));
|
||||
self.0.change_line(line);
|
||||
}
|
||||
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
|
||||
self.0.highlight_line(line)
|
||||
}
|
||||
fn current_line(&self) -> usize {
|
||||
self.0.current_line()
|
||||
}
|
||||
}
|
||||
|
||||
fn view(
|
||||
editor: &text_editor::Content<FontLayout>,
|
||||
source: Arc<str>,
|
||||
) -> Element<'_, (), iced::Theme, FontLayout> {
|
||||
text_editor(editor)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<Tracked>(source, crate::highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
|
||||
let before = "// earlier\n// unmodified\n@a";
|
||||
let after = "// earlier\n// unmodified\n@none";
|
||||
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
|
||||
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
tree
|
||||
};
|
||||
CHANGES.with(|changes| changes.borrow_mut().clear());
|
||||
assert!(replace(&mut editor, before, after));
|
||||
{
|
||||
let mut widget = view(&editor, Arc::from(after));
|
||||
let _ = widget
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &FontLayout, &limits);
|
||||
}
|
||||
// Font/metrics/bounds are already stable. This notification must come
|
||||
// from the helper's empty edit, not a fresh renderer initialization.
|
||||
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
|
||||
assert_eq!(editor.text(), after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Real paragraph advances and clipping through the actual preview dialog.
|
||||
//! Native screenshots additionally check raster ink and the platform renderer.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
|
||||
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
struct Glyph {
|
||||
text: String,
|
||||
paragraph_y: u32,
|
||||
line: usize,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
|
||||
struct GlyphRecorder {
|
||||
wanted: BTreeSet<String>,
|
||||
transforms: Vec<Transformation>,
|
||||
clips: Vec<Rectangle>,
|
||||
glyphs: Vec<Glyph>,
|
||||
quads: Vec<Rectangle>,
|
||||
}
|
||||
impl GlyphRecorder {
|
||||
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
|
||||
Self {
|
||||
wanted,
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clips: vec![viewport],
|
||||
glyphs: vec![],
|
||||
quads: vec![],
|
||||
}
|
||||
}
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for GlyphRecorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clips = vec![bounds];
|
||||
self.glyphs.clear();
|
||||
self.quads.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
|
||||
self.quads.push(quad.bounds * self.transform());
|
||||
}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for GlyphRecorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let text = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !self.wanted.contains(&text) {
|
||||
return;
|
||||
}
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
if run.text[glyph.start..glyph.end]
|
||||
.chars()
|
||||
.all(char::is_whitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let bounds = Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform();
|
||||
self.glyphs.push(Glyph {
|
||||
text: text.clone(),
|
||||
paragraph_y: position.y.to_bits(),
|
||||
line: run.line_i,
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds,
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(
|
||||
&mut self,
|
||||
editor: &Self::Editor,
|
||||
position: Point,
|
||||
color: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
|
||||
}
|
||||
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
|
||||
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScrollGeometry {
|
||||
id: Option<widget::Id>,
|
||||
bounds: Option<Rectangle>,
|
||||
content: Option<Rectangle>,
|
||||
offset: Vector,
|
||||
}
|
||||
impl widget::Operation for ScrollGeometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == self.id.as_ref() {
|
||||
self.bounds = Some(bounds);
|
||||
self.content = Some(content);
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn normalized(text: &str) -> String {
|
||||
text.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
|
||||
text.split('\n')
|
||||
.enumerate()
|
||||
.flat_map(|(line, text)| {
|
||||
text.char_indices()
|
||||
.filter(|(_, ch)| !ch.is_whitespace())
|
||||
.map(move |(byte, _)| (line, byte))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn declarations(long: bool) -> App {
|
||||
let name = if long {
|
||||
format!("Assess {} request", "界🚀".repeat(90))
|
||||
} else {
|
||||
"Assess 救援 request".into()
|
||||
};
|
||||
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
|
||||
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
|
||||
]) };
|
||||
let mut app = fixture();
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "救援/assess.arcadia".into();
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.show_source_file(false);
|
||||
let start = app
|
||||
.source_workspace
|
||||
.text()
|
||||
.find("oa_activity assess")
|
||||
.unwrap()
|
||||
+ "oa_activity ".len();
|
||||
app.source_editor.move_to(cursor(
|
||||
app.source_workspace.text(),
|
||||
start,
|
||||
start + "assess".len(),
|
||||
));
|
||||
begin(&mut app);
|
||||
let replacement = if long {
|
||||
"a".repeat(255)
|
||||
} else {
|
||||
"assess_damage".into()
|
||||
};
|
||||
input(&mut app, &replacement);
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn assert_page_glyphs_visible(app: &App, host: Size) {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let ready = form.approval.as_ref().unwrap();
|
||||
let file = &ready.preview.files()[form.file];
|
||||
let mut expected = BTreeMap::<String, usize>::new();
|
||||
for line in file.hunks()[form.hunk]
|
||||
.lines()
|
||||
.iter()
|
||||
.skip(form.line)
|
||||
.take(LINES_PER_PAGE)
|
||||
{
|
||||
for source in [
|
||||
line.before_text(&ready.prepared.before().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
line.after_text(&ready.prepared.after().bundle.files[file.path()])
|
||||
.unwrap(),
|
||||
] {
|
||||
if let Some(fragment) = preview::text_segment(source, form.segment) {
|
||||
let text = normalized(fragment);
|
||||
if !required_chars(&text).is_empty() {
|
||||
*expected.entry(text).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
let viewport = Rectangle::new(Point::ORIGIN, host);
|
||||
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
|
||||
let editor = text_editor::Content::<GlyphRecorder>::new();
|
||||
let mut view = dialog_content(app, &editor);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
|
||||
let mut geometry = ScrollGeometry {
|
||||
id: Some(preview_scroll_id(form, ready.id)),
|
||||
..ScrollGeometry::default()
|
||||
};
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
let bounds = geometry.bounds.unwrap();
|
||||
let content = geometry.content.unwrap();
|
||||
assert!(bounds.height >= 40.);
|
||||
let maximum = (content.height - bounds.height).max(0.);
|
||||
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
|
||||
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
|
||||
let mut reserved_bar_gap = true;
|
||||
for step in 0..=steps {
|
||||
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&renderer,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
preview_scroll_id(form, ready.id),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(offset),
|
||||
},
|
||||
),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
|
||||
assert!(
|
||||
(geometry.offset.y - offset).abs() <= 0.5,
|
||||
"actual scroll operation reaches the requested source offset"
|
||||
);
|
||||
renderer::Renderer::reset(&mut renderer, viewport);
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
// The vertical scrollbar's actual painted thumb fixes its excluded x
|
||||
// region; the outer scroll clip alone cannot detect overpainting.
|
||||
let scrollbar_left = renderer
|
||||
.quads
|
||||
.iter()
|
||||
.filter(|quad| {
|
||||
quad.width > 0.
|
||||
&& quad.width <= 16.
|
||||
&& quad.height > 0.
|
||||
&& quad.x >= bounds.x + bounds.width - 18.
|
||||
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
|
||||
&& quad.y >= bounds.y - 0.5
|
||||
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
|
||||
})
|
||||
.map(|quad| quad.x)
|
||||
.min_by(f32::total_cmp);
|
||||
if maximum > 0. {
|
||||
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
|
||||
reserved_bar_gap &= content.x + content.width <= left - 7.5;
|
||||
}
|
||||
for glyph in &renderer.glyphs {
|
||||
let right = glyph.bounds.x + glyph.bounds.width;
|
||||
if let Some(left) = scrollbar_left {
|
||||
assert!(
|
||||
right <= left - 0.5,
|
||||
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
|
||||
glyph.text,
|
||||
glyph.bytes,
|
||||
glyph.bounds
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
|
||||
"source glyph escaped reserved width: {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds
|
||||
);
|
||||
assert!(
|
||||
glyph.bounds.x >= glyph.clip.x - 0.5
|
||||
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
|
||||
"source glyph horizontally clipped: {:?} {:?} {:?}",
|
||||
glyph.text,
|
||||
glyph.bounds,
|
||||
glyph.clip
|
||||
);
|
||||
if glyph.bounds.y >= glyph.clip.y - 0.5
|
||||
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
|
||||
{
|
||||
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
|
||||
let chars = logical_line
|
||||
.char_indices()
|
||||
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
|
||||
.map(|(byte, _)| (glyph.line, byte));
|
||||
visible
|
||||
.entry((glyph.text.clone(), glyph.paragraph_y))
|
||||
.or_default()
|
||||
.extend(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
reserved_bar_gap,
|
||||
"source content must reserve actual scrollbar width plus gap"
|
||||
);
|
||||
assert!(
|
||||
bounds.width >= host.width - 30.,
|
||||
"preview must use available dialog width: {bounds:?} / {host:?}"
|
||||
);
|
||||
for (text, count) in expected {
|
||||
let required = required_chars(&text);
|
||||
let instances: Vec<_> = visible
|
||||
.iter()
|
||||
.filter(|((value, _), _)| value == &text)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
instances.len(),
|
||||
count,
|
||||
"all before/after occurrences must be seen: {text:?}"
|
||||
);
|
||||
for (_, seen) in instances {
|
||||
assert_eq!(
|
||||
seen, &required,
|
||||
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
|
||||
for long in [false, true] {
|
||||
let mut app = declarations(long);
|
||||
let ready = app
|
||||
.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
let before = evidence(&app);
|
||||
for (width, height) in [(1100., 700.), (580., 700.)] {
|
||||
app.window_size = Size::new(width, height);
|
||||
let host = Size::new((width - 84.).min(708.), height - 120.);
|
||||
let total = segment_total(app.source_rename.form.as_ref().unwrap());
|
||||
if long {
|
||||
assert!(
|
||||
total >= 3,
|
||||
"include unbroken ASCII and wide Unicode across segments"
|
||||
);
|
||||
}
|
||||
for segment in 0..total {
|
||||
assert_page_glyphs_visible(&app, host);
|
||||
if segment + 1 < total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, true));
|
||||
}
|
||||
}
|
||||
for _ in 1..total {
|
||||
event(&mut app, Event::Segment(invocation, ready.id, false));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
evidence(&app),
|
||||
before,
|
||||
"preview geometry/paging cannot alter transaction inputs"
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
ready.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Execute the controller's scroll operations on a retained, scrolled dialog.
|
||||
use super::*;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
|
||||
const HOST: Size = Size::new(720., 660.);
|
||||
|
||||
fn paged_fixture() -> App {
|
||||
let mut source = bundle();
|
||||
let name = "Long engineering name 雨 ".repeat(18);
|
||||
source.files.get_mut("defs.arcadia").unwrap().replace_range(
|
||||
..,
|
||||
&format!(
|
||||
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
|
||||
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
|
||||
),
|
||||
);
|
||||
let mut uses = String::new();
|
||||
for index in 0..16 {
|
||||
if index == 12 {
|
||||
uses.push_str(&"// unchanged gap\r\n".repeat(10));
|
||||
}
|
||||
uses.push_str(&format!(
|
||||
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
|
||||
));
|
||||
}
|
||||
source.files.insert("uses.arcadia".into(), uses);
|
||||
let mut app = fixture();
|
||||
app.window_size = HOST;
|
||||
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = "uses.arcadia".into();
|
||||
app.show_source_file(false);
|
||||
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
|
||||
app.source_editor
|
||||
.move_to(cursor(app.source_workspace.text(), start, start + 4));
|
||||
begin(&mut app);
|
||||
input(&mut app, "pump_long");
|
||||
let _ = preview(&mut app);
|
||||
app
|
||||
}
|
||||
|
||||
fn scroll_id(app: &App) -> widget::Id {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
|
||||
}
|
||||
|
||||
struct Geometry {
|
||||
id: widget::Id,
|
||||
offset: Option<Vector>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.id) {
|
||||
self.offset = Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
editor: text_editor::Content<FontLayout>,
|
||||
tree: widget::Tree,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(app: &App) -> Self {
|
||||
let editor = text_editor::Content::<FontLayout>::new();
|
||||
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
|
||||
Self { editor, tree }
|
||||
}
|
||||
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
||||
let mut view = dialog_content(app, &self.editor);
|
||||
// Preserve the actual scroll state across the new page's view diff.
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
|
||||
}
|
||||
fn offset(&mut self, app: &App) -> f32 {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
geometry.offset.expect("mounted preview scrollable").y
|
||||
}
|
||||
fn scroll_down(&mut self, app: &App) -> f32 {
|
||||
self.operate(
|
||||
app,
|
||||
&mut widget::operation::scrollable::scroll_to(
|
||||
scroll_id(app),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(10_000.),
|
||||
},
|
||||
),
|
||||
);
|
||||
let offset = self.offset(app);
|
||||
assert!(offset > 10., "fixture must really be scrolled");
|
||||
offset
|
||||
}
|
||||
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
|
||||
let mut count = 0;
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("preview paging must only schedule a widget operation");
|
||||
};
|
||||
self.operate(app, operation.as_mut());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
mounted.scroll_down(&app);
|
||||
let old_id = scroll_id(&app);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_ne!(old_id, scroll_id(&app));
|
||||
// Negative control: even after diff/layout with the new id and new file,
|
||||
// Iced retains the previous offset until the returned operation executes.
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
|
||||
for navigation in [
|
||||
Event::Line(invocation, approval, true),
|
||||
Event::Segment(invocation, approval, true),
|
||||
Event::Hunk(invocation, approval, true),
|
||||
Event::File(invocation, approval, false),
|
||||
Event::Search(invocation, "uses".into()),
|
||||
Event::Search(invocation, "no-path-match".into()),
|
||||
Event::Search(invocation, String::new()),
|
||||
] {
|
||||
mounted.scroll_down(&app);
|
||||
let task = app.update(Message::SourceRename(navigation));
|
||||
assert_eq!(mounted.execute(&app, task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
// Passive redraw and unchanged filter input preserve deliberate scrolling.
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
String::new(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::Input(
|
||||
invocation,
|
||||
"pump_long".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"clamped first-page request is a no-op"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let old_approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let old_page_task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
let next_page_task = app.update(Message::SourceRename(Event::Line(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
|
||||
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
|
||||
|
||||
let old_approval_task = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"defs".into(),
|
||||
)));
|
||||
input(&mut app, "pump_new");
|
||||
let _ = preview(&mut app); // Real new approval in the same invocation/page.
|
||||
assert_ne!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
old_approval
|
||||
);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old approval target is absent"
|
||||
);
|
||||
let task = app.update(Message::SourceRename(Event::File(
|
||||
invocation,
|
||||
old_approval,
|
||||
true,
|
||||
)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, task).await,
|
||||
0,
|
||||
"stale envelope is rejected before scheduling"
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let mut mounted = Mounted::new(&app);
|
||||
|
||||
let first_visit = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
let next_page = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next_page).await, 1);
|
||||
let return_visit = app.update(Message::SourceRename(Event::Line(
|
||||
invocation, approval, false,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, return_visit).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, first_visit).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old visit to the identical page is stale"
|
||||
);
|
||||
|
||||
let old_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"uses".into(),
|
||||
)));
|
||||
let new_filter = app.update(Message::SourceRename(Event::Search(
|
||||
invocation,
|
||||
"us".into(),
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, new_filter).await, 1);
|
||||
let offset = mounted.scroll_down(&app);
|
||||
assert_eq!(mounted.execute(&app, old_filter).await, 1);
|
||||
assert_eq!(
|
||||
mounted.offset(&app),
|
||||
offset,
|
||||
"old filter on the same page is stale"
|
||||
);
|
||||
|
||||
input(&mut app, "pump_new");
|
||||
let job = start_preview(&mut app);
|
||||
let completion = preview_completion(&app, job);
|
||||
// No intermediate frame mounts the temporary no-approval view. The old
|
||||
// Scrollable survives even though the new approval has a different id.
|
||||
let ready = app.update(Message::SourceRenameReady(completion));
|
||||
assert!(mounted.offset(&app) > 10.);
|
||||
assert_eq!(mounted.execute(&app, ready).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
|
||||
//! Child of source_rename_timing_tests, preserving the separate real App test.
|
||||
use super::*;
|
||||
use crate::{highlight, views::source_tests::FontLayout};
|
||||
use iced::advanced::widget::operation::Focusable;
|
||||
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{
|
||||
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
|
||||
};
|
||||
|
||||
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
|
||||
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
|
||||
#[derive(Default)]
|
||||
struct ObservedFontLayout {
|
||||
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
|
||||
}
|
||||
impl renderer::Renderer for ObservedFontLayout {
|
||||
fn start_layer(&mut self, _: Rectangle) {}
|
||||
fn end_layer(&mut self) {}
|
||||
fn start_transformation(&mut self, _: Transformation) {}
|
||||
fn end_transformation(&mut self) {}
|
||||
fn reset(&mut self, _: Rectangle) {
|
||||
self.drawn = None;
|
||||
}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for ObservedFontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
|
||||
self.drawn = Some((editor.downgrade(), position, clip));
|
||||
}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
type Content = text_editor::Content<ObservedFontLayout>;
|
||||
const HOST: Size = Size::new(640., 350.);
|
||||
|
||||
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
|
||||
text_editor(editor)
|
||||
.on_action(|_| ())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Length::Fill)
|
||||
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
|
||||
assert_eq!(editor.text(), expected);
|
||||
assert_eq!(editor.cursor(), cursor);
|
||||
let lines: Vec<_> = expected.split('\n').collect();
|
||||
assert_eq!(editor.line_count(), lines.len());
|
||||
for (index, part) in lines.iter().enumerate() {
|
||||
let (text, ending) = if index + 1 == lines.len() {
|
||||
(*part, text_editor::LineEnding::None)
|
||||
} else if let Some(text) = part.strip_suffix('\r') {
|
||||
(text, text_editor::LineEnding::CrLf)
|
||||
} else {
|
||||
(*part, text_editor::LineEnding::Lf)
|
||||
};
|
||||
let actual = editor.line(index).unwrap();
|
||||
assert_eq!(actual.text, text);
|
||||
assert_eq!(actual.ending, ending);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
|
||||
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
|
||||
let editor = weak.upgrade().expect("drawn editor is still live");
|
||||
let buffer = editor.buffer();
|
||||
assert_eq!(weak.bounds, Size::new(608., 318.));
|
||||
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
|
||||
assert_eq!(buffer.metrics().font_size, 14.);
|
||||
assert_eq!(
|
||||
buffer.scroll().horizontal,
|
||||
0.,
|
||||
"fixture origin fits the viewport"
|
||||
);
|
||||
let start = cursor.position;
|
||||
let end = cursor.selection.expect("approved alias selection");
|
||||
assert_eq!(start.line, end.line);
|
||||
assert_eq!(end.column - start.column, alias.len());
|
||||
let line = &buffer.lines[start.line];
|
||||
assert_eq!(&line.text()[start.column..end.column], alias);
|
||||
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
|
||||
let font = graphics::text::to_attributes(Font::MONOSPACE);
|
||||
for byte in start.column..end.column {
|
||||
let attrs = line.attrs_list().get_span(byte);
|
||||
assert_eq!(
|
||||
attrs.color_opt,
|
||||
Some(color),
|
||||
"accepted alias syntax attributes"
|
||||
);
|
||||
assert_eq!(attrs.family, font.family);
|
||||
}
|
||||
let mut covered = vec![false; alias.len()];
|
||||
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
|
||||
for glyph in run.glyphs {
|
||||
let from = glyph.start.max(start.column);
|
||||
let to = glyph.end.min(end.column);
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
|
||||
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
|
||||
let y = position.y + run.line_top;
|
||||
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
|
||||
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
|
||||
covered[from - start.column..to - start.column].fill(true);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
covered.into_iter().all(|seen| seen),
|
||||
"entire alias has visible shaped glyphs"
|
||||
);
|
||||
// This temporary strong upgrade is dropped before the next editor mutation.
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Arm {
|
||||
Constructor,
|
||||
Reuse,
|
||||
}
|
||||
impl Arm {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Constructor => "with_text",
|
||||
Self::Reuse => "same_line_reuse",
|
||||
}
|
||||
}
|
||||
fn phases(self) -> [&'static str; 4] {
|
||||
match self {
|
||||
Self::Constructor => [
|
||||
"constructor_replace_origin",
|
||||
"constructor_next_layout",
|
||||
"constructor_highlight_draw",
|
||||
"constructor_combined",
|
||||
],
|
||||
Self::Reuse => [
|
||||
"reuse_replace_origin",
|
||||
"reuse_next_layout",
|
||||
"reuse_highlight_draw",
|
||||
"reuse_combined",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trial(
|
||||
fixture: &Fixture,
|
||||
cursor: text_editor::Cursor,
|
||||
arm: Arm,
|
||||
trial: usize,
|
||||
samples: &mut Samples,
|
||||
) {
|
||||
let before = &fixture.bundle.files[ORIGIN];
|
||||
let after = &fixture.expected_bundle.files[ORIGIN];
|
||||
let mut editor = Content::with_text(before);
|
||||
editor.move_to(fixture.cursor);
|
||||
let mut renderer = ObservedFontLayout::default();
|
||||
let theme = iced::Theme::Light;
|
||||
let defaults = renderer::Style::default();
|
||||
let viewport = Rectangle::with_size(HOST);
|
||||
let limits = layout::Limits::new(Size::ZERO, HOST);
|
||||
let mut tree = {
|
||||
let mut widget = view(&editor, Arc::from(before.as_str()));
|
||||
let mut tree = Tree::new(widget.as_widget());
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
tree.state
|
||||
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
|
||||
.focus();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
tree
|
||||
};
|
||||
check_text(&editor, before, fixture.cursor);
|
||||
check_draw(&renderer, fixture.cursor, OLD);
|
||||
// No recorder Weak/Strong from setup may perturb replacement ownership.
|
||||
renderer.drawn = None;
|
||||
let next_source: Arc<str> = Arc::from(after.as_str());
|
||||
|
||||
// Only normal operations and four timestamps occur between start and end.
|
||||
// Source/model oracles, logging and recorder glyph inspection are outside.
|
||||
let started = Instant::now();
|
||||
let replaced = match arm {
|
||||
Arm::Constructor => {
|
||||
editor = Content::with_text(after);
|
||||
true
|
||||
}
|
||||
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
|
||||
};
|
||||
editor.move_to(cursor);
|
||||
let replaced_at = Instant::now();
|
||||
let mut widget = view(&editor, next_source);
|
||||
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
||||
let laid_out_at = Instant::now();
|
||||
widget.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&theme,
|
||||
&defaults,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let drawn_at = Instant::now();
|
||||
drop(widget);
|
||||
|
||||
assert!(replaced, "reuse must not silently time a fallback/refusal");
|
||||
check_text(&editor, after, cursor);
|
||||
check_draw(&renderer, cursor, NEW);
|
||||
renderer.drawn = None;
|
||||
for (phase, duration) in arm.phases().into_iter().zip([
|
||||
replaced_at.duration_since(started),
|
||||
laid_out_at.duration_since(replaced_at),
|
||||
drawn_at.duration_since(laid_out_at),
|
||||
drawn_at.duration_since(started),
|
||||
]) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
|
||||
"trial": trial, "warmup": trial == 0,
|
||||
"elapsed_ns": duration.as_nanos().to_string(),
|
||||
"elapsed_ms": duration.as_secs_f64() * 1000.0,
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
samples.0.entry(phase).or_default().push(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
|
||||
let fixture = Fixture::load();
|
||||
// Resolve the exact production approval and verify full Project/source once,
|
||||
// outside all intervals. The actual App Confirm evaluator remains separate.
|
||||
let cursor = {
|
||||
let app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
|
||||
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
|
||||
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
|
||||
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
|
||||
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
|
||||
})
|
||||
);
|
||||
let mut samples = Samples::default();
|
||||
for index in 0..=5 {
|
||||
let arms = if index % 2 == 0 {
|
||||
[Arm::Constructor, Arm::Reuse]
|
||||
} else {
|
||||
[Arm::Reuse, Arm::Constructor]
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
|
||||
);
|
||||
for arm in arms {
|
||||
trial(&fixture, cursor, arm, index, &mut samples);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Manual timing through the real Document and App paths. No disk writes,
|
||||
//! executor polling, layout, GPU work or first-paint measurement occurs here.
|
||||
//! Register as a test child of app::source_rename to use its actual worker helpers.
|
||||
use super::*;
|
||||
use crate::source_workspace::Workspace;
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[path = "source_rename_layout_timing_tests.rs"]
|
||||
mod layout_timing_tests;
|
||||
|
||||
const OLD: &str = "oa_restore_work";
|
||||
const NEW: &str = "oa_restore_pressure";
|
||||
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
|
||||
const ORIGIN: &str = "traceability.arcadia";
|
||||
|
||||
struct Fixture {
|
||||
bundle: SourceBundle,
|
||||
project: Project,
|
||||
expected_bundle: SourceBundle,
|
||||
expected_project: Project,
|
||||
target: Id,
|
||||
cursor: text_editor::Cursor,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn load() -> Self {
|
||||
// The optional entry selects a parent-pinned copy of this exact fixture,
|
||||
// not an arbitrary performance generator or a mutable GUI destination.
|
||||
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/release1/project.arcadia")
|
||||
});
|
||||
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
|
||||
let bundle = loaded.reachable_bundle();
|
||||
assert_eq!(
|
||||
bundle.files.len(),
|
||||
30,
|
||||
"use the preserved release1 walkthrough"
|
||||
);
|
||||
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no stabilization/migration"
|
||||
);
|
||||
assert!(!document.dirty());
|
||||
let project = document.project().clone();
|
||||
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
|
||||
assert_eq!(project.elements[&target].alias, OLD);
|
||||
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
|
||||
let mut expected_project = project.clone();
|
||||
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
|
||||
|
||||
// Independent literal source oracle, authored before running the rename.
|
||||
// Do not replace the alias substring in filenames or the trace's alias.
|
||||
let mut expected_bundle = bundle.clone();
|
||||
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
|
||||
assert_eq!(
|
||||
declarations.matches("oa_activity oa_restore_work ").count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
|
||||
*declarations = declarations
|
||||
.replace(
|
||||
"oa_activity oa_restore_work ",
|
||||
"oa_activity oa_restore_pressure ",
|
||||
)
|
||||
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
|
||||
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
|
||||
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
|
||||
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
|
||||
let source = &bundle.files[ORIGIN];
|
||||
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
|
||||
let position = |offset| {
|
||||
let prefix = &source[..offset];
|
||||
text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix
|
||||
.rsplit_once('\n')
|
||||
.map_or(prefix.len(), |(_, tail)| tail.len()),
|
||||
}
|
||||
};
|
||||
let cursor = text_editor::Cursor {
|
||||
position: position(start),
|
||||
selection: Some(position(start + OLD.len())),
|
||||
};
|
||||
assert!(nav::exact_cursor(source, cursor));
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
|
||||
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
|
||||
"identities": project.identities().len(), "active_file": ORIGIN,
|
||||
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
|
||||
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
|
||||
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
|
||||
})
|
||||
);
|
||||
Self {
|
||||
bundle,
|
||||
project,
|
||||
expected_bundle,
|
||||
expected_project,
|
||||
target,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
fn app(&self) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.source_workspace.active = ORIGIN.into();
|
||||
app.selected = app.project().id;
|
||||
app.page = Page::Text;
|
||||
app.sync();
|
||||
app.source_editor.move_to(self.cursor);
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.rename_admission().is_ok());
|
||||
app
|
||||
}
|
||||
|
||||
fn check_candidate(&self, prepared: &PreparedAliasRename) {
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.before().bundle, self.bundle);
|
||||
assert_eq!(prepared.before().decoded.project, self.project);
|
||||
assert_eq!(prepared.after().bundle, self.expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, self.expected_project);
|
||||
}
|
||||
|
||||
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
|
||||
assert!(
|
||||
std::ptr::eq(app.document.compiled(), candidate_address),
|
||||
"adopt exact candidate allocation"
|
||||
);
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
|
||||
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.document.undo());
|
||||
assert_eq!(app.project(), &self.project);
|
||||
assert_eq!(app.document.bundle(), &self.bundle);
|
||||
assert!(
|
||||
!app.document.dirty(),
|
||||
"live known saved baseline remains authoritative"
|
||||
);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"exactly one preceding model transaction"
|
||||
);
|
||||
assert!(app.document.redo());
|
||||
assert_eq!(app.project(), &self.expected_project);
|
||||
assert_eq!(app.document.bundle(), &self.expected_bundle);
|
||||
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
|
||||
|
||||
impl Samples {
|
||||
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
|
||||
let started = Instant::now();
|
||||
let result = action();
|
||||
let elapsed = started.elapsed();
|
||||
// Printing, assertions and dropping the returned value are outside this
|
||||
// interval. Full Confirm still includes production's own local drops.
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "timing", "phase": phase, "trial": trial,
|
||||
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
|
||||
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
if trial != 0 {
|
||||
self.0.entry(phase).or_default().push(elapsed);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn report(mut self) {
|
||||
for (phase, values) in &mut self.0 {
|
||||
assert_eq!(values.len(), 5);
|
||||
values.sort();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"record": "summary", "phase": phase, "samples": values.len(),
|
||||
"min_ms": values[0].as_secs_f64() * 1000.0,
|
||||
"median_ms": values[2].as_secs_f64() * 1000.0,
|
||||
"max_ms": values[4].as_secs_f64() * 1000.0
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(app: &App, context: &Context) -> Target {
|
||||
let snapshot = app
|
||||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap();
|
||||
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
|
||||
resolve_target(&snapshot, context, offset).unwrap()
|
||||
}
|
||||
|
||||
fn active_job(app: &App) -> Job {
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let id = form.pending.unwrap();
|
||||
assert_eq!(app.source_rename.in_flight, Some(id));
|
||||
Job {
|
||||
id,
|
||||
invocation: form.invocation,
|
||||
generation: form.generation,
|
||||
context: form.context.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let target = resolve(&app, &app.rename_context());
|
||||
assert_eq!(target.id, fixture.target);
|
||||
let request = samples
|
||||
.time("document_request_mint", trial, || {
|
||||
app.document.alias_rename_request(target.id, NEW.into())
|
||||
})
|
||||
.unwrap();
|
||||
let prepared = samples
|
||||
.time("worker_document_prepare", trial, || request.prepare())
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = samples
|
||||
.time("worker_preview_and_origin", trial, || {
|
||||
prepare_approval(prepared, &target, ORIGIN)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(approval.preview.files().len(), 2);
|
||||
assert_eq!(approval.preview.changed_line_count(), 6);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
// Same-destination save completion is a legitimate live update after Preview.
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let changed = samples
|
||||
.time("document_guarded_commit", trial, || {
|
||||
app.document.commit_alias_rename(&approval.prepared)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(changed);
|
||||
samples.time("app_sync", trial, || app.sync());
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
|
||||
let mut app = fixture.app();
|
||||
let begin = app.source_rename_message();
|
||||
// These Tasks are deliberately not polled: deterministic worker helpers
|
||||
// supply the same results through the real readiness messages below.
|
||||
drop(app.update(begin));
|
||||
let target_job = active_job(&app);
|
||||
let target = Arc::new(resolve(&app, &target_job.context));
|
||||
assert_eq!(target.id, fixture.target);
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: target_job,
|
||||
payload: Payload::Target(Ok(target.clone())),
|
||||
})));
|
||||
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
|
||||
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
|
||||
let generation = app.source_rename.form.as_ref().unwrap().generation;
|
||||
drop(app.update(Message::SourceRename(Event::Preview(
|
||||
invocation, generation,
|
||||
))));
|
||||
let preview_job = active_job(&app);
|
||||
let prepared = app
|
||||
.document
|
||||
.alias_rename_request(target.id, NEW.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap();
|
||||
fixture.check_candidate(&prepared);
|
||||
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
|
||||
drop(target);
|
||||
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
|
||||
let expected_cursor = approval.cursor;
|
||||
let approval_id = approval.id;
|
||||
drop(app.update(Message::SourceRenameReady(Completion {
|
||||
job: preview_job,
|
||||
payload: Payload::Preview(Ok(approval)),
|
||||
})));
|
||||
assert_eq!(
|
||||
app.source_rename
|
||||
.form
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.approval
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id,
|
||||
approval_id
|
||||
);
|
||||
app.document.mark_saved(app.document.snapshot());
|
||||
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
|
||||
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
|
||||
samples.time("confirm_returned_task_drop_without_polling", trial, || {
|
||||
drop(task)
|
||||
});
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(app.source_rename.in_flight.is_none());
|
||||
assert!(app.source_rename_undo_message().is_some());
|
||||
assert_eq!(app.source_editor.cursor(), expected_cursor);
|
||||
fixture.check_adopted(&mut app, candidate_address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
|
||||
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
|
||||
let fixture = Fixture::load();
|
||||
let mut samples = Samples::default();
|
||||
for trial in 0..=5 {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "record": "order", "trial": trial,
|
||||
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
|
||||
);
|
||||
if trial % 2 == 0 {
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
} else {
|
||||
whole_confirm(&fixture, &mut samples, trial);
|
||||
separate_phases(&fixture, &mut samples, trial);
|
||||
}
|
||||
}
|
||||
samples.report();
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Current draft navigation, isolated from model application and disk discovery.
|
||||
use super::*;
|
||||
use crate::source_navigation::{self as nav, query};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn admission_notice(error: query::AdmissionError) -> String {
|
||||
use nav::symbols::{BuildError, InputProblem, Resource};
|
||||
let (limit, resource) = match error {
|
||||
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
|
||||
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
|
||||
language::OutlineResource::SourceBytes => Resource::SourceBytes,
|
||||
language::OutlineResource::Tokens => Resource::Tokens,
|
||||
language::OutlineResource::Declarations => Resource::Records,
|
||||
}),
|
||||
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
|
||||
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
|
||||
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
|
||||
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
|
||||
},
|
||||
};
|
||||
let unit = match resource {
|
||||
Resource::Files => "loaded files",
|
||||
Resource::SourceBytes => "source bytes",
|
||||
Resource::PathBytes => "bytes in file paths",
|
||||
Resource::Tokens => "source tokens",
|
||||
Resource::Records => "declarations and references",
|
||||
};
|
||||
format!(
|
||||
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
|
||||
)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn source_symbol_context(&self) -> nav::SymbolContext {
|
||||
nav::SymbolContext {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
|
||||
Message::SourceSymbols(self.source_symbol_context(), event)
|
||||
}
|
||||
|
||||
pub fn source_find_close_message(&self) -> Message {
|
||||
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
|
||||
}
|
||||
pub fn source_key(&self) -> query::SnapshotKey {
|
||||
query::SnapshotKey {
|
||||
workspace: self.source_workspace.session(),
|
||||
revision: self.source_workspace.revision(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
|
||||
let files: Vec<_> = self
|
||||
.source_workspace
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, buffer)| query::DraftFile {
|
||||
path,
|
||||
text: &buffer.text,
|
||||
})
|
||||
.collect();
|
||||
let accepted = self.document.bundle();
|
||||
let validated: Vec<_> = accepted
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, text)| query::DraftFile { path, text })
|
||||
.collect();
|
||||
use_input(query::Input {
|
||||
key: self.source_key(),
|
||||
entry: &self.source_workspace.entry,
|
||||
files: &files,
|
||||
applied: self.source_workspace.applied,
|
||||
validation: self
|
||||
.source_workspace
|
||||
.applied
|
||||
.then_some(query::ValidatedSources {
|
||||
entry: &accepted.entry,
|
||||
files: &validated,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
|
||||
snapshot.key() == self.source_key()
|
||||
&& self.with_source_input(|input| snapshot.matches(input))
|
||||
}
|
||||
|
||||
pub fn source_navigation_available(&self) -> bool {
|
||||
self.page == Page::Text
|
||||
&& !self.source_rename.open()
|
||||
&& !self.busy
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.workflow.is_none()
|
||||
&& !self.hosted.visible
|
||||
&& self.model_menu.is_none()
|
||||
&& self.text_workspace.menu().is_none()
|
||||
&& self.architecture.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
|
||||
/// Mounted widget IDs change when another interaction supersedes a reveal.
|
||||
/// A queued focus operation therefore cannot find a subsequently reused editor.
|
||||
pub fn source_editor_id(&self) -> iced::widget::Id {
|
||||
iced::widget::Id::from(format!(
|
||||
"source-editor-{}-{}-{}",
|
||||
self.source_workspace.session(),
|
||||
self.source_workspace.active,
|
||||
self.source_symbols.interaction
|
||||
))
|
||||
}
|
||||
|
||||
/// The reviewed diagram-query union is not integrated into this source yet.
|
||||
/// Its eventual adapter must return the peer's captured chooser message.
|
||||
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn source_reveal_ticket(&self) -> Option<Id> {
|
||||
self.source_symbols
|
||||
.reveal
|
||||
.as_ref()
|
||||
.map(|reveal| reveal.ticket)
|
||||
}
|
||||
|
||||
pub(super) fn source_interaction(&mut self) {
|
||||
self.source_symbols.interaction = Id::new_v4();
|
||||
self.source_symbols.request = None;
|
||||
self.source_symbols.reveal = None;
|
||||
self.source_jump = None;
|
||||
}
|
||||
|
||||
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = select_line;
|
||||
self.source_symbols.reveal = Some(nav::Reveal {
|
||||
ticket: Id::new_v4(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
snapshot: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn restore_source_editor_focus(&mut self) {
|
||||
let cursor = self.source_editor.cursor();
|
||||
if self.source_navigation_available()
|
||||
&& nav::exact_cursor(self.source_workspace.text(), cursor)
|
||||
{
|
||||
self.queue_source_reveal(cursor, false);
|
||||
// This ticket restores focus to the live editor, not a saved cursor.
|
||||
// Iced's Cursor omits Word/Line selection kind; moving back to that
|
||||
// lossy value would collapse an actual word/line selection.
|
||||
self.source_jump = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
|
||||
let Some(reveal) = &self.source_symbols.reveal else {
|
||||
return Task::none();
|
||||
};
|
||||
if Some(reveal.ticket) != ticket
|
||||
|| reveal.interaction != self.source_symbols.interaction
|
||||
|| !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
reveal.raw_interaction,
|
||||
)
|
||||
|| reveal.snapshot != self.source_key()
|
||||
|| reveal.path != self.source_workspace.active
|
||||
|| !self.source_navigation_available()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let raw_interaction = reveal.raw_interaction;
|
||||
self.source_symbols.reveal = None;
|
||||
if let Some(cursor) = self.source_jump.take() {
|
||||
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
|
||||
return Task::none();
|
||||
}
|
||||
self.source_editor.move_to(cursor);
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
}
|
||||
// A valid ticket with no jump is focus-only. All intentional navigation
|
||||
// still takes the cursor branch above, including explicit line selection.
|
||||
nav::interaction::guarded_focus(
|
||||
self.source_editor_id(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
raw_interaction,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
|
||||
if !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
nav::SymbolEvent::Close => {
|
||||
return self.show_text_drawer(None);
|
||||
}
|
||||
nav::SymbolEvent::Back => {
|
||||
self.source_symbol_back();
|
||||
// A successful Back needs no empty results panel. Retain an
|
||||
// already open panel, or reveal an actionable return notice.
|
||||
if self.source_symbols.notice.is_some() {
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
let refs = self.text_workspace.references();
|
||||
return iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp,
|
||||
reference,
|
||||
index,
|
||||
..
|
||||
} => {
|
||||
let Some(results) = self.source_symbols.results.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
if results.stamp != stamp
|
||||
|| stamp.snapshot != self.source_key()
|
||||
|| self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_none_or(|cache| !self.source_snapshot_matches(cache))
|
||||
{
|
||||
self.source_symbols.notice =
|
||||
Some("Source changed. Run navigation again for current locations.".into());
|
||||
return Task::none();
|
||||
}
|
||||
let destination = if reference {
|
||||
results
|
||||
.references
|
||||
.get(index)
|
||||
.map(|row| (&row.path, row.position, row.end_position))
|
||||
} else {
|
||||
results
|
||||
.definitions
|
||||
.get(index)
|
||||
.map(|row| (&row.path, row.position, row.end_position))
|
||||
};
|
||||
if let Some((path, start, end)) = destination {
|
||||
self.source_symbol_jump(path, start, end);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
if !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
) {
|
||||
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
|
||||
return Task::none();
|
||||
}
|
||||
if self.source_symbols.in_flight.is_some() {
|
||||
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
|
||||
return Task::none();
|
||||
}
|
||||
let cursor = self.source_editor.cursor();
|
||||
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
|
||||
self.source_symbols.notice =
|
||||
Some("Place the cursor on an alias or its @ reference.".into());
|
||||
return Task::none();
|
||||
};
|
||||
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
|
||||
Some(serial) => serial,
|
||||
None => {
|
||||
self.reset_source_navigation_session();
|
||||
self.source_symbols.back.clear();
|
||||
self.source_symbols.cache = None;
|
||||
0
|
||||
}
|
||||
};
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
stamp: query::QueryStamp {
|
||||
snapshot: self.source_key(),
|
||||
serial: self.source_symbols.serial,
|
||||
},
|
||||
path: self.source_workspace.active.clone(),
|
||||
offset,
|
||||
cursor,
|
||||
action,
|
||||
};
|
||||
let cache = self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.filter(|cache| self.source_snapshot_matches(cache))
|
||||
.cloned();
|
||||
let input = if cache.is_none() {
|
||||
match self
|
||||
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
|
||||
{
|
||||
Ok(input) => Some(input),
|
||||
Err(error) => {
|
||||
self.source_symbols.notice = Some(admission_notice(error));
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.source_symbols.in_flight = Some(request.job);
|
||||
self.source_symbols.request = Some(request.clone());
|
||||
self.source_symbols.display_request = Some(request.clone());
|
||||
self.text_workspace
|
||||
.begin_references(request.stamp, request.path.clone());
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
let worker = Task::perform(
|
||||
async move {
|
||||
let worker_request = request.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let snapshot = match cache {
|
||||
Some(cache) => cache,
|
||||
None => Arc::new(
|
||||
input
|
||||
.expect("admitted input for uncached request")
|
||||
.capture()
|
||||
.map_err(admission_notice)?,
|
||||
),
|
||||
};
|
||||
let results = snapshot
|
||||
.query(query::Query {
|
||||
stamp: worker_request.stamp,
|
||||
path: &worker_request.path,
|
||||
byte_offset: worker_request.offset,
|
||||
action: worker_request.action,
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
|
||||
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
|
||||
})?;
|
||||
Ok((snapshot, Arc::new(results)))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
|
||||
nav::Completion { request, result }
|
||||
},
|
||||
Message::SourceSymbolsReady,
|
||||
);
|
||||
Task::batch([
|
||||
worker,
|
||||
iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. },
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
|
||||
let request = completion.request;
|
||||
if self.source_symbols.in_flight != Some(request.job) {
|
||||
return Task::none();
|
||||
}
|
||||
self.source_symbols.in_flight = None;
|
||||
let wanted_navigation = self
|
||||
.source_symbols
|
||||
.request
|
||||
.take()
|
||||
.is_some_and(|current| current == request)
|
||||
&& request.interaction == self.source_symbols.interaction
|
||||
&& request.stamp.snapshot == self.source_key()
|
||||
&& nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
request.raw_interaction,
|
||||
)
|
||||
&& request.path == self.source_workspace.active
|
||||
&& request.cursor == self.source_editor.cursor()
|
||||
&& self.source_navigation_available();
|
||||
let wanted_display = self
|
||||
.source_symbols
|
||||
.display_request
|
||||
.as_ref()
|
||||
.is_some_and(|current| current == &request)
|
||||
&& request.stamp.snapshot == self.source_key();
|
||||
if !wanted_display {
|
||||
return Task::none();
|
||||
}
|
||||
match completion.result {
|
||||
Ok((snapshot, results))
|
||||
if self.source_snapshot_matches(&snapshot)
|
||||
&& results.stamp == request.stamp
|
||||
&& results.action == request.action =>
|
||||
{
|
||||
self.source_symbols.cache = Some(snapshot);
|
||||
self.source_symbols.results = Some(results.clone());
|
||||
self.text_workspace.install_reference_files(
|
||||
results.stamp,
|
||||
results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.clone())
|
||||
.chain(results.references.iter().map(|row| row.path.clone())),
|
||||
);
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& let Some(row) = results.definitions.first()
|
||||
{
|
||||
self.source_symbol_jump(&row.path, row.position, row.end_position);
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
|
||||
}
|
||||
Err(error) => self.source_symbols.notice = Some(error),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
|
||||
let cursor = text_editor::Cursor {
|
||||
position: text_editor::Position {
|
||||
line: start.line,
|
||||
column: start.byte_column,
|
||||
},
|
||||
selection: Some(text_editor::Position {
|
||||
line: end.line,
|
||||
column: end.byte_column,
|
||||
}),
|
||||
};
|
||||
if self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(path)
|
||||
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
|
||||
{
|
||||
self.source_symbols.notice =
|
||||
Some("That source location is no longer available. Run navigation again.".into());
|
||||
return;
|
||||
}
|
||||
// The destination is admitted. Capture effective Word/Line ranges only
|
||||
// now, when this editor is actually about to leave its current position.
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if let Some(buffer) = self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&self.source_workspace.active)
|
||||
{
|
||||
let origin = nav::HistoryEntry {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
generation: buffer.generation(),
|
||||
cursor: departure,
|
||||
};
|
||||
self.source_symbols
|
||||
.back
|
||||
.retain(|entry| entry.session == origin.session);
|
||||
if self.source_symbols.back.len() == 64 {
|
||||
self.source_symbols.back.remove(0);
|
||||
}
|
||||
self.source_symbols.back.push(origin);
|
||||
}
|
||||
self.source_workspace.switch(path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
|
||||
fn source_symbol_back(&mut self) {
|
||||
while let Some(origin) = self.source_symbols.back.pop() {
|
||||
if origin.session != self.source_workspace.session() {
|
||||
continue;
|
||||
}
|
||||
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
|
||||
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
|
||||
return;
|
||||
};
|
||||
let unchanged = origin.generation == buffer.generation();
|
||||
let mut cursor = if unchanged {
|
||||
origin.cursor
|
||||
} else if origin.path == self.source_workspace.active {
|
||||
self.source_editor.cursor()
|
||||
} else {
|
||||
buffer.cursor
|
||||
};
|
||||
if !nav::exact_cursor(&buffer.text, cursor) {
|
||||
self.source_symbols.notice = Some(
|
||||
"The previous location changed. Open the file to choose a current position."
|
||||
.into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if !unchanged && origin.path == self.source_workspace.active {
|
||||
cursor = departure;
|
||||
}
|
||||
self.source_workspace.switch(&origin.path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
self.source_symbols.notice =
|
||||
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
|
||||
return;
|
||||
}
|
||||
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
||||
//! Text-only presentation and captured command envelopes. Source/model authority
|
||||
//! remains in the existing App controllers; dispatch this envelope before the
|
||||
//! generic source_interaction invalidation in App::update.
|
||||
use super::{App, Message};
|
||||
use crate::{context_menu, source_navigation as nav};
|
||||
use iced::{
|
||||
Point, Task,
|
||||
widget::{self, text_editor::Cursor},
|
||||
};
|
||||
use nav::query::{QueryStamp, SnapshotKey};
|
||||
use oa_core::{Id, source_project::SourceLocation};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrawerTab {
|
||||
References,
|
||||
Problems,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Find,
|
||||
GoToLine,
|
||||
Definition,
|
||||
References,
|
||||
RenameAlias,
|
||||
Back,
|
||||
UndoText,
|
||||
RedoText,
|
||||
UndoModel,
|
||||
RedoModel,
|
||||
UndoRename,
|
||||
RevertFile,
|
||||
ShowProblems,
|
||||
ShowStatus,
|
||||
ShowDiagramUsages,
|
||||
}
|
||||
impl Command {
|
||||
fn shortcut(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Find => Some("Ctrl F"),
|
||||
Self::GoToLine => Some("Ctrl G"),
|
||||
Self::Definition => Some("F12"),
|
||||
Self::References => Some("Shift F12"),
|
||||
Self::RenameAlias => Some("F2"),
|
||||
Self::Back => Some("Alt ←"),
|
||||
Self::UndoText => Some("Ctrl Z"),
|
||||
Self::RedoText => Some("Ctrl Y"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Find => "Find in file",
|
||||
Self::GoToLine => "Go to line",
|
||||
Self::Definition => "Go to definition",
|
||||
Self::References => "Find references",
|
||||
Self::RenameAlias => "Rename source alias",
|
||||
Self::Back => "Back in source",
|
||||
Self::UndoText => "Undo text edit",
|
||||
Self::RedoText => "Redo text edit",
|
||||
Self::UndoModel => "Undo model change",
|
||||
Self::RedoModel => "Redo model change",
|
||||
Self::UndoRename => "Undo source rename",
|
||||
Self::RevertFile => "Revert current file",
|
||||
Self::ShowProblems => "Source problems",
|
||||
Self::ShowStatus => "Source status",
|
||||
Self::ShowDiagramUsages => "Show diagram usages",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandContext {
|
||||
document: Id,
|
||||
document_revision: u64,
|
||||
source: SnapshotKey,
|
||||
path: String,
|
||||
cursor: Cursor,
|
||||
interaction: Id,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandRequest {
|
||||
context: CommandContext,
|
||||
command: Command,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuRequest {
|
||||
context: CommandContext,
|
||||
anchor: Point,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DiagnosticPublication {
|
||||
generation: Id,
|
||||
source: SnapshotKey,
|
||||
}
|
||||
impl DiagnosticPublication {
|
||||
pub fn generation(self) -> Id {
|
||||
self.generation
|
||||
}
|
||||
pub fn source(self) -> SnapshotKey {
|
||||
self.source
|
||||
}
|
||||
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
|
||||
DiagnosticTarget {
|
||||
publication: self,
|
||||
visit,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiagnosticTarget {
|
||||
publication: DiagnosticPublication,
|
||||
visit: Id,
|
||||
location: SourceLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
ShowDrawer(DrawerTab),
|
||||
HideDrawer,
|
||||
ResizeDrawer(f32),
|
||||
ToggleFile {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
path: String,
|
||||
},
|
||||
ReferenceScrolled {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
y: f32,
|
||||
},
|
||||
Command(CommandRequest),
|
||||
OpenMenu(MenuRequest),
|
||||
DismissMenu(Id),
|
||||
Invoke {
|
||||
token: Id,
|
||||
command: Command,
|
||||
},
|
||||
Diagnostic(DiagnosticTarget),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FrozenEntry {
|
||||
command: Command,
|
||||
action: Option<Action>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
enum Action {
|
||||
Dispatch(Box<Message>),
|
||||
Drawer(DrawerTab),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu {
|
||||
token: Id,
|
||||
popup_token: u64,
|
||||
anchor: Point,
|
||||
context: CommandContext,
|
||||
entries: Vec<FrozenEntry>,
|
||||
}
|
||||
impl Menu {
|
||||
pub fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReferencePresentation {
|
||||
stamp: Option<QueryStamp>,
|
||||
origin: String,
|
||||
files: BTreeSet<String>,
|
||||
installed: bool,
|
||||
collapsed_files: BTreeSet<String>,
|
||||
scroll_y: f32,
|
||||
}
|
||||
impl Default for ReferencePresentation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stamp: None,
|
||||
origin: String::new(),
|
||||
files: BTreeSet::new(),
|
||||
installed: false,
|
||||
collapsed_files: BTreeSet::new(),
|
||||
scroll_y: 0.,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ReferencePresentation {
|
||||
pub fn stamp(&self) -> Option<QueryStamp> {
|
||||
self.stamp
|
||||
}
|
||||
pub fn scroll_y(&self) -> f32 {
|
||||
self.scroll_y
|
||||
}
|
||||
pub fn collapsed(&self, path: &str) -> bool {
|
||||
self.collapsed_files.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
drawer: Option<DrawerTab>,
|
||||
drawer_visit: Id,
|
||||
drawer_height: f32,
|
||||
references: ReferencePresentation,
|
||||
menu: Option<Menu>,
|
||||
diagnostic_publication: Option<DiagnosticPublication>,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
drawer: None,
|
||||
drawer_visit: Id::new_v4(),
|
||||
drawer_height: 180.,
|
||||
references: ReferencePresentation::default(),
|
||||
menu: None,
|
||||
diagnostic_publication: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub fn drawer_visit(&self) -> Id {
|
||||
self.drawer_visit
|
||||
}
|
||||
pub fn reference_scroll_id(&self) -> widget::Id {
|
||||
widget::Id::from(format!("text-references-{}", self.drawer_visit))
|
||||
}
|
||||
pub fn drawer(&self) -> Option<DrawerTab> {
|
||||
self.drawer
|
||||
}
|
||||
pub fn drawer_height(&self) -> f32 {
|
||||
self.drawer_height
|
||||
}
|
||||
pub fn references(&self) -> &ReferencePresentation {
|
||||
&self.references
|
||||
}
|
||||
pub fn menu(&self) -> Option<&Menu> {
|
||||
self.menu.as_ref()
|
||||
}
|
||||
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
|
||||
self.diagnostic_publication
|
||||
}
|
||||
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
|
||||
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
|
||||
let publication = DiagnosticPublication {
|
||||
generation: Id::new_v4(),
|
||||
source,
|
||||
};
|
||||
self.diagnostic_publication = Some(publication);
|
||||
publication
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
pub fn dismiss_menu(&mut self) {
|
||||
self.menu = None;
|
||||
}
|
||||
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
|
||||
self.references = ReferencePresentation {
|
||||
stamp: Some(stamp),
|
||||
origin,
|
||||
..ReferencePresentation::default()
|
||||
};
|
||||
self.show_drawer(Some(DrawerTab::References));
|
||||
}
|
||||
/// Install presentation groups only for the current completed query. Repeated
|
||||
/// delivery of the same completion cannot undo the user's group choices.
|
||||
pub fn install_reference_files(
|
||||
&mut self,
|
||||
stamp: QueryStamp,
|
||||
paths: impl IntoIterator<Item = String>,
|
||||
) {
|
||||
let refs = &mut self.references;
|
||||
if refs.stamp != Some(stamp) || refs.installed {
|
||||
return;
|
||||
}
|
||||
refs.files = paths.into_iter().collect();
|
||||
refs.collapsed_files = refs.files.clone();
|
||||
let expanded = if refs.files.contains(&refs.origin) {
|
||||
Some(refs.origin.clone())
|
||||
} else {
|
||||
refs.files.first().cloned()
|
||||
};
|
||||
if let Some(path) = expanded {
|
||||
refs.collapsed_files.remove(&path);
|
||||
}
|
||||
refs.installed = true;
|
||||
}
|
||||
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
|
||||
self.drawer = drawer;
|
||||
self.drawer_visit = Id::new_v4();
|
||||
}
|
||||
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer != Some(DrawerTab::References)
|
||||
|| self.drawer_visit != visit
|
||||
|| refs.stamp != Some(stamp)
|
||||
|| !refs.files.contains(&path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !refs.collapsed_files.remove(&path) {
|
||||
refs.collapsed_files.insert(path);
|
||||
}
|
||||
// Collapsing a group can replace a pressed row at the same position.
|
||||
self.drawer_visit = Id::new_v4();
|
||||
true
|
||||
}
|
||||
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer == Some(DrawerTab::References)
|
||||
&& refs.stamp == Some(stamp)
|
||||
&& self.drawer_visit == visit
|
||||
&& y.is_finite()
|
||||
&& y >= 0.
|
||||
{
|
||||
refs.scroll_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn text_command_context(&self) -> CommandContext {
|
||||
CommandContext {
|
||||
document: self.canvas_document,
|
||||
document_revision: self.document.revision(),
|
||||
source: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
}
|
||||
}
|
||||
pub fn text_command_message(&self, command: Command) -> Message {
|
||||
Message::TextWorkspace(Event::Command(CommandRequest {
|
||||
context: self.text_command_context(),
|
||||
command,
|
||||
}))
|
||||
}
|
||||
pub fn text_menu_message(&self, anchor: Point) -> Message {
|
||||
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
|
||||
context: self.text_command_context(),
|
||||
anchor,
|
||||
}))
|
||||
}
|
||||
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
|
||||
let menu = self.text_workspace.menu.as_ref()?;
|
||||
Some(context_menu::Menu {
|
||||
token: menu.popup_token,
|
||||
anchor: menu.anchor,
|
||||
entries: menu
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
let mut rows = Vec::with_capacity(2);
|
||||
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
|
||||
rows.push(context_menu::Entry::separator());
|
||||
}
|
||||
rows.push(context_menu::Entry::Action {
|
||||
label: entry.command.label().into(),
|
||||
shortcut: entry.command.shortcut().map(str::to_owned),
|
||||
message: entry.action.as_ref().map(|_| {
|
||||
Message::TextWorkspace(Event::Invoke {
|
||||
token: menu.token(),
|
||||
command: entry.command,
|
||||
})
|
||||
}),
|
||||
});
|
||||
rows
|
||||
})
|
||||
.collect(),
|
||||
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
|
||||
})
|
||||
}
|
||||
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
|
||||
let publication = self.text_workspace.diagnostic_publication?;
|
||||
(publication.source == self.source_key()
|
||||
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
|
||||
.then(|| {
|
||||
Message::TextWorkspace(Event::Diagnostic(
|
||||
publication.target(self.text_workspace.drawer_visit(), location),
|
||||
))
|
||||
})
|
||||
}
|
||||
fn text_command_action(&self, command: Command) -> Option<Action> {
|
||||
// Menu has been taken before dispatch-time admission. This intentionally
|
||||
// uses navigation admission, not rename admission, for Find/text history.
|
||||
if !self.source_navigation_available() {
|
||||
return None;
|
||||
}
|
||||
let message =
|
||||
match command {
|
||||
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
|
||||
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
|
||||
Command::Definition => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
|
||||
Command::References => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
|
||||
Command::RenameAlias => {
|
||||
self.rename_admission().ok()?;
|
||||
self.source_rename_message()
|
||||
}
|
||||
Command::Back => {
|
||||
if !self
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.any(|entry| entry.session == self.source_workspace.session())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.source_symbol_message(nav::SymbolEvent::Back)
|
||||
}
|
||||
Command::UndoText => Message::SourceUndo,
|
||||
Command::RedoText => Message::SourceRedo,
|
||||
Command::UndoModel | Command::RedoModel => {
|
||||
if self.source_dirty() || self.draft_dirty() {
|
||||
return None;
|
||||
}
|
||||
if command == Command::UndoModel {
|
||||
if !self.document.can_undo() {
|
||||
return None;
|
||||
}
|
||||
Message::Undo
|
||||
} else {
|
||||
if !self.document.can_redo() {
|
||||
return None;
|
||||
}
|
||||
Message::Redo
|
||||
}
|
||||
}
|
||||
Command::UndoRename => self.source_rename_undo_message()?,
|
||||
Command::RevertFile => Message::RevertSource,
|
||||
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
|
||||
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
|
||||
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
|
||||
};
|
||||
Some(Action::Dispatch(Box::new(message)))
|
||||
}
|
||||
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::OpenMenu(request) => {
|
||||
if request.context != self.text_command_context()
|
||||
|| !self.source_navigation_available()
|
||||
|| self.text_workspace.menu.is_some()
|
||||
|| !request.anchor.x.is_finite()
|
||||
|| !request.anchor.y.is_finite()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.source_interaction();
|
||||
let commands = [
|
||||
Command::Find,
|
||||
Command::GoToLine,
|
||||
Command::Definition,
|
||||
Command::References,
|
||||
Command::RenameAlias,
|
||||
Command::Back,
|
||||
Command::UndoText,
|
||||
Command::RedoText,
|
||||
Command::UndoRename,
|
||||
Command::UndoModel,
|
||||
Command::RedoModel,
|
||||
Command::RevertFile,
|
||||
Command::ShowProblems,
|
||||
Command::ShowStatus,
|
||||
Command::ShowDiagramUsages,
|
||||
];
|
||||
let entries = commands
|
||||
.into_iter()
|
||||
.filter_map(|command| {
|
||||
let action = self.text_command_action(command);
|
||||
if command == Command::ShowDiagramUsages && action.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(FrozenEntry { command, action })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.menu_sequence = popup_token;
|
||||
self.text_workspace.menu = Some(Menu {
|
||||
token: Id::new_v4(),
|
||||
popup_token,
|
||||
anchor: request.anchor,
|
||||
context: self.text_command_context(),
|
||||
entries,
|
||||
});
|
||||
}
|
||||
Event::DismissMenu(token) => {
|
||||
if self
|
||||
.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.token == token)
|
||||
{
|
||||
self.text_workspace.menu = None;
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::Invoke { token, command } => {
|
||||
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
|
||||
menu.token == token && menu.context == self.text_command_context()
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
// Validate before closing; consume this invocation only once.
|
||||
// Never resolve a frozen row against a newly generated menu.
|
||||
let menu = self.text_workspace.menu.take().unwrap();
|
||||
let Some(action) = menu
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.and_then(|entry| entry.action)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
if self.text_command_action(command).is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
Event::Command(request) => {
|
||||
if request.context != self.text_command_context() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(action) = self.text_command_action(request.command) {
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
}
|
||||
Event::ShowDrawer(tab) => {
|
||||
if !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(Some(tab));
|
||||
}
|
||||
Event::HideDrawer => {
|
||||
if !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.show_text_drawer(None);
|
||||
}
|
||||
Event::ResizeDrawer(height) => {
|
||||
if self.source_navigation_available() && height.is_finite() {
|
||||
// The view applies the measured editor-allocation bound.
|
||||
let height = height.clamp(180., 1200.);
|
||||
if height != self.text_workspace.drawer_height {
|
||||
self.source_interaction();
|
||||
self.text_workspace.drawer_height = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToggleFile { stamp, visit, path } => {
|
||||
if self.source_navigation_available()
|
||||
&& self.text_workspace.toggle_file(stamp, visit, path)
|
||||
{
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::ReferenceScrolled { stamp, visit, y } => {
|
||||
// Passive geometry must neither invalidate pending navigation
|
||||
// nor reset the editor identity, even while commands are blocked.
|
||||
self.text_workspace.reference_scrolled(stamp, visit, y);
|
||||
}
|
||||
Event::Diagnostic(target) => {
|
||||
if !self.source_navigation_available()
|
||||
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|
||||
|| self.text_workspace.drawer_visit() != target.visit
|
||||
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|
||||
|| target.publication.source != self.source_key()
|
||||
|| !self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&target.location.path)
|
||||
.is_some_and(|file| valid_span(&file.text, &target.location))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.source_interaction();
|
||||
self.jump_to_source(target.location);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
|
||||
match action {
|
||||
Action::Dispatch(message) => self.update(*message),
|
||||
Action::Drawer(tab) => self.show_text_drawer(Some(tab)),
|
||||
}
|
||||
}
|
||||
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
|
||||
self.source_interaction();
|
||||
self.text_workspace.show_drawer(drawer);
|
||||
if drawer == Some(DrawerTab::References) {
|
||||
let refs = self.text_workspace.references();
|
||||
widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_span(text: &str, location: &SourceLocation) -> bool {
|
||||
let span = location.span;
|
||||
span.start <= span.end
|
||||
&& span.end <= text.len()
|
||||
&& text.is_char_boundary(span.start)
|
||||
&& text.is_char_boundary(span.end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod focus_selection_tests;
|
||||
@@ -0,0 +1,480 @@
|
||||
//! The mounted CPU renderer and App use the same cosmic-text editor engine, but
|
||||
//! Iced's Content is parameterized by renderer and cannot be shared between them.
|
||||
//! We hit-test the initial click in the laid-out CPU widget, seed that raw caret
|
||||
//! in App, and relay the real subsequent SelectWord/SelectLine and editing actions.
|
||||
//! App's own Content is never replaced or cursor-normalized after selection. This
|
||||
//! tests the actual controller/content boundary without a window or GPU; native
|
||||
//! replay remains the check of the complete production renderer/layout path.
|
||||
use super::*;
|
||||
use crate::app::Page;
|
||||
use crate::document::Document;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, Shell, layout, widget as core_widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::keyboard::{self, Key, Modifiers};
|
||||
use iced::widget::{button, container, row, text_editor, text_input};
|
||||
use iced::{Element, Event, Font, Size, mouse};
|
||||
use oa_core::{Project, source_project::SourceBundle};
|
||||
use std::{collections::BTreeMap, sync::Arc, sync::atomic::Ordering};
|
||||
|
||||
const SOURCE: &str = concat!(
|
||||
"// oa_contact tail\r\n",
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Selection\" id \"9e8f0000-0000-4000-8000-000000000091\" {}\r\n",
|
||||
);
|
||||
const HOST: Size = Size::new(620., 260.);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app
|
||||
}
|
||||
|
||||
fn ui<'a>(
|
||||
app: &App,
|
||||
content: &'a text_editor::Content<FontLayout>,
|
||||
) -> Element<'a, Message, iced::Theme, FontLayout> {
|
||||
let find: Element<'a, Message, iced::Theme, FontLayout> = if app.source_navigation.open {
|
||||
row![
|
||||
text_input("Find", "")
|
||||
.id("source-find")
|
||||
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
|
||||
container(button("Close").on_press(app.source_find_close_message()))
|
||||
.id("selection-find-close"),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().into()
|
||||
};
|
||||
// Column::push drops zero-height children. Keep both paths mounted even
|
||||
// while Find is hidden, as the production retained-slot layout does.
|
||||
widget::Column::from_vec(vec![
|
||||
container(find)
|
||||
.height(if app.source_navigation.open { 32. } else { 0. })
|
||||
.into(),
|
||||
widget::text_editor(content)
|
||||
.id(app.source_editor_id())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(iced::Fill)
|
||||
.on_action(Message::SourceEdit)
|
||||
.into(),
|
||||
])
|
||||
.width(iced::Fill)
|
||||
.height(iced::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
content: text_editor::Content<FontLayout>,
|
||||
tree: core_widget::Tree,
|
||||
}
|
||||
|
||||
// Operations follow widget-provided traversal instead of assuming a fixed
|
||||
// relationship between layout nodes, transparent containers and state trees.
|
||||
struct Inspection {
|
||||
editor: core_widget::Id,
|
||||
focused: Option<bool>,
|
||||
close_bounds: Option<iced::Rectangle>,
|
||||
}
|
||||
|
||||
impl core_widget::Operation for Inspection {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
|
||||
operate(self);
|
||||
}
|
||||
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&core_widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
state: &mut dyn core_widget::operation::Focusable,
|
||||
) {
|
||||
if id == Some(&self.editor) {
|
||||
assert!(
|
||||
self.focused.replace(state.is_focused()).is_none(),
|
||||
"duplicate editor ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&core_widget::Id>, bounds: iced::Rectangle) {
|
||||
if id == Some(&core_widget::Id::new("selection-find-close")) {
|
||||
assert!(
|
||||
self.close_bounds.replace(bounds).is_none(),
|
||||
"duplicate Close target ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mounted {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
content: text_editor::Content::with_text(SOURCE),
|
||||
tree: core_widget::Tree::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&mut self, app: &App, events: &[(Event, mouse::Cursor)]) -> Vec<Message> {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
for (event, cursor) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
*cursor,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::with_size(HOST),
|
||||
);
|
||||
if !matches!(event, Event::Mouse(mouse::Event::ButtonReleased(_))) {
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"mounted input must be handled: {event:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn operation(&mut self, app: &App, mut op: Box<dyn core_widget::Operation>) {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
loop {
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
op.as_mut(),
|
||||
);
|
||||
match op.finish() {
|
||||
core_widget::operation::Outcome::Chain(next) => op = next,
|
||||
core_widget::operation::Outcome::None => break,
|
||||
core_widget::operation::Outcome::Some(()) => panic!("focus does not emit messages"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn task(&mut self, app: &App, task: Task<Message>) {
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(op) = action else {
|
||||
panic!("expected a widget operation")
|
||||
};
|
||||
self.operation(app, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect(&mut self, app: &App) -> Inspection {
|
||||
let mut inspection = Inspection {
|
||||
editor: app.source_editor_id(),
|
||||
focused: None,
|
||||
close_bounds: None,
|
||||
};
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
&mut inspection,
|
||||
);
|
||||
inspection
|
||||
}
|
||||
|
||||
fn editor_focused(&mut self, app: &App) -> bool {
|
||||
self.inspect(app)
|
||||
.focused
|
||||
.expect("exactly one editor with the requested ID")
|
||||
}
|
||||
|
||||
fn close_message(&mut self, app: &App, escape: bool) -> Message {
|
||||
if escape {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(keyboard::key::Named::Escape),
|
||||
modified_key: Key::Named(keyboard::key::Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
self.events(app, &[(event, mouse::Cursor::Unavailable)])
|
||||
.is_empty()
|
||||
);
|
||||
// The existing subscription tests cover the captured-Escape bridge.
|
||||
// Deliver its App message after this actual input captured the key.
|
||||
Message::Escape
|
||||
} else {
|
||||
let center = self
|
||||
.inspect(app)
|
||||
.close_bounds
|
||||
.expect("the mounted Find Close target exists")
|
||||
.center();
|
||||
let cursor = mouse::Cursor::Available(center);
|
||||
let mut messages = self.events(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let close = messages.remove(0);
|
||||
assert!(matches!(close, Message::SourceFindClose(..)));
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, app: &mut App, line: bool) {
|
||||
// One mounted update batch avoids delays between Iced's consecutive-click
|
||||
// timestamps; content actions are applied in their emitted order below.
|
||||
let at = mouse::Cursor::Available(Point::new(65., 25.));
|
||||
let events = (0..if line { 3 } else { 2 })
|
||||
.flat_map(|_| {
|
||||
[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let messages = self.events(app, &events);
|
||||
assert_eq!(messages.len(), if line { 3 } else { 2 });
|
||||
for (index, message) in messages.into_iter().enumerate() {
|
||||
let Message::SourceEdit(action) = message else {
|
||||
panic!("editor action")
|
||||
};
|
||||
if index == 0 {
|
||||
assert!(matches!(action, text_editor::Action::Click(_)));
|
||||
self.content.perform(action);
|
||||
let caret = self.content.cursor();
|
||||
assert_eq!(caret.position.line, 0);
|
||||
assert!(
|
||||
(4..13).contains(&caret.position.column),
|
||||
"click must hit inside oa_contact: {caret:?}"
|
||||
);
|
||||
// App's GPU-typed Content has no public layout API. Copy only the
|
||||
// initial hit-tested caret before any Word/Line selection exists.
|
||||
app.source_editor.move_to(caret);
|
||||
} else {
|
||||
assert_eq!(
|
||||
action,
|
||||
if index == 1 {
|
||||
text_editor::Action::SelectWord
|
||||
} else {
|
||||
text_editor::Action::SelectLine
|
||||
}
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
self.content.selection().as_deref(),
|
||||
Some(if line {
|
||||
"// oa_contact tail"
|
||||
} else {
|
||||
"oa_contact"
|
||||
})
|
||||
);
|
||||
assert_eq!(app.source_editor.selection(), self.content.selection());
|
||||
assert!(self.editor_focused(app));
|
||||
}
|
||||
|
||||
fn type_replacement(&mut self, app: &mut App) {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Character("x".into()),
|
||||
modified_key: Key::Character("x".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: Some("x".into()),
|
||||
repeat: false,
|
||||
});
|
||||
let messages = self.events(app, &[(event, mouse::Cursor::Unavailable)]);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::SourceEdit(action) = messages.into_iter().next().unwrap() else {
|
||||
panic!("typed edit")
|
||||
};
|
||||
assert_eq!(
|
||||
action,
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('x'))
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn word_and_line_selection_survive_find_close_and_escape_then_replace_exact_bytes() {
|
||||
for line in [false, true] {
|
||||
for escape in [false, true] {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection();
|
||||
let cursor = app.source_editor.cursor();
|
||||
// Word/Line selection has coincident raw endpoints. Merely checking
|
||||
// cursor equality after a move_to roundtrip cannot detect the defect.
|
||||
assert_eq!(cursor.selection, Some(cursor.position));
|
||||
let task = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
let close = mounted.close_message(&app, escape);
|
||||
let _ = app.update(close);
|
||||
let ticket = app
|
||||
.source_reveal_ticket()
|
||||
.expect("guarded restoration ticket");
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection(),
|
||||
selected,
|
||||
"selected bytes must survive focus restoration"
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = SOURCE.replacen(selected.as_deref().unwrap(), "x", 1);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), expected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_direct_typing_control_and_old_cursor_roundtrip_discriminate_selection_loss() {
|
||||
for line in [false, true] {
|
||||
for old_roundtrip in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection().unwrap();
|
||||
let interior = app.source_editor.cursor();
|
||||
if old_roundtrip {
|
||||
app.source_editor.move_to(interior);
|
||||
assert_ne!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
}
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = if old_roundtrip {
|
||||
let mut text = SOURCE.to_owned();
|
||||
text.insert(interior.position.column, 'x');
|
||||
text
|
||||
} else {
|
||||
SOURCE.replacen(&selected, "x", 1)
|
||||
};
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), SOURCE.replacen(&selected, "x", 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_focus_ticket_and_later_raw_input_cannot_steal_focus_or_change_selected_bytes() {
|
||||
for later_raw_input in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let selected = app.source_editor.selection();
|
||||
let open = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, open).await;
|
||||
let close = app.source_find_close_message();
|
||||
let _ = app.update(close);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let focus = if later_raw_input {
|
||||
app.update(Message::SourceViewport(Some(ticket)))
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let reopen = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, reopen).await;
|
||||
if later_raw_input {
|
||||
app.source_symbols
|
||||
.raw_interaction
|
||||
.fetch_add(1, Ordering::SeqCst);
|
||||
mounted.task(&app, focus).await;
|
||||
} else {
|
||||
let stale = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, stale).await;
|
||||
}
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_cursor_navigation_still_moves_and_selects_requested_line() {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let target = text_editor::Cursor {
|
||||
position: text_editor::Position { line: 1, column: 3 },
|
||||
selection: None,
|
||||
};
|
||||
// Clear the previous semantic selection as actual show_source_file does when
|
||||
// moving to another source buffer, then exercise intentional navigation.
|
||||
app.show_source_file(false);
|
||||
app.queue_source_reveal(target, true);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert_eq!(app.source_editor.cursor().position, target.position);
|
||||
assert_eq!(app.source_editor.selection().as_deref(), Some("arcadia 7;"));
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Real lifecycle receipts, with all disk writes confined to unique temp roots.
|
||||
//! Returned App worker tasks are not polled; completions use the normal captured
|
||||
//! query input and are delivered deterministically through App::update.
|
||||
use super::*;
|
||||
use crate::app::{Page, read_snapshot, write_snapshot};
|
||||
use crate::document::Document;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use oa_core::{
|
||||
Project,
|
||||
language::{Diagnostic, Span},
|
||||
source_project::{SourceBundle, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), VALID.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
// with_project uses in-memory Home metadata and no recovery directory.
|
||||
assert!(app.recovery.directory.is_none());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn save(app: &mut App, path: &Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
send(app, Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
fn reveal(app: &mut App) {
|
||||
if let Some(ticket) = app.source_reveal_ticket() {
|
||||
send(app, Message::SourceViewport(Some(ticket)));
|
||||
}
|
||||
}
|
||||
fn query(app: &mut App, action: nav::query::Action) -> nav::Completion {
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(action));
|
||||
send(app, message);
|
||||
let request = app.source_symbols.request.clone().expect("admitted query");
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
let snapshot = Arc::new(app.with_source_input(|input| {
|
||||
nav::query::admit_owned(input, nav::query::Limits::default())
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap()
|
||||
}));
|
||||
let results = Arc::new(
|
||||
snapshot
|
||||
.query(nav::query::Query {
|
||||
stamp: request.stamp,
|
||||
path: &request.path,
|
||||
byte_offset: request.offset,
|
||||
action,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
nav::Completion {
|
||||
request,
|
||||
result: Ok((snapshot, results)),
|
||||
}
|
||||
}
|
||||
fn seed_navigation(app: &mut App, pending: bool) -> Option<nav::Completion> {
|
||||
let text = app.source_workspace.text();
|
||||
let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len();
|
||||
let prefix = &text[..start];
|
||||
let position = widget::text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix.rsplit_once('\n').unwrap().1.len(),
|
||||
};
|
||||
app.source_editor.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
let ready = query(app, nav::query::Action::Definition);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
reveal(app);
|
||||
assert_eq!(app.source_symbols.back.len(), 1);
|
||||
let ready = query(app, nav::query::Action::References);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some());
|
||||
assert!(app.source_symbols.display_request.is_some());
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
if pending {
|
||||
Some(query(app, nav::query::Action::Definition))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn warning(app: &App) -> SourceDiagnostic {
|
||||
let source = app.source_workspace.text();
|
||||
let primary = source.find("model").unwrap();
|
||||
let related = source.find("assess").unwrap();
|
||||
SourceDiagnostic {
|
||||
path: app.source_workspace.active.clone(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
code: "lifecycle_warning".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 2,
|
||||
column: 9,
|
||||
message: "Retained warning with a related source location".into(),
|
||||
span: Span {
|
||||
start: primary,
|
||||
end: primary + 5,
|
||||
},
|
||||
},
|
||||
related: vec![SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span {
|
||||
start: related,
|
||||
end: related + 6,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn warning_messages(app: &App) -> [Message; 2] {
|
||||
let warning = &app.source_diagnostics[0];
|
||||
[
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: warning.path.clone(),
|
||||
span: warning.diagnostic.span,
|
||||
})
|
||||
.unwrap(),
|
||||
app.text_diagnostic_message(warning.related[0].clone())
|
||||
.unwrap(),
|
||||
]
|
||||
}
|
||||
fn assert_cleared(app: &App, job: Option<Id>) {
|
||||
assert_eq!(app.source_symbols.in_flight, job);
|
||||
assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none());
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.references().stamp().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
let ready = seed_navigation(&mut app, true).unwrap();
|
||||
let job = ready.request.job;
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let old = warning_messages(&app);
|
||||
let warnings = app.source_diagnostics.clone();
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let raw = app.source_workspace.bundle();
|
||||
save(&mut app, &second_path);
|
||||
assert_eq!(app.path.as_deref(), Some(second_path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, source.workspace);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID);
|
||||
assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID);
|
||||
assert_eq!(app.source_diagnostics, warnings);
|
||||
let current = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.expect("identity-only save retained current warnings");
|
||||
assert_eq!(current.source(), app.source_key());
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert_cleared(&app, Some(job));
|
||||
for message in old {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
for message in warning_messages(&app) {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related warnings remain actionable"
|
||||
);
|
||||
reveal(&mut app);
|
||||
}
|
||||
let cursor = app.source_editor.cursor();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker()
|
||||
{
|
||||
for pending in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("broken.arcadia");
|
||||
let invalid = format!(
|
||||
"{} include \"missing.arcadia\";\r\n}}\r\n",
|
||||
VALID.strip_suffix("}\r\n").unwrap()
|
||||
);
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
let mut app = fixture();
|
||||
let ready = seed_navigation(&mut app, pending);
|
||||
let job = ready.as_ref().map(|ready| ready.request.job);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let old = warning_messages(&app);
|
||||
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let old_source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let open_menu = app.text_menu_message(Point::new(20., 20.));
|
||||
send(&mut app, open_menu);
|
||||
assert!(app.text_workspace.menu().is_some());
|
||||
let loaded = read_snapshot(path.clone()).unwrap();
|
||||
send(&mut app, Message::Loaded(Ok(Some(loaded))));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert_eq!(app.path.as_deref(), Some(path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, old_source.workspace);
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_cleared(&app, job);
|
||||
assert!(
|
||||
app.source_diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.severity == Severity::Error)
|
||||
);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(publication.source(), app.source_key());
|
||||
assert_ne!(publication.generation(), old_publication.generation());
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let issue = app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.path == app.source_workspace.active
|
||||
&& issue.diagnostic.span.end <= invalid.len()
|
||||
})
|
||||
.unwrap();
|
||||
let current = app
|
||||
.text_diagnostic_message(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
})
|
||||
.unwrap();
|
||||
send(&mut app, current);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
reveal(&mut app);
|
||||
if let Some(ready) = ready {
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
}
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() {
|
||||
for failed in [false, true] {
|
||||
let mut app = fixture();
|
||||
seed_navigation(&mut app, false);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
let results = app.source_symbols.results.clone().unwrap();
|
||||
let cache = app.source_symbols.cache.clone().unwrap();
|
||||
let display = app.source_symbols.display_request.clone();
|
||||
let back = app
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let key = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let result = if failed {
|
||||
Err("Fixture read failed before installing a workspace".into())
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
send(&mut app, Message::Loaded(result));
|
||||
assert_eq!(app.source_key(), key);
|
||||
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
|
||||
assert_eq!(app.source_diagnostics, diagnostics);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.cache.as_ref().unwrap(),
|
||||
&cache
|
||||
));
|
||||
assert_eq!(app.source_symbols.display_request, display);
|
||||
assert_eq!(
|
||||
app.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| (
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
back
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
let old = warning_messages(&app);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let draft = format!("{VALID}// unsaved current draft\r\n");
|
||||
app.source_workspace
|
||||
.edit(draft.clone(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert_ne!(publication.source(), app.source_key());
|
||||
let accepted = app.document.snapshot();
|
||||
save(&mut app, &second_path);
|
||||
assert!(
|
||||
app.text_workspace.diagnostic_publication().is_none(),
|
||||
"identity-only reset cannot turn an old source span into a current warning"
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
|
||||
);
|
||||
assert!(
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: app.source_diagnostics[0].path.clone(),
|
||||
span: app.source_diagnostics[0].diagnostic.span,
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), draft);
|
||||
assert!(app.source_dirty());
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second_path).unwrap(),
|
||||
VALID,
|
||||
"receipt records accepted bytes while the newer draft remains unsaved"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
use super::*;
|
||||
use crate::app::Page;
|
||||
use oa_core::{Project, language::Span};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
app.publish_source_diagnostics(diagnostics);
|
||||
assert!(app.source_navigation_available());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
// Exercise the real envelope path; worker/lint futures are deliberately not
|
||||
// polled here. The parent owns serialized compile/runtime verification.
|
||||
let _ = app.update(Message::TextWorkspace(event));
|
||||
}
|
||||
fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event {
|
||||
Event::ToggleFile {
|
||||
stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
fn open(app: &mut App) -> Id {
|
||||
let message = app.text_menu_message(Point::new(10., 20.));
|
||||
let _ = app.update(message);
|
||||
app.text_workspace.menu().unwrap().token()
|
||||
}
|
||||
fn invoke(app: &mut App, token: Id, command: Command) {
|
||||
send(app, Event::Invoke { token, command });
|
||||
}
|
||||
fn enabled(app: &App, command: Command) -> bool {
|
||||
app.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.is_some_and(|entry| entry.action.is_some())
|
||||
}
|
||||
fn back_entry(app: &App) -> nav::HistoryEntry {
|
||||
nav::HistoryEntry {
|
||||
session: app.source_workspace.session(),
|
||||
path: app.source_workspace.active.clone(),
|
||||
generation: app.source_workspace.files[&app.source_workspace.active].generation(),
|
||||
cursor: app.source_editor.cursor(),
|
||||
}
|
||||
}
|
||||
fn stamp(app: &App, serial: u64) -> QueryStamp {
|
||||
QueryStamp {
|
||||
snapshot: app.source_key(),
|
||||
serial,
|
||||
}
|
||||
}
|
||||
fn location(app: &App, start: usize, end: usize) -> SourceLocation {
|
||||
SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span { start, end },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() {
|
||||
let mut app = fixture();
|
||||
let old_interaction = app.source_symbols.interaction;
|
||||
let token = open(&mut app);
|
||||
assert_ne!(old_interaction, app.source_symbols.interaction);
|
||||
assert_eq!(
|
||||
app.text_workspace.menu.as_ref().unwrap().context,
|
||||
app.text_command_context()
|
||||
);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
let instance = app.source_find_instance;
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert_eq!(
|
||||
app.source_find_instance, instance,
|
||||
"replay must not reopen Find"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(!enabled(&app, Command::Back));
|
||||
app.source_symbols.back.push(back_entry(&app));
|
||||
assert!(
|
||||
!enabled(&app, Command::Back),
|
||||
"frozen presentation must remain disabled"
|
||||
);
|
||||
invoke(&mut app, token, Command::Back);
|
||||
assert_eq!(
|
||||
app.source_symbols.back.len(),
|
||||
1,
|
||||
"a newly enabled action cannot be substituted"
|
||||
);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
app.busy = true;
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
let new = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), new);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() {
|
||||
for change in 0..6 {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
match change {
|
||||
0 => {
|
||||
let value = format!("{}\n// changed", app.source_workspace.text());
|
||||
app.source_workspace.edit(value, app.source_editor.cursor());
|
||||
}
|
||||
1 => {
|
||||
let mut cursor = app.source_editor.cursor();
|
||||
cursor.position.column += 1;
|
||||
app.source_editor.move_to(cursor);
|
||||
}
|
||||
2 => app.canvas_document = Id::new_v4(),
|
||||
3 => app.source_workspace.active = "other.arcadia".into(),
|
||||
4 => app.source_symbols.interaction = Id::new_v4(),
|
||||
_ => {
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str("\n// accepted revision");
|
||||
assert!(app.document.apply_bundle(bundle).unwrap());
|
||||
}
|
||||
}
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open, "stale context case {change}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let pending_name = "Pending property draft";
|
||||
// Use the actual property input route before source edits coexist. Changing
|
||||
// Draft::value alone is overwritten by its authoritative raw input buffer.
|
||||
let _ = app.update(Message::Field("/name".into(), pending_name.into()));
|
||||
let assert_pending_property = |app: &App| {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(draft.inputs["/name"].raw, pending_name);
|
||||
assert_eq!(draft.inputs["/name"].editor.text(), pending_name);
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap()["name"].as_str(),
|
||||
Some(pending_name)
|
||||
);
|
||||
};
|
||||
assert_pending_property(&app);
|
||||
let initial = app.source_workspace.text().to_owned();
|
||||
app.source_workspace
|
||||
.edit(format!("{initial}\n// draft"), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert!(app.source_dirty() && app.draft_dirty());
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText));
|
||||
assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert_pending_property(&app);
|
||||
let token = open(&mut app);
|
||||
invoke(&mut app, token, Command::UndoText);
|
||||
assert_eq!(app.source_workspace.text(), initial);
|
||||
assert_pending_property(&app);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_toolbar_request_cannot_retarget_after_source_change() {
|
||||
let mut app = fixture();
|
||||
let request = app.text_command_message(Command::Find);
|
||||
app.source_workspace
|
||||
.edit("// changed".into(), app.source_editor.cursor());
|
||||
let _ = app.update(request);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap();
|
||||
let previous = app.text_workspace.diagnostic_publication().unwrap();
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(previous.source(), current.source());
|
||||
assert_ne!(previous.generation(), current.generation());
|
||||
for message in [primary, related] {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
app.source_interaction();
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.source_workspace
|
||||
.edit("// newer".into(), app.source_editor.cursor());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none());
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
app.source_workspace
|
||||
.edit("a雨b".into(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let mut missing = location(&app, 0, 1);
|
||||
missing.path = "missing.arcadia".into();
|
||||
for target in [
|
||||
location(&app, 3, 2),
|
||||
location(&app, 0, 6),
|
||||
location(&app, 2, 4),
|
||||
location(&app, 1, 3),
|
||||
missing,
|
||||
] {
|
||||
let message = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() {
|
||||
let mut app = fixture();
|
||||
let first = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(first, "b/use.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]);
|
||||
assert!(app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(!app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "a/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let old_scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 123.,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_symbols.interaction, interaction,
|
||||
"scroll is passive"
|
||||
);
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Status));
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::References));
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id);
|
||||
let second = stamp(&app, 2);
|
||||
app.text_workspace
|
||||
.begin_references(second, "absent.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["old.arcadia".into()]);
|
||||
app.text_workspace
|
||||
.install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 0.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "b/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
|
||||
let mut app = fixture();
|
||||
open(&mut app);
|
||||
app.text_workspace.reset();
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.diagnostic_publication().is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
send(&mut app, Event::ResizeDrawer(f32::NAN));
|
||||
assert_eq!(app.text_workspace.drawer_height(), 180.);
|
||||
send(&mut app, Event::ResizeDrawer(f32::MAX));
|
||||
assert_eq!(app.text_workspace.drawer_height(), 1200.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "uses.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["uses.arcadia".into()]);
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let event = toggle(&app, current, "not-a-group.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old = QueryStamp {
|
||||
serial: 0,
|
||||
..current
|
||||
};
|
||||
let event = toggle(&app, old, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
send(&mut app, Event::ResizeDrawer(f32::NAN));
|
||||
send(&mut app, Event::ResizeDrawer(180.));
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.request.is_some());
|
||||
let event = toggle(&app, current, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_ne!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(
|
||||
app.source_symbols.in_flight,
|
||||
Some(request.job),
|
||||
"intent does not release an occupied worker"
|
||||
);
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
send(&mut app, Event::ResizeDrawer(240.));
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
send(&mut app, Event::HideDrawer);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
let path = app.source_workspace.active.clone();
|
||||
app.text_workspace.begin_references(current, path.clone());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, [path.clone()]);
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed(&path));
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit(
|
||||
widget::text_editor::Edit::Insert(' '),
|
||||
)));
|
||||
assert_ne!(current.snapshot, app.source_key());
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(!app.text_workspace.references().collapsed(&path));
|
||||
assert_eq!(app.source_workspace.active, path);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty());
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
let sequence = app.text_workspace_menu().unwrap().token;
|
||||
app.reset(Project::empty(), None);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
app.page = Page::Text;
|
||||
let fresh = open(&mut app);
|
||||
assert_ne!(old, fresh);
|
||||
assert!(app.text_workspace_menu().unwrap().token > sequence);
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() {
|
||||
for hide in [false, true] {
|
||||
let mut app = fixture();
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let targets = [location(&app, 0, 1), location(&app, 1, 2)];
|
||||
let old = targets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|target| app.text_diagnostic_message(target).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty(),
|
||||
);
|
||||
let event = if hide {
|
||||
Event::HideDrawer
|
||||
} else {
|
||||
Event::ShowDrawer(DrawerTab::References)
|
||||
};
|
||||
send(&mut app, event);
|
||||
for reopened in [false, true] {
|
||||
if reopened {
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
} else {
|
||||
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
|
||||
}
|
||||
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
|
||||
assert_eq!(
|
||||
app.text_workspace.diagnostic_publication(),
|
||||
publication,
|
||||
"same list and source isolate the visit guard from publication freshness"
|
||||
);
|
||||
let context = app.text_command_context();
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
for message in old.iter().cloned() {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.text_command_context(),
|
||||
context,
|
||||
"stale diagnostic must fail before interaction invalidation"
|
||||
);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty()
|
||||
),
|
||||
history
|
||||
);
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
let fresh = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(fresh);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related targets remain usable"
|
||||
);
|
||||
app.source_interaction();
|
||||
}
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
|
||||
for replacement in 0..3 {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "a.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]);
|
||||
let old = toggle(&app, current, "a.arcadia".into());
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
match replacement {
|
||||
0 => {
|
||||
send(&mut app, Event::HideDrawer);
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::References));
|
||||
}
|
||||
1 => {
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
|
||||
send(&mut app, Event::ShowDrawer(DrawerTab::References));
|
||||
}
|
||||
_ => {
|
||||
let event = toggle(&app, current, "b.arcadia".into());
|
||||
send(&mut app, event);
|
||||
}
|
||||
}
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
assert_ne!(visit, old_visit);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: current,
|
||||
visit,
|
||||
y: 84.,
|
||||
},
|
||||
);
|
||||
let scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let groups =
|
||||
["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path));
|
||||
let context = app.text_command_context();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
for _ in 0..2 {
|
||||
send(&mut app, old.clone());
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 84.);
|
||||
assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id);
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
assert_eq!(
|
||||
["a.arcadia", "b.arcadia"]
|
||||
.map(|path| app.text_workspace.references().collapsed(path)),
|
||||
groups,
|
||||
);
|
||||
assert_eq!(app.text_command_context(), context);
|
||||
assert_eq!(app.source_symbols.request.as_ref(), Some(&request));
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
let fresh = toggle(&app, current, "a.arcadia".into());
|
||||
send(&mut app, fresh);
|
||||
assert_eq!(
|
||||
app.text_workspace.references().collapsed("a.arcadia"),
|
||||
!groups[0]
|
||||
);
|
||||
assert_ne!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_ne!(app.source_symbols.interaction, context.interaction);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,11 @@ use iced::{
|
||||
};
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(test)]
|
||||
mod contextual_source_tests;
|
||||
pub(crate) mod creation;
|
||||
pub(crate) mod scenario;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
pub(crate) mod state_edit;
|
||||
pub(crate) mod state_menu;
|
||||
@@ -739,6 +742,29 @@ pub enum Event {
|
||||
Submit,
|
||||
}
|
||||
impl Event {
|
||||
pub fn is_source_back(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit(
|
||||
_,
|
||||
source_draft::Edit::Back
|
||||
)))
|
||||
)
|
||||
}
|
||||
pub fn is_source(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Creation(creation::Event::Source(_))
|
||||
| Self::Scenario(scenario::Event::Source(_))
|
||||
| Self::Topology(topology::Event::Source(_))
|
||||
)
|
||||
}
|
||||
pub fn starts_form(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
@@ -869,6 +895,18 @@ impl State {
|
||||
|
||||
/// Clear the active form after its prepared command commits successfully.
|
||||
/// Rejected commands must leave the form available for repair and retry.
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.creation.source_active()
|
||||
|| self.scenario.source_active()
|
||||
|| self.topology.source_active()
|
||||
}
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.creation.source_open() || self.scenario.source_open() || self.topology.source_open()
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.creation.source_back() || self.scenario.source_back() || self.topology.source_back()
|
||||
}
|
||||
|
||||
pub fn acknowledge_commit(&mut self) {
|
||||
self.creation.acknowledge_commit();
|
||||
self.topology.acknowledge_commit();
|
||||
@@ -916,6 +954,40 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
if let Some(card) = app.behavior.creation.view(p) {
|
||||
return card.map(|event| Message::Behavior(Event::Creation(event)));
|
||||
}
|
||||
if let Some(owner) = app.behavior.pending_owner() {
|
||||
let kind = p.elements.get(&owner).map(oa_core::Element::kind);
|
||||
let unavailable = p.layer_of(owner) != Some(app.layer)
|
||||
|| (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario))
|
||||
|| (app.behavior.topology.pending_owner().is_some()
|
||||
&& !matches!(kind, Some(Kind::Process | Kind::FunctionalChain)));
|
||||
if unavailable {
|
||||
let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12);
|
||||
if let Some(name) = app
|
||||
.behavior
|
||||
.scenario
|
||||
.pending_name()
|
||||
.or_else(|| app.behavior.topology.pending_name())
|
||||
{
|
||||
out =
|
||||
out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14));
|
||||
}
|
||||
if let Some(child) = app.behavior.scenario.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e))));
|
||||
} else if let Some(child) = app.behavior.topology.source_view(p) {
|
||||
out = out.push(child.map(|e| Message::Behavior(Event::Topology(e))));
|
||||
} else {
|
||||
let cancel = if app.behavior.scenario.pending_owner().is_some() {
|
||||
Event::Scenario(scenario::Event::Cancel)
|
||||
} else if app.behavior.topology.pending_owner().is_some() {
|
||||
Event::Topology(topology::Event::Cancel)
|
||||
} else {
|
||||
Event::States(states::Event::Cancel)
|
||||
};
|
||||
out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel)));
|
||||
}
|
||||
return container(out).padding(16).width(Fill).height(Fill).into();
|
||||
}
|
||||
}
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,7 @@ impl Picker {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Open(Layer),
|
||||
Choose(Family),
|
||||
Name(String),
|
||||
@@ -97,6 +98,11 @@ struct Browser {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
|
||||
version: u32,
|
||||
document: Id,
|
||||
layer: Layer,
|
||||
@@ -125,6 +131,17 @@ pub struct Created {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.draft.is_some()
|
||||
}
|
||||
@@ -174,6 +191,49 @@ impl State {
|
||||
.command(p)
|
||||
}
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.family != Some(Family::Machine) {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.document != p.id {
|
||||
return Err("This behavior draft belongs to another document.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Machine { layer: d.layer },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.owner = Some(id);
|
||||
let preview = next
|
||||
.source
|
||||
.preview(p, next.owner)?
|
||||
.ok_or("New owner preview is missing.")?;
|
||||
if !eligible(&preview, next.layer, Picker::Owner, id) {
|
||||
return Err("This participant cannot own the machine.".into());
|
||||
}
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Cancel => {
|
||||
self.draft = None;
|
||||
@@ -184,6 +244,8 @@ impl State {
|
||||
return Err("Create or cancel the pending behavior first.".into());
|
||||
}
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
version: 1,
|
||||
document: p.id,
|
||||
layer,
|
||||
@@ -332,7 +394,7 @@ fn reference_context(p: &Project, e: &Element) -> String {
|
||||
};
|
||||
format!("{} · {} · {location}", e.kind(), e.alias)
|
||||
}
|
||||
fn reference_label(p: &Project, id: Id) -> String {
|
||||
pub(super) fn reference_label(p: &Project, id: Id) -> String {
|
||||
p.elements
|
||||
.get(&id)
|
||||
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
|
||||
@@ -340,6 +402,15 @@ fn reference_label(p: &Project, id: Id) -> String {
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Machine { layer: self.layer },
|
||||
self.owner,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.owner)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.owner, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.document != p.id {
|
||||
return Err("This draft belongs to another document.".into());
|
||||
}
|
||||
@@ -475,6 +546,21 @@ impl State {
|
||||
p: &'a Project,
|
||||
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
|
||||
let d = self.draft.as_ref()?;
|
||||
if let Some(view) = d.source.view(p) {
|
||||
return Some(
|
||||
column![
|
||||
text(format!(
|
||||
"State machine name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
view.map(Event::Source)
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut fields = column![].spacing(10);
|
||||
let title = if let Some(family) = d.family {
|
||||
fields = fields.push(
|
||||
@@ -488,7 +574,7 @@ impl State {
|
||||
Family::Sequence => {
|
||||
fields = fields.push(
|
||||
text(format!(
|
||||
"Owned by the {} layer root. Involve existing work after creation.",
|
||||
"Owned by the {} layer root. Add work and exchange contributions after creation.",
|
||||
d.layer
|
||||
))
|
||||
.size(12),
|
||||
@@ -531,7 +617,17 @@ impl State {
|
||||
}
|
||||
}
|
||||
Family::Machine => {
|
||||
if let Some(label) = d.source.label(d.owner) {
|
||||
fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12));
|
||||
}
|
||||
fields = fields.push(picker_view(p, d, Picker::Owner));
|
||||
fields = fields.push(
|
||||
button("Create new owner…")
|
||||
.on_press(Event::Source(super::source_draft::Event::Open(
|
||||
d.invocation,
|
||||
)))
|
||||
.padding(7),
|
||||
);
|
||||
fields = fields.push(
|
||||
checkbox(d.first_view)
|
||||
.label("Create the first state view")
|
||||
@@ -544,9 +640,9 @@ impl State {
|
||||
} else {
|
||||
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
|
||||
let purpose = match family {
|
||||
Family::Sequence => "A path through existing work and exchanges",
|
||||
Family::Sequence => "A path through work and exchanges",
|
||||
Family::Scenario => "Ordered interactions from an explicit viewpoint",
|
||||
Family::Machine => "States and modes of an existing participant",
|
||||
Family::Machine => "States and modes of an explicitly chosen participant",
|
||||
};
|
||||
fields = fields.push(
|
||||
button(
|
||||
@@ -598,7 +694,9 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
Picker::Owner => d.owner,
|
||||
Picker::Context => d.context,
|
||||
};
|
||||
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
|
||||
let title = match picker {
|
||||
Picker::Owner if staged_owner => "Or choose an existing participant",
|
||||
Picker::Owner => "Owned by participant",
|
||||
Picker::Context => "Scenario context · optional",
|
||||
};
|
||||
@@ -609,14 +707,17 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected {
|
||||
if let Some(id) = selected
|
||||
&& !staged_owner
|
||||
{
|
||||
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
|
||||
} else if picker == Picker::Context {
|
||||
out = out.push(text("No context selected").size(12));
|
||||
}
|
||||
if options.is_empty() {
|
||||
out = out.push(text(match picker {
|
||||
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
|
||||
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
|
||||
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
|
||||
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
First(Context, Tool),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
Viewport(Context, Size),
|
||||
@@ -132,7 +133,8 @@ pub enum ScrollArea {
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Scrolled(context, _, _)
|
||||
Self::First(context, _)
|
||||
| Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
@@ -162,6 +164,7 @@ impl std::fmt::Display for Operator {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -240,6 +243,13 @@ struct CallChoiceContext {
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
owner: Id,
|
||||
parent: Id,
|
||||
before: Option<Id>,
|
||||
@@ -272,6 +282,28 @@ struct Draft {
|
||||
choice_page: usize,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
@@ -452,8 +484,16 @@ impl State {
|
||||
}
|
||||
cached.as_ref().unwrap().1.clone()
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
let draft = self.draft.as_ref()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
fn created(command: &Command, parent: Id) -> Option<Id> {
|
||||
match command {
|
||||
Command::CreateElement(element) if element.parent == Some(parent) => {
|
||||
@@ -568,6 +608,61 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Lifeline {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: d.owner,
|
||||
viewpoint: viewpoint(p, d.owner),
|
||||
},
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -577,6 +672,9 @@ impl State {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
owner,
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -940,7 +1038,14 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool {
|
||||
execute_batch(p, &[Command::CreateElement(e)]).is_ok()
|
||||
}
|
||||
fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
let layer = p.elements[&d.owner].layer();
|
||||
let Some(owner) = p
|
||||
.elements
|
||||
.get(&d.owner)
|
||||
.filter(|e| e.kind() == Kind::Scenario)
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let layer = owner.layer();
|
||||
let mut values: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1256,6 +1361,28 @@ impl Draft {
|
||||
e
|
||||
}
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Lifeline {
|
||||
owner: self.owner,
|
||||
viewpoint: viewpoint(p, self.owner),
|
||||
},
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
return Err("The scenario no longer exists. Cancel or restore its owner.".into());
|
||||
}
|
||||
if !parents(p, self.owner).iter().any(|c| c.id == self.parent) {
|
||||
return Err("Choose this scenario or one of its operands as destination.".into());
|
||||
}
|
||||
@@ -1266,6 +1393,8 @@ impl Draft {
|
||||
.unwrap_or(self.tool.label());
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
|
||||
@@ -168,6 +168,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.wrap(),
|
||||
text(if pending {
|
||||
"Pending form · the highlighted sequence position is retained".to_owned()
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
|
||||
"Start with a lifeline representing existing or explicitly new work / participants."
|
||||
.into()
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
|
||||
"Choose a first message, action or another tool; no occurrence is selected yet.".into()
|
||||
} else {
|
||||
let guidance = match tool {
|
||||
CanvasTool::Message(_) => {
|
||||
@@ -186,6 +191,32 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.size(11)
|
||||
]
|
||||
.spacing(6);
|
||||
if !pending {
|
||||
if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
|
||||
header = header.push(
|
||||
row![
|
||||
text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12),
|
||||
button("Add first lifeline").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline))
|
||||
}))
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
|
||||
header = header.push(
|
||||
row![
|
||||
text("No occurrences yet").size(12),
|
||||
button("Add first message").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message))
|
||||
})),
|
||||
button("Add first action").on_press_maybe(clean.then_some({
|
||||
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action))
|
||||
}))
|
||||
]
|
||||
.spacing(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if limited {
|
||||
header = header
|
||||
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
|
||||
@@ -295,272 +326,301 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.as_ref()
|
||||
.filter(|d| d.owner == owner)
|
||||
{
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Lifeline {
|
||||
form = form.push(label(format!(
|
||||
"Insert: {}",
|
||||
slot_label(
|
||||
p,
|
||||
Slot {
|
||||
parent: d.parent,
|
||||
before: d.before
|
||||
}
|
||||
)
|
||||
)));
|
||||
if d.before.is_some() {
|
||||
form = form.push(
|
||||
button("Append to this sequence instead").on_press(event(Event::AppendInstead)),
|
||||
);
|
||||
}
|
||||
form = form.push(picker(
|
||||
parents(p, owner),
|
||||
Some(d.parent),
|
||||
"Destination sequence / operand…",
|
||||
Event::Parent,
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
|
||||
) {
|
||||
form = form.push(picker(
|
||||
sources(p, d),
|
||||
d.target,
|
||||
"Choose a named source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Message {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
MessageStyle::Asynchronous,
|
||||
MessageStyle::Call,
|
||||
MessageStyle::CallReply,
|
||||
MessageStyle::Reply,
|
||||
MessageStyle::Create,
|
||||
MessageStyle::Delete,
|
||||
MessageStyle::Timer,
|
||||
],
|
||||
Some(d.style),
|
||||
|style| event(Event::Style(style)),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(bounded_picker(
|
||||
app.behavior.scenario.call_choices(p, context, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier call…",
|
||||
Event::Call,
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Lifeline {
|
||||
form = form.push(label(format!(
|
||||
"Insert: {}",
|
||||
slot_label(
|
||||
p,
|
||||
Slot {
|
||||
parent: d.parent,
|
||||
before: d.before
|
||||
}
|
||||
)
|
||||
)));
|
||||
if d.before.is_some() {
|
||||
form = form.push(
|
||||
button("Append to this sequence instead")
|
||||
.on_press(event(Event::AppendInstead)),
|
||||
);
|
||||
}
|
||||
form = form.push(picker(
|
||||
parents(p, owner),
|
||||
Some(d.parent),
|
||||
"Destination sequence / operand…",
|
||||
Event::Parent,
|
||||
));
|
||||
if d.call.is_some() {
|
||||
form = form.push(label(
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
|
||||
) {
|
||||
form = form.push(picker(
|
||||
sources(p, d),
|
||||
d.target,
|
||||
"Choose a named source…",
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Lifeline {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label(
|
||||
"Create commits one new source and this lifeline together.",
|
||||
));
|
||||
}
|
||||
if viewpoint(p, owner) != ScenarioViewpoint::MixedLegacy {
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
} else {
|
||||
form=form.push(label("New source creation requires a strict viewpoint. Existing sources remain available."));
|
||||
}
|
||||
}
|
||||
if d.tool == Tool::Message {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
MessageStyle::Asynchronous,
|
||||
MessageStyle::Call,
|
||||
MessageStyle::CallReply,
|
||||
MessageStyle::Reply,
|
||||
MessageStyle::Create,
|
||||
MessageStyle::Delete,
|
||||
MessageStyle::Timer,
|
||||
],
|
||||
Some(d.style),
|
||||
|style| event(Event::Style(style)),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(bounded_picker(
|
||||
app.behavior.scenario.call_choices(p, context, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier call…",
|
||||
Event::Call,
|
||||
));
|
||||
if d.call.is_some() {
|
||||
form = form.push(label(
|
||||
"Reply reuses the call exchange in reverse and retains its correlation.",
|
||||
));
|
||||
}
|
||||
} else if d.style != MessageStyle::Timer {
|
||||
form = form.push(bounded_picker(
|
||||
exchanges(p, d),
|
||||
d,
|
||||
d.exchange,
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
));
|
||||
form = form.push(label(
|
||||
"Apply checks that the message is valid at this insertion position.",
|
||||
));
|
||||
}
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, true),
|
||||
d.sender,
|
||||
"Sending lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
if d.style != MessageStyle::Timer {
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, false),
|
||||
d.receiver,
|
||||
"Receiving lifeline…",
|
||||
Event::Receiver,
|
||||
));
|
||||
}
|
||||
if d.style == MessageStyle::Timer {
|
||||
form = form.push(
|
||||
text_input("Duration / deadline, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if p.layer_of(owner) == Some(Layer::Sa)
|
||||
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
|
||||
&& matches!(
|
||||
d.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
)
|
||||
{
|
||||
form = form.push(
|
||||
iced::widget::checkbox(d.operation.is_some())
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
|
||||
);
|
||||
if let Some(operation) = &d.operation {
|
||||
form = form
|
||||
.push(bounded_picker(
|
||||
crate::data_values::operation_choices(p, &d.proposed_message(p)),
|
||||
d,
|
||||
operation.allocation,
|
||||
"Choose the exact interface operation allocation…",
|
||||
Event::Operation,
|
||||
))
|
||||
.push(
|
||||
button("Keep operation unresolved")
|
||||
.on_press(event(Event::ClearOperation)),
|
||||
);
|
||||
if let Some(id) = operation.allocation {
|
||||
form = form.push(label(crate::data_values::operation_identity(p, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(description) =
|
||||
crate::data_values::operation_label(p, &d.proposed_message(p))
|
||||
{
|
||||
form = form.push(label(description));
|
||||
}
|
||||
match d.signature(p) {
|
||||
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
for (key, expression) in d.authored_values() {
|
||||
form = form
|
||||
.push(
|
||||
text(format!(
|
||||
"Retained value · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
} else if d.style != MessageStyle::Timer {
|
||||
form = form.push(bounded_picker(
|
||||
exchanges(p, d),
|
||||
d,
|
||||
d.exchange,
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
));
|
||||
form = form.push(label(
|
||||
"Apply checks that the message is valid at this insertion position.",
|
||||
));
|
||||
}
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, true),
|
||||
d.sender,
|
||||
"Sending lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
if d.style != MessageStyle::Timer {
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, false),
|
||||
d.receiver,
|
||||
"Receiving lifeline…",
|
||||
Event::Receiver,
|
||||
));
|
||||
}
|
||||
if d.style == MessageStyle::Timer {
|
||||
form = form.push(
|
||||
text_input("Duration / deadline, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if p.layer_of(owner) == Some(Layer::Sa)
|
||||
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
|
||||
&& matches!(
|
||||
d.style,
|
||||
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
|
||||
)
|
||||
{
|
||||
form = form.push(
|
||||
iced::widget::checkbox(d.operation.is_some())
|
||||
.label("Invoke a specific interface operation")
|
||||
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
|
||||
);
|
||||
if let Some(operation) = &d.operation {
|
||||
form = form
|
||||
.push(bounded_picker(
|
||||
crate::data_values::operation_choices(p, &d.proposed_message(p)),
|
||||
d,
|
||||
operation.allocation,
|
||||
"Choose the exact interface operation allocation…",
|
||||
Event::Operation,
|
||||
))
|
||||
.push(
|
||||
button("Keep operation unresolved")
|
||||
.on_press(event(Event::ClearOperation)),
|
||||
);
|
||||
if let Some(id) = operation.allocation {
|
||||
form = form.push(label(crate::data_values::operation_identity(p, id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(description) =
|
||||
crate::data_values::operation_label(p, &d.proposed_message(p))
|
||||
{
|
||||
form = form.push(label(description));
|
||||
}
|
||||
match d.signature(p) {
|
||||
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
for (key, expression) in d.authored_values() {
|
||||
form = form
|
||||
.push(
|
||||
text(format!(
|
||||
"Retained value · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
));
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(footer.into(), form.into());
|
||||
}
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(footer.into(), form.into());
|
||||
}
|
||||
workspace_layout(header.into(), details, canvas, form_panel, pending)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ impl Tool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Source(super::source_draft::Event),
|
||||
Start(Id, Tool, Option<Id>),
|
||||
Cancel,
|
||||
Submit,
|
||||
@@ -90,15 +91,22 @@ pub struct State {
|
||||
selected_context: Option<oa_core::topology_context::ContextAddress>,
|
||||
shelf: Option<Shelf>,
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Browser {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Draft {
|
||||
#[serde(default)]
|
||||
source: super::source_draft::State,
|
||||
#[serde(default = "Id::new_v4")]
|
||||
invocation: Id,
|
||||
#[serde(default)]
|
||||
source_origin: Option<(Id, oa_core::Element)>,
|
||||
|
||||
#[serde(with = "recovery_insertion")]
|
||||
insertion: Insertion,
|
||||
fixed: bool,
|
||||
@@ -117,6 +125,28 @@ struct Draft {
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_name(&self) -> Option<&str> {
|
||||
self.draft.as_ref().map(|d| d.name.as_str())
|
||||
}
|
||||
pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option<iced::Element<'a, Event>> {
|
||||
self.draft
|
||||
.as_ref()?
|
||||
.source
|
||||
.view(p)
|
||||
.map(|v| v.map(Event::Source))
|
||||
}
|
||||
|
||||
pub fn source_active(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.engaged())
|
||||
}
|
||||
|
||||
pub fn source_open(&self) -> bool {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
}
|
||||
|
||||
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
@@ -152,6 +182,58 @@ impl State {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if let Event::Source(event) = event {
|
||||
if self
|
||||
.draft
|
||||
.as_mut()
|
||||
.ok_or("Start the consumer form first.")?
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Start the consumer form first.")?;
|
||||
if d.tool != Tool::Work {
|
||||
return Err("This reference does not support staged source creation.".into());
|
||||
}
|
||||
if d.source_origin.as_ref().is_some_and(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&d.owner) != Some(owner)
|
||||
}) {
|
||||
return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into());
|
||||
}
|
||||
if d.source_origin.is_none() && d.source.engaged() {
|
||||
return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into());
|
||||
}
|
||||
let mut next = d.clone();
|
||||
if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_))
|
||||
{
|
||||
next.source_origin = Some((
|
||||
p.id,
|
||||
p.elements
|
||||
.get(&d.owner)
|
||||
.ok_or("The consumer owner no longer exists.")?
|
||||
.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(id) = next.source.update(
|
||||
p,
|
||||
super::source_draft::Scope::Work { owner: d.owner },
|
||||
d.invocation,
|
||||
event,
|
||||
)? {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
self.draft = Some(next);
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
return Err("Use the new source or go Back before editing the parent form.".into());
|
||||
}
|
||||
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
@@ -184,6 +266,9 @@ impl State {
|
||||
}
|
||||
});
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: Some((p.id, p.elements[&owner].clone())),
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
@@ -508,6 +593,22 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command {
|
||||
}
|
||||
impl Draft {
|
||||
fn command(&self, p: &Project) -> Result<Command, String> {
|
||||
if self.source.engaged()
|
||||
&& self.source_origin.as_ref().is_none_or(|(document, owner)| {
|
||||
*document != p.id || p.elements.get(&self.owner) != Some(owner)
|
||||
})
|
||||
{
|
||||
return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into());
|
||||
}
|
||||
self.source.check_scope(
|
||||
super::source_draft::Scope::Work { owner: self.owner },
|
||||
self.target,
|
||||
)?;
|
||||
let preview = self.source.preview(p, self.target)?;
|
||||
let command = self.consumer_command(preview.as_ref().unwrap_or(p))?;
|
||||
self.source.combine(p, self.target, command)
|
||||
}
|
||||
fn consumer_command(&self, p: &Project) -> Result<Command, String> {
|
||||
let owner = p
|
||||
.elements
|
||||
.get(&self.owner)
|
||||
@@ -615,6 +716,8 @@ impl Draft {
|
||||
});
|
||||
let name = if self.name.trim().is_empty() {
|
||||
default_name
|
||||
} else if self.source.label(self.target).is_some() {
|
||||
self.name.as_str()
|
||||
} else {
|
||||
self.name.trim()
|
||||
};
|
||||
@@ -1587,6 +1690,9 @@ mod tests {
|
||||
}
|
||||
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
|
||||
Draft {
|
||||
source: Default::default(),
|
||||
invocation: Id::new_v4(),
|
||||
source_origin: None,
|
||||
insertion: Insertion::default(),
|
||||
fixed: false,
|
||||
browsers: Default::default(),
|
||||
|
||||
@@ -33,7 +33,7 @@ pub enum Event {
|
||||
Search(String),
|
||||
Page(usize),
|
||||
}
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Draft {
|
||||
edit: Option<Id>,
|
||||
|
||||
@@ -206,8 +206,16 @@ impl State {
|
||||
}
|
||||
None
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
|
||||
let owner = self.pending_owner()?;
|
||||
if self
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.source.label(d.target).is_some())
|
||||
&& self.prepare_pending(p).ok().as_ref() != Some(command)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
|
||||
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
|
||||
{
|
||||
|
||||
@@ -369,6 +369,25 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card);
|
||||
if !pending && !p.children(owner).any(|e| is_contribution(p, owner, e.id)) {
|
||||
let title = if p.layer_of(owner) == Some(Layer::Oa) {
|
||||
"Add first activity contribution"
|
||||
} else {
|
||||
"Add first function contribution"
|
||||
};
|
||||
header = header.push(button(title).on_press_maybe(
|
||||
(!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty()).then(
|
||||
|| {
|
||||
Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: Insertion::default(),
|
||||
})
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
let details: Element<'_, Message> = match state.shelf {
|
||||
Some(Shelf::Tools) => controls.into(),
|
||||
_ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(),
|
||||
@@ -383,201 +402,223 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
if let Some(child) = d.source.view(p) {
|
||||
form_panel = column![
|
||||
text(format!(
|
||||
"Consumer name: {}",
|
||||
crate::views::short(&d.name, 60)
|
||||
))
|
||||
.size(12),
|
||||
child.map(|e| event(Event::Source(e)))
|
||||
]
|
||||
.spacing(6)
|
||||
.height(Fill)
|
||||
.into();
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
|
||||
if let Some(contextual) = &d.contextual {
|
||||
form = form
|
||||
.push(
|
||||
text_input("Link name (retain current when blank)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
)
|
||||
.push(super::contextual::view(p, owner, contextual));
|
||||
} else {
|
||||
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
|
||||
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
|
||||
guard.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
form = form.push(label(format!(
|
||||
"Insert on {} → {} · preserve path identity and condition [{}]",
|
||||
p.named(link.source()),
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
{
|
||||
form = form.push(label(format!(
|
||||
"Append after {} → new exchange · Create adds an explicit sequence link",
|
||||
p.named(before)
|
||||
)));
|
||||
} else if d.fixed && d.tool != Tool::Connect {
|
||||
form = form.push(label(
|
||||
d.insertion
|
||||
.before
|
||||
.map_or("Append an unconnected contribution".into(), |id| {
|
||||
format!("Unconnected contribution before {}", p.named(id))
|
||||
}),
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Work {
|
||||
if let Some(title) = d.source.label(d.target) {
|
||||
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
|
||||
}
|
||||
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
||||
super::super::source_draft::Event::Open(d.invocation),
|
||||
))));
|
||||
}
|
||||
if !legacy_order(p, owner).is_empty() {
|
||||
form = form.push(label(
|
||||
"This edit preserves the ordered path as explicit links in the same undo step.",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if matches!(
|
||||
d.tool,
|
||||
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
|
||||
) {
|
||||
let choices = targets(p, owner, d.tool)
|
||||
.into_iter()
|
||||
.filter(|c| compatible_source(p, d, c.id))
|
||||
.collect();
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
form = form.push(label("First path work"));
|
||||
}
|
||||
form = form.push(picker(
|
||||
choices,
|
||||
d,
|
||||
0,
|
||||
d.target,
|
||||
if d.tool == Tool::Branch {
|
||||
"Choose first path work…"
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
} else {
|
||||
"Choose existing source…"
|
||||
},
|
||||
Event::Target,
|
||||
));
|
||||
}
|
||||
if d.tool != Tool::Connect {
|
||||
form = form.push(
|
||||
text_input("Contribution name (defaults to source)", &d.name)
|
||||
.on_input(|v| event(Event::Name(v)))
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Control | Tool::Branch) {
|
||||
let kinds = if d.tool == Tool::Branch {
|
||||
vec![ControlKind::Choice, ControlKind::Fork]
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
vec![
|
||||
ControlKind::Choice,
|
||||
ControlKind::Merge,
|
||||
ControlKind::Fork,
|
||||
ControlKind::Join,
|
||||
ControlKind::Loop,
|
||||
]
|
||||
};
|
||||
form = form.push(
|
||||
pick_list(
|
||||
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
|
||||
Some(ControlChoice(d.control)),
|
||||
|v| event(Event::Control(v)),
|
||||
)
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
);
|
||||
}
|
||||
if d.tool == Tool::Branch {
|
||||
form = form.push(label("Second path work")).push(picker(
|
||||
targets(p, owner, Tool::Work),
|
||||
d,
|
||||
1,
|
||||
d.alternate,
|
||||
"Second path work…",
|
||||
Event::Alternate,
|
||||
));
|
||||
if d.control != ControlKind::Fork {
|
||||
form = form
|
||||
.push(label("First path guard"))
|
||||
.push(
|
||||
text_input("First path guard", &d.guard)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8),
|
||||
)
|
||||
.push(label("Second path guard"))
|
||||
.push(
|
||||
text_input("Second path guard (e.g. else)", &d.alternate_guard)
|
||||
.on_input(|v| event(Event::AlternateGuard(v)))
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.fixed {
|
||||
if let (Some(a), Some(b)) = (d.before, d.after) {
|
||||
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
|
||||
}
|
||||
} else {
|
||||
form = form
|
||||
.push(label("Preceding contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
2,
|
||||
d.before,
|
||||
"After contribution…",
|
||||
Event::Before,
|
||||
))
|
||||
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
|
||||
.push(label("Following contribution"))
|
||||
.push(picker(
|
||||
contributions(p, owner),
|
||||
d,
|
||||
3,
|
||||
d.after,
|
||||
"Before contribution…",
|
||||
Event::After,
|
||||
))
|
||||
.push(button("Clear following").on_press(event(Event::ClearAfter)));
|
||||
}
|
||||
if d.tool != Tool::Branch
|
||||
&& d.insertion.link.is_none()
|
||||
&& (d.before.is_some() || d.tool == Tool::Connect)
|
||||
{
|
||||
form = form.push(
|
||||
text_input(
|
||||
"Path condition (empty remains a Choice / Loop warning)",
|
||||
&d.guard,
|
||||
)
|
||||
.on_input(|v| event(Event::Guard(v)))
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
let valid = match d.command(p) {
|
||||
Ok(_) => true,
|
||||
Err(problem) => {
|
||||
form = form.push(label(problem));
|
||||
false
|
||||
}
|
||||
};
|
||||
let actions = row![
|
||||
button(if d.contextual.is_some() {
|
||||
"Apply link"
|
||||
} else {
|
||||
"Create"
|
||||
})
|
||||
.on_press_maybe(valid.then(|| event(Event::Submit))),
|
||||
button("Cancel").on_press(event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
form_panel = editor_panel(actions.into(), form.into());
|
||||
}
|
||||
panes(diagram, form_panel, pending)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Publication-and-visit-bound input for a mounted diagnostic list.
|
||||
//!
|
||||
//! Keep this wrapper at the same widget-tree position, inside the list's outer
|
||||
//! scroller. Pass `None` with an empty child while hidden/removed; unmounting the
|
||||
//! wrapper loses its ability to consume an outstanding release. Every change to
|
||||
//! row identity/order/action must have a fresh publication Id. Pass the pair
|
||||
//! (publication generation, drawer visit): a new visit resets armed controls even
|
||||
//! if Hide and Show coalesce without an intermediate hidden Tree. The child tree
|
||||
//! resets when either component changes, clearing button presses and row focus;
|
||||
//! the outer scroller and editor trees are not reset.
|
||||
//!
|
||||
//! Capturing stops Iced event bubbling and later consumers that respect capture.
|
||||
//! It cannot undo an earlier sibling's event handling. This is a row-list guard,
|
||||
//! not a root event filter or a guard for actions in child overlays. Diagnostic
|
||||
//! messages still need publication/snapshot and drawer-visit validation at dispatch.
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Vector, mouse, touch};
|
||||
use oa_core::Id;
|
||||
|
||||
pub struct Guard<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> {
|
||||
identity: Option<(Id, Id)>,
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> Guard<'a, Message, Theme, Renderer> {
|
||||
pub fn new(
|
||||
identity: Option<(Id, Id)>,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
identity,
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Pointer {
|
||||
Mouse,
|
||||
Touch(touch::Finger),
|
||||
}
|
||||
impl Pointer {
|
||||
fn press(event: &Event) -> Option<Self> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => Some(Self::Mouse),
|
||||
Event::Touch(touch::Event::FingerPressed { id, .. }) => Some(Self::Touch(*id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn release(event: &Event) -> Option<Self> {
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => Some(Self::Mouse),
|
||||
Event::Touch(
|
||||
touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. },
|
||||
) => Some(Self::Touch(*id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
identity: Option<(Id, Id)>,
|
||||
pressed: Option<Pointer>,
|
||||
swallowed: Vec<Pointer>,
|
||||
}
|
||||
impl State {
|
||||
fn quarantine(&mut self, pointer: Pointer) {
|
||||
if !self.swallowed.contains(&pointer) {
|
||||
self.swallowed.push(pointer);
|
||||
}
|
||||
}
|
||||
fn forget(&mut self, pointer: Pointer) -> bool {
|
||||
let found = self.swallowed.contains(&pointer);
|
||||
self.swallowed.retain(|item| *item != pointer);
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
|
||||
for Guard<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(State {
|
||||
identity: self.identity,
|
||||
pressed: None,
|
||||
swallowed: Vec::new(),
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.content)]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
if state.identity != self.identity {
|
||||
state.identity = self.identity;
|
||||
if let Some(pointer) = state.pressed.take() {
|
||||
state.quarantine(pointer);
|
||||
}
|
||||
tree.children = self.children();
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let release = Pointer::release(event);
|
||||
if let Some(pointer) = release {
|
||||
// Bounds may have become empty or moved. Consume the physical
|
||||
// gesture's terminal event regardless of its current position.
|
||||
if state.forget(pointer) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
// Iced Button has a single is_pressed bit, not a pointer identity.
|
||||
// A different finger/mouse release must not activate that button.
|
||||
if state.pressed.is_some_and(|pressed| pressed != pointer) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let press = Pointer::press(event);
|
||||
if let Some(pointer) = state.pressed.filter(|pointer| {
|
||||
press == Some(*pointer) || (release == Some(*pointer) && shell.is_event_captured())
|
||||
}) {
|
||||
// Recover an omitted terminal event, or cancel one intercepted by
|
||||
// an earlier consumer. An off-target mouse release / lost touch
|
||||
// clears Iced Button's pressed state without rebuilding this
|
||||
// publication's tree (which would also discard row focus).
|
||||
state.pressed = None;
|
||||
let cancel = match pointer {
|
||||
Pointer::Mouse => Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Pointer::Touch(id) => Event::Touch(touch::Event::FingerLost {
|
||||
id,
|
||||
position: iced::Point::ORIGIN,
|
||||
}),
|
||||
};
|
||||
let mut discarded = Vec::new();
|
||||
let mut cancellation = Shell::new(&mut discarded);
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&cancel,
|
||||
layout,
|
||||
mouse::Cursor::Unavailable,
|
||||
renderer,
|
||||
clipboard,
|
||||
&mut cancellation,
|
||||
viewport,
|
||||
);
|
||||
// Cancellation must not publish an action or capture the fresh
|
||||
// down. Preserve rendering requests from the state change only.
|
||||
shell.request_redraw();
|
||||
if cancellation.is_layout_invalid() {
|
||||
shell.invalidate_layout();
|
||||
}
|
||||
if cancellation.are_widgets_invalid() {
|
||||
shell.invalidate_widgets();
|
||||
}
|
||||
}
|
||||
if shell.is_event_captured() || self.identity.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(pointer) = press {
|
||||
// A fresh down starts a new gesture even if the platform omitted a
|
||||
// previous release (for example while the window lacked focus).
|
||||
state.forget(pointer);
|
||||
if let Some(pressed) = state.pressed {
|
||||
if pressed != pointer {
|
||||
state.quarantine(pointer);
|
||||
}
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let was_pressed = release.is_some() && release == state.pressed;
|
||||
if was_pressed {
|
||||
state.pressed = None;
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
if let Some(pointer) = press
|
||||
&& shell.is_event_captured()
|
||||
{
|
||||
state.pressed = Some(pointer);
|
||||
}
|
||||
if was_pressed {
|
||||
// FingerLost cancels Iced Button state but does not itself capture.
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut widget::Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>
|
||||
From<Guard<'a, Message, Theme, Renderer>> for Element<'a, Message, Theme, Renderer>
|
||||
{
|
||||
fn from(guard: Guard<'a, Message, Theme, Renderer>) -> Self {
|
||||
Element::new(guard)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,626 @@
|
||||
use super::*;
|
||||
use iced::advanced::widget::operation::{self, Focusable, Scrollable};
|
||||
use iced::widget::{Space, button, column, container, mouse_area, scrollable, text_input};
|
||||
use iced::{Point, Theme, event, keyboard};
|
||||
|
||||
type View = Element<'static, Message, Theme, ()>;
|
||||
const BOUNDS: Rectangle = Rectangle {
|
||||
x: 0.,
|
||||
y: 0.,
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Message {
|
||||
Primary(u8),
|
||||
Related(u8),
|
||||
BackgroundButton,
|
||||
BackgroundRelease,
|
||||
Submit(u8),
|
||||
Edit,
|
||||
}
|
||||
|
||||
fn rows(version: u8) -> View {
|
||||
column![
|
||||
button(Space::new())
|
||||
.width(200.)
|
||||
.height(40.)
|
||||
.padding(0)
|
||||
.on_press(Message::Primary(version)),
|
||||
button(Space::new())
|
||||
.width(200.)
|
||||
.height(40.)
|
||||
.padding(0)
|
||||
.on_press(Message::Related(version)),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
fn guarded(generation: Option<Id>, version: u8) -> View {
|
||||
let child: View = if generation.is_some() {
|
||||
rows(version)
|
||||
} else {
|
||||
Space::new().into()
|
||||
};
|
||||
Guard::new(
|
||||
generation.map(|publication| (publication, Id::nil())),
|
||||
child,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
fn press() -> Event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
}
|
||||
fn release() -> Event {
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
}
|
||||
fn enter() -> Event {
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Enter),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::Enter),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Enter),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
fn touch_down(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerPressed {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
fn touch_up(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerLifted {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
fn touch_lost(id: u64, position: Point) -> Event {
|
||||
Event::Touch(touch::Event::FingerLost {
|
||||
id: touch::Finger(id),
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
view: View,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(mut view: View) -> Self {
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
|
||||
);
|
||||
Self { view, tree, node }
|
||||
}
|
||||
fn replace(&mut self, mut view: View) {
|
||||
self.tree.diff(view.as_widget());
|
||||
self.node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
|
||||
);
|
||||
self.view = view;
|
||||
}
|
||||
fn event(&mut self, event: Event, point: Point) -> (Vec<Message>, event::Status) {
|
||||
self.event_with_capture(event, point, false)
|
||||
}
|
||||
fn event_with_capture(
|
||||
&mut self,
|
||||
event: Event,
|
||||
point: Point,
|
||||
captured: bool,
|
||||
) -> (Vec<Message>, event::Status) {
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
if captured {
|
||||
shell.capture_event();
|
||||
}
|
||||
self.view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&event,
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Available(point),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&BOUNDS,
|
||||
);
|
||||
let status = shell.event_status();
|
||||
(messages, status)
|
||||
}
|
||||
fn operate(&mut self, operation: &mut dyn widget::Operation) {
|
||||
self.view
|
||||
.as_widget_mut()
|
||||
.operate(&mut self.tree, Layout::new(&self.node), &(), operation);
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.operate(&mut operation::focusable::focus::<()>("row".into()));
|
||||
}
|
||||
fn focused(&mut self) -> bool {
|
||||
#[derive(Default)]
|
||||
struct Focus(bool);
|
||||
impl widget::Operation for Focus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn Focusable,
|
||||
) {
|
||||
self.0 |= state.is_focused();
|
||||
}
|
||||
}
|
||||
let mut focus = Focus::default();
|
||||
self.operate(&mut focus);
|
||||
focus.0
|
||||
}
|
||||
fn scroll(&mut self) -> Vector {
|
||||
#[derive(Default)]
|
||||
struct Offset(Option<Vector>);
|
||||
impl widget::Operation for Offset {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn Scrollable,
|
||||
) {
|
||||
self.0 = Some(translation);
|
||||
}
|
||||
}
|
||||
let mut offset = Offset::default();
|
||||
self.operate(&mut offset);
|
||||
offset.0.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_primary_and_related_mouse_clicks_survive_same_publication_diff() {
|
||||
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
|
||||
let generation = Id::new_v4();
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(generation), 1));
|
||||
assert_eq!(ui.event(press(), point), (vec![], event::Status::Captured));
|
||||
ui.replace(guarded(Some(generation), 1));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_new_visit_cancels_armed_rows_without_a_hidden_tree_frame() {
|
||||
for touch in [false, true] {
|
||||
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
|
||||
let publication = Id::new_v4();
|
||||
let old_visit = Id::new_v4();
|
||||
let new_visit = Id::new_v4();
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(Guard::new(Some((publication, old_visit)), rows(1)).into());
|
||||
let down = || if touch { touch_down(7, point) } else { press() };
|
||||
let up = || if touch { touch_up(7, point) } else { release() };
|
||||
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
|
||||
// Hide→Show has already happened in application state. No None
|
||||
// publication or empty child is ever diffed into this retained Tree.
|
||||
ui.replace(Guard::new(Some((publication, new_visit)), rows(2)).into());
|
||||
assert_eq!(ui.event(up(), point), (vec![], event::Status::Captured));
|
||||
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
|
||||
assert_eq!(
|
||||
ui.event(up(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_iced_button_retargets_a_press_after_plain_tree_diff() {
|
||||
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(rows(1));
|
||||
assert!(ui.event(press(), point).0.is_empty());
|
||||
ui.replace(rows(2));
|
||||
assert_eq!(ui.event(release(), point).0, vec![expected]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_primary_and_related_rows_consume_stale_release_then_accept_new_click() {
|
||||
for y in [20., 60.] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
ui.replace(guarded(Some(Id::new_v4()), 3));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
ui.event(press(), point);
|
||||
let expected = if y < 40. {
|
||||
Message::Primary(3)
|
||||
} else {
|
||||
Message::Related(3)
|
||||
};
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_zero_size_list_consumes_release_outside_old_bounds_once() {
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), Point::new(20., 20.));
|
||||
ui.replace(guarded(None, 0));
|
||||
ui.replace(guarded(None, 0));
|
||||
assert_eq!(ui.node.size(), Size::ZERO);
|
||||
let outside = Point::new(350., 250.);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
assert_eq!(ui.event(release(), Point::new(20., 20.)).0, vec![]);
|
||||
}
|
||||
|
||||
fn with_background(content: View) -> View {
|
||||
mouse_area(
|
||||
button(container(content).width(250.).height(100.))
|
||||
.padding(0)
|
||||
.on_press(Message::BackgroundButton),
|
||||
)
|
||||
.on_release(Message::BackgroundRelease)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_release_does_not_bubble_to_real_background_controls() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(with_background(guarded(Some(Id::new_v4()), 1)));
|
||||
ui.event(press(), point);
|
||||
ui.replace(with_background(guarded(None, 0)));
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
// The enclosing real button still supports a fresh ordinary click.
|
||||
ui.event(press(), point);
|
||||
assert_eq!(
|
||||
ui.event(release(), point).0,
|
||||
vec![Message::BackgroundButton]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_removing_unguarded_rows_exposes_background_release() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(with_background(rows(1)));
|
||||
ui.event(press(), point);
|
||||
ui.replace(with_background(Space::new().into()));
|
||||
assert_eq!(
|
||||
ui.event(release(), point).0,
|
||||
vec![Message::BackgroundRelease]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_touch_activates_primary_and_related_and_cancel_does_not_activate() {
|
||||
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
assert_eq!(
|
||||
ui.event(touch_down(1, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point),
|
||||
(vec![expected], event::Status::Captured)
|
||||
);
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_lost(2, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert!(ui.event(touch_up(2, point), point).0.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_touch_consumes_lift_or_cancel_even_when_hidden() {
|
||||
for hide in [false, true] {
|
||||
for lost in [false, true] {
|
||||
for y in [20., 60.] {
|
||||
let point = Point::new(20., y);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.replace(guarded(if hide { None } else { Some(Id::new_v4()) }, 2));
|
||||
let end = if lost {
|
||||
touch_lost(1, point)
|
||||
} else {
|
||||
touch_up(1, point)
|
||||
};
|
||||
assert_eq!(ui.event(end, point), (vec![], event::Status::Captured));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_finger_or_mouse_release_cannot_complete_another_fingers_press() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(2, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point).0,
|
||||
vec![Message::Primary(1)]
|
||||
);
|
||||
}
|
||||
|
||||
fn keyboard_row(version: u8) -> View {
|
||||
// Iced 0.14.2 Button has no focus/keyboard activation. A real focusable
|
||||
// submit control tests the wrapper's focus lifecycle without inventing it.
|
||||
text_input("Diagnostic action", "row")
|
||||
.id("row")
|
||||
.on_input(|_| Message::Edit)
|
||||
.on_submit(Message::Submit(version))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_preserves_focus_but_replacement_never_inherits_activation() {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
|
||||
ui.focus();
|
||||
assert!(ui.focused());
|
||||
ui.replace(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
|
||||
assert!(ui.focused());
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
|
||||
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(2)).into());
|
||||
assert!(!ui.focused());
|
||||
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
|
||||
ui.focus();
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
|
||||
ui.replace(Guard::new(None, Space::new()).into());
|
||||
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(3)).into());
|
||||
assert!(!ui.focused());
|
||||
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_control_plain_focusable_row_migrates_activation_to_replacement() {
|
||||
let mut ui = Mounted::new(keyboard_row(1));
|
||||
ui.focus();
|
||||
ui.replace(keyboard_row(2));
|
||||
assert!(ui.focused());
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_scroll_offset_survives_same_and_changed_publications() {
|
||||
fn pane(generation: Id) -> View {
|
||||
scrollable(Guard::new(
|
||||
Some((generation, Id::nil())),
|
||||
column![rows(1), Space::new().height(600.)],
|
||||
))
|
||||
.width(240.)
|
||||
.height(100.)
|
||||
.into()
|
||||
}
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(pane(generation));
|
||||
ui.event(
|
||||
Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: -120. },
|
||||
}),
|
||||
Point::new(20., 20.),
|
||||
);
|
||||
let offset = ui.scroll();
|
||||
assert_eq!(offset, Vector::new(0., 120.));
|
||||
ui.replace(pane(generation));
|
||||
assert_eq!(ui.scroll(), offset);
|
||||
ui.replace(pane(Id::new_v4()));
|
||||
assert_eq!(ui.scroll(), offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_touch_release_does_not_disarm_a_new_current_gesture() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(touch_down(1, point), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
ui.event(touch_down(2, point), point);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(1, point), point),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(touch_up(2, point), point).0,
|
||||
vec![Message::Primary(2)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_mouse_press_after_missing_stale_release_can_activate_current_row() {
|
||||
let point = Point::new(20., 20.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
ui.event(press(), point);
|
||||
ui.replace(guarded(Some(Id::new_v4()), 2));
|
||||
// The platform delivers another down without the previous up.
|
||||
ui.event(press(), point);
|
||||
assert_eq!(ui.event(release(), point).0, vec![Message::Primary(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missed_press_is_ignored_and_dragging_release_outside_does_not_activate() {
|
||||
let inside = Point::new(20., 20.);
|
||||
let outside = Point::new(350., 250.);
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
assert_eq!(ui.event(press(), outside), (vec![], event::Status::Ignored));
|
||||
assert_eq!(
|
||||
ui.event(release(), inside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.event(press(), inside);
|
||||
assert_eq!(
|
||||
ui.event(release(), outside),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.event(release(), inside),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_publication_missing_terminal_recovers_on_fresh_mouse_or_touch_down() {
|
||||
let primary = Point::new(20., 20.);
|
||||
let related = Point::new(20., 60.);
|
||||
for is_touch in [false, true] {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(guarded(Some(generation), 1));
|
||||
let down = |point| {
|
||||
if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
}
|
||||
};
|
||||
let up = |point| {
|
||||
if is_touch {
|
||||
touch_up(1, point)
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
};
|
||||
ui.event(down(primary), primary);
|
||||
ui.replace(guarded(Some(generation), 1));
|
||||
// The first gesture never receives its terminal event; same Id means
|
||||
// no publication reset can accidentally make this regression pass.
|
||||
assert_eq!(
|
||||
ui.event(down(related), related),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
|
||||
assert!(ui.event(up(primary), primary).0.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_captured_terminal_cancels_child_without_activation_or_lingering_press() {
|
||||
let primary = Point::new(20., 20.);
|
||||
let related = Point::new(20., 60.);
|
||||
for is_touch in [false, true] {
|
||||
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
|
||||
let down = |point| {
|
||||
if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
}
|
||||
};
|
||||
let up = |point| {
|
||||
if is_touch {
|
||||
touch_up(1, point)
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
};
|
||||
ui.event(down(primary), primary);
|
||||
assert_eq!(
|
||||
ui.event_with_capture(up(primary), primary, true),
|
||||
(vec![], event::Status::Captured)
|
||||
);
|
||||
// An unpaired second release must not expose a lingering button press.
|
||||
assert_eq!(
|
||||
ui.event(up(primary), primary),
|
||||
(vec![], event::Status::Ignored)
|
||||
);
|
||||
ui.event(down(related), related);
|
||||
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_terminal_cancellation_preserves_same_publication_focusable_child_state() {
|
||||
let generation = Id::new_v4();
|
||||
let mut ui = Mounted::new(
|
||||
Guard::new(
|
||||
Some((generation, Id::nil())),
|
||||
column![rows(1), keyboard_row(1)],
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
ui.event(press(), Point::new(20., 20.));
|
||||
// Focus through an operation avoids making the old pointer gesture whole.
|
||||
ui.focus();
|
||||
assert!(ui.focused());
|
||||
assert!(
|
||||
ui.event_with_capture(release(), Point::new(20., 20.), true)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
ui.focused(),
|
||||
"gesture cancellation must not remount the row tree"
|
||||
);
|
||||
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_zero_height_scroller_delivers_cancelled_mouse_and_touch_terminals_to_guard() {
|
||||
fn pane(generation: Option<Id>) -> View {
|
||||
scrollable(guarded(generation, 1))
|
||||
.width(240.)
|
||||
.height(if generation.is_some() { 100. } else { 0. })
|
||||
.into()
|
||||
}
|
||||
let point = Point::new(20., 20.);
|
||||
let outside = Point::new(350., 250.);
|
||||
for is_touch in [false, true] {
|
||||
let mut ui = Mounted::new(pane(Some(Id::new_v4())));
|
||||
let down = if is_touch {
|
||||
touch_down(1, point)
|
||||
} else {
|
||||
press()
|
||||
};
|
||||
assert_eq!(ui.event(down, point), (vec![], event::Status::Captured));
|
||||
ui.replace(pane(None));
|
||||
ui.replace(pane(None));
|
||||
assert_eq!(ui.node.size().height, 0.);
|
||||
let up = if is_touch {
|
||||
touch_up(1, outside)
|
||||
} else {
|
||||
release()
|
||||
};
|
||||
assert_eq!(ui.event(up, outside), (vec![], event::Status::Captured));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ use std::{fmt, sync::Arc};
|
||||
const HISTORY_LIMIT: usize = 100;
|
||||
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[path = "document_alias_rename.rs"]
|
||||
mod alias_rename;
|
||||
pub use alias_rename::PreparedAliasRename;
|
||||
|
||||
struct HistoryEntry {
|
||||
snapshot: Arc<CompiledSourceProject>,
|
||||
bytes: usize,
|
||||
@@ -91,11 +95,13 @@ impl From<Vec<SourceDiagnostic>> for DocumentError {
|
||||
}
|
||||
|
||||
pub struct Document {
|
||||
// Distinguishes equal accepted snapshots belonging to different documents.
|
||||
alias_owner: Arc<()>,
|
||||
current: Arc<CompiledSourceProject>,
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
current_bytes: usize,
|
||||
saved: Arc<SourceBundle>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
@@ -121,11 +127,12 @@ impl Document {
|
||||
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
|
||||
let current_bytes = estimated_bytes(¤t);
|
||||
Ok(Self {
|
||||
alias_owner: Arc::new(()),
|
||||
current,
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
saved: Arc::new(bundle),
|
||||
saved: Some(Arc::new(bundle)),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
@@ -156,7 +163,7 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
|
||||
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
|
||||
}
|
||||
|
||||
/// Recovered work is a detached copy until explicitly saved by its user.
|
||||
@@ -181,10 +188,24 @@ impl Document {
|
||||
/// Records the bytes actually saved, even when later edits remain current.
|
||||
/// The I/O adapter owns document identity and save completion ordering.
|
||||
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
|
||||
self.saved = Arc::new(snapshot.bundle.clone());
|
||||
self.saved = Some(Arc::new(snapshot.bundle.clone()));
|
||||
self.recovered_unsaved = false;
|
||||
}
|
||||
|
||||
/// Bind an installed or recovered repair destination to source read there.
|
||||
/// These bytes may be invalid or differ from the retained accepted model,
|
||||
/// but must replace the previous disk baseline. Accepted history is retained;
|
||||
/// applying or undoing to old saved content does not save this destination.
|
||||
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
|
||||
self.saved = Some(Arc::new(source));
|
||||
}
|
||||
|
||||
/// Recovery changed the current destination, but its source could not be
|
||||
/// reloaded. No accepted or historical snapshot is known to be saved there.
|
||||
pub(crate) fn invalidate_saved_baseline(&mut self) {
|
||||
self.saved = None;
|
||||
}
|
||||
|
||||
/// Check a source Save as before writing any files. A retained included file
|
||||
/// must never be displaced by the new entry, including on a later Undo.
|
||||
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
|
||||
@@ -380,8 +401,17 @@ impl Document {
|
||||
return false;
|
||||
}
|
||||
let bytes = estimated_bytes(&compiled);
|
||||
self.commit_snapshot(Arc::new(compiled), bytes)
|
||||
}
|
||||
|
||||
// Only validated internal paths supply a changed snapshot and its exact
|
||||
// estimate. The ordinary commit wrapper checks bundle inequality; the alias
|
||||
// path checks its private changed flag and exact accepted-snapshot authority.
|
||||
// Alias preparation shares this algorithm while retaining the live save and
|
||||
// history state, and does not clone/recompile the candidate on adoption.
|
||||
fn commit_snapshot(&mut self, compiled: Arc<CompiledSourceProject>, bytes: usize) -> bool {
|
||||
self.past.push(HistoryEntry {
|
||||
snapshot: std::mem::replace(&mut self.current, Arc::new(compiled)),
|
||||
snapshot: std::mem::replace(&mut self.current, compiled),
|
||||
bytes: std::mem::replace(&mut self.current_bytes, bytes),
|
||||
});
|
||||
self.future.clear();
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Alias-only worker preparation and guarded adoption. Public compiled DTOs do
|
||||
//! not authorize adoption; only this module can mint a prepared rename.
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A pinned intent minted by its owning Document. Move it to a worker and call
|
||||
/// `prepare`; preparation has no access to the live Document or its histories.
|
||||
#[derive(Debug)]
|
||||
pub struct AliasRenameRequest {
|
||||
owner: Arc<()>,
|
||||
before: Arc<CompiledSourceProject>,
|
||||
revision: u64,
|
||||
limits: ProjectLimits,
|
||||
target: Id,
|
||||
old_alias: String,
|
||||
new_alias: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedData {
|
||||
request: AliasRenameRequest,
|
||||
after: Arc<CompiledSourceProject>,
|
||||
before_bytes: usize,
|
||||
after_bytes: usize,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Immutable, Clone-compatible transport for preview and approval. Neither
|
||||
/// mutable candidate access nor deserialization can manufacture this authority.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedAliasRename(Arc<PreparedData>);
|
||||
|
||||
impl PreparedAliasRename {
|
||||
pub fn before(&self) -> &CompiledSourceProject {
|
||||
&self.0.request.before
|
||||
}
|
||||
|
||||
pub fn after(&self) -> &CompiledSourceProject {
|
||||
&self.0.after
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Id {
|
||||
self.0.request.target
|
||||
}
|
||||
|
||||
pub fn old_alias(&self) -> &str {
|
||||
&self.0.request.old_alias
|
||||
}
|
||||
|
||||
pub fn new_alias(&self) -> &str {
|
||||
&self.0.request.new_alias
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.0.request.revision
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.0.changed
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(message: &str) -> DocumentError {
|
||||
ModelError(message.into()).into()
|
||||
}
|
||||
|
||||
fn check_history_capacity(before_bytes: usize, after_bytes: usize) -> Result<(), DocumentError> {
|
||||
if before_bytes.saturating_add(after_bytes) > HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn alias_rename_request(
|
||||
&self,
|
||||
target: Id,
|
||||
replacement: String,
|
||||
) -> Result<AliasRenameRequest, DocumentError> {
|
||||
if self.revision == u64::MAX {
|
||||
return Err(refusal(
|
||||
"The document revision is exhausted. Reopen before renaming.",
|
||||
));
|
||||
}
|
||||
let old_alias = self
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find_map(|(id, alias)| (id == target).then(|| alias.to_owned()))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?;
|
||||
// A candidate has a nonzero estimate. Avoid expensive preparation when
|
||||
// even the current snapshot leaves no room for its immediate Undo.
|
||||
if old_alias != replacement && self.current_bytes >= HISTORY_BYTES {
|
||||
return Err(refusal(
|
||||
"This rename exceeds the model Undo budget. No rename was applied.",
|
||||
));
|
||||
}
|
||||
Ok(AliasRenameRequest {
|
||||
owner: self.alias_owner.clone(),
|
||||
before: self.current.clone(),
|
||||
revision: self.revision,
|
||||
limits: self.limits,
|
||||
target,
|
||||
old_alias,
|
||||
new_alias: replacement,
|
||||
})
|
||||
}
|
||||
|
||||
/// App must separately consume its current approval and check workspace,
|
||||
/// invocation and draft freshness. This checks accepted Document authority.
|
||||
pub fn commit_alias_rename(
|
||||
&mut self,
|
||||
prepared: &PreparedAliasRename,
|
||||
) -> Result<bool, DocumentError> {
|
||||
let data = &prepared.0;
|
||||
let request = &data.request;
|
||||
if self.revision == u64::MAX
|
||||
|| self.revision != request.revision
|
||||
|| !Arc::ptr_eq(&self.alias_owner, &request.owner)
|
||||
|| !Arc::ptr_eq(&self.current, &request.before)
|
||||
{
|
||||
return Err(refusal(
|
||||
"This rename preview is stale. Preview the current alias again.",
|
||||
));
|
||||
}
|
||||
if !data.changed {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.current_bytes != data.before_bytes {
|
||||
return Err(refusal(
|
||||
"The rename history estimate is stale. Preview again.",
|
||||
));
|
||||
}
|
||||
check_history_capacity(self.current_bytes, data.after_bytes)?;
|
||||
Ok(self.commit_snapshot(data.after.clone(), data.after_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl AliasRenameRequest {
|
||||
/// Run on the preparation worker. The typed command and source updater are
|
||||
/// the only candidate construction path, followed by alias-only invariants.
|
||||
pub fn prepare(self) -> Result<PreparedAliasRename, DocumentError> {
|
||||
let before_project = &self.before.decoded.project;
|
||||
let candidate = prepare_command(
|
||||
before_project,
|
||||
&Command::SetAlias(self.target, self.new_alias.clone()),
|
||||
)?;
|
||||
let mut expected = before_project.clone();
|
||||
assign_expected_alias(&mut expected, self.target, &self.new_alias)?;
|
||||
let identities: BTreeMap<_, _> = before_project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
if candidate.project != expected || candidate.identity_map != identities {
|
||||
return Err(refusal(
|
||||
"Alias preparation changed unrelated model data or identities.",
|
||||
));
|
||||
}
|
||||
let after = source_project::update_bundle_with_identity_map(
|
||||
&self.before,
|
||||
&candidate.project,
|
||||
&candidate.identity_map,
|
||||
self.limits,
|
||||
)?;
|
||||
let before = &self.before;
|
||||
if after.decoded.project != expected
|
||||
|| before.bundle.entry != after.bundle.entry
|
||||
|| !before.bundle.files.keys().eq(after.bundle.files.keys())
|
||||
|| before.index.lexical_owners != after.index.lexical_owners
|
||||
|| !before
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path))
|
||||
.eq(after
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.map(|(id, loc)| (id, &loc.path)))
|
||||
{
|
||||
return Err(refusal(
|
||||
"Alias preparation changed source membership or ownership.",
|
||||
));
|
||||
}
|
||||
let changed = before.bundle != after.bundle;
|
||||
let before_bytes = estimated_bytes(before);
|
||||
let after_bytes = estimated_bytes(&after);
|
||||
if changed {
|
||||
check_history_capacity(before_bytes, after_bytes)?;
|
||||
}
|
||||
Ok(PreparedAliasRename(Arc::new(PreparedData {
|
||||
request: self,
|
||||
after: Arc::new(after),
|
||||
before_bytes,
|
||||
after_bytes,
|
||||
changed,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// Independent full-model expectation: SetAlias may change exactly one header
|
||||
// field, across every supported identity family. Do not reuse command execution
|
||||
// to derive the expected result, which would hide collateral command changes.
|
||||
fn assign_expected_alias(project: &mut Project, id: Id, alias: &str) -> Result<(), DocumentError> {
|
||||
if project.id == id {
|
||||
project.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.elements.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.relations.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.diagrams.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.artifact_links.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else if let Some(record) = project.traces.get_mut(&id) {
|
||||
record.alias = alias.to_owned();
|
||||
} else {
|
||||
project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.find_map(|diagram| diagram.nodes.get_mut(&id))
|
||||
.ok_or_else(|| refusal("The alias target is no longer present."))?
|
||||
.alias = alias.to_owned();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "document_alias_rename_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,459 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> Document {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), r#"arcadia 7;
|
||||
project mission "Испытание" id "10000000-0000-4000-8000-000000000001" {
|
||||
include "work.arcadia"; include "views.arcadia";
|
||||
}
|
||||
"#.replace('\n', "\r\n")),
|
||||
("work.arcadia".into(), r#"// @assess and assess are explanatory prose.
|
||||
oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; }
|
||||
oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence
|
||||
assess; }
|
||||
sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {}
|
||||
interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; }
|
||||
trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; }
|
||||
artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity
|
||||
transfer; waypoints [{ x 4.20e2; y -16; }]; }]; }
|
||||
node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; }
|
||||
"#.replace('\n', "\r\n")),
|
||||
]),
|
||||
};
|
||||
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
document.bundle(),
|
||||
&bundle,
|
||||
"fixture must need no migration or ID insertion"
|
||||
);
|
||||
document
|
||||
}
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename {
|
||||
document
|
||||
.alias_rename_request(target, replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Capture every live Document state relevant to refusal/no-op. Arc identity also
|
||||
// catches replacing an equal snapshot or saved marker behind the user's back.
|
||||
struct Unchanged {
|
||||
current: Arc<CompiledSourceProject>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
past: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
future: Vec<(Arc<CompiledSourceProject>, usize)>,
|
||||
revision: u64,
|
||||
current_bytes: usize,
|
||||
recovered: bool,
|
||||
}
|
||||
|
||||
impl Unchanged {
|
||||
fn capture(document: &Document) -> Self {
|
||||
Self {
|
||||
current: document.snapshot(),
|
||||
saved: document.saved.clone(),
|
||||
past: document
|
||||
.past
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
future: document
|
||||
.future
|
||||
.iter()
|
||||
.map(|h| (h.snapshot.clone(), h.bytes))
|
||||
.collect(),
|
||||
revision: document.revision,
|
||||
current_bytes: document.current_bytes,
|
||||
recovered: document.recovered_unsaved,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert(&self, document: &Document) {
|
||||
assert!(Arc::ptr_eq(&self.current, &document.current));
|
||||
match (&self.saved, &document.saved) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("saved baseline changed"),
|
||||
}
|
||||
for (before, after) in [
|
||||
(&self.past, &document.past),
|
||||
(&self.future, &document.future),
|
||||
] {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for ((snapshot, bytes), entry) in before.iter().zip(after) {
|
||||
assert!(Arc::ptr_eq(snapshot, &entry.snapshot));
|
||||
assert_eq!(*bytes, entry.bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(self.revision, document.revision);
|
||||
assert_eq!(self.current_bytes, document.current_bytes);
|
||||
assert_eq!(self.recovered, document.recovered_unsaved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() {
|
||||
// Literal role-specific replacements are independent of the updater/tokenizer.
|
||||
// They deliberately exclude prose, historical review text and URI spelling.
|
||||
type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]);
|
||||
let cases: &[AliasCase<'_>] = &[
|
||||
(
|
||||
1,
|
||||
"mission",
|
||||
"renamed",
|
||||
&[("project mission ", "project renamed ")],
|
||||
),
|
||||
(
|
||||
2,
|
||||
"assess",
|
||||
"renamed",
|
||||
&[
|
||||
("oa_activity assess ", "oa_activity renamed "),
|
||||
("\r\n assess;", "\r\n renamed;"),
|
||||
("@assess;", "@renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
5,
|
||||
"transfer",
|
||||
"renamed",
|
||||
&[
|
||||
("interaction transfer ", "interaction renamed "),
|
||||
("\r\n transfer;", "\r\n renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
6,
|
||||
"review",
|
||||
"renamed",
|
||||
&[
|
||||
("trace review ", "trace renamed "),
|
||||
("owner @review;", "owner @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
7,
|
||||
"evidence",
|
||||
"renamed",
|
||||
&[("artifact evidence ", "artifact renamed ")],
|
||||
),
|
||||
(
|
||||
8,
|
||||
"overview",
|
||||
"renamed",
|
||||
&[
|
||||
("diagram overview ", "diagram renamed "),
|
||||
("diagram @overview;", "diagram @renamed;"),
|
||||
],
|
||||
),
|
||||
(
|
||||
9,
|
||||
"placement",
|
||||
"renamed",
|
||||
&[("node placement ", "node renamed ")],
|
||||
),
|
||||
];
|
||||
for &(number, old, new, replacements) in cases {
|
||||
let mut document = fixture();
|
||||
let original = document.snapshot();
|
||||
let mut expected_model = document.project().clone();
|
||||
// Explicit per-family oracle, separate from production expectation helper.
|
||||
match number {
|
||||
1 => expected_model.alias = new.into(),
|
||||
2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(),
|
||||
5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(),
|
||||
6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(),
|
||||
7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(),
|
||||
8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(),
|
||||
9 => {
|
||||
expected_model
|
||||
.diagrams
|
||||
.get_mut(&id(8))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(9))
|
||||
.unwrap()
|
||||
.alias = new.into()
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let mut expected_bundle = document.bundle().clone();
|
||||
for source in expected_bundle.files.values_mut() {
|
||||
for &(before, after) in replacements {
|
||||
*source = source.replace(before, after);
|
||||
}
|
||||
}
|
||||
let state = Unchanged::capture(&document);
|
||||
let prepared = prepare(&document, id(number), new);
|
||||
state.assert(&document);
|
||||
assert!(prepared.changed());
|
||||
assert_eq!(prepared.old_alias(), old);
|
||||
assert_eq!(prepared.new_alias(), new);
|
||||
assert_eq!(prepared.target(), id(number));
|
||||
assert_eq!(prepared.revision(), 0);
|
||||
assert_eq!(prepared.before().bundle, original.bundle);
|
||||
assert_eq!(prepared.after().bundle, expected_bundle);
|
||||
assert_eq!(prepared.after().decoded.project, expected_model);
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert!(
|
||||
Arc::ptr_eq(&document.current, &prepared.0.after),
|
||||
"adopt the exact prepared Arc"
|
||||
);
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.dirty());
|
||||
let after = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&prepared.clone()).is_err());
|
||||
after.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &original));
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &expected_bundle);
|
||||
assert_eq!(document.project(), &expected_model);
|
||||
document.mark_saved(document.snapshot());
|
||||
let reopened =
|
||||
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected_model);
|
||||
assert_eq!(reopened.bundle(), &expected_bundle);
|
||||
assert!(!reopened.dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_keyword_alias_is_valid_without_normalizing_input() {
|
||||
assert!(matches!(
|
||||
language::tokenize("oa_activity")[0].class,
|
||||
language::TokenClass::Keyword
|
||||
));
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "oa_activity");
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "oa_activity");
|
||||
assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_identical_aliases_preserve_live_state_and_redo() {
|
||||
let mut document = fixture();
|
||||
let first = prepare(&document, id(2), "changed");
|
||||
document.commit_alias_rename(&first).unwrap();
|
||||
assert!(document.undo());
|
||||
document.invalidate_saved_baseline();
|
||||
document.mark_recovered();
|
||||
let state = Unchanged::capture(&document);
|
||||
for replacement in ["", "has space", "project", "assist", " assess", "assess "] {
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), replacement.into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
state.assert(&document);
|
||||
}
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(Id::nil(), "new".into())
|
||||
.is_err()
|
||||
);
|
||||
let no_op = prepare(&document, id(2), "assess");
|
||||
assert!(!no_op.changed());
|
||||
assert!(!document.commit_alias_rename(&no_op).unwrap());
|
||||
state.assert(&document);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project().elements[&id(2)].alias, "changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() {
|
||||
let mut document = fixture();
|
||||
let old = prepare(&document, id(2), "renamed");
|
||||
let mut second = fixture();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
// Even an identical shared accepted Arc cannot confer this Document's owner.
|
||||
second.current = document.current.clone();
|
||||
let second_state = Unchanged::capture(&second);
|
||||
assert!(second.commit_alias_rename(&old).is_err());
|
||||
second_state.assert(&second);
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Later name".into()))
|
||||
.unwrap();
|
||||
let edited = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
edited.assert(&document);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &old.0.request.before));
|
||||
let undone = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&old).is_err());
|
||||
undone.assert(&document);
|
||||
document.revision = u64::MAX - 1;
|
||||
let last = prepare(&document, id(2), "renamed");
|
||||
document
|
||||
.execute(Command::Rename(id(2), "Exhaust revision".into()))
|
||||
.unwrap();
|
||||
document.undo();
|
||||
assert!(Arc::ptr_eq(&document.current, &last.0.request.before));
|
||||
let exhausted = Unchanged::capture(&document);
|
||||
assert!(document.commit_alias_rename(&last).is_err());
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_err()
|
||||
);
|
||||
exhausted.assert(&document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_retains_latest_saved_baseline_and_recovery_state() {
|
||||
for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let original = document.snapshot();
|
||||
match baseline {
|
||||
"saved_before" => document.mark_saved(original.clone()),
|
||||
"unknown" => document.invalidate_saved_baseline(),
|
||||
"raw_repair" => {
|
||||
let mut raw = document.bundle().clone();
|
||||
raw.files
|
||||
.insert("project.arcadia".into(), "invalid repair source".into());
|
||||
document.bind_repair_baseline(raw);
|
||||
}
|
||||
"recovered" => document.mark_recovered(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let saved = document.saved.clone();
|
||||
let recovered = document.recovered_unsaved;
|
||||
assert!(document.commit_alias_rename(&prepared).unwrap());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert!(document.dirty());
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.saved, saved);
|
||||
assert_eq!(document.recovered_unsaved, recovered);
|
||||
assert_eq!(document.dirty(), baseline != "saved_before");
|
||||
}
|
||||
// A completed save after Preview remains authoritative, including when it
|
||||
// clears recovery; a worker must not restore its earlier state on Confirm.
|
||||
let mut document = fixture();
|
||||
document.mark_recovered();
|
||||
document.invalidate_saved_baseline();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
document.mark_saved(document.snapshot());
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert!(!document.recovered_unsaved);
|
||||
document.undo();
|
||||
assert!(!document.dirty());
|
||||
// Undo from saved C to B, then a new B->C rename can correctly finish clean.
|
||||
document.redo();
|
||||
document.mark_saved(document.snapshot());
|
||||
document.undo();
|
||||
let return_to_saved = prepare(&document, id(2), "renamed");
|
||||
document.commit_alias_rename(&return_to_saved).unwrap();
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_public_preview_copies_cannot_change_prepared_authority() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
let exact = prepared.after().clone();
|
||||
let mut counterfeit = prepared.after().clone();
|
||||
counterfeit.decoded.project.name = "Injected unrelated edit".into();
|
||||
counterfeit.index.declarations.clear();
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(document.apply_compiled(counterfeit).is_err());
|
||||
unchanged.assert(&document);
|
||||
let mut unrelated = exact.decoded.project.clone();
|
||||
unrelated.name = "A self-consistent unrelated candidate".into();
|
||||
let forged_dto = source_project::compile_bundle(
|
||||
&source_project::format_bundle(&unrelated),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project);
|
||||
// No API converts either DTO to PreparedAliasRename. Mutating owned copies
|
||||
// leaves the opaque capability and its exact adopted model/source intact.
|
||||
document.commit_alias_rename(&prepared).unwrap();
|
||||
assert_eq!(document.project(), &exact.decoded.project);
|
||||
assert_eq!(document.bundle(), &exact.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() {
|
||||
let mut document = fixture();
|
||||
let mut bundle = document.bundle().clone();
|
||||
let padding = HISTORY_BYTES / 2 - document.current_bytes;
|
||||
bundle
|
||||
.files
|
||||
.get_mut("views.arcadia")
|
||||
.unwrap()
|
||||
.push_str(&format!("//{}\r\n", "x".repeat(padding - 4)));
|
||||
document.apply_bundle(bundle).unwrap();
|
||||
assert_eq!(document.current_bytes, HISTORY_BYTES / 2);
|
||||
// A same-length alias retains the exact estimate; older history is evictable.
|
||||
let boundary = prepare(&document, id(2), "rating");
|
||||
assert_eq!(
|
||||
boundary.0.before_bytes + boundary.0.after_bytes,
|
||||
HISTORY_BYTES
|
||||
);
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "longer_alias".into())
|
||||
.unwrap()
|
||||
.prepare()
|
||||
.is_err()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
document.commit_alias_rename(&boundary).unwrap();
|
||||
assert_eq!(document.past.len(), 1);
|
||||
assert!(document.undo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before));
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert!(Arc::ptr_eq(&document.current, &boundary.0.after));
|
||||
assert!(check_history_capacity(usize::MAX, 1).is_err());
|
||||
assert!(check_history_capacity(HISTORY_BYTES, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() {
|
||||
let mut document = fixture();
|
||||
let prepared = prepare(&document, id(2), "renamed");
|
||||
// Inject cache corruption to falsify the commit-side check independently of
|
||||
// the preparation admission. Normal edits also change revision and Arc.
|
||||
document.current_bytes = HISTORY_BYTES;
|
||||
let unchanged = Unchanged::capture(&document);
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "renamed".into())
|
||||
.is_err()
|
||||
);
|
||||
assert!(document.commit_alias_rename(&prepared).is_err());
|
||||
// No-op intent needs no Undo reservation even at the admission boundary.
|
||||
assert!(
|
||||
document
|
||||
.alias_rename_request(id(2), "assess".into())
|
||||
.is_ok()
|
||||
);
|
||||
unchanged.assert(&document);
|
||||
}
|
||||
@@ -445,9 +445,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Describe a path through exchanges",
|
||||
&[
|
||||
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
|
||||
"A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
|
||||
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
|
||||
"Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
|
||||
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
|
||||
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
@@ -490,7 +490,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Create a scenario",
|
||||
&[
|
||||
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
|
||||
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
|
||||
"Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
|
||||
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
|
||||
"Use Action, Observe state or Fragment for other behavior. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
|
||||
"Choose a message style and Draw message. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. The form retains the chosen position while you select the exchange and details. Escape cancels the gesture.",
|
||||
@@ -543,7 +543,8 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Build a state machine",
|
||||
&[
|
||||
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names. If the participant does not exist yet, cancel and create it in Architecture first.",
|
||||
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names.",
|
||||
"If the owner does not exist, Create new owner retains the machine name and first-view option. Choose its participant kind and semantic parent explicitly. Use this new source returns without changing the model; Create and open commits owner, machine and optional view together.",
|
||||
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
@@ -784,7 +785,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity",
|
||||
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename F2 preview diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
@@ -797,10 +798,27 @@ fn article(topic: Topic) -> Article {
|
||||
"Save all writes the entry and its included UTF-8 files. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Follow definitions and lexical references",
|
||||
"Place the caret on a declaration alias or an @reference. Choose Definition (F12) to locate its declaration, or References (Shift+F12) to list lexical uses. Choose a row when a definition is ambiguous, draft or detached. Lexical uses exclude comments and strings; they do not establish typed relationships or resolve an ambiguous alias to one declaration. Back (Alt+Left) returns to the previous source location; if that draft changed, it restores the current cursor instead of an obsolete selection. These shortcuts work while the source editor has focus; the Text toolbar also offers the actions.",
|
||||
),
|
||||
explain(
|
||||
"Read the navigation scope",
|
||||
"Queries inspect bounded snapshots of currently loaded drafts, without applying them or loading missing included files. The panel identifies partial scope, draft evidence, detached or unverified files, and alias or ID collisions. Counts show total, displayed and omitted rows. A missing result in partial scope does not establish that the whole project has no match. If sources change, stale rows are disabled; run Definition or References again. Row locations use one-based lines and UTF-8 byte columns. Navigation preserves your source drafts and the accepted model.",
|
||||
),
|
||||
explain(
|
||||
"Names, aliases and IDs serve different purposes",
|
||||
"The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.",
|
||||
),
|
||||
walkthrough(
|
||||
"Rename a source alias",
|
||||
&[
|
||||
"Place the caret on a declaration alias or live @reference, then choose Rename alias or press F2 in the source editor. First finish or revert pending source, property and behavior drafts; close or reattach any extra loaded detached files. Accepted edits do not need to be saved first.",
|
||||
"Edit Source alias and choose Preview (Enter also previews). Pasting line breaks, tabs or other control characters is rejected without changing the field; choose Preview again after correcting the paste. Review the changed files, hunks and lines; long lines have additional segments. The engineering name and UUID stay unchanged. Comments and quoted prose keep their original spelling. This preview covers the rename; a later Save can include earlier accepted edits too.",
|
||||
"Choose Rename alias to accept the complete preview, or Cancel/Escape to leave everything unchanged. An invalid, unchanged, stale or oversized preview cannot be confirmed. After editing the replacement, choose Preview again. If the source context changes, close the form and invoke Rename again on the current alias.",
|
||||
"Use Undo rename immediately, or the model Undo/Redo controls, to restore the complete source/model snapshot. Ctrl+Z in Text still undoes source drafting. Save all and reopen use the normal project workflow; an unconfirmed preview is not saved.",
|
||||
],
|
||||
),
|
||||
example(
|
||||
"Readable references",
|
||||
"For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.",
|
||||
@@ -897,7 +915,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"A new behavior is still a draft",
|
||||
"Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
|
||||
"Inside a source subform, Back or the first Escape returns to the unchanged parent. Use this new source stages the prerequisite only. Canceling the parent discards it; choosing an existing reference excludes the staged source from creation. Recovery retains both forms and their reserved identities. An open dialog still receives Escape first. Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
|
||||
),
|
||||
explain(
|
||||
"Aliases and references",
|
||||
|
||||
@@ -6,6 +6,7 @@ mod connection_tool;
|
||||
mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagnostic_input;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
@@ -24,6 +25,7 @@ mod recovery;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_navigation;
|
||||
mod source_rename;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
mod style;
|
||||
|
||||
@@ -1,147 +1,147 @@
|
||||
use super::artifacts::{Editor, Event, Raw};
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
|
||||
};
|
||||
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
|
||||
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
|
||||
for (layer, label, selected) in [
|
||||
(Layer::Oa, "Operational", editor.oa.clone()),
|
||||
(Layer::Sa, "System", editor.sa.clone()),
|
||||
] {
|
||||
content = content.push(text(label).size(12)).push(
|
||||
row![
|
||||
pick_list(editor.choices(p, trace, layer), selected, move |v| {
|
||||
Event::Pick(layer, Some(v))
|
||||
})
|
||||
.placeholder("Choose exact artifact identity")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
button("No identity")
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
p: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
) -> Option<View<'a, Event>> {
|
||||
let value = editor.drafts.get(facet)?;
|
||||
let (_, which) = artifacts::parts(facet)?;
|
||||
let mut content =
|
||||
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
|
||||
match value {
|
||||
Raw::Text(_) if which == ArtifactFacet::Description => {
|
||||
let draft = editor.descriptions.get(facet)?;
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_editor(draft)
|
||||
.height(110)
|
||||
.on_action(move |action| Event::Description(facet.clone(), action)),
|
||||
);
|
||||
}
|
||||
Raw::Text(value) => {
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_input("Exact text", value)
|
||||
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Owner(value) => {
|
||||
let mut choices: Vec<_> = p
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| Choice {
|
||||
id,
|
||||
label: format!("{} · @{alias}", p.named(id)),
|
||||
})
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
|
||||
let raw_facet = facet.clone();
|
||||
let pick_facet = facet.clone();
|
||||
content = content
|
||||
.push(
|
||||
pick_list(choices, selected, move |v: Choice| {
|
||||
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
|
||||
})
|
||||
.placeholder("Explicit final owner")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
)
|
||||
.push(
|
||||
text_input("Owner identity (retains rejected input)", value)
|
||||
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Relation(value) => {
|
||||
let facet = facet.clone();
|
||||
let variants = [
|
||||
ArtifactRelation::References,
|
||||
ArtifactRelation::Satisfies,
|
||||
ArtifactRelation::Verifies,
|
||||
ArtifactRelation::Implements,
|
||||
ArtifactRelation::DerivedFrom,
|
||||
];
|
||||
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
|
||||
content = content.push(
|
||||
pick_list(choices, Some(RelationChoice(*value)), move |v| {
|
||||
Event::Input(facet.clone(), Raw::Relation(v.0))
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: value,
|
||||
} => {
|
||||
let toggle_facet = facet.clone();
|
||||
let retained = value.clone();
|
||||
let input_facet = facet.clone();
|
||||
let enabled = *present;
|
||||
content = content
|
||||
.push(
|
||||
checkbox(*present)
|
||||
.label("Present (checked with empty text means an explicit empty value)")
|
||||
.on_toggle(move |present| {
|
||||
Event::Input(
|
||||
toggle_facet.clone(),
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: retained.clone(),
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.push(
|
||||
text_input("Retained optional text", value)
|
||||
.on_input(move |text| {
|
||||
Event::Input(
|
||||
input_facet.clone(),
|
||||
Raw::Optional {
|
||||
present: enabled,
|
||||
text,
|
||||
},
|
||||
)
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(content.into())
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct RelationChoice(ArtifactRelation);
|
||||
impl std::fmt::Display for RelationChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.0)
|
||||
}
|
||||
}
|
||||
use super::artifacts::{Editor, Event, Raw};
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
|
||||
};
|
||||
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
|
||||
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
|
||||
for (layer, label, selected) in [
|
||||
(Layer::Oa, "Operational", editor.oa.clone()),
|
||||
(Layer::Sa, "System", editor.sa.clone()),
|
||||
] {
|
||||
content = content.push(text(label).size(12)).push(
|
||||
row![
|
||||
pick_list(editor.choices(p, trace, layer), selected, move |v| {
|
||||
Event::Pick(layer, Some(v))
|
||||
})
|
||||
.placeholder("Choose exact artifact identity")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
button("No identity")
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
p: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
) -> Option<View<'a, Event>> {
|
||||
let value = editor.drafts.get(facet)?;
|
||||
let (_, which) = artifacts::parts(facet)?;
|
||||
let mut content =
|
||||
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
|
||||
match value {
|
||||
Raw::Text(_) if which == ArtifactFacet::Description => {
|
||||
let draft = editor.descriptions.get(facet)?;
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_editor(draft)
|
||||
.height(110)
|
||||
.on_action(move |action| Event::Description(facet.clone(), action)),
|
||||
);
|
||||
}
|
||||
Raw::Text(value) => {
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_input("Exact text", value)
|
||||
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Owner(value) => {
|
||||
let mut choices: Vec<_> = p
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| Choice {
|
||||
id,
|
||||
label: format!("{} · @{alias}", p.named(id)),
|
||||
})
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
|
||||
let raw_facet = facet.clone();
|
||||
let pick_facet = facet.clone();
|
||||
content = content
|
||||
.push(
|
||||
pick_list(choices, selected, move |v: Choice| {
|
||||
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
|
||||
})
|
||||
.placeholder("Explicit final owner")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
)
|
||||
.push(
|
||||
text_input("Owner identity (retains rejected input)", value)
|
||||
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Relation(value) => {
|
||||
let facet = facet.clone();
|
||||
let variants = [
|
||||
ArtifactRelation::References,
|
||||
ArtifactRelation::Satisfies,
|
||||
ArtifactRelation::Verifies,
|
||||
ArtifactRelation::Implements,
|
||||
ArtifactRelation::DerivedFrom,
|
||||
];
|
||||
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
|
||||
content = content.push(
|
||||
pick_list(choices, Some(RelationChoice(*value)), move |v| {
|
||||
Event::Input(facet.clone(), Raw::Relation(v.0))
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: value,
|
||||
} => {
|
||||
let toggle_facet = facet.clone();
|
||||
let retained = value.clone();
|
||||
let input_facet = facet.clone();
|
||||
let enabled = *present;
|
||||
content = content
|
||||
.push(
|
||||
checkbox(*present)
|
||||
.label("Present (checked with empty text means an explicit empty value)")
|
||||
.on_toggle(move |present| {
|
||||
Event::Input(
|
||||
toggle_facet.clone(),
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: retained.clone(),
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.push(
|
||||
text_input("Retained optional text", value)
|
||||
.on_input(move |text| {
|
||||
Event::Input(
|
||||
input_facet.clone(),
|
||||
Raw::Optional {
|
||||
present: enabled,
|
||||
text,
|
||||
},
|
||||
)
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(content.into())
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct RelationChoice(ArtifactRelation);
|
||||
impl std::fmt::Display for RelationChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +1,79 @@
|
||||
//! Explicit attachment pair and final destination field controls.
|
||||
use super::{
|
||||
attachments::{Editor, Event, Family},
|
||||
*,
|
||||
};
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, column, pick_list, row, text, text_input},
|
||||
};
|
||||
|
||||
pub(super) fn pairs<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
trace: &'a Trace,
|
||||
) -> View<'a, Event> {
|
||||
let mut content = column![
|
||||
text("Existing children and relationship endpoints").size(16),
|
||||
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
|
||||
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
|
||||
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
|
||||
].spacing(9);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let label = if layer == Layer::Oa {
|
||||
"Operational"
|
||||
} else {
|
||||
"System"
|
||||
};
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
|
||||
content = content.push(
|
||||
row![
|
||||
pick_list(
|
||||
editor.choices(project, trace, layer),
|
||||
editor.selection(layer).cloned(),
|
||||
move |choice| Event::Pick(layer, Some(choice))
|
||||
)
|
||||
.placeholder(format!("Choose an existing {label} identity"))
|
||||
.text_size(12)
|
||||
.padding(7)
|
||||
.width(Fill),
|
||||
button(text("No identity on this side").size(11))
|
||||
.style(crate::style::nav)
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
button(text("Use exact attachment pair").size(12))
|
||||
.padding(8)
|
||||
.style(crate::style::nav)
|
||||
.on_press(Event::Stage),
|
||||
);
|
||||
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Option<View<'a, Message>> {
|
||||
let draft = editor.drafts.get(facet)?;
|
||||
let (id, _) = attachments::actual_slot(facet, layer)?;
|
||||
let path = attachments::path(project, facet, layer)?;
|
||||
Some(
|
||||
column![
|
||||
text(format!("Destination record: {}", project.named(id))).size(12),
|
||||
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
|
||||
]
|
||||
.spacing(7)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
//! Explicit attachment pair and final destination field controls.
|
||||
use super::{
|
||||
attachments::{Editor, Event, Family},
|
||||
*,
|
||||
};
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, column, pick_list, row, text, text_input},
|
||||
};
|
||||
|
||||
pub(super) fn pairs<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
trace: &'a Trace,
|
||||
) -> View<'a, Event> {
|
||||
let mut content = column![
|
||||
text("Existing children and relationship endpoints").size(16),
|
||||
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
|
||||
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
|
||||
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
|
||||
].spacing(9);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let label = if layer == Layer::Oa {
|
||||
"Operational"
|
||||
} else {
|
||||
"System"
|
||||
};
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
|
||||
content = content.push(
|
||||
row![
|
||||
pick_list(
|
||||
editor.choices(project, trace, layer),
|
||||
editor.selection(layer).cloned(),
|
||||
move |choice| Event::Pick(layer, Some(choice))
|
||||
)
|
||||
.placeholder(format!("Choose an existing {label} identity"))
|
||||
.text_size(12)
|
||||
.padding(7)
|
||||
.width(Fill),
|
||||
button(text("No identity on this side").size(11))
|
||||
.style(crate::style::nav)
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
button(text("Use exact attachment pair").size(12))
|
||||
.padding(8)
|
||||
.style(crate::style::nav)
|
||||
.on_press(Event::Stage),
|
||||
);
|
||||
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Option<View<'a, Message>> {
|
||||
let draft = editor.drafts.get(facet)?;
|
||||
let (id, _) = attachments::actual_slot(facet, layer)?;
|
||||
let path = attachments::path(project, facet, layer)?;
|
||||
Some(
|
||||
column![
|
||||
text(format!("Destination record: {}", project.named(id))).size(12),
|
||||
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
|
||||
]
|
||||
.spacing(7)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,350 +1,350 @@
|
||||
//! Closed presence-only adapter. The core independently validates the full final record.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Family {
|
||||
Member(ReferenceCollection),
|
||||
Literal,
|
||||
Annotation,
|
||||
Binding,
|
||||
}
|
||||
impl std::fmt::Display for Family {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
|
||||
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
|
||||
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
|
||||
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
|
||||
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
|
||||
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
|
||||
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
|
||||
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
|
||||
Self::Member(ReferenceCollection::Steps) => "Ordered steps",
|
||||
Self::Member(ReferenceCollection::FragmentBranches) => "Fragment branches",
|
||||
Self::Literal => "Enumeration literals",
|
||||
Self::Annotation => "Message values",
|
||||
Self::Binding => "Scenario lifeline bindings",
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(super) const FAMILIES: [Family; 13] = [
|
||||
Family::Member(ReferenceCollection::Steps),
|
||||
Family::Member(ReferenceCollection::FragmentBranches),
|
||||
Family::Member(ReferenceCollection::FunctionInputItems),
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems),
|
||||
Family::Member(ReferenceCollection::SignalItems),
|
||||
Family::Member(ReferenceCollection::ExchangeItems),
|
||||
Family::Member(ReferenceCollection::CategoryExchanges),
|
||||
Family::Member(ReferenceCollection::TransitionTriggers),
|
||||
Family::Member(ReferenceCollection::TransitionEffects),
|
||||
Family::Member(ReferenceCollection::Constrained),
|
||||
Family::Literal,
|
||||
Family::Annotation,
|
||||
Family::Binding,
|
||||
];
|
||||
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
|
||||
let family = match facet {
|
||||
FacetSelector::Member { collection, .. } => Family::Member(*collection),
|
||||
FacetSelector::Literal(_) => Family::Literal,
|
||||
FacetSelector::Annotation(_) => Family::Annotation,
|
||||
FacetSelector::Binding(_) => Family::Binding,
|
||||
_ => return None,
|
||||
};
|
||||
FAMILIES.contains(&family).then_some(family)
|
||||
}
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
family(facet).is_some()
|
||||
}
|
||||
|
||||
// Fixed paths for the closed collections and the exact enum literal set.
|
||||
// They are selected by the typed facet; callers cannot supply arbitrary paths.
|
||||
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
|
||||
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
|
||||
let tails: &[&str] = match family {
|
||||
Family::Member(ReferenceCollection::Steps) => {
|
||||
return [
|
||||
"/body/Oa/Process/steps",
|
||||
"/body/Sa/FunctionalChain/steps",
|
||||
"/body/Oa/Shared/Scenario/steps",
|
||||
"/body/Sa/Shared/Scenario/steps",
|
||||
"/body/Oa/Shared/Occurrence/body/Branch/occurrences",
|
||||
"/body/Sa/Shared/Occurrence/body/Branch/occurrences",
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array))
|
||||
.map(str::to_owned);
|
||||
}
|
||||
Family::Member(ReferenceCollection::FragmentBranches) => {
|
||||
&["Occurrence/body/Fragment/branches"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::FunctionInputItems) => {
|
||||
&["Port/contract/FunctionInput/incoming_items"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems) => {
|
||||
&["Port/contract/FunctionOutput/outgoing_items"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
|
||||
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
|
||||
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
|
||||
Family::Literal => &["DataType/definition/Enumeration/literals"],
|
||||
Family::Annotation => &["Occurrence/body/Exchange/values"],
|
||||
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
|
||||
Family::Member(ReferenceCollection::ExchangeItems) => {
|
||||
return [
|
||||
"Interaction",
|
||||
"FunctionalExchange",
|
||||
"BoundaryExchange",
|
||||
"CommunicationMean",
|
||||
]
|
||||
.iter()
|
||||
.map(|kind| format!("/body/{kind}/items"))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array));
|
||||
}
|
||||
Family::Member(ReferenceCollection::TransitionTriggers) => {
|
||||
return value
|
||||
.pointer("/body/StateTransition/triggers")
|
||||
.is_some_and(Value::is_array)
|
||||
.then(|| "/body/StateTransition/triggers".into());
|
||||
}
|
||||
Family::Member(ReferenceCollection::TransitionEffects) => {
|
||||
return value
|
||||
.pointer("/body/StateTransition/effects")
|
||||
.is_some_and(Value::is_array)
|
||||
.then(|| "/body/StateTransition/effects".into());
|
||||
}
|
||||
};
|
||||
prefixes
|
||||
.iter()
|
||||
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array))
|
||||
}
|
||||
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
|
||||
match facet {
|
||||
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
.map(|id| serde_json::to_value(id).expect("Id")),
|
||||
FacetSelector::Literal(keys) => if layer == Layer::Oa {
|
||||
keys.oa.as_ref()
|
||||
} else {
|
||||
keys.sa.as_ref()
|
||||
}
|
||||
.map(|v| Value::String(v.clone())),
|
||||
FacetSelector::Annotation(keys) => if layer == Layer::Oa {
|
||||
keys.oa.as_ref()
|
||||
} else {
|
||||
keys.sa.as_ref()
|
||||
}
|
||||
.map(|key| serde_json::to_value(key).expect("Annotation key")),
|
||||
FacetSelector::Binding(keys) => if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
.map(|id| serde_json::to_value(id).expect("Binding key")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
|
||||
if family(a).is_none() || family(a) != family(b) {
|
||||
return false;
|
||||
}
|
||||
[Layer::Oa, Layer::Sa]
|
||||
.into_iter()
|
||||
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
|
||||
}
|
||||
pub(super) fn project_presence(
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
present: bool,
|
||||
) -> Result<(), String> {
|
||||
let family = family(facet).ok_or("This keyed family remains read-only.")?;
|
||||
let key = key(facet, layer)
|
||||
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
|
||||
let path =
|
||||
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
|
||||
let list = value
|
||||
.pointer_mut(&path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("Destination collection is unavailable.")?;
|
||||
if present {
|
||||
if !list.contains(&key) {
|
||||
list.push(key);
|
||||
}
|
||||
} else {
|
||||
list.retain(|v| v != &key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub(super) fn keyed_value(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
) -> Result<(), String> {
|
||||
if keyed_rows::supported(facet) {
|
||||
return self.project_row(project, trace, value, facet, decision);
|
||||
}
|
||||
let present = match decision {
|
||||
SemanticDecision::KeepTarget => return Ok(()),
|
||||
SemanticDecision::TakeSource => {
|
||||
// A missing source identity is a target-local row, not a removal.
|
||||
if key(facet, self.direction.source_layer()).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
|
||||
}
|
||||
SemanticDecision::Candidate => *self
|
||||
.presence
|
||||
.get(facet)
|
||||
.ok_or("Choose final membership presence.")?,
|
||||
};
|
||||
project_presence(value, facet, self.direction.target_layer(), present)
|
||||
}
|
||||
|
||||
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return vec![];
|
||||
};
|
||||
let values: Vec<_> = [trace.oa, trace.sa]
|
||||
.into_iter()
|
||||
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
|
||||
.collect();
|
||||
FAMILIES
|
||||
.into_iter()
|
||||
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
||||
if self.member_family == Family::Binding {
|
||||
return self.binding_choices(project, layer, true);
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return vec![];
|
||||
};
|
||||
let id = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let Some(mut draft) = Draft::from_project(project, id) else {
|
||||
return vec![];
|
||||
};
|
||||
if layer == self.direction.target_layer() {
|
||||
draft.set_context(self.combined_value(project, true).ok());
|
||||
}
|
||||
let selected = if layer == Layer::Oa {
|
||||
&self.member_oa
|
||||
} else {
|
||||
&self.member_sa
|
||||
};
|
||||
let Some(path) = path(&draft.context_value(), self.member_family) else {
|
||||
return selected.iter().cloned().collect();
|
||||
};
|
||||
crate::forms::reference_choices(
|
||||
&draft,
|
||||
project,
|
||||
&format!("{path}/0"),
|
||||
selected.as_ref().map_or(Id::nil(), |c| c.id),
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|c| !c.id.is_nil())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn edit_pair(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
facet: FacetSelector,
|
||||
) -> Result<(), String> {
|
||||
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
|
||||
match facet {
|
||||
FacetSelector::Annotation(keys) => {
|
||||
self.annotation_oa.selected = keys.oa.map(|key| crate::data_values::FieldPathKey {
|
||||
path: key.path,
|
||||
field: key.field,
|
||||
});
|
||||
self.annotation_sa.selected = keys.sa.map(|key| crate::data_values::FieldPathKey {
|
||||
path: key.path,
|
||||
field: key.field,
|
||||
});
|
||||
}
|
||||
FacetSelector::Binding(keys) => {
|
||||
self.member_oa = keys.oa.map(|id| view::choice(project, id));
|
||||
self.member_sa = keys.sa.map(|id| view::choice(project, id));
|
||||
}
|
||||
FacetSelector::Member { keys, .. } => {
|
||||
self.member_oa = keys.oa.map(|id| view::choice(project, id));
|
||||
self.member_sa = keys.sa.map(|id| view::choice(project, id));
|
||||
}
|
||||
FacetSelector::Literal(keys) => {
|
||||
self.literal_oa = keys.oa.is_some();
|
||||
self.literal_sa = keys.sa.is_some();
|
||||
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
self.tab = Tab::Members;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
|
||||
if !FAMILIES.contains(&self.member_family) {
|
||||
return Err("This keyed family remains read-only.".into());
|
||||
}
|
||||
let facet = match self.member_family {
|
||||
Family::Member(collection) => FacetSelector::Member {
|
||||
collection,
|
||||
keys: KeyPair {
|
||||
oa: self.member_oa.as_ref().map(|c| c.id),
|
||||
sa: self.member_sa.as_ref().map(|c| c.id),
|
||||
},
|
||||
},
|
||||
Family::Literal => FacetSelector::Literal(KeyPair {
|
||||
oa: self.literal_oa.then(|| self.literal.clone()),
|
||||
sa: self.literal_sa.then(|| self.literal.clone()),
|
||||
}),
|
||||
Family::Annotation => FacetSelector::Annotation(KeyPair {
|
||||
oa: self
|
||||
.annotation_oa
|
||||
.selected
|
||||
.as_ref()
|
||||
.map(|key| AnnotationKey {
|
||||
path: key.path.clone(),
|
||||
field: key.field,
|
||||
}),
|
||||
sa: self
|
||||
.annotation_sa
|
||||
.selected
|
||||
.as_ref()
|
||||
.map(|key| AnnotationKey {
|
||||
path: key.path.clone(),
|
||||
field: key.field,
|
||||
}),
|
||||
}),
|
||||
Family::Binding => FacetSelector::Binding(KeyPair {
|
||||
oa: self.member_oa.as_ref().map(|c| c.id),
|
||||
sa: self.member_sa.as_ref().map(|c| c.id),
|
||||
}),
|
||||
};
|
||||
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||
capture_facet(project, trace.oa, trace.sa, &facet)?;
|
||||
self.stage_row_references(&facet)?;
|
||||
if let FacetSelector::Member {
|
||||
keys:
|
||||
KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
},
|
||||
..
|
||||
} = &facet
|
||||
{
|
||||
self.references.insert(*oa, *sa);
|
||||
}
|
||||
self.staged.insert(facet.clone());
|
||||
self.selected = Some(facet);
|
||||
self.tab = Tab::Facts;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
//! Closed presence-only adapter. The core independently validates the full final record.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Family {
|
||||
Member(ReferenceCollection),
|
||||
Literal,
|
||||
Annotation,
|
||||
Binding,
|
||||
}
|
||||
impl std::fmt::Display for Family {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
|
||||
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
|
||||
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
|
||||
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
|
||||
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
|
||||
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
|
||||
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
|
||||
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
|
||||
Self::Member(ReferenceCollection::Steps) => "Ordered steps",
|
||||
Self::Member(ReferenceCollection::FragmentBranches) => "Fragment branches",
|
||||
Self::Literal => "Enumeration literals",
|
||||
Self::Annotation => "Message values",
|
||||
Self::Binding => "Scenario lifeline bindings",
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(super) const FAMILIES: [Family; 13] = [
|
||||
Family::Member(ReferenceCollection::Steps),
|
||||
Family::Member(ReferenceCollection::FragmentBranches),
|
||||
Family::Member(ReferenceCollection::FunctionInputItems),
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems),
|
||||
Family::Member(ReferenceCollection::SignalItems),
|
||||
Family::Member(ReferenceCollection::ExchangeItems),
|
||||
Family::Member(ReferenceCollection::CategoryExchanges),
|
||||
Family::Member(ReferenceCollection::TransitionTriggers),
|
||||
Family::Member(ReferenceCollection::TransitionEffects),
|
||||
Family::Member(ReferenceCollection::Constrained),
|
||||
Family::Literal,
|
||||
Family::Annotation,
|
||||
Family::Binding,
|
||||
];
|
||||
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
|
||||
let family = match facet {
|
||||
FacetSelector::Member { collection, .. } => Family::Member(*collection),
|
||||
FacetSelector::Literal(_) => Family::Literal,
|
||||
FacetSelector::Annotation(_) => Family::Annotation,
|
||||
FacetSelector::Binding(_) => Family::Binding,
|
||||
_ => return None,
|
||||
};
|
||||
FAMILIES.contains(&family).then_some(family)
|
||||
}
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
family(facet).is_some()
|
||||
}
|
||||
|
||||
// Fixed paths for the closed collections and the exact enum literal set.
|
||||
// They are selected by the typed facet; callers cannot supply arbitrary paths.
|
||||
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
|
||||
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
|
||||
let tails: &[&str] = match family {
|
||||
Family::Member(ReferenceCollection::Steps) => {
|
||||
return [
|
||||
"/body/Oa/Process/steps",
|
||||
"/body/Sa/FunctionalChain/steps",
|
||||
"/body/Oa/Shared/Scenario/steps",
|
||||
"/body/Sa/Shared/Scenario/steps",
|
||||
"/body/Oa/Shared/Occurrence/body/Branch/occurrences",
|
||||
"/body/Sa/Shared/Occurrence/body/Branch/occurrences",
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array))
|
||||
.map(str::to_owned);
|
||||
}
|
||||
Family::Member(ReferenceCollection::FragmentBranches) => {
|
||||
&["Occurrence/body/Fragment/branches"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::FunctionInputItems) => {
|
||||
&["Port/contract/FunctionInput/incoming_items"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems) => {
|
||||
&["Port/contract/FunctionOutput/outgoing_items"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
|
||||
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
|
||||
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
|
||||
Family::Literal => &["DataType/definition/Enumeration/literals"],
|
||||
Family::Annotation => &["Occurrence/body/Exchange/values"],
|
||||
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
|
||||
Family::Member(ReferenceCollection::ExchangeItems) => {
|
||||
return [
|
||||
"Interaction",
|
||||
"FunctionalExchange",
|
||||
"BoundaryExchange",
|
||||
"CommunicationMean",
|
||||
]
|
||||
.iter()
|
||||
.map(|kind| format!("/body/{kind}/items"))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array));
|
||||
}
|
||||
Family::Member(ReferenceCollection::TransitionTriggers) => {
|
||||
return value
|
||||
.pointer("/body/StateTransition/triggers")
|
||||
.is_some_and(Value::is_array)
|
||||
.then(|| "/body/StateTransition/triggers".into());
|
||||
}
|
||||
Family::Member(ReferenceCollection::TransitionEffects) => {
|
||||
return value
|
||||
.pointer("/body/StateTransition/effects")
|
||||
.is_some_and(Value::is_array)
|
||||
.then(|| "/body/StateTransition/effects".into());
|
||||
}
|
||||
};
|
||||
prefixes
|
||||
.iter()
|
||||
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array))
|
||||
}
|
||||
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
|
||||
match facet {
|
||||
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
.map(|id| serde_json::to_value(id).expect("Id")),
|
||||
FacetSelector::Literal(keys) => if layer == Layer::Oa {
|
||||
keys.oa.as_ref()
|
||||
} else {
|
||||
keys.sa.as_ref()
|
||||
}
|
||||
.map(|v| Value::String(v.clone())),
|
||||
FacetSelector::Annotation(keys) => if layer == Layer::Oa {
|
||||
keys.oa.as_ref()
|
||||
} else {
|
||||
keys.sa.as_ref()
|
||||
}
|
||||
.map(|key| serde_json::to_value(key).expect("Annotation key")),
|
||||
FacetSelector::Binding(keys) => if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
.map(|id| serde_json::to_value(id).expect("Binding key")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
|
||||
if family(a).is_none() || family(a) != family(b) {
|
||||
return false;
|
||||
}
|
||||
[Layer::Oa, Layer::Sa]
|
||||
.into_iter()
|
||||
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
|
||||
}
|
||||
pub(super) fn project_presence(
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
present: bool,
|
||||
) -> Result<(), String> {
|
||||
let family = family(facet).ok_or("This keyed family remains read-only.")?;
|
||||
let key = key(facet, layer)
|
||||
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
|
||||
let path =
|
||||
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
|
||||
let list = value
|
||||
.pointer_mut(&path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("Destination collection is unavailable.")?;
|
||||
if present {
|
||||
if !list.contains(&key) {
|
||||
list.push(key);
|
||||
}
|
||||
} else {
|
||||
list.retain(|v| v != &key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub(super) fn keyed_value(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
) -> Result<(), String> {
|
||||
if keyed_rows::supported(facet) {
|
||||
return self.project_row(project, trace, value, facet, decision);
|
||||
}
|
||||
let present = match decision {
|
||||
SemanticDecision::KeepTarget => return Ok(()),
|
||||
SemanticDecision::TakeSource => {
|
||||
// A missing source identity is a target-local row, not a removal.
|
||||
if key(facet, self.direction.source_layer()).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
|
||||
}
|
||||
SemanticDecision::Candidate => *self
|
||||
.presence
|
||||
.get(facet)
|
||||
.ok_or("Choose final membership presence.")?,
|
||||
};
|
||||
project_presence(value, facet, self.direction.target_layer(), present)
|
||||
}
|
||||
|
||||
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return vec![];
|
||||
};
|
||||
let values: Vec<_> = [trace.oa, trace.sa]
|
||||
.into_iter()
|
||||
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
|
||||
.collect();
|
||||
FAMILIES
|
||||
.into_iter()
|
||||
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
||||
if self.member_family == Family::Binding {
|
||||
return self.binding_choices(project, layer, true);
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return vec![];
|
||||
};
|
||||
let id = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let Some(mut draft) = Draft::from_project(project, id) else {
|
||||
return vec![];
|
||||
};
|
||||
if layer == self.direction.target_layer() {
|
||||
draft.set_context(self.combined_value(project, true).ok());
|
||||
}
|
||||
let selected = if layer == Layer::Oa {
|
||||
&self.member_oa
|
||||
} else {
|
||||
&self.member_sa
|
||||
};
|
||||
let Some(path) = path(&draft.context_value(), self.member_family) else {
|
||||
return selected.iter().cloned().collect();
|
||||
};
|
||||
crate::forms::reference_choices(
|
||||
&draft,
|
||||
project,
|
||||
&format!("{path}/0"),
|
||||
selected.as_ref().map_or(Id::nil(), |c| c.id),
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|c| !c.id.is_nil())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn edit_pair(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
facet: FacetSelector,
|
||||
) -> Result<(), String> {
|
||||
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
|
||||
match facet {
|
||||
FacetSelector::Annotation(keys) => {
|
||||
self.annotation_oa.selected = keys.oa.map(|key| crate::data_values::FieldPathKey {
|
||||
path: key.path,
|
||||
field: key.field,
|
||||
});
|
||||
self.annotation_sa.selected = keys.sa.map(|key| crate::data_values::FieldPathKey {
|
||||
path: key.path,
|
||||
field: key.field,
|
||||
});
|
||||
}
|
||||
FacetSelector::Binding(keys) => {
|
||||
self.member_oa = keys.oa.map(|id| view::choice(project, id));
|
||||
self.member_sa = keys.sa.map(|id| view::choice(project, id));
|
||||
}
|
||||
FacetSelector::Member { keys, .. } => {
|
||||
self.member_oa = keys.oa.map(|id| view::choice(project, id));
|
||||
self.member_sa = keys.sa.map(|id| view::choice(project, id));
|
||||
}
|
||||
FacetSelector::Literal(keys) => {
|
||||
self.literal_oa = keys.oa.is_some();
|
||||
self.literal_sa = keys.sa.is_some();
|
||||
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
self.tab = Tab::Members;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
|
||||
if !FAMILIES.contains(&self.member_family) {
|
||||
return Err("This keyed family remains read-only.".into());
|
||||
}
|
||||
let facet = match self.member_family {
|
||||
Family::Member(collection) => FacetSelector::Member {
|
||||
collection,
|
||||
keys: KeyPair {
|
||||
oa: self.member_oa.as_ref().map(|c| c.id),
|
||||
sa: self.member_sa.as_ref().map(|c| c.id),
|
||||
},
|
||||
},
|
||||
Family::Literal => FacetSelector::Literal(KeyPair {
|
||||
oa: self.literal_oa.then(|| self.literal.clone()),
|
||||
sa: self.literal_sa.then(|| self.literal.clone()),
|
||||
}),
|
||||
Family::Annotation => FacetSelector::Annotation(KeyPair {
|
||||
oa: self
|
||||
.annotation_oa
|
||||
.selected
|
||||
.as_ref()
|
||||
.map(|key| AnnotationKey {
|
||||
path: key.path.clone(),
|
||||
field: key.field,
|
||||
}),
|
||||
sa: self
|
||||
.annotation_sa
|
||||
.selected
|
||||
.as_ref()
|
||||
.map(|key| AnnotationKey {
|
||||
path: key.path.clone(),
|
||||
field: key.field,
|
||||
}),
|
||||
}),
|
||||
Family::Binding => FacetSelector::Binding(KeyPair {
|
||||
oa: self.member_oa.as_ref().map(|c| c.id),
|
||||
sa: self.member_sa.as_ref().map(|c| c.id),
|
||||
}),
|
||||
};
|
||||
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||
capture_facet(project, trace.oa, trace.sa, &facet)?;
|
||||
self.stage_row_references(&facet)?;
|
||||
if let FacetSelector::Member {
|
||||
keys:
|
||||
KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
},
|
||||
..
|
||||
} = &facet
|
||||
{
|
||||
self.references.insert(*oa, *sa);
|
||||
}
|
||||
self.staged.insert(facet.clone());
|
||||
self.selected = Some(facet);
|
||||
self.tab = Tab::Facts;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,345 +1,345 @@
|
||||
//! Atomic message-value and lifeline-binding rows with exact, immutable keys.
|
||||
use super::*;
|
||||
use crate::data_values::{FieldPathChoice, FieldPathKey};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AddressDraft {
|
||||
pub selected: Option<FieldPathKey>,
|
||||
pub scope: Option<FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RowDraft {
|
||||
pub expression: String,
|
||||
pub editor: text_editor::Content,
|
||||
pub local: Option<Choice>,
|
||||
}
|
||||
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
matches!(
|
||||
facet,
|
||||
FacetSelector::Annotation(_) | FacetSelector::Binding(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelector {
|
||||
match facet {
|
||||
FacetSelector::Annotation(keys) => FacetSelector::Annotation(KeyPair {
|
||||
oa: (layer == Layer::Oa).then(|| keys.oa.clone()).flatten(),
|
||||
sa: (layer == Layer::Sa).then(|| keys.sa.clone()).flatten(),
|
||||
}),
|
||||
FacetSelector::Binding(keys) => FacetSelector::Binding(KeyPair {
|
||||
oa: if layer == Layer::Oa { keys.oa } else { None },
|
||||
sa: if layer == Layer::Sa { keys.sa } else { None },
|
||||
}),
|
||||
_ => facet.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
|
||||
if layer == Layer::Oa {
|
||||
&self.annotation_oa
|
||||
} else {
|
||||
&self.annotation_sa
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn annotation_address_mut(&mut self, layer: Layer) -> &mut AddressDraft {
|
||||
if layer == Layer::Oa {
|
||||
&mut self.annotation_oa
|
||||
} else {
|
||||
&mut self.annotation_sa
|
||||
}
|
||||
}
|
||||
|
||||
fn row_owner(&self, project: &Project, layer: Layer) -> Option<Element> {
|
||||
let trace = self.effective_trace(project)?;
|
||||
let id = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
if layer == self.direction.target_layer()
|
||||
&& let Ok(value) = self.combined_value(project, true)
|
||||
&& let Ok(element) = serde_json::from_value(value)
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
project.elements.get(&id).cloned()
|
||||
}
|
||||
|
||||
pub(super) fn annotation_choices(
|
||||
&self,
|
||||
project: &Project,
|
||||
layer: Layer,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
let element = self.row_owner(project, layer).ok_or("Choose a trace.")?;
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
message_kind,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = element.shared()
|
||||
else {
|
||||
return Err(
|
||||
"Choose an exchange on this message before selecting a carried field.".into(),
|
||||
);
|
||||
};
|
||||
let exchange = project
|
||||
.relations
|
||||
.get(exchange)
|
||||
.ok_or("The message exchange is unavailable.")?;
|
||||
crate::data_values::at_scope(
|
||||
project,
|
||||
exchange,
|
||||
message_kind,
|
||||
self.annotation_address(layer).scope.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn binding_choices(
|
||||
&self,
|
||||
project: &Project,
|
||||
layer: Layer,
|
||||
referenced: bool,
|
||||
) -> Vec<Choice> {
|
||||
let Some(element) = self.row_owner(project, layer) else {
|
||||
return vec![];
|
||||
};
|
||||
let scenario = if referenced {
|
||||
match element.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::ScenarioUse { scenario, .. },
|
||||
..
|
||||
}) => *scenario,
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
element
|
||||
.parent
|
||||
.and_then(|parent| validation::ancestor_of_kind(project, parent, Kind::Scenario))
|
||||
};
|
||||
let mut choices: Vec<_> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.kind() == Kind::Lifeline
|
||||
&& e.layer() == layer
|
||||
&& scenario.is_some()
|
||||
&& e.parent == scenario
|
||||
})
|
||||
.map(|e| view::choice(project, e.id))
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
|
||||
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
|
||||
let pairs: Vec<_> = match facet {
|
||||
FacetSelector::Annotation(KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
}) => {
|
||||
if oa.path.len() != sa.path.len() {
|
||||
return Err("Paired annotation paths must have the same depth; review distinct addresses separately.".into());
|
||||
}
|
||||
oa.path
|
||||
.iter()
|
||||
.chain(std::iter::once(&oa.field))
|
||||
.copied()
|
||||
.zip(sa.path.iter().chain(std::iter::once(&sa.field)).copied())
|
||||
.collect()
|
||||
}
|
||||
FacetSelector::Binding(KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
}) => vec![(*oa, *sa)],
|
||||
_ => vec![],
|
||||
};
|
||||
// A repeated recursive field may occur several times in one address.
|
||||
// Never let the last occurrence silently replace a conflicting pair.
|
||||
let mut supplied = BTreeMap::new();
|
||||
for (oa, sa) in pairs {
|
||||
if supplied.insert(oa, sa).is_some_and(|prior| prior != sa) {
|
||||
return Err(
|
||||
"This address pairs the same Operational field with different System fields."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.references.extend(supplied);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn initialize_row(&mut self, project: &Project, facet: &FacetSelector) {
|
||||
if self.row_drafts.contains_key(facet) {
|
||||
return;
|
||||
}
|
||||
let value = self
|
||||
.effective_trace(project)
|
||||
.and_then(|t| capture_facet(project, t.oa, t.sa, facet).ok())
|
||||
.and_then(|f| scalar::value(&f, self.direction.target_layer()));
|
||||
let present = value.as_ref().is_some_and(|v| !v.is_null());
|
||||
let mut draft = RowDraft::default();
|
||||
if let Some(value) = value {
|
||||
draft.expression = value["expression"].as_str().unwrap_or_default().into();
|
||||
draft.editor = text_editor::Content::with_text(&draft.expression);
|
||||
draft.local = value["local"]
|
||||
.as_str()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.map(|id| view::choice(project, id));
|
||||
}
|
||||
self.presence.entry(facet.clone()).or_insert(present);
|
||||
self.row_drafts.insert(facet.clone(), draft);
|
||||
}
|
||||
|
||||
pub(super) fn edit_row(
|
||||
&mut self,
|
||||
facet: &FacetSelector,
|
||||
action: impl FnOnce(&mut RowDraft),
|
||||
) -> Result<(), String> {
|
||||
if !supported(facet)
|
||||
|| self.selected.as_ref() != Some(facet)
|
||||
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
return Err("Select this row and choose Set final value before editing it.".into());
|
||||
}
|
||||
action(
|
||||
self.row_drafts
|
||||
.get_mut(facet)
|
||||
.ok_or("The row draft is unavailable.")?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn project_row(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
) -> Result<(), String> {
|
||||
if decision == SemanticDecision::KeepTarget {
|
||||
return Ok(());
|
||||
}
|
||||
let source_layer = self.direction.source_layer();
|
||||
let target_layer = self.direction.target_layer();
|
||||
if decision == SemanticDecision::TakeSource && keyed::key(facet, source_layer).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let target_key = keyed::key(facet, target_layer)
|
||||
.ok_or("Choose the destination row address before setting or taking its value.")?;
|
||||
let row = if decision == SemanticDecision::Candidate {
|
||||
if !self.presence.get(facet).copied().unwrap_or(false) {
|
||||
Value::Null
|
||||
} else {
|
||||
let draft = self
|
||||
.row_drafts
|
||||
.get(facet)
|
||||
.ok_or("Set the final row value.")?;
|
||||
match facet {
|
||||
FacetSelector::Annotation(_) => {
|
||||
let key: AnnotationKey = serde_json::from_value(target_key.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(MessageValue {
|
||||
path: key.path,
|
||||
field: key.field,
|
||||
expression: draft.expression.clone(),
|
||||
})
|
||||
.expect("Message value")
|
||||
}
|
||||
FacetSelector::Binding(_) => {
|
||||
serde_json::json!({"referenced": target_key, "local": draft.local.as_ref().ok_or("Choose the destination scenario's local lifeline.")?.id})
|
||||
}
|
||||
_ => return Err("Unsupported row.".into()),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let row = scalar::value(&fact, source_layer)
|
||||
.ok_or("Source row collection is not applicable; review its constructor first.")?;
|
||||
if row.is_null() {
|
||||
row
|
||||
} else {
|
||||
let map = |id: Id| -> Result<Id, String> {
|
||||
if source_layer == Layer::Oa {
|
||||
self.references.get(&id).copied().ok_or_else(|| {
|
||||
format!("Choose a reference counterpart for {}.", project.named(id))
|
||||
})
|
||||
} else {
|
||||
let matches: Vec<_> = self
|
||||
.references
|
||||
.iter()
|
||||
.filter(|(_, sa)| **sa == id)
|
||||
.map(|(oa, _)| *oa)
|
||||
.collect();
|
||||
if matches.len() == 1 {
|
||||
Ok(matches[0])
|
||||
} else {
|
||||
Err(format!(
|
||||
"Choose one unambiguous reference counterpart for {}.",
|
||||
project.named(id)
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
match facet {
|
||||
FacetSelector::Annotation(_) => {
|
||||
let mut row: MessageValue =
|
||||
serde_json::from_value(row).map_err(|e| e.to_string())?;
|
||||
row.path = row.path.into_iter().map(map).collect::<Result<_, _>>()?;
|
||||
row.field = map(row.field)?;
|
||||
if serde_json::to_value(AnnotationKey::from(&row)).expect("key")
|
||||
!= target_key
|
||||
{
|
||||
return Err("Mapped annotation address disagrees with the selected destination address.".into());
|
||||
}
|
||||
serde_json::to_value(row).expect("row")
|
||||
}
|
||||
FacetSelector::Binding(_) => {
|
||||
let row: LifelineBinding =
|
||||
serde_json::from_value(row).map_err(|e| e.to_string())?;
|
||||
let mapped = LifelineBinding {
|
||||
referenced: map(row.referenced)?,
|
||||
local: map(row.local)?,
|
||||
};
|
||||
if serde_json::to_value(mapped.referenced).expect("Id") != target_key {
|
||||
return Err("Mapped binding key disagrees with the selected destination lifeline.".into());
|
||||
}
|
||||
serde_json::to_value(mapped).expect("row")
|
||||
}
|
||||
_ => return Err("Unsupported row.".into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let family = keyed::family(facet).ok_or("Unsupported row.")?;
|
||||
let path = keyed::path(value, family)
|
||||
.ok_or("The destination constructor has no collection for this row.")?;
|
||||
let rows = value
|
||||
.pointer_mut(&path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("Destination rows are unavailable.")?;
|
||||
let position = rows.iter().position(|row| match facet {
|
||||
FacetSelector::Annotation(_) => {
|
||||
serde_json::json!({"path": row["path"], "field": row["field"]}) == target_key
|
||||
}
|
||||
FacetSelector::Binding(_) => row["referenced"] == target_key,
|
||||
_ => false,
|
||||
});
|
||||
match (position, row.is_null()) {
|
||||
(Some(index), false) => rows[index] = row,
|
||||
(Some(index), true) => {
|
||||
rows.remove(index);
|
||||
}
|
||||
(None, false) => rows.push(row),
|
||||
(None, true) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
//! Atomic message-value and lifeline-binding rows with exact, immutable keys.
|
||||
use super::*;
|
||||
use crate::data_values::{FieldPathChoice, FieldPathKey};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AddressDraft {
|
||||
pub selected: Option<FieldPathKey>,
|
||||
pub scope: Option<FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RowDraft {
|
||||
pub expression: String,
|
||||
pub editor: text_editor::Content,
|
||||
pub local: Option<Choice>,
|
||||
}
|
||||
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
matches!(
|
||||
facet,
|
||||
FacetSelector::Annotation(_) | FacetSelector::Binding(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelector {
|
||||
match facet {
|
||||
FacetSelector::Annotation(keys) => FacetSelector::Annotation(KeyPair {
|
||||
oa: (layer == Layer::Oa).then(|| keys.oa.clone()).flatten(),
|
||||
sa: (layer == Layer::Sa).then(|| keys.sa.clone()).flatten(),
|
||||
}),
|
||||
FacetSelector::Binding(keys) => FacetSelector::Binding(KeyPair {
|
||||
oa: if layer == Layer::Oa { keys.oa } else { None },
|
||||
sa: if layer == Layer::Sa { keys.sa } else { None },
|
||||
}),
|
||||
_ => facet.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
|
||||
if layer == Layer::Oa {
|
||||
&self.annotation_oa
|
||||
} else {
|
||||
&self.annotation_sa
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn annotation_address_mut(&mut self, layer: Layer) -> &mut AddressDraft {
|
||||
if layer == Layer::Oa {
|
||||
&mut self.annotation_oa
|
||||
} else {
|
||||
&mut self.annotation_sa
|
||||
}
|
||||
}
|
||||
|
||||
fn row_owner(&self, project: &Project, layer: Layer) -> Option<Element> {
|
||||
let trace = self.effective_trace(project)?;
|
||||
let id = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
if layer == self.direction.target_layer()
|
||||
&& let Ok(value) = self.combined_value(project, true)
|
||||
&& let Ok(element) = serde_json::from_value(value)
|
||||
{
|
||||
return Some(element);
|
||||
}
|
||||
project.elements.get(&id).cloned()
|
||||
}
|
||||
|
||||
pub(super) fn annotation_choices(
|
||||
&self,
|
||||
project: &Project,
|
||||
layer: Layer,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
let element = self.row_owner(project, layer).ok_or("Choose a trace.")?;
|
||||
let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
message_kind,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = element.shared()
|
||||
else {
|
||||
return Err(
|
||||
"Choose an exchange on this message before selecting a carried field.".into(),
|
||||
);
|
||||
};
|
||||
let exchange = project
|
||||
.relations
|
||||
.get(exchange)
|
||||
.ok_or("The message exchange is unavailable.")?;
|
||||
crate::data_values::at_scope(
|
||||
project,
|
||||
exchange,
|
||||
message_kind,
|
||||
self.annotation_address(layer).scope.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn binding_choices(
|
||||
&self,
|
||||
project: &Project,
|
||||
layer: Layer,
|
||||
referenced: bool,
|
||||
) -> Vec<Choice> {
|
||||
let Some(element) = self.row_owner(project, layer) else {
|
||||
return vec![];
|
||||
};
|
||||
let scenario = if referenced {
|
||||
match element.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::ScenarioUse { scenario, .. },
|
||||
..
|
||||
}) => *scenario,
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
element
|
||||
.parent
|
||||
.and_then(|parent| validation::ancestor_of_kind(project, parent, Kind::Scenario))
|
||||
};
|
||||
let mut choices: Vec<_> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.kind() == Kind::Lifeline
|
||||
&& e.layer() == layer
|
||||
&& scenario.is_some()
|
||||
&& e.parent == scenario
|
||||
})
|
||||
.map(|e| view::choice(project, e.id))
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
|
||||
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
|
||||
let pairs: Vec<_> = match facet {
|
||||
FacetSelector::Annotation(KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
}) => {
|
||||
if oa.path.len() != sa.path.len() {
|
||||
return Err("Paired annotation paths must have the same depth; review distinct addresses separately.".into());
|
||||
}
|
||||
oa.path
|
||||
.iter()
|
||||
.chain(std::iter::once(&oa.field))
|
||||
.copied()
|
||||
.zip(sa.path.iter().chain(std::iter::once(&sa.field)).copied())
|
||||
.collect()
|
||||
}
|
||||
FacetSelector::Binding(KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
}) => vec![(*oa, *sa)],
|
||||
_ => vec![],
|
||||
};
|
||||
// A repeated recursive field may occur several times in one address.
|
||||
// Never let the last occurrence silently replace a conflicting pair.
|
||||
let mut supplied = BTreeMap::new();
|
||||
for (oa, sa) in pairs {
|
||||
if supplied.insert(oa, sa).is_some_and(|prior| prior != sa) {
|
||||
return Err(
|
||||
"This address pairs the same Operational field with different System fields."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.references.extend(supplied);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn initialize_row(&mut self, project: &Project, facet: &FacetSelector) {
|
||||
if self.row_drafts.contains_key(facet) {
|
||||
return;
|
||||
}
|
||||
let value = self
|
||||
.effective_trace(project)
|
||||
.and_then(|t| capture_facet(project, t.oa, t.sa, facet).ok())
|
||||
.and_then(|f| scalar::value(&f, self.direction.target_layer()));
|
||||
let present = value.as_ref().is_some_and(|v| !v.is_null());
|
||||
let mut draft = RowDraft::default();
|
||||
if let Some(value) = value {
|
||||
draft.expression = value["expression"].as_str().unwrap_or_default().into();
|
||||
draft.editor = text_editor::Content::with_text(&draft.expression);
|
||||
draft.local = value["local"]
|
||||
.as_str()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.map(|id| view::choice(project, id));
|
||||
}
|
||||
self.presence.entry(facet.clone()).or_insert(present);
|
||||
self.row_drafts.insert(facet.clone(), draft);
|
||||
}
|
||||
|
||||
pub(super) fn edit_row(
|
||||
&mut self,
|
||||
facet: &FacetSelector,
|
||||
action: impl FnOnce(&mut RowDraft),
|
||||
) -> Result<(), String> {
|
||||
if !supported(facet)
|
||||
|| self.selected.as_ref() != Some(facet)
|
||||
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
return Err("Select this row and choose Set final value before editing it.".into());
|
||||
}
|
||||
action(
|
||||
self.row_drafts
|
||||
.get_mut(facet)
|
||||
.ok_or("The row draft is unavailable.")?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn project_row(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
) -> Result<(), String> {
|
||||
if decision == SemanticDecision::KeepTarget {
|
||||
return Ok(());
|
||||
}
|
||||
let source_layer = self.direction.source_layer();
|
||||
let target_layer = self.direction.target_layer();
|
||||
if decision == SemanticDecision::TakeSource && keyed::key(facet, source_layer).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let target_key = keyed::key(facet, target_layer)
|
||||
.ok_or("Choose the destination row address before setting or taking its value.")?;
|
||||
let row = if decision == SemanticDecision::Candidate {
|
||||
if !self.presence.get(facet).copied().unwrap_or(false) {
|
||||
Value::Null
|
||||
} else {
|
||||
let draft = self
|
||||
.row_drafts
|
||||
.get(facet)
|
||||
.ok_or("Set the final row value.")?;
|
||||
match facet {
|
||||
FacetSelector::Annotation(_) => {
|
||||
let key: AnnotationKey = serde_json::from_value(target_key.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(MessageValue {
|
||||
path: key.path,
|
||||
field: key.field,
|
||||
expression: draft.expression.clone(),
|
||||
})
|
||||
.expect("Message value")
|
||||
}
|
||||
FacetSelector::Binding(_) => {
|
||||
serde_json::json!({"referenced": target_key, "local": draft.local.as_ref().ok_or("Choose the destination scenario's local lifeline.")?.id})
|
||||
}
|
||||
_ => return Err("Unsupported row.".into()),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let row = scalar::value(&fact, source_layer)
|
||||
.ok_or("Source row collection is not applicable; review its constructor first.")?;
|
||||
if row.is_null() {
|
||||
row
|
||||
} else {
|
||||
let map = |id: Id| -> Result<Id, String> {
|
||||
if source_layer == Layer::Oa {
|
||||
self.references.get(&id).copied().ok_or_else(|| {
|
||||
format!("Choose a reference counterpart for {}.", project.named(id))
|
||||
})
|
||||
} else {
|
||||
let matches: Vec<_> = self
|
||||
.references
|
||||
.iter()
|
||||
.filter(|(_, sa)| **sa == id)
|
||||
.map(|(oa, _)| *oa)
|
||||
.collect();
|
||||
if matches.len() == 1 {
|
||||
Ok(matches[0])
|
||||
} else {
|
||||
Err(format!(
|
||||
"Choose one unambiguous reference counterpart for {}.",
|
||||
project.named(id)
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
match facet {
|
||||
FacetSelector::Annotation(_) => {
|
||||
let mut row: MessageValue =
|
||||
serde_json::from_value(row).map_err(|e| e.to_string())?;
|
||||
row.path = row.path.into_iter().map(map).collect::<Result<_, _>>()?;
|
||||
row.field = map(row.field)?;
|
||||
if serde_json::to_value(AnnotationKey::from(&row)).expect("key")
|
||||
!= target_key
|
||||
{
|
||||
return Err("Mapped annotation address disagrees with the selected destination address.".into());
|
||||
}
|
||||
serde_json::to_value(row).expect("row")
|
||||
}
|
||||
FacetSelector::Binding(_) => {
|
||||
let row: LifelineBinding =
|
||||
serde_json::from_value(row).map_err(|e| e.to_string())?;
|
||||
let mapped = LifelineBinding {
|
||||
referenced: map(row.referenced)?,
|
||||
local: map(row.local)?,
|
||||
};
|
||||
if serde_json::to_value(mapped.referenced).expect("Id") != target_key {
|
||||
return Err("Mapped binding key disagrees with the selected destination lifeline.".into());
|
||||
}
|
||||
serde_json::to_value(mapped).expect("row")
|
||||
}
|
||||
_ => return Err("Unsupported row.".into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let family = keyed::family(facet).ok_or("Unsupported row.")?;
|
||||
let path = keyed::path(value, family)
|
||||
.ok_or("The destination constructor has no collection for this row.")?;
|
||||
let rows = value
|
||||
.pointer_mut(&path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("Destination rows are unavailable.")?;
|
||||
let position = rows.iter().position(|row| match facet {
|
||||
FacetSelector::Annotation(_) => {
|
||||
serde_json::json!({"path": row["path"], "field": row["field"]}) == target_key
|
||||
}
|
||||
FacetSelector::Binding(_) => row["referenced"] == target_key,
|
||||
_ => false,
|
||||
});
|
||||
match (position, row.is_null()) {
|
||||
(Some(index), false) => rows[index] = row,
|
||||
(Some(index), true) => {
|
||||
rows.remove(index);
|
||||
}
|
||||
(None, false) => rows.push(row),
|
||||
(None, true) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,254 +1,254 @@
|
||||
//! Independent membership and complete final order; the core validates every proposal.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn collection(which: OrderedCollection) -> ReferenceCollection {
|
||||
match which {
|
||||
OrderedCollection::Steps => ReferenceCollection::Steps,
|
||||
OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches,
|
||||
}
|
||||
}
|
||||
pub(super) fn ordered(which: ReferenceCollection) -> Option<OrderedCollection> {
|
||||
match which {
|
||||
ReferenceCollection::Steps => Some(OrderedCollection::Steps),
|
||||
ReferenceCollection::FragmentBranches => Some(OrderedCollection::FragmentBranches),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn read(value: &Value, which: OrderedCollection) -> Result<Vec<Id>, String> {
|
||||
let path = keyed::path(value, keyed::Family::Member(collection(which)))
|
||||
.ok_or("This ordered collection is unavailable on the destination.")?;
|
||||
serde_json::from_value(value.pointer(&path).unwrap().clone()).map_err(|e| e.to_string())
|
||||
}
|
||||
fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<(), String> {
|
||||
let path = keyed::path(value, keyed::Family::Member(collection(which)))
|
||||
.ok_or("This ordered collection is unavailable on the destination.")?;
|
||||
*value.pointer_mut(&path).unwrap() = serde_json::to_value(ids).expect("Ordered identities");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub(super) fn ordered_members(
|
||||
&self,
|
||||
project: &Project,
|
||||
which: OrderedCollection,
|
||||
) -> Result<Vec<Id>, String> {
|
||||
read(&self.combine(project, true, true)?, which)
|
||||
}
|
||||
|
||||
pub(super) fn initialize_order(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
which: OrderedCollection,
|
||||
) -> Result<(), String> {
|
||||
if !self.order_drafts.contains_key(&which) {
|
||||
let ids = self.ordered_members(project, which)?;
|
||||
self.order_drafts.insert(which, ids);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_order(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
which: OrderedCollection,
|
||||
) -> Result<(), String> {
|
||||
self.require_order_candidate(which)?;
|
||||
let members = self.ordered_members(project, which)?;
|
||||
let draft = self
|
||||
.order_drafts
|
||||
.get_mut(&which)
|
||||
.ok_or("Choose a final order first.")?;
|
||||
// This is an explicit user action. Ordinary refresh never discards an authored order.
|
||||
let mut seen = BTreeSet::new();
|
||||
draft.retain(|id| members.contains(id) && seen.insert(*id));
|
||||
for id in members {
|
||||
if seen.insert(id) {
|
||||
draft.push(id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_order_candidate(&self, which: OrderedCollection) -> Result<(), String> {
|
||||
let facet = FacetSelector::Order(which);
|
||||
if self.selected.as_ref() != Some(&facet)
|
||||
|| self.decisions.get(&facet) != Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
return Err("Select Set final value for this order before changing it.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn move_order(
|
||||
&mut self,
|
||||
which: OrderedCollection,
|
||||
index: usize,
|
||||
delta: i32,
|
||||
) -> Result<(), String> {
|
||||
self.require_order_candidate(which)?;
|
||||
let ids = self
|
||||
.order_drafts
|
||||
.get_mut(&which)
|
||||
.ok_or("Choose a final order first.")?;
|
||||
let destination = match delta {
|
||||
-1 => index.checked_sub(1),
|
||||
1 => index.checked_add(1),
|
||||
_ => None,
|
||||
};
|
||||
let destination = destination
|
||||
.filter(|i| index < ids.len() && *i < ids.len())
|
||||
.ok_or("Choose an existing adjacent position in the final order.")?;
|
||||
ids.swap(index, destination);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn project_order(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
which: OrderedCollection,
|
||||
decision: SemanticDecision,
|
||||
) -> Result<(), String> {
|
||||
let current = read(value, which)?;
|
||||
let supplied = match decision {
|
||||
SemanticDecision::KeepTarget => return Ok(()),
|
||||
SemanticDecision::Candidate => self
|
||||
.order_drafts
|
||||
.get(&which)
|
||||
.cloned()
|
||||
.ok_or("Choose a final order.")?,
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact =
|
||||
capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
|
||||
let source: Vec<Id> = serde_json::from_value(
|
||||
scalar::value(&fact, self.direction.source_layer())
|
||||
.ok_or("The source has no applicable order.")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let map = self.order_reference_map()?;
|
||||
source
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
map.get(&id).copied().ok_or_else(|| {
|
||||
format!(
|
||||
"Pair source order member {} explicitly in Reference pairs.",
|
||||
project.named(id)
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
};
|
||||
let actual: BTreeSet<_> = current.iter().copied().collect();
|
||||
let desired: BTreeSet<_> = supplied.iter().copied().collect();
|
||||
if desired.len() != supplied.len() {
|
||||
return Err(
|
||||
"The final order contains a duplicate identity; reconcile its members explicitly."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if desired != actual {
|
||||
if decision == SemanticDecision::TakeSource
|
||||
&& desired.is_subset(&actual)
|
||||
&& current
|
||||
.iter()
|
||||
.filter(|id| desired.contains(id))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
== supplied
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Final order must contain every surviving destination member exactly once. Reconcile members, then choose their positions; membership is reviewed separately.".into());
|
||||
}
|
||||
write(value, which, supplied)
|
||||
}
|
||||
|
||||
fn order_reference_map(&self) -> Result<BTreeMap<Id, Id>, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (a, b) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(a, b).is_some_and(|old| old != b) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(super) fn project_ordered_member(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
form_context: bool,
|
||||
) -> Result<(), String> {
|
||||
let FacetSelector::Member {
|
||||
collection: member_collection,
|
||||
keys,
|
||||
} = facet
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let which = ordered(*member_collection).expect("Ordered member");
|
||||
let before = read(value, which)?;
|
||||
self.keyed_value(project, trace, value, facet, decision)?;
|
||||
let after = read(value, which)?;
|
||||
let explicit = self
|
||||
.decisions
|
||||
.get(&FacetSelector::Order(which))
|
||||
.is_some_and(|d| *d != SemanticDecision::KeepTarget);
|
||||
if decision != SemanticDecision::TakeSource || explicit || after.len() <= before.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let (source_key, target_key) = if self.direction == TransitionDirection::OaToSa {
|
||||
(keys.oa, keys.sa)
|
||||
} else {
|
||||
(keys.sa, keys.oa)
|
||||
};
|
||||
let (Some(source_key), Some(target_key)) = (source_key, target_key) else {
|
||||
return Ok(());
|
||||
};
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
|
||||
let source: Vec<Id> = serde_json::from_value(
|
||||
scalar::value(&fact, self.direction.source_layer())
|
||||
.ok_or("Source order is unavailable.")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let position = source
|
||||
.iter()
|
||||
.position(|id| *id == source_key)
|
||||
.ok_or("Source member is absent.")?;
|
||||
let map = self.order_reference_map()?;
|
||||
let anchor = |id: &Id| {
|
||||
map.get(id)
|
||||
.and_then(|mapped| before.iter().position(|id| id == mapped))
|
||||
};
|
||||
let left = source[..position].iter().rev().find_map(anchor);
|
||||
let right = source[position + 1..].iter().find_map(anchor);
|
||||
let at = match (left, right) {
|
||||
(Some(a), Some(b)) if a + 1 == b => Some(b),
|
||||
(Some(a), None) if a + 1 == before.len() => Some(a + 1),
|
||||
(None, Some(0)) => Some(0),
|
||||
(None, None) if before.is_empty() => Some(0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(at) = at {
|
||||
let mut ordered = before;
|
||||
ordered.insert(at, target_key);
|
||||
write(value, which, ordered)
|
||||
} else if form_context {
|
||||
// An uncommitted membership list lets the user author a final order.
|
||||
// The actual request still rejects ambiguous placement until explicitly decided.
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Ordered member insertion has no unique unchanged anchor; select an explicit final Order in Properties.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
//! Independent membership and complete final order; the core validates every proposal.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn collection(which: OrderedCollection) -> ReferenceCollection {
|
||||
match which {
|
||||
OrderedCollection::Steps => ReferenceCollection::Steps,
|
||||
OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches,
|
||||
}
|
||||
}
|
||||
pub(super) fn ordered(which: ReferenceCollection) -> Option<OrderedCollection> {
|
||||
match which {
|
||||
ReferenceCollection::Steps => Some(OrderedCollection::Steps),
|
||||
ReferenceCollection::FragmentBranches => Some(OrderedCollection::FragmentBranches),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn read(value: &Value, which: OrderedCollection) -> Result<Vec<Id>, String> {
|
||||
let path = keyed::path(value, keyed::Family::Member(collection(which)))
|
||||
.ok_or("This ordered collection is unavailable on the destination.")?;
|
||||
serde_json::from_value(value.pointer(&path).unwrap().clone()).map_err(|e| e.to_string())
|
||||
}
|
||||
fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<(), String> {
|
||||
let path = keyed::path(value, keyed::Family::Member(collection(which)))
|
||||
.ok_or("This ordered collection is unavailable on the destination.")?;
|
||||
*value.pointer_mut(&path).unwrap() = serde_json::to_value(ids).expect("Ordered identities");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub(super) fn ordered_members(
|
||||
&self,
|
||||
project: &Project,
|
||||
which: OrderedCollection,
|
||||
) -> Result<Vec<Id>, String> {
|
||||
read(&self.combine(project, true, true)?, which)
|
||||
}
|
||||
|
||||
pub(super) fn initialize_order(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
which: OrderedCollection,
|
||||
) -> Result<(), String> {
|
||||
if !self.order_drafts.contains_key(&which) {
|
||||
let ids = self.ordered_members(project, which)?;
|
||||
self.order_drafts.insert(which, ids);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_order(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
which: OrderedCollection,
|
||||
) -> Result<(), String> {
|
||||
self.require_order_candidate(which)?;
|
||||
let members = self.ordered_members(project, which)?;
|
||||
let draft = self
|
||||
.order_drafts
|
||||
.get_mut(&which)
|
||||
.ok_or("Choose a final order first.")?;
|
||||
// This is an explicit user action. Ordinary refresh never discards an authored order.
|
||||
let mut seen = BTreeSet::new();
|
||||
draft.retain(|id| members.contains(id) && seen.insert(*id));
|
||||
for id in members {
|
||||
if seen.insert(id) {
|
||||
draft.push(id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_order_candidate(&self, which: OrderedCollection) -> Result<(), String> {
|
||||
let facet = FacetSelector::Order(which);
|
||||
if self.selected.as_ref() != Some(&facet)
|
||||
|| self.decisions.get(&facet) != Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
return Err("Select Set final value for this order before changing it.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn move_order(
|
||||
&mut self,
|
||||
which: OrderedCollection,
|
||||
index: usize,
|
||||
delta: i32,
|
||||
) -> Result<(), String> {
|
||||
self.require_order_candidate(which)?;
|
||||
let ids = self
|
||||
.order_drafts
|
||||
.get_mut(&which)
|
||||
.ok_or("Choose a final order first.")?;
|
||||
let destination = match delta {
|
||||
-1 => index.checked_sub(1),
|
||||
1 => index.checked_add(1),
|
||||
_ => None,
|
||||
};
|
||||
let destination = destination
|
||||
.filter(|i| index < ids.len() && *i < ids.len())
|
||||
.ok_or("Choose an existing adjacent position in the final order.")?;
|
||||
ids.swap(index, destination);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn project_order(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
which: OrderedCollection,
|
||||
decision: SemanticDecision,
|
||||
) -> Result<(), String> {
|
||||
let current = read(value, which)?;
|
||||
let supplied = match decision {
|
||||
SemanticDecision::KeepTarget => return Ok(()),
|
||||
SemanticDecision::Candidate => self
|
||||
.order_drafts
|
||||
.get(&which)
|
||||
.cloned()
|
||||
.ok_or("Choose a final order.")?,
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact =
|
||||
capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
|
||||
let source: Vec<Id> = serde_json::from_value(
|
||||
scalar::value(&fact, self.direction.source_layer())
|
||||
.ok_or("The source has no applicable order.")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let map = self.order_reference_map()?;
|
||||
source
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
map.get(&id).copied().ok_or_else(|| {
|
||||
format!(
|
||||
"Pair source order member {} explicitly in Reference pairs.",
|
||||
project.named(id)
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
}
|
||||
};
|
||||
let actual: BTreeSet<_> = current.iter().copied().collect();
|
||||
let desired: BTreeSet<_> = supplied.iter().copied().collect();
|
||||
if desired.len() != supplied.len() {
|
||||
return Err(
|
||||
"The final order contains a duplicate identity; reconcile its members explicitly."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if desired != actual {
|
||||
if decision == SemanticDecision::TakeSource
|
||||
&& desired.is_subset(&actual)
|
||||
&& current
|
||||
.iter()
|
||||
.filter(|id| desired.contains(id))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
== supplied
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Final order must contain every surviving destination member exactly once. Reconcile members, then choose their positions; membership is reviewed separately.".into());
|
||||
}
|
||||
write(value, which, supplied)
|
||||
}
|
||||
|
||||
fn order_reference_map(&self) -> Result<BTreeMap<Id, Id>, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (a, b) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(a, b).is_some_and(|old| old != b) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(super) fn project_ordered_member(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
value: &mut Value,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
form_context: bool,
|
||||
) -> Result<(), String> {
|
||||
let FacetSelector::Member {
|
||||
collection: member_collection,
|
||||
keys,
|
||||
} = facet
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let which = ordered(*member_collection).expect("Ordered member");
|
||||
let before = read(value, which)?;
|
||||
self.keyed_value(project, trace, value, facet, decision)?;
|
||||
let after = read(value, which)?;
|
||||
let explicit = self
|
||||
.decisions
|
||||
.get(&FacetSelector::Order(which))
|
||||
.is_some_and(|d| *d != SemanticDecision::KeepTarget);
|
||||
if decision != SemanticDecision::TakeSource || explicit || after.len() <= before.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let (source_key, target_key) = if self.direction == TransitionDirection::OaToSa {
|
||||
(keys.oa, keys.sa)
|
||||
} else {
|
||||
(keys.sa, keys.oa)
|
||||
};
|
||||
let (Some(source_key), Some(target_key)) = (source_key, target_key) else {
|
||||
return Ok(());
|
||||
};
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
|
||||
let source: Vec<Id> = serde_json::from_value(
|
||||
scalar::value(&fact, self.direction.source_layer())
|
||||
.ok_or("Source order is unavailable.")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let position = source
|
||||
.iter()
|
||||
.position(|id| *id == source_key)
|
||||
.ok_or("Source member is absent.")?;
|
||||
let map = self.order_reference_map()?;
|
||||
let anchor = |id: &Id| {
|
||||
map.get(id)
|
||||
.and_then(|mapped| before.iter().position(|id| id == mapped))
|
||||
};
|
||||
let left = source[..position].iter().rev().find_map(anchor);
|
||||
let right = source[position + 1..].iter().find_map(anchor);
|
||||
let at = match (left, right) {
|
||||
(Some(a), Some(b)) if a + 1 == b => Some(b),
|
||||
(Some(a), None) if a + 1 == before.len() => Some(a + 1),
|
||||
(None, Some(0)) => Some(0),
|
||||
(None, None) if before.is_empty() => Some(0),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(at) = at {
|
||||
let mut ordered = before;
|
||||
ordered.insert(at, target_key);
|
||||
write(value, which, ordered)
|
||||
} else if form_context {
|
||||
// An uncommitted membership list lets the user author a final order.
|
||||
// The actual request still rejects ambiguous placement until explicitly decided.
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Ordered member insertion has no unique unchanged anchor; select an explicit final Order in Properties.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! Literal source search and line navigation, independent of accepted model state.
|
||||
mod controller;
|
||||
pub mod interaction;
|
||||
pub mod query;
|
||||
pub mod selection;
|
||||
pub mod symbols;
|
||||
pub use controller::*;
|
||||
use iced::widget::text_editor::{Cursor, Position};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Ephemeral editor navigation; no model commands or filesystem operations.
|
||||
use super::query;
|
||||
use iced::widget::text_editor::{Cursor, Position};
|
||||
use oa_core::Id;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SymbolEvent {
|
||||
Query(query::Action),
|
||||
Back,
|
||||
Close,
|
||||
Choose {
|
||||
stamp: query::QueryStamp,
|
||||
visit: Id,
|
||||
reference: bool,
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Identity of the mounted source view that emitted an action. Message queues
|
||||
/// may outlive that view; request-time stamping alone cannot detect replay.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymbolContext {
|
||||
pub session: Id,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HistoryEntry {
|
||||
pub session: Id,
|
||||
pub path: String,
|
||||
pub generation: u64,
|
||||
pub cursor: Cursor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Request {
|
||||
pub job: Id,
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: u64,
|
||||
pub stamp: query::QueryStamp,
|
||||
pub path: String,
|
||||
pub offset: usize,
|
||||
pub cursor: Cursor,
|
||||
pub action: query::Action,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Completion {
|
||||
pub request: Request,
|
||||
pub result: Result<(Arc<query::Snapshot>, Arc<query::Results>), String>,
|
||||
}
|
||||
|
||||
pub struct Reveal {
|
||||
pub ticket: Id,
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: u64,
|
||||
pub snapshot: query::SnapshotKey,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub struct SymbolState {
|
||||
pub interaction: Id,
|
||||
pub raw_interaction: Arc<AtomicU64>,
|
||||
pub serial: u64,
|
||||
pub in_flight: Option<Id>,
|
||||
pub request: Option<Request>,
|
||||
/// Latest admitted query owns displayed data independently of whether its
|
||||
/// original navigation intent is still current. Hiding a drawer cancels
|
||||
/// navigation, but a matching completion can still populate retained data.
|
||||
pub display_request: Option<Request>,
|
||||
pub cache: Option<Arc<query::Snapshot>>,
|
||||
pub results: Option<Arc<query::Results>>,
|
||||
pub notice: Option<String>,
|
||||
pub back: Vec<HistoryEntry>,
|
||||
pub reveal: Option<Reveal>,
|
||||
}
|
||||
|
||||
impl Default for SymbolState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interaction: Id::new_v4(),
|
||||
raw_interaction: Arc::new(AtomicU64::new(0)),
|
||||
serial: 0,
|
||||
in_flight: None,
|
||||
request: None,
|
||||
display_request: None,
|
||||
cache: None,
|
||||
results: None,
|
||||
notice: None,
|
||||
back: Vec::new(),
|
||||
reveal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iced uses zero-based UTF-8 byte columns. Reject stale or split-codepoint
|
||||
/// coordinates; do not normalize CRLF or silently clamp query destinations.
|
||||
pub fn byte_offset(text: &str, position: Position) -> Option<usize> {
|
||||
let start = if position.line == 0 {
|
||||
0
|
||||
} else {
|
||||
text.match_indices('\n').nth(position.line - 1)?.0 + 1
|
||||
};
|
||||
let end = text[start..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |end| start + end);
|
||||
let offset = start.checked_add(position.column)?;
|
||||
(offset <= end && text.is_char_boundary(offset)).then_some(offset)
|
||||
}
|
||||
|
||||
pub fn cursor_offset(text: &str, cursor: Cursor) -> Option<usize> {
|
||||
let position = cursor.selection.map_or(cursor.position, |selection| {
|
||||
if (selection.line, selection.column) < (cursor.position.line, cursor.position.column) {
|
||||
selection
|
||||
} else {
|
||||
cursor.position
|
||||
}
|
||||
});
|
||||
byte_offset(text, position)
|
||||
}
|
||||
|
||||
pub fn exact_cursor(text: &str, cursor: Cursor) -> bool {
|
||||
byte_offset(text, cursor.position).is_some()
|
||||
&& cursor
|
||||
.selection
|
||||
.is_none_or(|position| byte_offset(text, position).is_some())
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Raw input invalidation and focus checks at widget-operation execution time.
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, Vector, keyboard, mouse, touch};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
/// Exhaustion fails closed: a saturated clock can never authorize navigation.
|
||||
/// Replace the clock with a new workspace/controller, rather than wrapping it.
|
||||
pub fn is_current(clock: &AtomicU64, expected: u64) -> bool {
|
||||
expected != u64::MAX && clock.load(Ordering::Acquire) == expected
|
||||
}
|
||||
|
||||
fn invalidate(clock: &AtomicU64, event: &Event) {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { .. })
|
||||
| Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. })
|
||||
| Event::Touch(touch::Event::FingerPressed { .. })
|
||||
| Event::Window(iced::window::Event::Unfocused)
|
||||
) {
|
||||
let _ = clock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
|
||||
Some(value.saturating_add(1))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent, including overlays: invalidate before the child can capture input.
|
||||
pub fn observe<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>(
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(Observed { content, clock })
|
||||
}
|
||||
|
||||
struct Observed<'a, Message, Theme, Renderer> {
|
||||
content: Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
|
||||
for Observed<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
self.content.as_widget().state()
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content
|
||||
.as_widget()
|
||||
.draw(tree, renderer, theme, style, layout, cursor, viewport);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(tree, layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
invalidate(&self.clock, event);
|
||||
self.content.as_widget_mut().update(
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_widget()
|
||||
.mouse_interaction(tree, layout, cursor, viewport, renderer)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let clock = self.clock.clone();
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
|
||||
}
|
||||
}
|
||||
|
||||
struct ObservedOverlay<'a, Message, Theme, Renderer> {
|
||||
content: overlay::Element<'a, Message, Theme, Renderer>,
|
||||
clock: Arc<AtomicU64>,
|
||||
}
|
||||
impl<Message, Theme, Renderer: renderer::Renderer> overlay::Overlay<Message, Theme, Renderer>
|
||||
for ObservedOverlay<'_, Message, Theme, Renderer>
|
||||
{
|
||||
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||
self.content.as_overlay_mut().layout(renderer, bounds)
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
self.content
|
||||
.as_overlay()
|
||||
.draw(renderer, theme, style, layout, cursor);
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.operate(layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
invalidate(&self.clock, event);
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.update(event, layout, cursor, renderer, clipboard, shell);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content
|
||||
.as_overlay()
|
||||
.mouse_interaction(layout, cursor, renderer)
|
||||
}
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
layout: Layout<'a>,
|
||||
renderer: &Renderer,
|
||||
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let clock = self.clock.clone();
|
||||
self.content
|
||||
.as_overlay_mut()
|
||||
.overlay(layout, renderer)
|
||||
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
|
||||
}
|
||||
fn index(&self) -> f32 {
|
||||
self.content.as_overlay().index()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect all focus owners, then focus only the mounted target. Iced executes
|
||||
/// Operation::Chain synchronously, without an intervening input event. Both
|
||||
/// traversals recheck the raw clock; no result message authorizes later focus.
|
||||
pub fn guarded_focus<Message: Send + 'static>(
|
||||
target: widget::Id,
|
||||
clock: Arc<AtomicU64>,
|
||||
expected: u64,
|
||||
) -> Task<Message> {
|
||||
widget::operate(GuardedFocus::new(target, clock, expected))
|
||||
}
|
||||
|
||||
struct GuardedFocus {
|
||||
target: widget::Id,
|
||||
clock: Arc<AtomicU64>,
|
||||
expected: u64,
|
||||
target_count: usize,
|
||||
foreign_focus: bool,
|
||||
apply: bool,
|
||||
}
|
||||
impl GuardedFocus {
|
||||
fn new(target: widget::Id, clock: Arc<AtomicU64>, expected: u64) -> Self {
|
||||
Self {
|
||||
target,
|
||||
clock,
|
||||
expected,
|
||||
target_count: 0,
|
||||
foreign_focus: false,
|
||||
apply: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<Message: 'static> widget::Operation<Message> for GuardedFocus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation<Message>)) {
|
||||
if is_current(&self.clock, self.expected) {
|
||||
visit(self);
|
||||
}
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
if !is_current(&self.clock, self.expected) {
|
||||
return;
|
||||
}
|
||||
if id == Some(&self.target) {
|
||||
self.target_count += 1;
|
||||
if self.apply {
|
||||
state.focus();
|
||||
}
|
||||
} else if state.is_focused() {
|
||||
self.foreign_focus = true;
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> widget::operation::Outcome<Message> {
|
||||
if !self.apply
|
||||
&& self.target_count == 1
|
||||
&& !self.foreign_focus
|
||||
&& is_current(&self.clock, self.expected)
|
||||
{
|
||||
widget::operation::Outcome::Chain(Box::new(Self {
|
||||
apply: true,
|
||||
..Self::new(self.target.clone(), self.clock.clone(), self.expected)
|
||||
}))
|
||||
} else {
|
||||
widget::operation::Outcome::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,190 @@
|
||||
use super::*;
|
||||
use iced::advanced::widget::operation::{Focusable, Outcome};
|
||||
use iced::widget::{column, text_input};
|
||||
use iced::{Point, Theme};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FocusMap(Vec<(Option<widget::Id>, Rectangle, bool)>);
|
||||
impl widget::Operation for FocusMap {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&widget::Id>, bounds: Rectangle, state: &mut dyn Focusable) {
|
||||
self.0.push((id.cloned(), bounds, state.is_focused()));
|
||||
}
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
element: Element<'static, (), Theme, ()>,
|
||||
tree: widget::Tree,
|
||||
node: layout::Node,
|
||||
}
|
||||
impl Mounted {
|
||||
fn new(clock: Arc<AtomicU64>) -> Self {
|
||||
let content = column![
|
||||
text_input("Editor", "source")
|
||||
.id("editor")
|
||||
.on_input(|_| ())
|
||||
.padding(12),
|
||||
text_input("Find", "")
|
||||
.id("find")
|
||||
.on_input(|_| ())
|
||||
.padding(12),
|
||||
]
|
||||
.spacing(10)
|
||||
.into();
|
||||
let mut element = observe(content, clock);
|
||||
let mut tree = widget::Tree::new(element.as_widget());
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(400., 200.)),
|
||||
);
|
||||
Self {
|
||||
element,
|
||||
tree,
|
||||
node,
|
||||
}
|
||||
}
|
||||
fn pass(&mut self, operation: &mut dyn widget::Operation) {
|
||||
self.element.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&self.node),
|
||||
&(),
|
||||
operation,
|
||||
);
|
||||
}
|
||||
fn run(&mut self, operation: impl widget::Operation + 'static) {
|
||||
let mut next: Box<dyn widget::Operation> = Box::new(operation);
|
||||
loop {
|
||||
self.pass(next.as_mut());
|
||||
match next.finish() {
|
||||
Outcome::Chain(chained) => next = chained,
|
||||
Outcome::None => break,
|
||||
Outcome::Some(()) => panic!("focus must not publish an application message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn focus(&mut self) -> FocusMap {
|
||||
let mut focus = FocusMap::default();
|
||||
self.pass(&mut focus);
|
||||
focus
|
||||
}
|
||||
fn click(&mut self, id: &str) {
|
||||
let bounds = self
|
||||
.focus()
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
||||
.unwrap()
|
||||
.1;
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
self.element.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Layout::new(&self.node),
|
||||
mouse::Cursor::Available(bounds.center()),
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(Size::new(400., 200.)),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"focus-only click must not rely on App messages"
|
||||
);
|
||||
}
|
||||
fn focused(&mut self, id: &str) -> bool {
|
||||
self.focus()
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
|
||||
.unwrap()
|
||||
.2
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_find_click_invalidates_without_app_message_and_preserves_focus() {
|
||||
let clock = Arc::new(AtomicU64::new(0));
|
||||
let mut ui = Mounted::new(clock.clone());
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
||||
assert!(ui.focused("editor"));
|
||||
ui.click("find");
|
||||
assert!(!is_current(&clock, 0));
|
||||
assert!(ui.focused("find"));
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
|
||||
assert!(ui.focused("find"));
|
||||
// Even a matching clock cannot authorize stealing another widget's focus.
|
||||
ui.run(GuardedFocus::new(
|
||||
"editor".into(),
|
||||
clock.clone(),
|
||||
clock.load(Ordering::Acquire),
|
||||
));
|
||||
assert!(ui.focused("find"));
|
||||
assert!(!ui.focused("editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_rechecks_clock_between_inspection_and_application_and_missing_target_is_inert() {
|
||||
let clock = Arc::new(AtomicU64::new(0));
|
||||
let mut ui = Mounted::new(clock.clone());
|
||||
let mut operation = GuardedFocus::new("editor".into(), clock.clone(), 0);
|
||||
ui.pass(&mut operation);
|
||||
let Outcome::Chain(mut apply) = <GuardedFocus as widget::Operation<()>>::finish(&operation)
|
||||
else {
|
||||
panic!("mounted, unopposed target must be eligible");
|
||||
};
|
||||
clock.store(1, Ordering::Release);
|
||||
ui.pass(apply.as_mut());
|
||||
assert!(!ui.focused("editor"));
|
||||
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 1));
|
||||
assert!(ui.focused("editor"));
|
||||
ui.run(GuardedFocus::new("removed-editor".into(), clock, 1));
|
||||
assert!(ui.focused("editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_navigation_inputs_advance_but_passive_geometry_does_not_and_exhaustion_refuses() {
|
||||
let clock = AtomicU64::new(0);
|
||||
let events = [
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowLeft),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
}),
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
}),
|
||||
Event::Touch(touch::Event::FingerPressed {
|
||||
id: touch::Finger(1),
|
||||
position: Point::ORIGIN,
|
||||
}),
|
||||
Event::Window(iced::window::Event::Unfocused),
|
||||
];
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
invalidate(&clock, event);
|
||||
assert_eq!(clock.load(Ordering::Acquire), index as u64 + 1);
|
||||
}
|
||||
invalidate(
|
||||
&clock,
|
||||
&Event::Mouse(mouse::Event::CursorMoved {
|
||||
position: Point::ORIGIN,
|
||||
}),
|
||||
);
|
||||
invalidate(&clock, &Event::Window(iced::window::Event::Focused));
|
||||
assert!(is_current(&clock, 5));
|
||||
clock.store(u64::MAX - 1, Ordering::Release);
|
||||
invalidate(&clock, &events[0]);
|
||||
assert!(!is_current(&clock, u64::MAX));
|
||||
invalidate(&clock, &events[0]);
|
||||
assert_eq!(clock.load(Ordering::Acquire), u64::MAX);
|
||||
assert!(!is_current(&clock, 0));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
//! Effective selected-byte ranges for a committed departure from a source view.
|
||||
//!
|
||||
//! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw
|
||||
//! endpoints can therefore save an empty range for a visibly selected word/line.
|
||||
//! Capture only after navigation admission and destination validation succeed,
|
||||
//! immediately before replacing/leaving the live editor. Never use this to inspect
|
||||
//! pending queries, failed/no-op navigation, or restore focus to the same editor.
|
||||
use iced::advanced::{graphics, text};
|
||||
use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
|
||||
|
||||
/// Capture the effective range for restoration on fresh Content via `move_to`.
|
||||
///
|
||||
/// A nonempty selection is collapsed to its start in the departing Content; text
|
||||
/// is not edited. The returned Normal range preserves selected bytes and typing
|
||||
/// replacement, not Word/Line gesture-extension mode or an interior click point.
|
||||
/// Unequal raw endpoint direction is preserved; coincident Word/Line endpoints
|
||||
/// canonically restore with the active cursor at the effective end.
|
||||
///
|
||||
/// The renderer constraint is intentional: the inspected Iced graphics editor
|
||||
/// collapses any selection to its effective start for `Move(Left)`, and its copied
|
||||
/// selection joins editor lines with LF regardless of physical CRLF/LF endings.
|
||||
/// Only the selected text is temporarily copied; retained state is one Cursor.
|
||||
pub fn capture<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
let original = content.cursor();
|
||||
let Some(selected) = content.selection().filter(|selected| !selected.is_empty()) else {
|
||||
// Moving an unselected caret Left would be a user-visible navigation.
|
||||
return original;
|
||||
};
|
||||
let active_start = original.selection.is_some_and(|anchor| {
|
||||
(original.position.line, original.position.column) < (anchor.line, anchor.column)
|
||||
});
|
||||
content.perform(Action::Move(Motion::Left));
|
||||
let start = content.cursor().position;
|
||||
let end = match selected.rsplit_once('\n') {
|
||||
Some((prefix, suffix)) => Position {
|
||||
line: start.line + prefix.bytes().filter(|byte| *byte == b'\n').count() + 1,
|
||||
column: suffix.len(),
|
||||
},
|
||||
None => Position {
|
||||
line: start.line,
|
||||
column: start.column + selected.len(),
|
||||
},
|
||||
};
|
||||
// These sums describe an existing selection in this same allocated buffer;
|
||||
// neither can exceed its line count / existing UTF-8 line length. A checked
|
||||
// error after collapsing would leave a failed departure with lost selection.
|
||||
debug_assert!(content.cursor().selection.is_none());
|
||||
debug_assert!(start.line < content.line_count() && end.line < content.line_count());
|
||||
debug_assert!((start.line, start.column) < (end.line, end.column));
|
||||
Cursor {
|
||||
position: if active_start { start } else { end },
|
||||
selection: Some(if active_start { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::widget::text_editor::Edit;
|
||||
use std::sync::Arc;
|
||||
|
||||
type EditorContent = Content<iced::Renderer>;
|
||||
|
||||
fn at(line: usize, column: usize) -> Position {
|
||||
Position { line, column }
|
||||
}
|
||||
|
||||
fn range(start: Position, end: Position, active_start: bool) -> Cursor {
|
||||
Cursor {
|
||||
position: if active_start { start } else { end },
|
||||
selection: Some(if active_start { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
fn caret(content: &mut EditorContent, position: Position) {
|
||||
content.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn roundtrip(
|
||||
source: &str,
|
||||
prepare: impl Fn(&mut EditorContent),
|
||||
selected: &str,
|
||||
expected: Cursor,
|
||||
lossy_raw: bool,
|
||||
) {
|
||||
let mut departing = EditorContent::with_text(source);
|
||||
prepare(&mut departing);
|
||||
assert_eq!(departing.text(), source);
|
||||
assert_eq!(departing.selection().as_deref(), Some(selected));
|
||||
let raw = departing.cursor();
|
||||
|
||||
// Independent direct-typing control retains the original semantic mode.
|
||||
let replacement = Action::Edit(Edit::Paste(Arc::new("替🚀".into())));
|
||||
let mut direct = EditorContent::with_text(source);
|
||||
prepare(&mut direct);
|
||||
direct.perform(replacement.clone());
|
||||
|
||||
let captured = capture(&mut departing);
|
||||
assert_eq!(captured, expected);
|
||||
assert_eq!(
|
||||
departing.text(),
|
||||
source,
|
||||
"capture cannot edit physical source bytes"
|
||||
);
|
||||
assert!(departing.cursor().selection.is_none());
|
||||
let mut restored = EditorContent::with_text(source);
|
||||
restored.move_to(captured);
|
||||
assert_eq!(restored.selection().as_deref(), Some(selected));
|
||||
assert_eq!(restored.text(), source);
|
||||
restored.perform(replacement);
|
||||
assert_eq!(
|
||||
restored.text(),
|
||||
direct.text(),
|
||||
"restored range must replace exactly the original selected bytes"
|
||||
);
|
||||
assert_eq!(restored.cursor(), direct.cursor());
|
||||
if lossy_raw {
|
||||
let mut old = EditorContent::with_text(source);
|
||||
old.move_to(raw);
|
||||
assert_ne!(
|
||||
old.selection().as_deref(),
|
||||
Some(selected),
|
||||
"negative control must discriminate raw cursor storage"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
captured, raw,
|
||||
"ordinary directional ranges are already canonical"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_normal_ranges_preserve_forward_and_reverse_direction() {
|
||||
let source = "先 αβ 救助 🚀 tail\r\n";
|
||||
for active_start in [false, true] {
|
||||
let expected = range(
|
||||
at(0, "先 ".len()),
|
||||
at(0, "先 αβ 救助 🚀".len()),
|
||||
active_start,
|
||||
);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.move_to(expected),
|
||||
"αβ 救助 🚀",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_unicode_words_capture_the_selected_occurrence_without_searching() {
|
||||
let source = "éclair éclair éclair\r\n";
|
||||
let start = "éclair ".len();
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, at(0, start + "écl".len()));
|
||||
content.perform(Action::SelectWord);
|
||||
},
|
||||
"éclair",
|
||||
range(at(0, start), at(0, start + "éclair".len()), false),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_selection_expands_past_its_interior_unicode_caret() {
|
||||
let source = "first\r\n救助 🚀 tail\r\nlast";
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, at(1, "救助 ".len()));
|
||||
content.perform(Action::SelectLine);
|
||||
},
|
||||
"救助 🚀 tail",
|
||||
range(at(1, 0), at(1, "救助 🚀 tail".len()), false),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_ranges_use_copied_lf_and_utf8_columns_for_physical_crlf_and_mixed_endings() {
|
||||
for source in ["pré α\r\n中 🚀\r\nfin Ω", "pré α\n中 🚀\r\nfin Ω"] {
|
||||
for active_start in [false, true] {
|
||||
let expected = range(at(0, "pré ".len()), at(2, "fin ".len()), active_start);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.move_to(expected),
|
||||
"α\n中 🚀\nfin ",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_word_and_line_modes_capture_multiline_ranges_in_both_directions() {
|
||||
let source = "one alpha\r\ntwo beta\nthree gamma";
|
||||
for line in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let (start, end, selected) = match (line, reverse) {
|
||||
(false, false) => (at(0, 4), at(2, 11), "alpha\ntwo beta\nthree gamma"),
|
||||
(false, true) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
|
||||
(true, _) => (at(0, 0), at(2, 11), "one alpha\ntwo beta\nthree gamma"),
|
||||
};
|
||||
roundtrip(
|
||||
source,
|
||||
|content| {
|
||||
caret(content, if reverse { at(2, 8) } else { at(0, 6) });
|
||||
content.perform(if line {
|
||||
Action::SelectLine
|
||||
} else {
|
||||
Action::SelectWord
|
||||
});
|
||||
// Iced preserves existing Word/Line mode for Select motions.
|
||||
content.perform(Action::Select(if reverse {
|
||||
Motion::DocumentStart
|
||||
} else {
|
||||
Motion::DocumentEnd
|
||||
}));
|
||||
},
|
||||
selected,
|
||||
range(start, end, reverse),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_newline_selection_restores_endpoint_on_the_empty_last_line() {
|
||||
let source = "雨\r\n🚀\r\n";
|
||||
let expected = range(at(0, 0), at(2, 0), false);
|
||||
roundtrip(
|
||||
source,
|
||||
|content| content.perform(Action::SelectAll),
|
||||
"雨\n🚀\n",
|
||||
expected,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_selection_do_not_move_the_departing_caret() {
|
||||
for source in ["", "\r\n", "alpha\r\n\r\ntail"] {
|
||||
for empty_selection in [false, true] {
|
||||
let mut content = EditorContent::with_text(source);
|
||||
let position = if source.starts_with("alpha") {
|
||||
at(1, 0)
|
||||
} else {
|
||||
at(0, 0)
|
||||
};
|
||||
caret(&mut content, position);
|
||||
if empty_selection {
|
||||
content.perform(Action::SelectLine);
|
||||
assert_eq!(content.selection().as_deref(), Some(""));
|
||||
} else {
|
||||
assert!(content.selection().is_none());
|
||||
}
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection();
|
||||
assert_eq!(capture(&mut content), raw);
|
||||
assert_eq!(content.cursor(), raw);
|
||||
assert_eq!(content.selection(), selected);
|
||||
assert_eq!(content.text(), source);
|
||||
}
|
||||
}
|
||||
for empty_normal in [false, true] {
|
||||
let mut content = EditorContent::with_text("éclair");
|
||||
let position = at(0, "écl".len());
|
||||
content.move_to(Cursor {
|
||||
position,
|
||||
selection: empty_normal.then_some(position),
|
||||
});
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection();
|
||||
assert_eq!(capture(&mut content), raw);
|
||||
assert_eq!(
|
||||
content.cursor(),
|
||||
raw,
|
||||
"an unselected interior caret must not move left"
|
||||
);
|
||||
assert_eq!(content.selection(), selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
//! Pure preview and exact origin mapping for the guarded alias-rename controller.
|
||||
pub mod origin;
|
||||
pub mod preview;
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Map a role-proven alias occurrence through the exact prepared source change.
|
||||
//! This is cursor restoration, not target resolution or commit authority.
|
||||
use oa_core::language::{self, Outline, OutlineLimits, Span, TokenClass};
|
||||
use std::fmt;
|
||||
|
||||
const LIMITS: OutlineLimits = OutlineLimits {
|
||||
max_source_bytes: 64 * 1024 * 1024,
|
||||
max_tokens: 1_000_000,
|
||||
max_declarations: 250_000,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OriginError {
|
||||
Budget,
|
||||
InvalidSource,
|
||||
MissingOrigin,
|
||||
ChangedStructure,
|
||||
}
|
||||
|
||||
impl fmt::Display for OriginError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Budget => "The alias cursor preview exceeds its source scan budget. No rename was applied.",
|
||||
Self::InvalidSource => "The alias cursor could not be verified in this source. No rename was applied.",
|
||||
Self::MissingOrigin => "The original alias occurrence no longer matches. Preview again; no rename was applied.",
|
||||
Self::ChangedStructure => "The preview changed source outside the expected alias tokens. No rename was applied.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OriginError {}
|
||||
|
||||
fn word(class: TokenClass) -> bool {
|
||||
matches!(class, TokenClass::Identifier | TokenClass::Keyword)
|
||||
}
|
||||
|
||||
/// The controller passes the exact alias span returned by the admitted symbol
|
||||
/// hit. `marker` is Some only for a reference (its separate @ span), including
|
||||
/// invocation with the caret on @. A declaration passes None.
|
||||
pub fn map(
|
||||
before: &str,
|
||||
after: &str,
|
||||
old_alias: &str,
|
||||
new_alias: &str,
|
||||
origin: Span,
|
||||
marker: Option<Span>,
|
||||
) -> Result<Span, OriginError> {
|
||||
let old = language::outline(before, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
let new = language::outline(after, LIMITS).map_err(|_| OriginError::Budget)?;
|
||||
if old.has_lexical_errors || new.has_lexical_errors {
|
||||
return Err(OriginError::InvalidSource);
|
||||
}
|
||||
if old.tokens.len() != new.tokens.len() {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
let roles_before = roles(&old);
|
||||
let roles_after = roles(&new);
|
||||
let mut mapped = None;
|
||||
for (index, (a, b)) in old.tokens.iter().zip(&new.tokens).enumerate() {
|
||||
let left = &before[a.span.start..a.span.end];
|
||||
let right = &after[b.span.start..b.span.end];
|
||||
let alias_pair = word(a.class)
|
||||
&& word(b.class)
|
||||
&& left == old_alias
|
||||
&& right == new_alias
|
||||
&& roles_before[index].is_some()
|
||||
&& roles_after[index].is_some()
|
||||
&& roles_before[index].map(|role| role.is_some())
|
||||
== roles_after[index].map(|role| role.is_some());
|
||||
if a.span == origin {
|
||||
if !alias_pair || roles_before[index] != Some(marker) {
|
||||
return Err(OriginError::MissingOrigin);
|
||||
}
|
||||
mapped = Some(b.span);
|
||||
}
|
||||
if !alias_pair && (left != right || a.class != b.class) {
|
||||
return Err(OriginError::ChangedStructure);
|
||||
}
|
||||
}
|
||||
mapped.ok_or(OriginError::MissingOrigin)
|
||||
}
|
||||
|
||||
/// Outer None is not an alias role; Some(None) is a declaration alias;
|
||||
/// Some(Some(span)) is a live @ reference with its exact separate marker.
|
||||
/// Scan sorted declaration spans and tokens together, without repeated searches.
|
||||
fn roles(outline: &Outline<'_>) -> Vec<Option<Option<Span>>> {
|
||||
let mut result = vec![None; outline.tokens.len()];
|
||||
let mut declarations = outline.declarations.iter().peekable();
|
||||
let mut significant = None;
|
||||
for (index, token) in outline.tokens.iter().enumerate() {
|
||||
while declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span.start < token.span.start)
|
||||
{
|
||||
declarations.next();
|
||||
}
|
||||
if word(token.class) {
|
||||
if declarations
|
||||
.peek()
|
||||
.is_some_and(|d| d.alias_span == token.span)
|
||||
{
|
||||
result[index] = Some(None);
|
||||
} else if let Some(previous) = significant {
|
||||
let span: Span = previous;
|
||||
if &outline.source[span.start..span.end] == "@" {
|
||||
result[index] = Some(Some(span));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(token.class, TokenClass::Whitespace | TokenClass::Comment) {
|
||||
significant = Some(token.span);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn occurrence(source: &str, needle: &str, which: usize) -> Span {
|
||||
let start = source.match_indices(needle).nth(which).unwrap().0;
|
||||
Span {
|
||||
start,
|
||||
end: start + needle.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_last_repeated_reference_with_unicode_crlf_and_comment_separator() {
|
||||
let before =
|
||||
"oa_activity a \"救援\" {}\r\n// @a is prose\r\nsource @a; target @ // a\r\n a;\r\n";
|
||||
let after = "oa_activity renamed \"救援\" {}\r\n// @a is prose\r\nsource @renamed; target @ // a\r\n renamed;\r\n";
|
||||
let start = before.rfind(" a;").unwrap() + 1;
|
||||
let marker = before.rfind("@ //").unwrap();
|
||||
let mapped = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"renamed",
|
||||
Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
},
|
||||
Some(Span {
|
||||
start: marker,
|
||||
end: marker + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], "renamed");
|
||||
assert_eq!(mapped.start, after.rfind(" renamed;").unwrap() + 1);
|
||||
assert_eq!(
|
||||
before[..start].bytes().filter(|b| *b == b'\n').count(),
|
||||
after[..mapped.start]
|
||||
.bytes()
|
||||
.filter(|b| *b == b'\n')
|
||||
.count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_final_same_line_occurrence_not_first_or_prose() {
|
||||
let before = "oa_activity a \"a @a\" {} source @a; target @a;";
|
||||
let after = "oa_activity longer \"a @a\" {} source @longer; target @longer;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let result = map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
},
|
||||
Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.start, after.rfind("@longer").unwrap() + 1);
|
||||
let prose = occurrence(before, "a @a", 0);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"longer",
|
||||
Span {
|
||||
start: prose.start,
|
||||
end: prose.start + 1
|
||||
},
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permits_identifier_keyword_transitions_in_alias_roles() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let alias = occurrence(before, "a \"Name", 0);
|
||||
let origin = Span {
|
||||
start: alias.start,
|
||||
end: alias.start + 1,
|
||||
};
|
||||
for replacement in ["none", "include", "oa_activity"] {
|
||||
assert!(oa_core::valid_alias(replacement));
|
||||
assert_eq!(
|
||||
language::tokenize(replacement)[0].class,
|
||||
TokenClass::Keyword
|
||||
);
|
||||
let after = format!("oa_activity {replacement} \"Name\" {{}} source @{replacement};");
|
||||
let mapped = map(before, &after, "a", replacement, origin, None).unwrap();
|
||||
assert_eq!(&after[mapped.start..mapped.end], replacement);
|
||||
assert_eq!(
|
||||
map(&after, before, replacement, "a", mapped, None).unwrap(),
|
||||
origin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_changed_trivia_non_alias_words_and_wrong_marker() {
|
||||
let before = "oa_activity a \"Name\" {} source @a;";
|
||||
let after = "oa_activity b \"Name\" {} source @b;";
|
||||
let start = before.rfind("@a").unwrap();
|
||||
let span = Span {
|
||||
start: start + 1,
|
||||
end: start + 2,
|
||||
};
|
||||
let marker = Some(Span {
|
||||
start,
|
||||
end: start + 1,
|
||||
});
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace("Name", "Other"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(
|
||||
before,
|
||||
&after.replace(" source", " source"),
|
||||
"a",
|
||||
"b",
|
||||
span,
|
||||
marker
|
||||
),
|
||||
Err(OriginError::ChangedStructure)
|
||||
);
|
||||
assert_eq!(
|
||||
map(before, after, "a", "b", span, None),
|
||||
Err(OriginError::MissingOrigin)
|
||||
);
|
||||
assert!(
|
||||
map(
|
||||
before,
|
||||
after,
|
||||
"a",
|
||||
"b",
|
||||
Span {
|
||||
start: 0,
|
||||
end: usize::MAX
|
||||
},
|
||||
marker
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Complete, bounded line-ordinal previews of an already prepared alias rename.
|
||||
//!
|
||||
//! Source text remains in the caller's immutable before/after snapshots. This
|
||||
//! module owns only paths and indexes; it is not rename or commit authority.
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::{fmt, ops::Range};
|
||||
|
||||
const CONTEXT: usize = 3;
|
||||
// A byte-grid boundary can move back by at most three UTF-8 continuation bytes,
|
||||
// so a 252-byte grid yields fragments of at most 255 bytes, independent of input.
|
||||
const SEGMENT_GRID: usize = 252;
|
||||
|
||||
/// Count all bounded fragments of exact source text; an empty line has one
|
||||
/// empty fragment. Page these as well as lines, never mount an unbounded line.
|
||||
pub fn segment_count(text: &str) -> usize {
|
||||
text.len().div_ceil(SEGMENT_GRID).max(1)
|
||||
}
|
||||
|
||||
/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the
|
||||
/// original bytes, including CRLF. No ellipsis or truncation replaces content.
|
||||
pub fn text_segment(text: &str, index: usize) -> Option<&str> {
|
||||
if index >= segment_count(text) {
|
||||
return None;
|
||||
}
|
||||
let mut start = index.checked_mul(SEGMENT_GRID)?;
|
||||
let mut end = start.saturating_add(SEGMENT_GRID).min(text.len());
|
||||
while !text.is_char_boundary(start) {
|
||||
start -= 1;
|
||||
}
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(start..end)
|
||||
}
|
||||
const LIMITS: Limits = Limits {
|
||||
files: 256,
|
||||
changed_lines: 50_000,
|
||||
display_bytes: 8 * 1024 * 1024,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
files: usize,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Preview {
|
||||
files: Vec<File>,
|
||||
changed_lines: usize,
|
||||
display_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct File {
|
||||
path: String,
|
||||
hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Hunk {
|
||||
lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Line {
|
||||
number: usize,
|
||||
before: Range<usize>,
|
||||
after: Range<usize>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PreviewError {
|
||||
MembershipChanged,
|
||||
LineCountChanged(String),
|
||||
LineEndingChanged { path: String, line: usize },
|
||||
TooManyFiles { limit: usize },
|
||||
TooManyChangedLines { limit: usize },
|
||||
TooManyDisplayBytes { limit: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for PreviewError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MembershipChanged => write!(
|
||||
f,
|
||||
"Rename preview refused: the entry or source file membership changed. No rename was applied."
|
||||
),
|
||||
Self::LineCountChanged(path) => write!(
|
||||
f,
|
||||
"Rename preview refused: the line count changed in {path}. No rename was applied."
|
||||
),
|
||||
Self::LineEndingChanged { path, line } => write!(
|
||||
f,
|
||||
"Rename preview refused: the line ending changed in {path}:{line}. No rename was applied."
|
||||
),
|
||||
Self::TooManyFiles { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyChangedLines { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied."
|
||||
),
|
||||
Self::TooManyDisplayBytes { limit } => write!(
|
||||
f,
|
||||
"Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PreviewError {}
|
||||
|
||||
impl Preview {
|
||||
pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result<Self, PreviewError> {
|
||||
Self::with_limits(before, after, LIMITS)
|
||||
}
|
||||
|
||||
fn with_limits(
|
||||
before: &SourceBundle,
|
||||
after: &SourceBundle,
|
||||
limits: Limits,
|
||||
) -> Result<Self, PreviewError> {
|
||||
if before.entry != after.entry
|
||||
|| !before.files.contains_key(&before.entry)
|
||||
|| !before.files.keys().eq(after.files.keys())
|
||||
{
|
||||
return Err(PreviewError::MembershipChanged);
|
||||
}
|
||||
let mut preview = Self {
|
||||
files: Vec::new(),
|
||||
changed_lines: 0,
|
||||
display_bytes: 0,
|
||||
};
|
||||
for ((path, old), (_, new)) in before.files.iter().zip(&after.files) {
|
||||
if old == new {
|
||||
continue;
|
||||
}
|
||||
if preview.files.len() == limits.files {
|
||||
return Err(PreviewError::TooManyFiles {
|
||||
limit: limits.files,
|
||||
});
|
||||
}
|
||||
// A changed source line can contain arbitrarily many renamed tokens.
|
||||
// Count the line once; this count is never a semantic-reference count.
|
||||
let mut changed = Vec::new();
|
||||
let mut old_lines = Lines::new(old);
|
||||
let mut new_lines = Lines::new(new);
|
||||
let mut count = 0;
|
||||
loop {
|
||||
match (old_lines.next(), new_lines.next()) {
|
||||
(None, None) => break,
|
||||
(Some(a), Some(b)) => {
|
||||
count += 1;
|
||||
if old[a.content_end..a.end] != new[b.content_end..b.end] {
|
||||
return Err(PreviewError::LineEndingChanged {
|
||||
path: path.clone(),
|
||||
line: count,
|
||||
});
|
||||
}
|
||||
if old[a.start..a.end] != new[b.start..b.end] {
|
||||
if preview.changed_lines == limits.changed_lines {
|
||||
return Err(PreviewError::TooManyChangedLines {
|
||||
limit: limits.changed_lines,
|
||||
});
|
||||
}
|
||||
preview.changed_lines += 1;
|
||||
changed.push(count - 1);
|
||||
}
|
||||
}
|
||||
_ => return Err(PreviewError::LineCountChanged(path.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
// Merge touching context intervals in order, without pairwise diff.
|
||||
// At most 7 indexed lines per changed line are retained; unchanged
|
||||
// lines outside these intervals are scanned but never indexed.
|
||||
let mut ranges: Vec<Range<usize>> = Vec::new();
|
||||
for line in changed {
|
||||
let range =
|
||||
line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count);
|
||||
if let Some(previous) = ranges.last_mut()
|
||||
&& range.start <= previous.end
|
||||
{
|
||||
previous.end = previous.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
ranges.push(range);
|
||||
}
|
||||
preview.charge(path.len(), limits)?;
|
||||
let mut file = File {
|
||||
path: path.clone(),
|
||||
hunks: Vec::new(),
|
||||
};
|
||||
let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate();
|
||||
for range in ranges {
|
||||
let mut hunk = Hunk { lines: Vec::new() };
|
||||
for (ordinal, (a, b)) in pairs.by_ref() {
|
||||
if ordinal < range.start {
|
||||
continue;
|
||||
}
|
||||
// Charge exact UTF-8 bytes, including preserved endings and
|
||||
// both copies of shared context, before adding the index.
|
||||
// No render-time omission or shortening grants admission.
|
||||
preview.charge(a.end - a.start, limits)?;
|
||||
preview.charge(b.end - b.start, limits)?;
|
||||
hunk.lines.push(Line {
|
||||
number: ordinal + 1,
|
||||
changed: old[a.start..a.end] != new[b.start..b.end],
|
||||
before: a.start..a.end,
|
||||
after: b.start..b.end,
|
||||
});
|
||||
if ordinal + 1 == range.end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
file.hunks.push(hunk);
|
||||
}
|
||||
preview.files.push(file);
|
||||
}
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> {
|
||||
let Some(total) = self.display_bytes.checked_add(bytes) else {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
};
|
||||
if total > limits.display_bytes {
|
||||
return Err(PreviewError::TooManyDisplayBytes {
|
||||
limit: limits.display_bytes,
|
||||
});
|
||||
}
|
||||
self.display_bytes = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All admitted files in source path order; suitable for a file picker/search.
|
||||
pub fn files(&self) -> &[File] {
|
||||
&self.files
|
||||
}
|
||||
|
||||
/// Changed source lines, not occurrences, declarations or semantic uses.
|
||||
pub fn changed_line_count(&self) -> usize {
|
||||
self.changed_lines
|
||||
}
|
||||
|
||||
/// Exact displayed source bytes for both sides plus each stored path once.
|
||||
/// Index metadata is separately bounded by 7 lines per changed line.
|
||||
pub fn display_bytes(&self) -> usize {
|
||||
self.display_bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Every hunk is present; the caller may page these indexes, never truncate.
|
||||
pub fn hunks(&self) -> &[Hunk] {
|
||||
&self.hunks
|
||||
}
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
/// The complete ordered line slice supports paging even a large single hunk.
|
||||
pub fn lines(&self) -> &[Line] {
|
||||
&self.lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// One-based source line number, identical on both sides.
|
||||
pub fn number(&self) -> usize {
|
||||
self.number
|
||||
}
|
||||
|
||||
pub fn changed(&self) -> bool {
|
||||
self.changed
|
||||
}
|
||||
|
||||
/// Borrow from the immutable original file; includes its exact line ending.
|
||||
pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> {
|
||||
original.get(self.before.clone())
|
||||
}
|
||||
|
||||
/// Borrow from the immutable candidate file; includes its exact line ending.
|
||||
pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> {
|
||||
candidate.get(self.after.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct RawLine {
|
||||
start: usize,
|
||||
content_end: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
struct Lines<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lines<'a> {
|
||||
fn new(text: &'a str) -> Self {
|
||||
Self {
|
||||
bytes: text.as_bytes(),
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Lines<'_> {
|
||||
type Item = RawLine;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.offset == self.bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let start = self.offset;
|
||||
// The compiler and source navigation count LF, not bare CR, as a line
|
||||
// boundary. Preserve CRLF as an ending and bare CR as source content.
|
||||
while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' {
|
||||
self.offset += 1;
|
||||
}
|
||||
let mut content_end = self.offset;
|
||||
if self.offset < self.bytes.len() {
|
||||
if self.offset > start && self.bytes[self.offset - 1] == b'\r' {
|
||||
content_end -= 1;
|
||||
}
|
||||
self.offset += 1;
|
||||
}
|
||||
Some(RawLine {
|
||||
start,
|
||||
content_end,
|
||||
end: self.offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "救援.arcadia".into(),
|
||||
files: [("救援.arcadia".into(), source.into())].into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() {
|
||||
let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200));
|
||||
let pieces: Vec<_> = (0..segment_count(&text))
|
||||
.map(|i| text_segment(&text, i).unwrap())
|
||||
.collect();
|
||||
assert!(pieces.len() > 2);
|
||||
assert!(pieces.iter().all(|part| part.len() <= 255));
|
||||
assert_eq!(pieces.concat(), text);
|
||||
assert_eq!(text_segment(&text, segment_count(&text)), None);
|
||||
assert_eq!(text_segment(&text, usize::MAX), None);
|
||||
assert_eq!(segment_count(""), 1);
|
||||
assert_eq!(text_segment("", 0), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() {
|
||||
let old = bundle("// 救援\r\n@a @a\ncontext\r@a");
|
||||
let new = bundle("// 救援\r\n@long @long\ncontext\r@long");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 2);
|
||||
let file = &preview.files()[0];
|
||||
assert_eq!(file.path(), "救援.arcadia");
|
||||
assert_eq!(file.hunks().len(), 1);
|
||||
let lines = file.hunks()[0].lines();
|
||||
assert_eq!(
|
||||
lines.iter().map(Line::number).collect::<Vec<_>>(),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert_eq!(
|
||||
lines[0].before_text(&old.files[&old.entry]),
|
||||
Some("// 救援\r\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1].after_text(&new.files[&new.entry]),
|
||||
Some("@long @long\n")
|
||||
);
|
||||
assert_eq!(
|
||||
lines[2].after_text(&new.files[&new.entry]),
|
||||
Some("context\r@long")
|
||||
);
|
||||
assert!(lines[1].changed() && lines[2].changed());
|
||||
assert!(!lines[0].changed());
|
||||
assert_eq!(
|
||||
preview.display_bytes(),
|
||||
old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_last_lines_form_complete_separate_hunks_and_pages() {
|
||||
let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na");
|
||||
let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong");
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
let hunks = preview.files()[0].hunks();
|
||||
assert_eq!(hunks.len(), 2);
|
||||
let page_numbers: Vec<_> = hunks
|
||||
.iter()
|
||||
.flat_map(|h| h.lines().chunks(2))
|
||||
.flat_map(|page| page.iter().map(Line::number))
|
||||
.collect();
|
||||
assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]);
|
||||
assert_eq!(
|
||||
hunks[1].lines()[3].after_text(&new.files[&new.entry]),
|
||||
Some("long")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearby_changes_merge_and_all_changed_files_are_reachable() {
|
||||
let mut old = bundle("a\n1\n2\n3\n4\n5\na\n");
|
||||
let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n");
|
||||
old.files.insert("other.arcadia".into(), "a a a".into());
|
||||
new.files.insert("other.arcadia".into(), "b b b".into());
|
||||
let preview = Preview::build(&old, &new).unwrap();
|
||||
assert_eq!(preview.changed_line_count(), 3);
|
||||
assert_eq!(preview.files().len(), 2);
|
||||
assert_eq!(preview.files()[0].path(), "other.arcadia");
|
||||
assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks().len(), 1);
|
||||
assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_empty_sources_have_no_preview() {
|
||||
for source in ["", "a\r\nb\n", "a"] {
|
||||
let original = bundle(source);
|
||||
let preview = Preview::build(&original, &original).unwrap();
|
||||
assert!(preview.is_empty());
|
||||
assert_eq!(preview.changed_line_count(), 0);
|
||||
assert_eq!(preview.display_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_membership_entry_line_count_and_line_ending_changes() {
|
||||
let old = bundle("a\nb");
|
||||
let mut new = old.clone();
|
||||
new.files.insert("extra.arcadia".into(), "".into());
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
let mut new = old.clone();
|
||||
new.entry = "missing.arcadia".into();
|
||||
assert_eq!(
|
||||
Preview::build(&old, &new).unwrap_err(),
|
||||
PreviewError::MembershipChanged
|
||||
);
|
||||
assert!(matches!(
|
||||
Preview::build(&old, &bundle("a\nb\nc")),
|
||||
Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("a\nb\n"), &bundle("a\n")),
|
||||
Err(PreviewError::LineCountChanged(_))
|
||||
));
|
||||
for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] {
|
||||
assert_eq!(
|
||||
Preview::build(&bundle(a), &bundle(b)).unwrap_err(),
|
||||
PreviewError::LineEndingChanged {
|
||||
path: old.entry.clone(),
|
||||
line: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() {
|
||||
let old = bundle("a\ncontext\na");
|
||||
let new = bundle("bbb\ncontext\nbbb");
|
||||
let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len();
|
||||
let limits = Limits {
|
||||
files: 1,
|
||||
changed_lines: 2,
|
||||
display_bytes: exact,
|
||||
};
|
||||
assert!(Preview::with_limits(&old, &new, limits).is_ok());
|
||||
assert_eq!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(),
|
||||
PreviewError::TooManyFiles { limit: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyChangedLines { limit: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: exact - 1,
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap_err(),
|
||||
PreviewError::TooManyDisplayBytes { limit: exact - 1 }
|
||||
);
|
||||
let long = bundle(&"a".repeat(LIMITS.display_bytes));
|
||||
assert!(matches!(
|
||||
Preview::build(&bundle("b"), &long),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_apply_to_the_whole_preview_not_each_file() {
|
||||
let mut old = bundle("a");
|
||||
let mut new = bundle("b");
|
||||
old.files.insert("second.arcadia".into(), "a".into());
|
||||
new.files.insert("second.arcadia".into(), "b".into());
|
||||
let limits = Limits {
|
||||
files: 2,
|
||||
changed_lines: 2,
|
||||
display_bytes: old.entry.len() + "second.arcadia".len() + 4,
|
||||
};
|
||||
let complete = Preview::with_limits(&old, &new, limits).unwrap();
|
||||
assert_eq!(complete.files().len(), 2);
|
||||
assert_eq!(complete.changed_line_count(), 2);
|
||||
assert_eq!(complete.display_bytes(), limits.display_bytes);
|
||||
assert!(matches!(
|
||||
Preview::with_limits(&old, &new, Limits { files: 1, ..limits }),
|
||||
Err(PreviewError::TooManyFiles { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
changed_lines: 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyChangedLines { limit: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
Preview::with_limits(
|
||||
&old,
|
||||
&new,
|
||||
Limits {
|
||||
display_bytes: limits.display_bytes - 1,
|
||||
..limits
|
||||
}
|
||||
),
|
||||
Err(PreviewError::TooManyDisplayBytes { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Per-file drafts and navigation. Accepted model/source history lives in Document.
|
||||
use iced::widget::text_editor::Cursor;
|
||||
use oa_core::{
|
||||
language,
|
||||
Id, language,
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -86,6 +86,7 @@ pub struct Buffer {
|
||||
pub cursor: Cursor,
|
||||
past: Vec<String>,
|
||||
future: Vec<String>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
@@ -99,14 +100,20 @@ impl Buffer {
|
||||
},
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.text != self.baseline
|
||||
}
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
session: Id,
|
||||
revision: u64,
|
||||
pub entry: String,
|
||||
pub active: String,
|
||||
pub files: BTreeMap<String, Buffer>,
|
||||
@@ -117,6 +124,35 @@ pub struct Workspace {
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Ephemeral editor identity, never a model identity or recovery payload.
|
||||
pub fn session(&self) -> Id {
|
||||
self.session
|
||||
}
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
fn advance(&mut self) -> u64 {
|
||||
if let Some(revision) = self.revision.checked_add(1) {
|
||||
self.revision = revision;
|
||||
} else {
|
||||
self.reset_navigation_session();
|
||||
}
|
||||
self.revision
|
||||
}
|
||||
/// A changed filesystem root must not reuse queued navigation from Save As.
|
||||
pub fn reset_navigation_session(&mut self) {
|
||||
self.session = Id::new_v4();
|
||||
self.revision = 0;
|
||||
for buffer in self.files.values_mut() {
|
||||
buffer.generation = 0;
|
||||
}
|
||||
}
|
||||
pub fn set_reachable(&mut self, reachable: BTreeSet<String>) {
|
||||
if self.reachable != reachable {
|
||||
self.advance();
|
||||
self.reachable = reachable;
|
||||
}
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> RecoveryWorkspace {
|
||||
RecoveryWorkspace {
|
||||
entry: self.entry.clone(),
|
||||
@@ -136,6 +172,8 @@ impl Workspace {
|
||||
pub fn from_recovery(saved: RecoveryWorkspace) -> Result<Self, String> {
|
||||
saved.validate()?;
|
||||
Ok(Self {
|
||||
session: Id::new_v4(),
|
||||
revision: 0,
|
||||
entry: saved.entry,
|
||||
active: saved.active,
|
||||
files: saved
|
||||
@@ -154,6 +192,8 @@ impl Workspace {
|
||||
}
|
||||
pub fn new(bundle: &SourceBundle, applied: bool) -> Self {
|
||||
Self {
|
||||
session: Id::new_v4(),
|
||||
revision: 0,
|
||||
entry: bundle.entry.clone(),
|
||||
active: bundle.entry.clone(),
|
||||
files: bundle
|
||||
@@ -191,11 +231,20 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
pub fn edit(&mut self, text: String, cursor: Cursor) {
|
||||
let changed = self.files.get(&self.active).is_none_or(|b| b.text != text);
|
||||
let generation = if changed {
|
||||
self.advance()
|
||||
} else {
|
||||
self.revision
|
||||
};
|
||||
let buffer = self
|
||||
.files
|
||||
.entry(self.active.clone())
|
||||
.or_insert_with(|| Buffer::new(String::new()));
|
||||
buffer.cursor = cursor;
|
||||
if changed {
|
||||
buffer.generation = generation;
|
||||
}
|
||||
if text != buffer.text {
|
||||
buffer.past.push(std::mem::replace(&mut buffer.text, text));
|
||||
if buffer.past.len() > 100 {
|
||||
@@ -268,6 +317,8 @@ impl Workspace {
|
||||
} else {
|
||||
buffer.future.push(previous);
|
||||
}
|
||||
let generation = self.advance();
|
||||
self.files.get_mut(&self.active).unwrap().generation = generation;
|
||||
self.trim_history(DRAFT_HISTORY_BYTES);
|
||||
true
|
||||
}
|
||||
@@ -281,6 +332,7 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
pub fn accept(&mut self, bundle: &SourceBundle) {
|
||||
let generation = self.advance();
|
||||
if self.entry != bundle.entry
|
||||
&& !bundle.files.contains_key(&self.entry)
|
||||
&& self.files.get(&self.entry).is_some_and(|b| !b.dirty())
|
||||
@@ -291,7 +343,13 @@ impl Workspace {
|
||||
self.reachable = bundle.files.keys().cloned().collect();
|
||||
for (path, text) in &bundle.files {
|
||||
let cursor = self.files.get(path).map(|b| b.cursor);
|
||||
let previous_generation = self
|
||||
.files
|
||||
.get(path)
|
||||
.filter(|b| b.text == *text)
|
||||
.map(|b| b.generation);
|
||||
let mut buffer = Buffer::new(text.clone());
|
||||
buffer.generation = previous_generation.unwrap_or(generation);
|
||||
if let Some(cursor) = cursor {
|
||||
buffer.cursor = cursor;
|
||||
}
|
||||
@@ -303,10 +361,22 @@ impl Workspace {
|
||||
self.applied = true;
|
||||
}
|
||||
pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet<String>) {
|
||||
let changed = self.reachable != reachable
|
||||
|| bundle
|
||||
.files
|
||||
.keys()
|
||||
.any(|path| !self.files.contains_key(path));
|
||||
let generation = if changed {
|
||||
self.advance()
|
||||
} else {
|
||||
self.revision
|
||||
};
|
||||
for (path, text) in &bundle.files {
|
||||
self.files
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| Buffer::new(text.clone()));
|
||||
self.files.entry(path.clone()).or_insert_with(|| {
|
||||
let mut buffer = Buffer::new(text.clone());
|
||||
buffer.generation = generation;
|
||||
buffer
|
||||
});
|
||||
}
|
||||
self.reachable = reachable;
|
||||
}
|
||||
@@ -314,6 +384,7 @@ impl Workspace {
|
||||
if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) {
|
||||
return false;
|
||||
}
|
||||
self.advance();
|
||||
self.files.remove(path);
|
||||
if self.active == path {
|
||||
self.active = self.entry.clone();
|
||||
@@ -376,6 +447,148 @@ pub fn lint(bundle: &SourceBundle) -> Vec<SourceDiagnostic> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::Project;
|
||||
|
||||
#[test]
|
||||
fn fresh_session_cannot_reuse_a_retained_buffer_generation() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "main.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("main.arcadia".into(), "first".into()),
|
||||
("other.arcadia".into(), "other".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let cursor = workspace.files["main.arcadia"].cursor;
|
||||
workspace.edit("second".into(), cursor);
|
||||
workspace.reset_navigation_session();
|
||||
let before = workspace.files["main.arcadia"].generation();
|
||||
workspace.edit("third".into(), cursor);
|
||||
assert_ne!(workspace.files["main.arcadia"].generation(), before);
|
||||
workspace.switch("other.arcadia", cursor);
|
||||
workspace.revision = u64::MAX;
|
||||
workspace.edit("changed other".into(), cursor);
|
||||
let after_wrap = workspace.files["main.arcadia"].generation();
|
||||
workspace.switch("main.arcadia", cursor);
|
||||
workspace.edit("fourth".into(), cursor);
|
||||
assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.text(), "third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "entry\r\n".into()),
|
||||
("part.arcadia".into(), "雨 draft\r\n".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let reopened = Workspace::new(&bundle, true);
|
||||
let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap();
|
||||
assert_ne!(workspace.session(), reopened.session());
|
||||
assert_ne!(workspace.session(), recovered.session());
|
||||
assert_eq!(workspace.bundle(), recovered.bundle());
|
||||
let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap();
|
||||
assert!(saved.get("session").is_none() && saved.get("revision").is_none());
|
||||
|
||||
let cursor = Cursor {
|
||||
position: iced::widget::text_editor::Position { line: 0, column: 3 },
|
||||
selection: None,
|
||||
};
|
||||
let revision = workspace.revision();
|
||||
workspace.edit(workspace.text().into(), cursor);
|
||||
assert_eq!(
|
||||
workspace.revision(),
|
||||
revision,
|
||||
"selection is not a text change"
|
||||
);
|
||||
workspace.switch("part.arcadia", cursor);
|
||||
assert_eq!(
|
||||
workspace.revision(),
|
||||
revision,
|
||||
"switching retains the snapshot"
|
||||
);
|
||||
let untouched = workspace.files["project.arcadia"].generation();
|
||||
workspace.edit("雨 unfinished !\r\n".into(), cursor);
|
||||
let edited = workspace.files["part.arcadia"].generation();
|
||||
assert!(workspace.revision() > revision);
|
||||
assert_eq!(workspace.files["project.arcadia"].generation(), untouched);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
assert!(workspace.files["part.arcadia"].generation() > edited);
|
||||
assert!(workspace.undo(true));
|
||||
assert_eq!(workspace.text(), "雨 unfinished !\r\n");
|
||||
let previous_session = workspace.session();
|
||||
let previous_text = workspace.bundle();
|
||||
workspace.reset_navigation_session();
|
||||
assert_ne!(workspace.session(), previous_session);
|
||||
assert_eq!(workspace.bundle(), previous_text);
|
||||
assert!(
|
||||
workspace.undo(false),
|
||||
"session reset preserves text history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "entry".into()),
|
||||
("part.arcadia".into(), "part".into()),
|
||||
]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
let entry_generation = workspace.files[&bundle.entry].generation();
|
||||
let initial_revision = workspace.revision();
|
||||
workspace.set_reachable(BTreeSet::from([bundle.entry.clone()]));
|
||||
assert!(workspace.revision() > initial_revision);
|
||||
let membership_revision = workspace.revision();
|
||||
workspace.set_reachable(workspace.reachable.clone());
|
||||
assert_eq!(workspace.revision(), membership_revision);
|
||||
assert!(workspace.discard_detached("part.arcadia"));
|
||||
assert!(workspace.revision() > membership_revision);
|
||||
let closed_revision = workspace.revision();
|
||||
workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()]));
|
||||
assert!(workspace.revision() > closed_revision);
|
||||
assert_eq!(
|
||||
workspace.files[&bundle.entry].generation(),
|
||||
entry_generation
|
||||
);
|
||||
assert!(workspace.files["part.arcadia"].generation() > closed_revision);
|
||||
let discovered_revision = workspace.revision();
|
||||
workspace.discovered(&bundle, workspace.reachable.clone());
|
||||
assert_eq!(workspace.revision(), discovered_revision);
|
||||
workspace.accept(&bundle);
|
||||
assert!(
|
||||
workspace.revision() > discovered_revision,
|
||||
"validation evidence changed"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.files[&bundle.entry].generation(),
|
||||
entry_generation
|
||||
);
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), "original".into())]),
|
||||
};
|
||||
let mut workspace = Workspace::new(&bundle, true);
|
||||
workspace.revision = u64::MAX;
|
||||
let before = workspace.session();
|
||||
let cursor = workspace.files[&bundle.entry].cursor;
|
||||
workspace.edit("changed".into(), cursor);
|
||||
assert_ne!(workspace.session(), before);
|
||||
assert_eq!(workspace.revision(), 0);
|
||||
assert!(workspace.undo(false));
|
||||
assert_eq!(workspace.bundle(), bundle);
|
||||
}
|
||||
#[test]
|
||||
fn draft_history_budget_covers_inactive_files_without_discarding_drafts() {
|
||||
let bundle = SourceBundle {
|
||||
|
||||
@@ -377,7 +377,8 @@ fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() {
|
||||
send(&mut app, Message::SourceForSelection);
|
||||
assert_eq!(app.page, Page::Text);
|
||||
assert!(app.source_jump.is_some());
|
||||
send(&mut app, Message::SourceViewport);
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
assert!(app.source_jump.is_none());
|
||||
send(&mut app, Message::SourceForSelection);
|
||||
assert!(app.source_jump.is_some());
|
||||
@@ -409,7 +410,8 @@ fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
|
||||
cursor.selection,
|
||||
Some(text_editor::Position { line: 0, column: 9 })
|
||||
);
|
||||
send(&mut app, Message::SourceViewport);
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
send(
|
||||
&mut app,
|
||||
|
||||
+317
-172
@@ -1,3 +1,5 @@
|
||||
mod source_symbols;
|
||||
mod text_workspace;
|
||||
pub(crate) mod traceability;
|
||||
use crate::{
|
||||
app::{App, Choice, FileOperation, Message, Page, Pending},
|
||||
@@ -275,7 +277,7 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
p.relations.len()
|
||||
)),
|
||||
action(
|
||||
&format!("{} checks", app.issue_count()),
|
||||
&format!("{} model checks", app.issue_count()),
|
||||
Message::Page(Page::Validation)
|
||||
)
|
||||
]
|
||||
@@ -349,6 +351,13 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
if app.pending.is_some() {
|
||||
return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into());
|
||||
}
|
||||
if app.source_rename.open() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::source_rename::dialog(app),
|
||||
(app.window_size.width - 32.).clamp(120., 760.),
|
||||
);
|
||||
}
|
||||
root
|
||||
}
|
||||
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
|
||||
@@ -1002,181 +1011,317 @@ fn source_files(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
|
||||
fn source(app: &App) -> View<'_, Message> {
|
||||
use crate::source_navigation::Event as SourceEvent;
|
||||
let navigation: View<'_, Message> = if app.source_navigation.open {
|
||||
column![
|
||||
row![
|
||||
text_input("Find literal text…", &app.source_navigation.query)
|
||||
.id("source-find")
|
||||
.on_input(|query| Message::SourceNavigation(SourceEvent::Query(query)))
|
||||
.on_submit(Message::SourceNavigation(SourceEvent::Next(false)))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
action(
|
||||
"Previous",
|
||||
Message::SourceNavigation(SourceEvent::Next(true))
|
||||
),
|
||||
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
|
||||
label(app.source_navigation.label()),
|
||||
action("Close", Message::SourceNavigation(SourceEvent::Close)),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
text_input("Line", &app.source_navigation.line)
|
||||
.id("source-line")
|
||||
.on_input(|line| Message::SourceNavigation(SourceEvent::Line(line)))
|
||||
.on_submit(Message::SourceNavigation(SourceEvent::Go))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
.width(80),
|
||||
action("Go to line", Message::SourceNavigation(SourceEvent::Go)),
|
||||
label("Enter or F3: next match · Shift F3: previous · Esc: close"),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery {
|
||||
let edits_block_recovery =
|
||||
!app.source_workspace.applied && app.source_workspace.has_edits();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
container(
|
||||
column![
|
||||
tooltip(
|
||||
text(format!("Interrupted save · {filename}")).size(15),
|
||||
text(path.display().to_string()).size(12),
|
||||
tooltip::Position::Bottom,
|
||||
),
|
||||
text(if edits_block_recovery {
|
||||
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
|
||||
} else if app.source_workspace.applied {
|
||||
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
|
||||
} else {
|
||||
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
|
||||
})
|
||||
.size(13),
|
||||
button(text("Recover interrupted save").size(12))
|
||||
.padding(10)
|
||||
.on_press_maybe(
|
||||
(!edits_block_recovery && !app.busy).then_some(Message::SourceRecover),
|
||||
),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
let mut diagnostics = column![].spacing(5);
|
||||
let mut visible_diagnostics = 0;
|
||||
for (index, issue) in app.source_diagnostics.iter().enumerate() {
|
||||
// The persistent recovery banner supplies the action and explanation;
|
||||
// repeating the internal journal path obscures ordinary diagnostics.
|
||||
if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" {
|
||||
continue;
|
||||
use crate::app::text_workspace::DrawerTab;
|
||||
use oa_core::source_project::SourceLocation;
|
||||
let visible = app.text_workspace.drawer() == Some(DrawerTab::Problems);
|
||||
let publication = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.filter(|publication| visible && publication.source() == app.source_key());
|
||||
let mut diagnostics = column![].spacing(3).width(Fill);
|
||||
if visible {
|
||||
if publication.is_none() && !app.source_diagnostics.is_empty() {
|
||||
diagnostics = diagnostics
|
||||
.push(text("Source changed. Waiting for current source checks…").size(12));
|
||||
}
|
||||
visible_diagnostics += 1;
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{}:{}:{} {}",
|
||||
issue.path,
|
||||
issue.diagnostic.line,
|
||||
issue.diagnostic.column,
|
||||
issue.diagnostic.message
|
||||
))
|
||||
.size(12)
|
||||
.color(match issue.diagnostic.severity {
|
||||
validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64),
|
||||
validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23),
|
||||
}),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 6])
|
||||
.style(style::nav)
|
||||
.on_press(Message::SourceDiagnostic(index)),
|
||||
);
|
||||
for related in &issue.related {
|
||||
diagnostics = diagnostics.push(action(
|
||||
&format!("Related declaration · {}", related.path),
|
||||
Message::SourceRelated(related.clone()),
|
||||
));
|
||||
if app.source_diagnostics.is_empty() {
|
||||
diagnostics = diagnostics.push(text("No source problems reported.").size(12));
|
||||
}
|
||||
for issue in &app.source_diagnostics {
|
||||
let location = SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
};
|
||||
let severity = match issue.diagnostic.severity {
|
||||
validation::Severity::Error => "Error",
|
||||
validation::Severity::Warning => "Warning",
|
||||
};
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{severity} · {}:{}:{} · {}",
|
||||
issue.path,
|
||||
issue.diagnostic.line,
|
||||
issue.diagnostic.column,
|
||||
issue.diagnostic.message,
|
||||
))
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 6])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.text_diagnostic_message(location)),
|
||||
);
|
||||
for related in &issue.related {
|
||||
diagnostics = diagnostics.push(
|
||||
button(
|
||||
text(format!("Related declaration · {}", related.path))
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding([4, 12])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.text_diagnostic_message(related.clone())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let editor = widget::text_editor(&app.source_editor)
|
||||
.id("source-editor")
|
||||
.on_action(Message::SourceEdit)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Fill)
|
||||
.highlight_with::<crate::highlight::ArcadiaHighlighter>(
|
||||
app.source_snapshot.clone(),
|
||||
crate::highlight::format,
|
||||
);
|
||||
column![
|
||||
container(
|
||||
column![
|
||||
row![
|
||||
container(heading(
|
||||
"Textual model",
|
||||
"One model across files · comments and identities preserved"
|
||||
))
|
||||
.width(Fill),
|
||||
button(text("Apply all").size(12))
|
||||
.padding(10)
|
||||
.on_press(Message::ApplySource),
|
||||
action("Revert file", Message::RevertSource)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
row![
|
||||
action("Undo text", Message::SourceUndo),
|
||||
action("Redo text", Message::SourceRedo),
|
||||
button(text("Undo model").size(12)).padding([8,10]).style(style::nav)
|
||||
.on_press_maybe((app.document.can_undo() && !app.source_dirty()).then_some(Message::Undo)),
|
||||
button(text("Redo model").size(12)).padding([8,10]).style(style::nav)
|
||||
.on_press_maybe((app.document.can_redo() && !app.source_dirty()).then_some(Message::Redo)),
|
||||
action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))),
|
||||
label(if app.source_dirty() {
|
||||
"Drafts pending"
|
||||
} else {
|
||||
"Model in sync"
|
||||
})
|
||||
]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center),
|
||||
recovery,
|
||||
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
|
||||
label(if app.pending_recovery.is_some() && !app.source_workspace.applied {
|
||||
"The previous graphical model remains available while these files are recovered."
|
||||
} else if !app.source_workspace.applied {
|
||||
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
|
||||
} else if app.source_workspace.detached_dirty() {
|
||||
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
|
||||
} else {
|
||||
"Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)."
|
||||
}),
|
||||
navigation,
|
||||
scrollable(diagnostics)
|
||||
.height((visible_diagnostics as f32 * 32.).min(132.))
|
||||
]
|
||||
.spacing(12)
|
||||
let guard = crate::diagnostic_input::Guard::new(
|
||||
publication.map(|p| (p.generation(), app.text_workspace.drawer_visit())),
|
||||
diagnostics,
|
||||
);
|
||||
text_workspace::view(app, source_symbols::panel(app), guard.into())
|
||||
}
|
||||
|
||||
pub(crate) fn source_binding(
|
||||
press: widget::text_editor::KeyPress,
|
||||
context: &crate::source_navigation::SymbolContext,
|
||||
rename: Option<&crate::app::source_rename::Begin>,
|
||||
) -> Option<widget::text_editor::Binding<Message>> {
|
||||
use crate::source_navigation::{SymbolEvent, query::Action};
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
use widget::text_editor::{Binding, Status};
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers.is_empty()
|
||||
&& press.key == Key::Named(Named::F2)
|
||||
&& let Some(begin) = rename
|
||||
{
|
||||
return Some(Binding::Custom(Message::SourceRename(
|
||||
crate::app::source_rename::Event::Begin(begin.clone()),
|
||||
)));
|
||||
}
|
||||
// Windows owns Alt+Space for its window menu. Iced's default binding would
|
||||
// otherwise insert a supplied space payload, even after that menu closes.
|
||||
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers == iced::keyboard::Modifiers::ALT
|
||||
&& matches!(
|
||||
press.key.as_ref(),
|
||||
Key::Named(Named::Space) | Key::Character(" ")
|
||||
)
|
||||
.padding(18),
|
||||
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
|
||||
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
|
||||
]
|
||||
.into()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& !press.modifiers.control()
|
||||
&& !press.modifiers.logo()
|
||||
{
|
||||
let event = match &press.key {
|
||||
Key::Named(Named::F12) if !press.modifiers.alt() => {
|
||||
Some(SymbolEvent::Query(if press.modifiers.shift() {
|
||||
Action::References
|
||||
} else {
|
||||
Action::Definition
|
||||
}))
|
||||
}
|
||||
Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => {
|
||||
Some(SymbolEvent::Back)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event {
|
||||
return Some(Binding::Custom(Message::SourceSymbols(
|
||||
context.clone(),
|
||||
event,
|
||||
)));
|
||||
}
|
||||
}
|
||||
Binding::from_key_press(press)
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
pub(crate) mod source_tests {
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
|
||||
};
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||||
|
||||
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
|
||||
pub(crate) struct FontLayout;
|
||||
impl renderer::Renderer for FontLayout {
|
||||
fn start_layer(&mut self, _: Rectangle) {}
|
||||
fn end_layer(&mut self) {}
|
||||
fn start_transformation(&mut self, _: Transformation) {}
|
||||
fn end_transformation(&mut self) {}
|
||||
fn reset(&mut self, _: Rectangle) {}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(
|
||||
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut (), handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for FontLayout {
|
||||
type Font = Font;
|
||||
type Paragraph = graphics::text::Paragraph;
|
||||
type Editor = graphics::text::Editor;
|
||||
const ICON_FONT: Font = Font::DEFAULT;
|
||||
const CHECKMARK_ICON: char = '0';
|
||||
const ARROW_DOWN_ICON: char = '0';
|
||||
const SCROLL_UP_ICON: char = '0';
|
||||
const SCROLL_DOWN_ICON: char = '0';
|
||||
const SCROLL_LEFT_ICON: char = '0';
|
||||
const SCROLL_RIGHT_ICON: char = '0';
|
||||
const ICED_LOGO: char = '0';
|
||||
fn default_font(&self) -> Font {
|
||||
Font::DEFAULT
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
Pixels(16.)
|
||||
}
|
||||
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
|
||||
use core_widget::operation::Focusable;
|
||||
use iced::keyboard::{
|
||||
self, Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
};
|
||||
use widget::text_editor::{Binding, Content, KeyPress, State, Status};
|
||||
let baseline = "// is excluded\r\n// 雨\r\n";
|
||||
let context = crate::source_navigation::SymbolContext {
|
||||
session: Id::new_v4(),
|
||||
path: "救援/assess.arcadia".into(),
|
||||
};
|
||||
for (key, physical_key, modifiers, payload, inserted) in [
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT,
|
||||
" ",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Key::Character(" ".into()),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT,
|
||||
" ",
|
||||
false,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::empty(),
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::CTRL | Modifiers::ALT,
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Character("e".into()),
|
||||
Physical::Code(Code::KeyE),
|
||||
Modifiers::CTRL | Modifiers::ALT,
|
||||
"€",
|
||||
true,
|
||||
),
|
||||
(
|
||||
Key::Named(Named::Space),
|
||||
Physical::Code(Code::Space),
|
||||
Modifiers::ALT | Modifiers::SHIFT,
|
||||
" ",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let mut content = Content::<FontLayout>::with_text(baseline);
|
||||
content.move_to(widget::text_editor::Cursor {
|
||||
position: widget::text_editor::Position { line: 0, column: 5 },
|
||||
selection: None,
|
||||
});
|
||||
let press = KeyPress {
|
||||
key: key.clone(),
|
||||
modified_key: key.clone(),
|
||||
physical_key,
|
||||
modifiers,
|
||||
text: Some(payload.into()),
|
||||
status: Status::Focused { is_hovered: false },
|
||||
};
|
||||
assert!(
|
||||
matches!(
|
||||
Binding::<Message>::from_key_press(press),
|
||||
Some(Binding::Insert(_))
|
||||
),
|
||||
"the supplied event must expose the default insertion path"
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
{
|
||||
let mut editor: View<'_, Message, iced::Theme, FontLayout> =
|
||||
widget::text_editor(&content)
|
||||
.on_action(Message::SourceEdit)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(Fill)
|
||||
.key_binding(|press| source_binding(press, &context, None))
|
||||
.into();
|
||||
let mut tree = core_widget::Tree::new(editor.as_widget());
|
||||
let size = Size::new(600., 240.);
|
||||
let node = editor.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, size),
|
||||
);
|
||||
tree.state
|
||||
.downcast_mut::<State<shaped::highlighter::PlainText>>()
|
||||
.focus();
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: key.clone(),
|
||||
modified_key: key,
|
||||
physical_key,
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: Some(payload.into()),
|
||||
repeat: false,
|
||||
});
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
editor.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(size),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
usize::from(inserted),
|
||||
"modifiers={modifiers:?}"
|
||||
);
|
||||
for message in messages {
|
||||
let Message::SourceEdit(action) = message else {
|
||||
panic!("unexpected navigation action");
|
||||
};
|
||||
content.perform(action);
|
||||
}
|
||||
let expected = if inserted {
|
||||
format!("// is{payload} excluded\r\n// 雨\r\n")
|
||||
} else {
|
||||
baseline.into()
|
||||
};
|
||||
assert_eq!(content.text(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn checks(app: &App) -> View<'_, Message> {
|
||||
let issues = &app.model_issues;
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
source_navigation::{
|
||||
SymbolEvent,
|
||||
query::{Declaration, Disposition, Evidence, Identity, Membership, Position},
|
||||
},
|
||||
style,
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{button, column, container, scrollable, text, tooltip},
|
||||
};
|
||||
|
||||
pub(super) fn panel(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::text_workspace::{DrawerTab, Event};
|
||||
use std::collections::BTreeSet;
|
||||
let state = &app.source_symbols;
|
||||
let visible = app.text_workspace.drawer() == Some(DrawerTab::References);
|
||||
let presentation = app.text_workspace.references();
|
||||
let mut content = column![].spacing(0).width(Fill);
|
||||
if visible && let Some(notice) = &state.notice {
|
||||
content = content.push(text(notice.clone()).size(12));
|
||||
}
|
||||
if visible && let Some(results) = &state.results {
|
||||
let stale = results.stamp.snapshot != app.source_key();
|
||||
let enabled = !stale && app.source_navigation_available();
|
||||
let files: BTreeSet<_> = results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.chain(results.references.iter().map(|row| row.path.as_str()))
|
||||
.collect();
|
||||
let mut ordered: Vec<_> = files.iter().copied().collect();
|
||||
// Keep the query's captured origin first even after navigating elsewhere.
|
||||
if let Some(origin) = state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| request.path.as_str())
|
||||
{
|
||||
ordered.sort_by_key(|path| (*path != origin, *path));
|
||||
}
|
||||
for path in ordered {
|
||||
let collapsed = presentation.collapsed(path);
|
||||
let count = results
|
||||
.definitions
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count()
|
||||
+ results
|
||||
.references
|
||||
.iter()
|
||||
.filter(|row| row.path == path)
|
||||
.count();
|
||||
let suffix = distinguishing_path(path, &files);
|
||||
content = content.push(
|
||||
button(
|
||||
text(format!(
|
||||
"{} {suffix} · {count}",
|
||||
if collapsed { "▸" } else { "▾" }
|
||||
))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.height(24)
|
||||
.width(Fill)
|
||||
.padding([2, 6])
|
||||
.style(style::nav)
|
||||
.on_press_maybe(app.source_navigation_available().then(|| {
|
||||
Message::TextWorkspace(Event::ToggleFile {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path: path.to_owned(),
|
||||
})
|
||||
})),
|
||||
);
|
||||
if collapsed {
|
||||
continue;
|
||||
}
|
||||
for (index, definition) in results
|
||||
.definitions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
content = content.push(location_row(
|
||||
declaration_title(&definition.declaration),
|
||||
path,
|
||||
definition.position,
|
||||
provenance(
|
||||
definition.membership,
|
||||
definition.evidence,
|
||||
definition.declaration.identity,
|
||||
false,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: false,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
for (index, reference) in results
|
||||
.references
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.path == path)
|
||||
{
|
||||
let title = reference.context.as_ref().map_or_else(
|
||||
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|
||||
|context| format!("use · {}", declaration_title(context)),
|
||||
);
|
||||
content = content.push(location_row(
|
||||
title,
|
||||
path,
|
||||
reference.position,
|
||||
provenance(
|
||||
reference.membership,
|
||||
reference.evidence,
|
||||
reference
|
||||
.context
|
||||
.as_ref()
|
||||
.map_or(Identity::Missing, |context| context.identity),
|
||||
true,
|
||||
),
|
||||
enabled.then(|| {
|
||||
app.source_symbol_message(SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference: true,
|
||||
index,
|
||||
})
|
||||
}),
|
||||
));
|
||||
}
|
||||
// Full path is readable by ordinary scrolling, independently of hover.
|
||||
content = content.push(
|
||||
container(
|
||||
text(path.to_owned())
|
||||
.font(iced::Font::MONOSPACE)
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.padding([3, 8]),
|
||||
);
|
||||
}
|
||||
let scope = &results.scope;
|
||||
let explanation = match results.disposition {
|
||||
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
|
||||
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
|
||||
Disposition::Choices => {
|
||||
"Choose a declaration; draft and detached candidates need an explicit choice."
|
||||
}
|
||||
Disposition::Direct => "Definition in the current sources.",
|
||||
Disposition::LexicalReferences => {
|
||||
"Lexical uses of this alias; these do not establish a semantic target."
|
||||
}
|
||||
};
|
||||
content = content.push(text(explanation).size(12));
|
||||
if stale {
|
||||
content = content.push(
|
||||
text("Source changed. Run Definition or References again for current locations.")
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if results.total_definitions > 1 {
|
||||
content = content.push(
|
||||
text("Alias collision: uses are not attributed to a single declaration.").size(12),
|
||||
);
|
||||
}
|
||||
content = content
|
||||
.push(
|
||||
text(format!(
|
||||
"{} · {} included / {} detached / {} unverified files · {} scope issues",
|
||||
if scope.partial() {
|
||||
"Partial include scope"
|
||||
} else {
|
||||
"Complete include scope"
|
||||
},
|
||||
scope.included_files,
|
||||
scope.detached_files,
|
||||
scope.unverified_files,
|
||||
scope.issues.len()
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(if !scope.applied {
|
||||
"Unapplied drafts"
|
||||
} else if scope.validated {
|
||||
"Validated sources"
|
||||
} else {
|
||||
"Draft sources"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Declarations",
|
||||
results.definitions.len(),
|
||||
results.total_definitions,
|
||||
results.omitted_definitions,
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
text(counts(
|
||||
"Lexical uses",
|
||||
results.references.len(),
|
||||
results.total_references,
|
||||
results.omitted_references,
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
let identity = if visible {
|
||||
state
|
||||
.display_request
|
||||
.as_ref()
|
||||
.map(|request| (request.job, app.text_workspace.drawer_visit()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let guarded = crate::diagnostic_input::Guard::new(identity, content);
|
||||
let mut body = scrollable(
|
||||
container(guarded)
|
||||
.width(Fill)
|
||||
.padding(iced::Padding::ZERO.right(14)),
|
||||
)
|
||||
.id(app.text_workspace.reference_scroll_id())
|
||||
.height(Fill)
|
||||
.width(Fill);
|
||||
if let Some(stamp) = presentation.stamp() {
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
body = body.on_scroll(move |viewport| {
|
||||
Message::TextWorkspace(Event::ReferenceScrolled {
|
||||
stamp,
|
||||
visit,
|
||||
y: viewport.absolute_offset().y,
|
||||
})
|
||||
});
|
||||
}
|
||||
body.into()
|
||||
}
|
||||
|
||||
/// Shortest suffix that still distinguishes duplicate basenames and suffix paths.
|
||||
fn distinguishing_path<'a>(path: &'a str, files: &std::collections::BTreeSet<&str>) -> &'a str {
|
||||
let starts = std::iter::once(0).chain(path.match_indices('/').map(|(index, _)| index + 1));
|
||||
for start in starts.collect::<Vec<_>>().into_iter().rev() {
|
||||
let candidate = &path[start..];
|
||||
if files
|
||||
.iter()
|
||||
.filter(|other| {
|
||||
**other == candidate
|
||||
|| other
|
||||
.strip_suffix(candidate)
|
||||
.is_some_and(|prefix| prefix.ends_with('/'))
|
||||
})
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
|
||||
format!("{label}: {total} total · {shown} shown · {omitted} omitted")
|
||||
}
|
||||
|
||||
fn declaration_title(declaration: &Declaration) -> String {
|
||||
let name = declaration
|
||||
.name
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |name| format!(" \"{}\"", preview(name, 48)));
|
||||
format!(
|
||||
"{}{} · @{}",
|
||||
preview(&declaration.kind, 24),
|
||||
name,
|
||||
preview(&declaration.alias, 48)
|
||||
)
|
||||
}
|
||||
|
||||
fn provenance(
|
||||
membership: Membership,
|
||||
evidence: Evidence,
|
||||
identity: Identity,
|
||||
context: bool,
|
||||
) -> String {
|
||||
let membership = match membership {
|
||||
Membership::Included => "Included",
|
||||
Membership::Detached => "Detached",
|
||||
Membership::Unverified => "Unverified",
|
||||
};
|
||||
let evidence = match evidence {
|
||||
Evidence::Validated => "Validated",
|
||||
Evidence::Draft => "Draft",
|
||||
};
|
||||
let collision = match identity {
|
||||
Identity::Collision { count } => format!(
|
||||
" · {}ID collision ({count})",
|
||||
if context { "Context " } else { "" },
|
||||
),
|
||||
Identity::Missing | Identity::Unique(_) => String::new(),
|
||||
};
|
||||
format!("{membership} · {evidence}{collision}")
|
||||
}
|
||||
|
||||
fn location_row(
|
||||
title: String,
|
||||
path: &str,
|
||||
position: Position,
|
||||
status: String,
|
||||
event: Option<Message>,
|
||||
) -> Element<'static, Message> {
|
||||
let line = position.line.saturating_add(1);
|
||||
let column = position.byte_column.saturating_add(1);
|
||||
let full =
|
||||
format!("{path}:{line}:{column} · {title} · {status} (1-based line and UTF-8 byte column)");
|
||||
tooltip(
|
||||
button(
|
||||
container(
|
||||
text(format!("{line}:{column} {title} · {status}"))
|
||||
.size(12)
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
)
|
||||
.clip(true)
|
||||
.width(Fill),
|
||||
)
|
||||
.padding([3, 12])
|
||||
.height(28)
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press_maybe(event),
|
||||
text(full).size(12),
|
||||
tooltip::Position::Top,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Keep headings compact without copying the remainder of a long admitted value.
|
||||
fn preview(value: &str, limit: usize) -> String {
|
||||
let mut chars = value.chars();
|
||||
let mut result: String = chars
|
||||
.by_ref()
|
||||
.take(limit)
|
||||
.map(|ch| if ch.is_whitespace() { ' ' } else { ch })
|
||||
.collect();
|
||||
if chars.next().is_some() {
|
||||
result.push('…');
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn suffixes_distinguish_nested_duplicate_basenames_without_changing_unicode() {
|
||||
let files = [
|
||||
"a/core.arcadia",
|
||||
"b/core.arcadia",
|
||||
"core.arcadia",
|
||||
"救援/actors.arcadia",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
distinguishing_path("a/core.arcadia", &files),
|
||||
"a/core.arcadia"
|
||||
);
|
||||
assert_eq!(
|
||||
distinguishing_path("b/core.arcadia", &files),
|
||||
"b/core.arcadia"
|
||||
);
|
||||
assert_eq!(distinguishing_path("core.arcadia", &files), "core.arcadia");
|
||||
assert_eq!(
|
||||
distinguishing_path("救援/actors.arcadia", &files),
|
||||
"actors.arcadia"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
//! Shapes and draws the production summary, checking actual paragraph advances.
|
||||
//! Native validation remains necessary for platform raster ink/fallback fonts.
|
||||
use super::*;
|
||||
use crate::source_navigation::query;
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, layout, renderer, text as shaped, widget::Tree};
|
||||
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Cluster {
|
||||
paragraph: String,
|
||||
bytes: std::ops::Range<usize>,
|
||||
bounds: Rectangle,
|
||||
clip: Rectangle,
|
||||
}
|
||||
struct Recorder {
|
||||
clips: Vec<Rectangle>,
|
||||
transforms: Vec<Transformation>,
|
||||
clusters: Vec<Cluster>,
|
||||
}
|
||||
impl Recorder {
|
||||
fn transform(&self) -> Transformation {
|
||||
*self.transforms.last().unwrap()
|
||||
}
|
||||
fn clip(&self, bounds: Rectangle) -> Rectangle {
|
||||
self.clips
|
||||
.last()
|
||||
.unwrap()
|
||||
.intersection(&(bounds * self.transform()))
|
||||
.unwrap_or(Rectangle::with_size(Size::ZERO))
|
||||
}
|
||||
}
|
||||
impl renderer::Renderer for Recorder {
|
||||
fn start_layer(&mut self, bounds: Rectangle) {
|
||||
self.clips.push(self.clip(bounds));
|
||||
}
|
||||
fn end_layer(&mut self) {
|
||||
self.clips.pop().unwrap();
|
||||
}
|
||||
fn start_transformation(&mut self, transform: Transformation) {
|
||||
self.transforms.push(self.transform() * transform);
|
||||
}
|
||||
fn end_transformation(&mut self) {
|
||||
self.transforms.pop().unwrap();
|
||||
}
|
||||
fn reset(&mut self, bounds: Rectangle) {
|
||||
self.clips = vec![bounds];
|
||||
self.transforms = vec![Transformation::IDENTITY];
|
||||
self.clusters.clear();
|
||||
}
|
||||
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
|
||||
fn allocate_image(
|
||||
&mut self,
|
||||
handle: &iced::advanced::image::Handle,
|
||||
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) {
|
||||
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
|
||||
}
|
||||
}
|
||||
impl shaped::Renderer for Recorder {
|
||||
type Font = Font;
|
||||
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
|
||||
type Editor = <FontLayout as shaped::Renderer>::Editor;
|
||||
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
|
||||
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
|
||||
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
|
||||
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
|
||||
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
|
||||
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
|
||||
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
|
||||
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
|
||||
fn default_font(&self) -> Font {
|
||||
shaped::Renderer::default_font(&FontLayout)
|
||||
}
|
||||
fn default_size(&self) -> Pixels {
|
||||
shaped::Renderer::default_size(&FontLayout)
|
||||
}
|
||||
fn fill_paragraph(
|
||||
&mut self,
|
||||
paragraph: &Self::Paragraph,
|
||||
position: Point,
|
||||
_: Color,
|
||||
clip: Rectangle,
|
||||
) {
|
||||
let buffer = paragraph.buffer();
|
||||
let content = buffer
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let clip = self.clip(clip);
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
self.clusters.push(Cluster {
|
||||
paragraph: content.clone(),
|
||||
bytes: glyph.start..glyph.end,
|
||||
bounds: Rectangle {
|
||||
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
|
||||
y: position.y + run.line_top,
|
||||
width: glyph.w,
|
||||
height: run.line_height,
|
||||
} * self.transform(),
|
||||
clip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
|
||||
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
|
||||
}
|
||||
|
||||
const FLAGS: [&str; 5] = ["Stale", "Partial", "Collision", "Detached", "Unverified"];
|
||||
|
||||
fn draw_and_check_flags(
|
||||
content: Element<'_, Message, iced::Theme, Recorder>,
|
||||
width: f32,
|
||||
) -> Vec<String> {
|
||||
let viewport = Rectangle::with_size(Size::new(width, 24.));
|
||||
let mut renderer = Recorder {
|
||||
clips: vec![viewport],
|
||||
transforms: vec![Transformation::IDENTITY],
|
||||
clusters: Vec::new(),
|
||||
};
|
||||
let mut view: Element<'_, Message, iced::Theme, Recorder> =
|
||||
container(content).width(Fill).height(24).clip(true).into();
|
||||
let mut tree = Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&renderer,
|
||||
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||
);
|
||||
assert_eq!(node.size(), viewport.size());
|
||||
view.as_widget().draw(
|
||||
&tree,
|
||||
&mut renderer,
|
||||
&iced::Theme::Light,
|
||||
&renderer::Style::default(),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&viewport,
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
for flag in FLAGS {
|
||||
for byte in 0..flag.len() {
|
||||
let matching: Vec<_> = renderer
|
||||
.clusters
|
||||
.iter()
|
||||
.filter(|cluster| {
|
||||
cluster
|
||||
.paragraph
|
||||
.find(flag)
|
||||
.is_some_and(|start| cluster.bytes.contains(&(start + byte)))
|
||||
})
|
||||
.collect();
|
||||
if matching.is_empty() {
|
||||
failures.push(format!("missing shaped flag byte: {flag}[{byte}]"));
|
||||
}
|
||||
for cluster in matching {
|
||||
let b = cluster.bounds;
|
||||
let c = cluster.clip;
|
||||
if !(b.x >= c.x - 0.5
|
||||
&& b.y >= c.y - 0.5
|
||||
&& b.x + b.width <= c.x + c.width + 0.5
|
||||
&& b.y + b.height <= c.y + c.height + 0.5)
|
||||
{
|
||||
failures.push(format!("flag {flag} is clipped: glyph {b:?}, clip {c:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
|
||||
fn old_summary(value: String) -> Element<'static, Message, iced::Theme, Recorder> {
|
||||
text(value)
|
||||
.size(12)
|
||||
.wrapping(widget::text::Wrapping::None)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_reference_exceptions_fit_allocated_width_with_wide_alias_and_large_counts() {
|
||||
let mut app = App::with_project(oa_core::Project::empty());
|
||||
let old_key = App::with_project(oa_core::Project::empty()).source_key();
|
||||
assert_ne!(old_key, app.source_key());
|
||||
for alias in ["雨".repeat(32), "W".repeat(32)] {
|
||||
// Unicode aliases are presentation stress: the current lexer admits
|
||||
// ASCII identifiers only. Six-digit totals are within the query's
|
||||
// 250,000-record cap; this does not build that many source records.
|
||||
let old_value = format!(
|
||||
"@{alias} · 100000 declarations · 100000 uses · Stale · Partial · Collision · Detached · Unverified"
|
||||
);
|
||||
app.source_symbols.results = Some(Arc::new(query::Results {
|
||||
stamp: query::QueryStamp {
|
||||
snapshot: old_key,
|
||||
serial: 1,
|
||||
},
|
||||
action: query::Action::References,
|
||||
origin_path: app.source_workspace.active.clone(),
|
||||
origin_offset: 0,
|
||||
origin: None,
|
||||
alias: Some(alias.clone()),
|
||||
scope: query::Scope {
|
||||
closure_complete: false,
|
||||
applied: false,
|
||||
validated: false,
|
||||
included_files: 28,
|
||||
detached_files: 1,
|
||||
unverified_files: 1,
|
||||
issues: Vec::new(),
|
||||
},
|
||||
disposition: query::Disposition::LexicalReferences,
|
||||
definitions: Vec::new(),
|
||||
references: Vec::new(),
|
||||
total_definitions: 100_000,
|
||||
total_references: 100_000,
|
||||
omitted_definitions: 100_000,
|
||||
omitted_references: 100_000,
|
||||
}));
|
||||
for width in [840., 720.] {
|
||||
let positive = draw_and_check_flags(reference_summary(&app), width);
|
||||
assert!(
|
||||
positive.is_empty(),
|
||||
"reserved flags, width {width}, alias {alias:?}: {positive:?}"
|
||||
);
|
||||
// Observe the exact old presentation without predicting font widths.
|
||||
// This is emitted under --nocapture, whether it clips or fits.
|
||||
let old = draw_and_check_flags(old_summary(old_value.clone()), width);
|
||||
println!(
|
||||
"old alias-first summary width={width} alias={alias:?}: {} missing/clipped flag-byte checks; first={:?}",
|
||||
old.len(),
|
||||
old.first()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Deliberate evaluator stress, beyond the production 32-character subject
|
||||
// bound: require the same evaluator to reject a clipped old-style paragraph.
|
||||
// This is not evidence of a production/native overflow at 840 pixels.
|
||||
let stress = format!("@{} · {}", "W".repeat(256), FLAGS.join(" · "));
|
||||
let rejected = draw_and_check_flags(old_summary(stress), 720.);
|
||||
assert!(
|
||||
!rejected.is_empty(),
|
||||
"negative control must detect missing/clipped warning text"
|
||||
);
|
||||
}
|
||||
@@ -2633,6 +2633,62 @@ trace links, occurrence identity, comments and file ownership. Each open file
|
||||
retains its own unapplied draft across file navigation. Located lint diagnostics
|
||||
refer to the raw draft bytes rather than an identity-stabilized intermediate.
|
||||
Property editing MUST NOT silently discard a pending source draft.</p>
|
||||
<p>In Textual model, place the caret on a declaration alias or an <span class="docutils literal">@reference</span>
|
||||
and choose <strong>Definition</strong> (<span class="docutils literal">F12</span>) or <strong>References</strong> (<span class="docutils literal">Shift+F12</span>).
|
||||
<strong>Back</strong> (<span class="docutils literal">Alt+Left</span>) returns to the previous source location in this
|
||||
workspace. These shortcuts act while the source editor has focus; the Text
|
||||
toolbar also exposes the actions. If the previous draft changed, Back restores
|
||||
its current cursor rather than reusing an obsolete selection.</p>
|
||||
<p>Symbol navigation reads bounded snapshots of the currently loaded source
|
||||
drafts. It does not apply drafts or load missing included files. The results
|
||||
identify partial scope, draft evidence, detached or unverified files, alias
|
||||
ambiguity and authored-ID collisions. Draft, detached and ambiguous definition
|
||||
candidates require an explicit row choice. References are lexical uses of an
|
||||
alias, not a list of resolved typed relationships; comments and strings are
|
||||
excluded. Declaration and use counts include any rows omitted by the result
|
||||
limits. If sources change, stale rows cannot navigate: run the query again.
|
||||
Navigation MUST preserve the accepted model and every source draft, and select
|
||||
locations in the original UTF-8 bytes. Displayed locations use one-based lines
|
||||
and UTF-8 byte columns.</p>
|
||||
<p><strong>Rename alias</strong> (<span class="docutils literal">F2</span> while the source editor has focus) changes the alias
|
||||
under the caret or its exact selection. It requires a unique authored identity
|
||||
in the complete accepted source bundle, with no pending source, property or
|
||||
behavior drafts and no extra loaded detached files. Accepted but unsaved source
|
||||
is eligible. The operation MUST NOT implicitly apply a draft or allocate an ID.
|
||||
The engineering name and UUID remain unchanged.</p>
|
||||
<p>The <strong>Source alias</strong> field does not trim or normalize the entered alias. Pasting
|
||||
line breaks, tabs or other control characters is explicitly rejected as a whole:
|
||||
the field and its selection remain unchanged, and any previous preview approval
|
||||
is invalidated. <strong>Preview</strong>
|
||||
prepares a typed alias change and displays every changed file and source line;
|
||||
comments, strings, engineering data, file membership and declaration ownership
|
||||
remain unchanged. Enter in the field requests Preview, never confirmation.
|
||||
The preview is for this operation against accepted source, so a later Save may
|
||||
also contain earlier accepted edits. A changing rename requires enough model
|
||||
history capacity to retain its immediate Undo. An invalid, identical, stale or
|
||||
oversized candidate cannot enable confirmation.</p>
|
||||
<p>The preview MUST provide access to all admitted files, hunks, lines and long-line
|
||||
segments. Refuse a candidate whose complete preview exceeds the editor's bounds:
|
||||
256 changed files, 50,000 changed lines or 8 MiB of displayed source bytes
|
||||
(both versions of changed/context lines and file paths). Index metadata is
|
||||
separately bounded to seven retained line records per changed line. These
|
||||
limits do not restrict the language itself. A changed-line count is not a count
|
||||
of semantic references. Cancel, Preview and Rename alias remain reachable
|
||||
outside the independently scrolling source preview.</p>
|
||||
<p>Explicit <strong>Rename alias</strong> confirms the current preview once, as one accepted
|
||||
model/source transaction. A source, document, recovery or replacement change
|
||||
invalidates its approval even if later bytes match the old preview. Closing the
|
||||
form cancels approval; a late worker result MUST NOT reopen it. Rejected,
|
||||
canceled and unchanged operations preserve accepted bytes, drafts, save state,
|
||||
revisions, selection and both histories. Successful rename selects the new alias
|
||||
at the original occurrence rather than the first matching spelling in the file.
|
||||
Editing the replacement permits a new Preview in the same form. After a source
|
||||
context change, close the form and invoke Rename again on the current alias.</p>
|
||||
<p><strong>Undo rename</strong> is guarded against intervening edits and uses model history;
|
||||
the persistent <strong>Undo model</strong> and <strong>Redo model</strong> controls restore the complete
|
||||
accepted snapshots. Text <span class="docutils literal">Ctrl+Z</span> remains <strong>Undo text</strong>. Save is separate and
|
||||
uses the ordinary guarded source-save contract. An unconfirmed preview and Undo
|
||||
history are not persisted on reopening.</p>
|
||||
<p>Changing a graphical UUID must atomically update map keys and every reference.
|
||||
Alias changes preserve UUID identity. Deletion must account for owned elements,
|
||||
incident relationships, ordered occurrences, and diagram placements without
|
||||
|
||||
@@ -2054,6 +2054,69 @@ retains its own unapplied draft across file navigation. Located lint diagnostics
|
||||
refer to the raw draft bytes rather than an identity-stabilized intermediate.
|
||||
Property editing MUST NOT silently discard a pending source draft.
|
||||
|
||||
In Textual model, place the caret on a declaration alias or an ``@reference``
|
||||
and choose **Definition** (``F12``) or **References** (``Shift+F12``).
|
||||
**Back** (``Alt+Left``) returns to the previous source location in this
|
||||
workspace. These shortcuts act while the source editor has focus; the Text
|
||||
toolbar also exposes the actions. If the previous draft changed, Back restores
|
||||
its current cursor rather than reusing an obsolete selection.
|
||||
|
||||
Symbol navigation reads bounded snapshots of the currently loaded source
|
||||
drafts. It does not apply drafts or load missing included files. The results
|
||||
identify partial scope, draft evidence, detached or unverified files, alias
|
||||
ambiguity and authored-ID collisions. Draft, detached and ambiguous definition
|
||||
candidates require an explicit row choice. References are lexical uses of an
|
||||
alias, not a list of resolved typed relationships; comments and strings are
|
||||
excluded. Declaration and use counts include any rows omitted by the result
|
||||
limits. If sources change, stale rows cannot navigate: run the query again.
|
||||
Navigation MUST preserve the accepted model and every source draft, and select
|
||||
locations in the original UTF-8 bytes. Displayed locations use one-based lines
|
||||
and UTF-8 byte columns.
|
||||
|
||||
**Rename alias** (``F2`` while the source editor has focus) changes the alias
|
||||
under the caret or its exact selection. It requires a unique authored identity
|
||||
in the complete accepted source bundle, with no pending source, property or
|
||||
behavior drafts and no extra loaded detached files. Accepted but unsaved source
|
||||
is eligible. The operation MUST NOT implicitly apply a draft or allocate an ID.
|
||||
The engineering name and UUID remain unchanged.
|
||||
|
||||
The **Source alias** field does not trim or normalize the entered alias. Pasting
|
||||
line breaks, tabs or other control characters is explicitly rejected as a whole:
|
||||
the field and its selection remain unchanged, and any previous preview approval
|
||||
is invalidated. **Preview**
|
||||
prepares a typed alias change and displays every changed file and source line;
|
||||
comments, strings, engineering data, file membership and declaration ownership
|
||||
remain unchanged. Enter in the field requests Preview, never confirmation.
|
||||
The preview is for this operation against accepted source, so a later Save may
|
||||
also contain earlier accepted edits. A changing rename requires enough model
|
||||
history capacity to retain its immediate Undo. An invalid, identical, stale or
|
||||
oversized candidate cannot enable confirmation.
|
||||
|
||||
The preview MUST provide access to all admitted files, hunks, lines and long-line
|
||||
segments. Refuse a candidate whose complete preview exceeds the editor's bounds:
|
||||
256 changed files, 50,000 changed lines or 8 MiB of displayed source bytes
|
||||
(both versions of changed/context lines and file paths). Index metadata is
|
||||
separately bounded to seven retained line records per changed line. These
|
||||
limits do not restrict the language itself. A changed-line count is not a count
|
||||
of semantic references. Cancel, Preview and Rename alias remain reachable
|
||||
outside the independently scrolling source preview.
|
||||
|
||||
Explicit **Rename alias** confirms the current preview once, as one accepted
|
||||
model/source transaction. A source, document, recovery or replacement change
|
||||
invalidates its approval even if later bytes match the old preview. Closing the
|
||||
form cancels approval; a late worker result MUST NOT reopen it. Rejected,
|
||||
canceled and unchanged operations preserve accepted bytes, drafts, save state,
|
||||
revisions, selection and both histories. Successful rename selects the new alias
|
||||
at the original occurrence rather than the first matching spelling in the file.
|
||||
Editing the replacement permits a new Preview in the same form. After a source
|
||||
context change, close the form and invoke Rename again on the current alias.
|
||||
|
||||
**Undo rename** is guarded against intervening edits and uses model history;
|
||||
the persistent **Undo model** and **Redo model** controls restore the complete
|
||||
accepted snapshots. Text ``Ctrl+Z`` remains **Undo text**. Save is separate and
|
||||
uses the ordinary guarded source-save contract. An unconfirmed preview and Undo
|
||||
history are not persisted on reopening.
|
||||
|
||||
Changing a graphical UUID must atomically update map keys and every reference.
|
||||
Alias changes preserve UUID identity. Deletion must account for owned elements,
|
||||
incident relationships, ordered occurrences, and diagram placements without
|
||||
|
||||
+12837
-12837
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
# Source CLI
|
||||
|
||||
Use `arcadia` to check or update local `.arcadia` projects without opening Studio
|
||||
or starting a server. Pass the entry file explicitly; its relative include paths
|
||||
resolve from their containing source files.
|
||||
|
||||
Build with `cargo build --locked -p oa-cli --bin arcadia`. The resulting command
|
||||
is `target/debug/arcadia.exe` on Windows or `target/debug/arcadia` on Linux unless
|
||||
your Cargo configuration chooses another target directory. A workspace release
|
||||
build also includes it. Use the platform's usual executable path prefix when
|
||||
running these examples.
|
||||
|
||||
```sh
|
||||
arcadia check project.arcadia
|
||||
arcadia check project.arcadia --json
|
||||
arcadia stabilize project.arcadia
|
||||
arcadia stabilize project.arcadia --write
|
||||
arcadia rename-alias project.arcadia old_alias new_alias
|
||||
arcadia rename-alias project.arcadia old_alias new_alias --write --json
|
||||
```
|
||||
|
||||
`lint` is an alias for `check`. Checks accept incomplete engineering models with
|
||||
warnings; syntax errors and structural violations fail. Located diagnostics
|
||||
refer to the named file and coordinate space. Line and column numbers are
|
||||
one-based and columns count Unicode scalar values; byte ranges are half-open
|
||||
UTF-8 offsets.
|
||||
|
||||
`stabilize` previews missing persistent identities and any supported source
|
||||
migration. `rename-alias` changes one existing global alias and its typed
|
||||
references while preserving comments, strings and unrelated source. Pass alias
|
||||
names without `@`. The command does not rename an element's display name.
|
||||
|
||||
Both editing commands preview by default. Human output writes unified file
|
||||
diffs to stdout and diagnostics to stderr. `--json` writes one versioned report
|
||||
with diagnostics, migration information, exact before/after text for every
|
||||
changed file, diffs and the write result. Unchanged files are not included in
|
||||
that report. They are still checked for conflicts before a write.
|
||||
|
||||
Add `--write` to save the candidate prepared by that invocation. Even an empty
|
||||
diff checks the captured files and pending-save state before returning. A failed
|
||||
write reports `written: false` and retains its proposed diff. This does not
|
||||
guarantee rollback; the reported conflict or recovery outcome determines the
|
||||
next step. A pending save
|
||||
requires explicit recovery through the existing project recovery workflow;
|
||||
the CLI does not recover it automatically.
|
||||
|
||||
Missing IDs in a separate earlier preview remain provisional. A later command
|
||||
may allocate different IDs until you save them. This CLI does not persist a
|
||||
review plan for later application. It does not canonicalize the whole project
|
||||
or delete detached files. Source versions 3–7 use the core's supported migration
|
||||
rules; invalid source is not rewritten.
|
||||
|
||||
| Exit code | Meaning |
|
||||
| --- | --- |
|
||||
| 0 | Valid check or successful preview/write; warnings may remain |
|
||||
| 1 | Invalid source or refused semantic alias change |
|
||||
| 2 | Invalid command arguments |
|
||||
| 3 | File/path, conflict, pending-save or output failure |
|
||||
|
||||
Admission bounds match the desktop: 256 files, include depth 32, 1024 include
|
||||
edges, 64 MiB total UTF-8, 100,000 graph objects, 250,000 records and 500,000
|
||||
ordered references. `arcadia --help` lists the commands and bounds. These limits
|
||||
are not a claim of interactive performance at the maximum project size.
|
||||
|
||||
For the notation itself, start with [the text-first examples](../examples/text-first/README.md)
|
||||
and the [language contract](language.rst).
|
||||
+78
-26
@@ -1,3 +1,9 @@
|
||||
> Historical record — superseded on 2026-09-11. Live plans, ownership and blockers
|
||||
> were migrated once to [Gitea issue 1, comment 408](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-408)
|
||||
> and [issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2).
|
||||
> Do not update this as a live ledger. [STATE.md](STATE.md) links the verified integrated result.
|
||||
> The historical text below is preserved; its old active-status wording is not current guidance.
|
||||
|
||||
# First release execution plan
|
||||
|
||||
Updated 2026-09-11, Codex-9e8r6t `/root`.
|
||||
@@ -5,27 +11,50 @@ Updated 2026-09-11, Codex-9e8r6t `/root`.
|
||||
[TASK_LEDGER.md](TASK_LEDGER.md) separates evidence from assumptions;
|
||||
[STATE.md](STATE.md) owns active leases and results.
|
||||
|
||||
Resumed after the user's virtualization/reboot update. STATE.md supersedes the
|
||||
historical [RESTART_HANDOFF.md](RESTART_HANDOFF.md) for live status. Postboot
|
||||
focused3 ran25 passing tests and one failing overflow-fixture assertion; its
|
||||
test-only correction passed all26 focused4 tests. Strict Clippy, formatting and
|
||||
matching GUI5657 build also passed; fresh native acceptance continues.
|
||||
Nondeleting compression completed across282 generated binaries with identical
|
||||
hashes and11.78GB free; supported Ubuntu26.04 installation passed exit0. Actual
|
||||
root shell identifies Ubuntu26.04/WSL2. User328 replaces failed mirrored mode
|
||||
with VirtioProxy; the VPN is immutable. Both new guest startups timed out before
|
||||
execution, then the distinct WslService restart failed at OS access permissions.
|
||||
All60 apt archives and five Rust components are downloaded and hash-verified;
|
||||
guest signature verification/install and Linux Cargo remain pending. No compiler
|
||||
or WSL client lease is active. The user has now paused Linux builds/checking
|
||||
until they report WSL networking resolved; do not schedule more Linux setup,
|
||||
launches, diagnostics or checks before that notice (Gitea comment356).
|
||||
Lifeline/Chain history and Machine Create/Undo/postboot reopen passed on GUI52;
|
||||
Fresh GUI5657 cancellation/recovery, Lifeline direct creation/history and Machine
|
||||
minimum-size owner/Create/Undo/Redo/reopen now pass; full679 desktop tests pass.
|
||||
|
||||
## Immediate implementation and acceptance
|
||||
|
||||
1. Preserve the verified creation integration and matching GUI stage, committed
|
||||
as 4bb4aaf and transferred with independent anonymous readback/hash verification. Final immutable
|
||||
desktop653/653 passed after creation ancestry/guide changes, alongside focused
|
||||
creation21/guide3. StrictClippy/fmt pass after a behavior-equivalent history
|
||||
rewrite; affected history63 and GUI build also pass. Preserve exact artifact
|
||||
scopes and serialize future Cargo/source leases.
|
||||
2. Named process/scenario/machine creation, exact Undo/Redo and nine-file reopen
|
||||
have passed at actual minimum size. Wide/minimum cancellation passed; SA draft
|
||||
crash recovery is active. Address observed blank canvases and prerequisite
|
||||
creation detours through the staged-source plan: source subform retains the
|
||||
parent draft and final adoption atomically creates prerequisite and consumer.
|
||||
First slice covers Lifeline, process Work and machine owner, with semantic
|
||||
first-action prompts. Independent review precedes final integration/native QA.
|
||||
3. Operate the rebuilt app at minimum/normal sizes. Complete the saved basic
|
||||
1. Preserve and publish the completed native acceptance of GUI `5657BAB5…D3846CE` using
|
||||
the [v2 plan](release-contextual-source-native-v2-plan-20260911.md). All26
|
||||
affected tests, strict all-target Clippy, formatting and matching build pass;
|
||||
the full679 immutable Windows test run also passed. Minimum-size
|
||||
child visibility, exact recovery/cancel and direct Lifeline Create/Undo/Redo
|
||||
now pass, along with Machine owner visibility/Create/Undo/Redo/reopen. Preserve
|
||||
independent first UUID carriers and serialize future compiler/source leases.
|
||||
Prepare an updated Windows prerelease only with honest per-binary source
|
||||
identity, package verification and clean packaged runtime checks.
|
||||
2. Exercise the implemented source subform for Lifeline, process/chain Work and
|
||||
machine owner, plus semantic first-action prompts. Verify parent-draft
|
||||
retention, explicit prerequisite and consumer adoption, exact source/history,
|
||||
cancellation and recovery against the independent staged-source oracle.
|
||||
Final source guards and guide corrections are complete and reviewed; native
|
||||
acceptance remains; source/evidence are integrated in `df6f595`. Named creation, nine-file reopen and
|
||||
SA pending-draft recovery already have their separate native passes.
|
||||
3. Complete local SA B2 and the saved basic
|
||||
supplementary move/camera/invalid-draft and minimum-size story; the main basic
|
||||
model and explicit transition passed full Project+SourceBundle, Undo/Redo and
|
||||
reopen on candidate3 at wide size. Exercise new
|
||||
scenario workspace, creation and caller return with real inputs and saved
|
||||
model evidence, then correct and reverify concrete defects.
|
||||
reopen on candidate 3 at wide size. OA B2 Action, State, bound Reuse and Fragment
|
||||
now pass complete model/two-source Create, one Undo, one Redo and final OA
|
||||
reopen/Save with exact original identities. Preserve the existing B3 layer/size
|
||||
scope; do not infer SA B2 or broader camera/focus coverage from these passes.
|
||||
4. Integrate standalone B4 scenario lifetimes and the external independent
|
||||
scenario contract without overwriting local layout work. Resolve source-derived
|
||||
lifetime versus insertion bounds, activation-after-Delete and lane-map issues.
|
||||
@@ -39,12 +68,24 @@ The updated objective explicitly requires functional constraints/calculations,
|
||||
quantities/units and maintained model libraries. Current metadata/includes are
|
||||
insufficient. This work runs independently of useful behavior improvements.
|
||||
|
||||
Peer comment 231 ratifies schema 8/local-definition core steps in an isolated
|
||||
worktree. Coordinate those actual shared types and ownership before local
|
||||
integration; the current local core remains descriptive. Source grammar,
|
||||
evaluation, server/CLI/UI representation and acceptance are separate unfinished
|
||||
steps. Peer956 now reports focus regression5/5 and isolated numeric26/26 plus
|
||||
analysis28/28; their codec/lifecycle work remains isolated. Local integration and
|
||||
coherent union gates remain necessary. Do not replace peer efforts with a
|
||||
parallel local implementation. Comment338 separately reserves our bounded
|
||||
syntax-only outline seam for current-text navigation; its private query module
|
||||
and prepared tests remain uncompiled pending review.
|
||||
|
||||
The newest objective also requires complete uncluttered text-only workflows.
|
||||
Combine the local three-task usability audit with the other host's language/
|
||||
identity/API design. Two concise current-grammar recipes now pass their bounded
|
||||
REST identity/source/persistence experiment. Next evaluate
|
||||
cross-file definitions/references, safe alias rename and identity-preserving
|
||||
patch preview on repeated-name modular models. Do not equate shorter examples
|
||||
REST identity/source/persistence experiment. The published CLI now covers checked
|
||||
stabilization and typed alias rename with exact source-preserving preview/write.
|
||||
Next evaluate cross-file definitions/references and engineering task completion
|
||||
on repeated-name modular models. Do not equate shorter examples
|
||||
or parser throughput with task usability, and do not silently change omissions.
|
||||
|
||||
- Compare real candidate semantics before large changes. Use primary Capella,
|
||||
@@ -55,15 +96,23 @@ or parser throughput with task usability, and do not silently change omissions.
|
||||
- Probe libraries through two projects/two instances, pinned offline content,
|
||||
exports/dependency closure, bindings, distinct origin/local identities, updates
|
||||
with local divergence, detachment and exact transactional rollback.
|
||||
- Run a small pure-core experiment for the selected candidate. Independent
|
||||
criticism must precede broad schema/source/API migration. Retain current typed
|
||||
Project, Command and source-writer boundaries unless evidence requires change.
|
||||
- Review and test the peer-ratified pure core and its explicit migration/admission
|
||||
boundaries before local integration. Retain independent falsifiers and current
|
||||
typed Project, Command and source-writer guarantees across representations.
|
||||
- Implement the ratified contracts across validation, visitors, deletion,
|
||||
identity, OA/SA transitions, source/reST, schema/REST, ergonomic graphical
|
||||
authoring and help. Add real examples and independent parity acceptance.
|
||||
|
||||
## Integration and release
|
||||
|
||||
- GUI/server release 1, `v0.2.0-preview.authoring.20260911`, and standalone CLI
|
||||
release 2, `cli-v0.2.0-preview.authoring.20260911`, are published and read back.
|
||||
Their exact notices, archive, runtime and uploaded-asset evidence is preserved.
|
||||
CLI packaging is integrated at `27df4a3`; instruction sync `07eb3cd` and
|
||||
contextual source checkpoint `df6f595` and its raw evidence are integrated.
|
||||
CLI publication `d95b1ab` and OA native checkpoint `c4bad4b` are also integrated;
|
||||
then publish later coherent milestones under the assigned owner. Preserve each
|
||||
artifact's source/profile/native scope and complete final-release gates separately.
|
||||
- Publish coherent commits and verified bundle transfers through Gitea; keep
|
||||
concise host-scoped handoffs and Wiki. Do not overwrite another host's branch.
|
||||
- Refresh relevant source/schema/example/doc gates and run current core,
|
||||
@@ -71,9 +120,12 @@ or parser throughput with task usability, and do not silently change omissions.
|
||||
- Build matching optimized desktop/server packages. Regenerate complete notices
|
||||
for actual inputs and verify archive contents, hashes and clean-runtime launch.
|
||||
Historical ELF fixtures and build preparation do not replace execution.
|
||||
- Obtain a viable Linux environment and run current GUI/server workflows there.
|
||||
Supported WSL2 failed; no repeated rejected workaround. Continue independent
|
||||
work while the environment question is pending.
|
||||
- Follow the external owner's supported Linux guest route and run current
|
||||
GUI/server workflows when viable. This local host has no usable Linux runtime;
|
||||
supported WSL2 failed. Preserve failed attempts rather than repeating rejected
|
||||
workarounds, and continue independent Windows/semantic work.
|
||||
- After the user reboot, check local Ubuntu registration and launch first. A
|
||||
version2 default alone is not an installed/running Linux environment.
|
||||
- Conduct fresh independent requirements, semantic, usability, architecture,
|
||||
correctness, runtime and documentation reviews. Resolve all material findings,
|
||||
reverify affected behavior and only then declare release completion.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Restart handoff — 2026-09-11
|
||||
|
||||
Historical reboot checkpoint. User has returned and enabled virtualization;
|
||||
current STATE.md and the postboot native/layout/WSL reports supersede the pause
|
||||
and pending-result wording below. Preserve this record for exact resume history.
|
||||
|
||||
Codex-9e8r6t /root. User explicitly requested a reboot pause to make WSL2/Ubuntu
|
||||
available. Full PROJECT_GOAL remains active and incomplete. Resume only after
|
||||
the user returns; do not schedule automatic work during the reboot.
|
||||
|
||||
## Saved state
|
||||
|
||||
- Branch `codex/release1-9e8r6t`: contextual source `df6f595`, CLI publication
|
||||
evidence `d95b1ab` and complete local OA scenario evidence `c4bad4b` were pushed
|
||||
and read back. The following reboot commit also preserves reviewed WIP below.
|
||||
- Both Windows Studio/server and standalone CLI prereleases remain published.
|
||||
This checkpoint does not update either release or establish Linux acceptance.
|
||||
- Immutable GUI52DE82 native source trial: Lifeline Save-adoption and Chain
|
||||
direct Create each passed complete model/source, one Undo and one Redo with
|
||||
original IDs. Machine direct Create/Save passed six exact files; one Undo/Save
|
||||
passed four reachable/six physical files. Machine Redo/reopen remain pending.
|
||||
Test GUI closed normally, and its returned window was absent at 13:38 UTC.
|
||||
`work` contains the saved Machine Undo state. Do not infer persistent history;
|
||||
use preserved first machine checkpoint for isolated reopen and a fresh trial
|
||||
for missing history coverage. See release-contextual-source-native-20260911.md
|
||||
and experiments/contextual-source-native-20260911/README.md.
|
||||
- Reviewed WIP: source_draft.rs essentials-first layout, explicit wrapped kinds
|
||||
before unbounded parent text, plus creation.rs staged-owner caption fix.
|
||||
Three real Paragraph tests are prepared. First compile failed in test helpers
|
||||
(ambiguous macro and wrong Id constructor); corrected only those helpers.
|
||||
Corrected focused2 was interrupted for this reboot before any tests ran;
|
||||
Clippy/fmt/GUI did not launch. There is NO new passing suite or matching GUI.
|
||||
- Worker release_audit stopped its command with Ctrl+C; all Cargo/rustc/linker
|
||||
processes were absent. All 238 frozen input hashes were unchanged.
|
||||
Final source_draft SHA prefix C8D36BD6; creation SHA prefix 0FB19BE3.
|
||||
Exact freeze inputs3 SHA256
|
||||
`6B3F3088FF18AF3899C3764B3F3671DA9240A5DE184D871BF08F8012456E556B`.
|
||||
|
||||
## Next actions after reboot
|
||||
|
||||
1. Read AGENTS, PROJECT_GOAL, STATE, PLAN and latest Gitea issue 1/2 comments and
|
||||
relevant Wiki. Preserve Codex-956atf canvas/chooser/schema8 and Codex-ytr83r
|
||||
B4/Linux ownership. User issue2 comment250 allows domain-constrained diagram
|
||||
manipulation; arbitrary free movement is not required.
|
||||
2. Recheck `wsl --list --verbose`. Before reboot it reported no installed
|
||||
distribution despite default version2. Launch/setup Ubuntu as needed with
|
||||
the user handling any authentication. Verify a real usable Linux environment
|
||||
before claiming a build. Do not repeat rejected VM workarounds.
|
||||
3. Validate the frozen input manifest, then resume a new focused3 run. Keep
|
||||
focused1 failure and focused2 interrupted evidence. On a pass, run strict
|
||||
desktop all-target Clippy, formatting and matching GUI build; use a distinct
|
||||
immutable stage. No broad full-suite repeat without a new reason.
|
||||
4. Root alone performs native Computer Use with @oai/sky. Relaunch the corrected
|
||||
artifact in a fresh profile and verify initial Name/kind/parent visibility at
|
||||
1100×700, the owner label, direct Lifeline Create, remaining cancellation/
|
||||
recovery and machine history/reopen using independent expected contracts.
|
||||
5. Continue SA B2, basic supplementary workflows, peer integration, complete
|
||||
semantic/text/REST/library work, Windows/Linux packaging and fresh final
|
||||
cross-disciplinary acceptance. The full release remains unfinished.
|
||||
|
||||
Private runtime and build stages under `.local` are retained; no temporary
|
||||
binary, token or credential was committed. Raw interruption/native records are
|
||||
archived separately with exact hashes. All workers are idle for the reboot.
|
||||
+96
-186
@@ -1,202 +1,112 @@
|
||||
# Progress Ledger — first release
|
||||
# Integrated checkpoint — first release
|
||||
|
||||
Updated 2026-09-11, Codex-9e8r6t `/root`, Windows host.
|
||||
**Release remains unaccepted; meaningful product and integration work is active.**
|
||||
Updated 2026-09-12 by Codex-9e8r6t `/root`, Windows host.
|
||||
**Full OA/SA release acceptance is incomplete. Linux work remains user-paused.**
|
||||
|
||||
## Objective and current source
|
||||
[PROJECT_GOAL.md](../PROJECT_GOAL.md) owns the application requirements.
|
||||
Current objective attachment `9467fa86-4606-4fe2-93ec-122137d03079` was read in full.
|
||||
Instruction synchronization `b70bf1d` preserves the user specification and
|
||||
[AGENTS.md](../AGENTS.md). It changes instructions, not the verified package inputs.
|
||||
|
||||
Deliver the complete OA/SA first release in [PROJECT_GOAL.md](../PROJECT_GOAL.md).
|
||||
The latest authoritative attachment is
|
||||
`815ff7b9-73c4-41a2-a809-635ecf8389d6/goal-objective.md`, read in full and preserved
|
||||
in that file. It requires constraints/calculations, quantities/units and reusable
|
||||
libraries, plus uncluttered human/AI text-only modeling at small and large scale.
|
||||
Long homogeneous lists and poor behavior editing remain explicit concerns.
|
||||
## Delivered Windows milestone
|
||||
|
||||
Local branch: `codex/release1-9e8r6t`, base `1797516`, committed head `4bb4aaf`.
|
||||
Named behavior creation, its guide and independent oracle, concise text-first
|
||||
examples and prior integration evidence are committed and remotely transferred.
|
||||
Its final ancestry/guide correction passed full653; post-lint history63, strict
|
||||
Clippy/fmt and matching GUI build passed. Current uncommitted work corrects the
|
||||
oracle's retained-file rule, clarifies source-7 constructor defaults, records
|
||||
native creation and implements contextual prerequisite creation. Preserve all
|
||||
workers' edits. No open Gitea PRs at the latest read, comments through138.
|
||||
[Source navigation prerelease](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.navigation.20260911)
|
||||
was published and read back on September 12 at 10:54:02 UTC. Extract the whole ZIP and run
|
||||
`arcadia-studio.exe`; keep its sibling `libunwind.dll`. Studio, the optional REST
|
||||
server, examples, offline help and dependency notices are included.
|
||||
|
||||
## Completed work and scoped evidence
|
||||
The tag points to exact raw-input reconstruction **802855f7**, based on corrected
|
||||
source **33c6d83** and distinct from the original build capture. The
|
||||
package pairs GUI B183 with the unchanged, separately verified EFEE server.
|
||||
All 559 package files and all three downloaded release assets were verified.
|
||||
The binaries use the development profile; this is a prerelease.
|
||||
|
||||
- Palette inline naming and focused Escape: `7d1df4f`, independently published as
|
||||
`codex/palette-authoring-20260910`. Candidate 2 focused tests, GUI build and
|
||||
strict desktop Clippy passed. Its native F2 rename/Save/Undo/Redo matched exact
|
||||
three-file expectations; Entity creation matched the complete independent
|
||||
model/source oracle. Candidate3 completed ordinary Activity, existing placement,
|
||||
allocation, direct Interaction and F2: complete basic model/four sources PASS.
|
||||
Explicit SA actor/destination transition passed complete model/six sources;
|
||||
Undo four-source baseline, Redo six-source result and reopen all passed. Six
|
||||
physical files are byte-identical across reopen/reuse-preview Cancel/Save.
|
||||
Immutable checkpoints/logs: `.local/native-qa/palette-20260910-v3/`.
|
||||
- Recovery naming autofocus: `31b7dc3`, 13 focused checkpoint tests passed after
|
||||
integrated compilation. Its new autofocus behavior still needs native check.
|
||||
- Other-host behavior/topology/scenario/history: original `01bb52a` integrated
|
||||
as `057eddd`; constructor cache fix `b6b76ef` integrated as `a70de55`; explicit
|
||||
history seed fix `4ecad78` cherry-picked as `81668f9`.
|
||||
- Corrected immutable desktop artifact completed **631/631 passed**,408.18s,
|
||||
exit0; all three prior618-artifact failures are fixed. Exact artifact/logs in
|
||||
`.local/scenario-workspace-20260910/tests-81668f9/`. It covers3978bbe workspace,
|
||||
Back/OpenUse guards and both history fixes, but not later Sensor/creation work.
|
||||
- A real same-size owner/document Sensor regression led to `d114a27`; affected
|
||||
scenario53/53 passed. Current native candidate3 predates that correction.
|
||||
Mounted workspace tests cover500×570 and wide geometry; native current-build
|
||||
scenario acceptance remains pending.
|
||||
- Named local creation passed final21/21 and guide3/3 after ancestry-label
|
||||
correction. The matching immutable desktop suite passed **653/653**,362.83s,
|
||||
zero failures/ignored; exact artifact864B0F…E86/log/result under
|
||||
`.local/behavior-creation-20260911/tests-final`. Independent integration review
|
||||
found no additional correctness blocker. StrictClippy/all-targets/-Dwarnings
|
||||
and workspacefmt pass after one behavior-equivalent history_fields let-chain
|
||||
correction; affected history63/63 and matching GUI rebuild pass. Current native
|
||||
GUI is `gui-verified` SHA D4D9C2BD…B20C31. At actual 1100×700 client size,
|
||||
named OA Process, explicit ParticipantInteraction Scenario with capability
|
||||
context and SA owner-linked State machine plus requested first view passed
|
||||
complete independent Project/SourceBundle checks at 7/8/9 files. Each single
|
||||
Undo/Redo preserved exact expected semantics, sources and role identities.
|
||||
Reopen/Save retained all nine physical source files byte-for-byte. Wide/minimum
|
||||
draft cancellations retained the initial six files exactly. Detailed report:
|
||||
[native creation](release-behavior-creation-native-20260911.md).
|
||||
- Independent creation oracle v2 corrects a harness assumption: Save retains
|
||||
known detached source files after Undo. Full Project and reachable source
|
||||
equality remain mandatory; the complete physical inventory must equal the
|
||||
explicit pre-Undo checkpoint overlaid with expected reachable bytes. Unknown,
|
||||
changed or missing retained files fail. No product defect was hidden by this
|
||||
correction; see [oracle review](release-behavior-creation-oracle-20260911.md).
|
||||
- Fresh source-7 constructor probes passed 54 explicit/omitted comparisons and
|
||||
12 required-field refusals across OA/SA, with complete typed models, canonical
|
||||
reparse and exact authored no-op/name-edit retention. Constructor and Scenario
|
||||
default wording now describes current behavior; strict HTML regeneration
|
||||
passed. Other constructor families are explicitly outside this evidence.
|
||||
See [default review](release-constructor-defaults-review-20260911.md).
|
||||
- Source schema checks passed: 72 fixtures, 204 schemas, 14 families, 90 scalar
|
||||
leaves. API/language reST rendered with strict docutils without warnings.
|
||||
- Packaging committed `b98f135`: portable Linux adapter, shared package checks,
|
||||
Docker input validation and exact upstream notices. Independent review found
|
||||
and fixed five concrete defects. Python 11/11 and PowerShell contracts pass.
|
||||
Historical Windows-input ZIP has 557 files; real Linux notices close over
|
||||
339 packages/681 files. Linux archive adapter used inert ELF fixtures, not
|
||||
actual current application binaries. Exact Fira/Cantarell notice Git bytes
|
||||
were independently verified. Current-source notices need regeneration.
|
||||
- Independent native Capella creation study completed, saved and closed cleanly;
|
||||
report `release-capella-creation-study-20260910.md` is complete. Viewpoint-first
|
||||
naming and distinct local create/existing selection inform the implementation.
|
||||
- Textual usability audit completed three static tasks and example counts. The
|
||||
existing grammar already permits omitted initial IDs/defaults; key gaps are
|
||||
cross-file reference context, safe alias refactoring and reviewed patches.
|
||||
Source7 EBNF/schema documentation and viewpoint omission guidance are corrected;
|
||||
generated language HTML passed strict docutils. Both concise snippets passed
|
||||
26 real REST requests: identity-only stabilization, exact no-op/name edit,
|
||||
stale/invalid nonmutation and restart persistence. All120 current core/server
|
||||
files match the preserved source7 binary's documented snapshot. This closes
|
||||
the bounded recipe gap, not large-model or native text-editor acceptance. The
|
||||
verified recipe is now `examples/text-first` and in the language reference.
|
||||
The GUI adds Definition/References/Back for current Text drafts and corrects
|
||||
repair/recovery saved-state handling. All 755 desktop tests passed (one ignored),
|
||||
with strict Clippy, formatting and a matching build. Earlier navigation native
|
||||
checks cover minimum-size interaction, ambiguous/partial scope and the 30-file
|
||||
engineering workflow; the corrected binary separately passed repair/rollback,
|
||||
Close/Cancel and exact Save checks. Fresh package acceptance verified Home/help,
|
||||
native Open, navigation, edit/Undo, Apply, Save and complete saved-model/source
|
||||
equality. Independent server checks verified exact API content and persistence
|
||||
across restart. Each result retains its specific build and workflow scope.
|
||||
|
||||
Detailed evidence: [integrated gates](release-integrated-gates-20260910.md),
|
||||
[palette native](release-palette-native-20260910.md),
|
||||
[scenario workspace](release-scenario-workspace-20260910.md),
|
||||
[creation design](release-local-behavior-creation-20260910.md),
|
||||
[packaging implementation](release-packaging-implementation-20260910.md),
|
||||
[independent packaging review](release-packaging-independent-review-20260910.md).
|
||||
Authoritative records:
|
||||
|
||||
## Active ownership and execution
|
||||
- [Publication, artifacts and limitations](release-navigation-prerelease-published-20260912.md)
|
||||
- [Per-binary preparation](release-navigation-prerelease-preparation-20260911.md)
|
||||
and [independent archive/server review](release-navigation-prerelease-review-20260911.md)
|
||||
- [Fresh native package acceptance](release-navigation-prerelease-native-20260911.md)
|
||||
and [integrated navigation evidence](release-text-navigation-integrated-20260911.md)
|
||||
- [Previous contextual authoring milestone](release-contextual-source-prerelease-published-20260911.md)
|
||||
- [Verified code/evidence checkpoint](release-postboot-checkpoint-review-20260911.md)
|
||||
- [Standalone CLI prerelease](release-cli-prerelease-published-20260911.md),
|
||||
supplying checked source stabilization and typed alias rename separately
|
||||
|
||||
- Root: integration, common ledgers, source/API checks and exclusive native input.
|
||||
Full653 is terminal PASS. V4 creation/Undo/Redo/reopen is complete; actual SA
|
||||
pending-draft crash recovery is active on the same verified GUI. The forced
|
||||
interruption targeted only isolated QA PID5184 after checkpoint persistence;
|
||||
restarted PID1284/window6161106 uses the same private profile and runtime.
|
||||
Recovery is now terminal PASS: full draft fields/IDs retained, autofocus,
|
||||
first Escape and a fresh Save As preserve the complete nine-file model. The
|
||||
current wide GUI is saved on `checkpoint-recovery-cancel/project.arcadia`;
|
||||
treat that checkpoint as immutable and reopen a work copy before editing.
|
||||
- `release_audit`: prior creation and constructor reports frozen. Now owns the
|
||||
staged prerequisite creation implementation for Scenario Lifeline/process Work/
|
||||
New machine owner, semantic first-action prompts, relevant App/recovery/guide
|
||||
wiring and focused tests. Cargo writer is reserved to this worker when needed;
|
||||
freeze source during tests and request independent review before integration.
|
||||
- `behavior_audit`: v2 native oracle frozen, warnings-denied compilation and
|
||||
falsifiers pass, executable056EEFD…F6A36. Now independently challenges staged
|
||||
source semantics and expected behavior; no production/Cargo/native ownership.
|
||||
- `palette_finish`: native report/evidence frozen: 13 creation oracle commands
|
||||
and 19 separate recovery checks pass. Now owns new `crates/oa-cli` for local
|
||||
source check/lint, preview/explicit-write stabilization and alias rename/diff.
|
||||
Uses existing source-file diagnostics/expectations/journal writer, no grammar
|
||||
or core changes. Root owns workspace/lock integration; serialize Cargo with
|
||||
release_audit. Preview UUIDs remain candidates until that invocation writes.
|
||||
- Separate host Codex-956atf: standalone scenario lifetime projection, independent
|
||||
native scenario oracle, quantity/calculation design and library design. It has
|
||||
its own cold toolchain/results. Its source-only reviews are not local passes.
|
||||
## Material unfinished scope
|
||||
|
||||
Verified bundles for eb5161b and 4ecad78 are retained in
|
||||
`.local/integration-20260910`. Local refs `codex/behavior-snapshot-eb5161b` and
|
||||
`codex/history-seed-20260910` preserve exact commits. B4 lifetime projection and
|
||||
its guide delta are not integrated yet; coordinate shared scene wiring. Review
|
||||
found insertion versus lifetime bounds, activation after branch-local Delete and
|
||||
unassigned-lane map differences that must be addressed before acceptance.
|
||||
Current-draft Definition/References/Back was implemented in source checkpoint
|
||||
**[36db9356](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/commit/36db9356cce3c7614ae37eec9fe17678ca210afe)**.
|
||||
The [initial native trial](release-text-navigation-native-initial-20260911.md)
|
||||
found four interaction defects. Their [correction](release-text-navigation-native-corrections-20260911.md)
|
||||
at **eeeaa126** passed 188 focused tests, the full 750-test desktop suite and
|
||||
strict Clippy, with exact input readback and independent review. Matching GUI5478
|
||||
[native evaluation](release-text-navigation-native-v3-20260911.md) verified those
|
||||
fixes, ambiguous/partial source navigation, and the 30-file engineering workflow;
|
||||
explicit Save preserved complete models, identities and source bytes. It also
|
||||
found a repair-mode false-clean status against the prior saved destination.
|
||||
Its [saved-baseline correction](release-text-repair-save-state-20260911.md)
|
||||
at **33c6d83** passes the full 755-test suite, strict Clippy and matching GUI B183
|
||||
build. Its [native repair verification](release-text-repair-save-native-v4-20260911.md)
|
||||
passed missing-include and duplicate-alias repairs plus interrupted-save rollback:
|
||||
Apply/Recover retain unsaved status, Close warns, Cancel retains work, and explicit
|
||||
Save yields exact complete model/identity/source equality. The fresh package
|
||||
passed its separate native and independent server checks and is now delivered above.
|
||||
The [integrated evaluation](release-text-navigation-integrated-20260911.md) links
|
||||
exact input archives, earlier full-test scope and independent findings.
|
||||
The published contextual preview remains unchanged.
|
||||
|
||||
## Unresolved requirements and gates
|
||||
The integrated release does not yet contain Text alias rename, newer peer canvas/chooser and
|
||||
scenario-lifetime work, complete constraints/calculations, dimensional quantities,
|
||||
maintained model libraries.
|
||||
Those additions require coherent graphical/text/REST realization and validation.
|
||||
Some scenario forms retain old scroll positions and dense labels remain crowded.
|
||||
Local SA action/state/reuse/fragment and supplementary camera/focus tasks retain
|
||||
explicit unfinished scopes in [scenario evidence](release-scenario-native-local-20260911.md).
|
||||
|
||||
1. Finish native basic supplementary move/camera/focus/minimum matrix and current
|
||||
scenario/process/state tasks. Main basic/transition/history/reopen passed at
|
||||
wide size. Check raw drafts, recovery, exact source and real task completion.
|
||||
2. Implement/review/natively verify contextual
|
||||
missing-source and first-action flows. Named creation/history/reopen passed;
|
||||
operand-editing and qualified Action allocation remain separate open work.
|
||||
3. Constraints/calculations and quantity compatibility are currently descriptive
|
||||
metadata; same-project includes are not maintained cross-project libraries.
|
||||
Independent semantic designs and falsification are active before broad schema
|
||||
changes. See [library review brief](release-library-candidate-review-20260910.md).
|
||||
Other-host proposals expose cosmetic package revision/type compatibility,
|
||||
subjectless analysis closure and text-only instance normalization decisions;
|
||||
full reports and paired criticism are now available from verified c6d296a.
|
||||
Its 218-assertion maintenance experiment supports explicit baseline values
|
||||
with one-level Case overrides; complete freeze and effective-case evaluation
|
||||
are required. Refined baseline/library contracts remain active externally.
|
||||
4. Implement and evaluate the source-only CLI baseline, then compare language
|
||||
improvements using both local usability and complete other-host proposals.
|
||||
Current grammar+tooling, domain syntax, namespaces and portable identity
|
||||
modules remain distinct candidates. Concise examples/CLI alone do not prove
|
||||
uncluttered modular engineering authoring or safe large-model changes.
|
||||
5. Run coherent current desktop/core/server, specification, examples, REST and
|
||||
optimized packaging gates after integration. Prior passes remain scoped.
|
||||
6. Current Linux runtime requires a viable environment: supported WSL2 import
|
||||
failed once with HCS_E_HYPERV_NOT_INSTALLED; no distro registered. The user
|
||||
has a pending environment question. No repeated denied QEMU workaround.
|
||||
7. Fresh independent cross-disciplinary release review remains required after
|
||||
actual workflow and semantic acceptance, with all material findings resolved.
|
||||
Optimized builds, remaining real engineering workflows and a fresh independent
|
||||
cross-disciplinary end-state review remain necessary. Old MVP exclusions and
|
||||
individual passing tests do not reduce the user's full requirements.
|
||||
|
||||
## Handoff and publication
|
||||
Ubuntu/WSL setup and all Linux diagnostics/builds/checks are paused until the user
|
||||
explicitly reports networking resolved. Preserve existing configuration, caches
|
||||
and evidence. [WSL readiness](release-wsl-readiness-9e8r6t-20260911.md) owns the
|
||||
historical attempts; it is not a current Linux application acceptance claim.
|
||||
|
||||
Use Gitea issue #1 and current Wiki to coordinate as **Codex-9e8r6t**. Read before
|
||||
resuming; preserve the other host's work. Direct Git push now fails authentication;
|
||||
anonymous fetch and Gitea MCP still work. Integration3978bbe was published and
|
||||
independently fetched/hash-verified as a Git bundle on
|
||||
`codex/integration-transfer-20260910`; Wiki `Integration3978bbe` and issue#97 own
|
||||
exact transfer instructions. Other host has integrated it and an equivalent
|
||||
Sensor fix. Creation4bb4aaf was likewise published on
|
||||
`codex/integration-transfer-20260911`, file
|
||||
`harness/transfers/creation-4bb4aaf.bundle.b64`: decoded105022 bytes,
|
||||
SHA256 `0DEFAE91A56430076C432758F923BC0675911DA9FA7588C9961FF15F47EAEBF6`,
|
||||
prerequisite3978bbe. Anonymous Git readback, strict base64 length/hash check and
|
||||
`git bundle verify` passed; issue#128 gives selective integration instructions.
|
||||
Publish subsequent corrected oracle/native/default evidence similarly; preserve
|
||||
the other host's later B4/reference chooser/design work and avoid duplicates.
|
||||
## Coordination and history
|
||||
|
||||
Other-host c6d296a transfer is verified and imported as local
|
||||
`codex/behavior-snapshot-c6d296a`: 334830 bytes,
|
||||
SHA256 `526A80E2D32ED0E57A7B2372B5F02D4652E8751DFEBA81953C4F045225D6AACB`,
|
||||
prerequisite1797516, from `codex/behavior-transfer-20260910` file
|
||||
`harness/transfers/behavior-c6d296a.bundle.b64`. Selected analysis/library/text
|
||||
reports, criticism, experiments and independent scenario oracle are restored;
|
||||
51 evidence files compare byte-for-byte with Git blobs. No source/ledger merge.
|
||||
Issue#136/#143 and refreshed Wiki Authoring20260911 record the handoff.
|
||||
The user requested splitting oversized [issue 1](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1).
|
||||
Live coordination now uses focused [behavior/canvas](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2),
|
||||
[activity movement](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3),
|
||||
[child work](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4),
|
||||
[lifecycle Windows acceptance](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5),
|
||||
[source navigation](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6),
|
||||
[quantities/calculations](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/7),
|
||||
[maintained libraries](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/8),
|
||||
and [Text alias rename](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/9).
|
||||
Read current comments for ownership and execution state; this checkpoint does
|
||||
not track active leases.
|
||||
[Authoring Wiki](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/wiki/Authoring20260911)
|
||||
is the shared entrypoint. Durable decisions remain in [DECISIONS.md](DECISIONS.md)
|
||||
and topic reports; the [text-navigation design](release-text-navigation-design-20260911.md)
|
||||
records the current bounded design and its falsifiers.
|
||||
The [Text alias-rename proposal](release-source-alias-rename-design-20260911.md)
|
||||
and its [critique](release-source-alias-rename-critique-20260911.md) are design
|
||||
preparation, not implemented or verified Text capability.
|
||||
|
||||
The old ledgers, including all historical platform and native evidence, were
|
||||
archived byte-for-byte before this consolidation in
|
||||
[history/2026-09-10-ledgers-before-second-consolidation](history/2026-09-10-ledgers-before-second-consolidation/STATE.md),
|
||||
with hashes.json. Their historical-current wording does not supersede this file.
|
||||
Legacy TASK_LEDGER/PLAN live content was migrated once in
|
||||
[comment 408](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-408).
|
||||
Those files are marked historical. Previous detailed STATE was preserved
|
||||
[byte-for-byte](history/2026-09-11-state-before-gitea-migration/STATE.md), including
|
||||
failed attempts, prior releases, transfer paths and earlier evidence links.
|
||||
Do not restore historical active-status or environment-next-step wording.
|
||||
|
||||
+94
-26
@@ -1,6 +1,28 @@
|
||||
> Historical record — superseded on 2026-09-11. Live plans, ownership and blockers
|
||||
> were migrated once to [Gitea issue 1, comment 408](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-408)
|
||||
> and [issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2).
|
||||
> Do not update this as a live ledger. [STATE.md](STATE.md) links the verified integrated result.
|
||||
> The historical text below is preserved; its old active-status wording is not current guidance.
|
||||
|
||||
# Task Ledger — first release
|
||||
|
||||
Updated 2026-09-11 by Codex-9e8r6t `/root`. Release not accepted.
|
||||
Updated 2026-09-11 by Codex-9e8r6t `/root`. Full release not accepted.
|
||||
|
||||
User resumed after enabling virtualization and explicitly authorized installing
|
||||
Ubuntu26.04 in WSL2. [RESTART_HANDOFF.md](RESTART_HANDOFF.md) is the historical
|
||||
pause; STATE.md owns current capacity, compiler and native status.
|
||||
User also explicitly permits disk cleanup including caches. Both scoped cache
|
||||
deletion commands were rejected before execution; nondeleting compression
|
||||
recovered space with all282 generated-binary hashes unchanged. Ordinary Ubuntu
|
||||
installation and an initial actual WSL2 root shell passed. Current VirtioProxy
|
||||
startups fail before guest execution, and a distinct WslService restart was
|
||||
denied by OS permissions. Owner328 remains the newest WSL solution after the
|
||||
latest issue check through348; its exact configuration is already applied.
|
||||
Five Rust components and60 apt packages are downloaded and hash-verified;
|
||||
guest signature verification/install and application acceptance remain pending.
|
||||
The user's latest direction pauses Linux work until they notify us that WSL
|
||||
networking is resolved. Continue Windows/product work; preserve existing Linux
|
||||
configuration, verified inputs and evidence without further setup/checks.
|
||||
|
||||
## Goal and success criteria
|
||||
|
||||
@@ -27,6 +49,14 @@ passing checks and fresh independent cross-disciplinary review.
|
||||
- Gitea coordination and shared Wiki updates are required. Identify host and
|
||||
worker consistently and preserve overlapping work. Explicit project messages
|
||||
are authorized; unrelated external messages are not.
|
||||
- Updated AGENTS requires runnable versioned milestone prereleases. User
|
||||
issue 1 comment 178 authorizes reusing existing MCP credentials programmatically for
|
||||
Git push and REST asset upload, without printing or committing credentials.
|
||||
- Remote AGENTS.md, blob `4b8465c8993107062ad2974c15a958d449530e92`, adds concise
|
||||
harness/Gitea maintenance and preservation of useful historical knowledge
|
||||
(user comment 229). Root's instruction sync `07eb3cd` is integrated;
|
||||
contextual source `df6f595`, CLI publication `d95b1ab` and OA native evidence
|
||||
`c4bad4b` are committed and pushed.
|
||||
|
||||
## Verified facts
|
||||
|
||||
@@ -35,38 +65,65 @@ The current model has one typed core, validated atomic commands and coordinated
|
||||
Project/SourceBundle history shared by desktop and REST. Included files share
|
||||
one global namespace. Current Constraint expressions are strings; numeric unit
|
||||
strings do not convert values or establish dimensional compatibility. There is
|
||||
no package/version/instance/update contract for cross-project libraries.
|
||||
no locally integrated package/version/instance/update contract for cross-project
|
||||
libraries; peer implementation is tracked separately below.
|
||||
|
||||
Candidate3 completed the main basic/transition/history/reopen story with exact
|
||||
four/six-source independent model oracles; supplementary native cases remain.
|
||||
Corrected immutable desktop631/631 passed; later Sensor correction passed53/53.
|
||||
Final creation21/guide3 and matching full desktop653/653 passed after ancestry
|
||||
label correction. StrictClippy/fmt pass after one behavior-equivalent history
|
||||
let-chain rewrite; affected history63 and matching GUI build pass. Native named
|
||||
creation passes complete model and 7/8/9-source oracles, individual Undo/Redo and
|
||||
byte-exact nine-file reopen at actual minimum size. Cancellation preserves the
|
||||
six-file baseline. Blank process/scenario canvases provide no useful first-action
|
||||
prompt; this is an observed usability finding. SA draft recovery remains active. Mounted
|
||||
layout tests establish pane geometry, not native task acceptance. Packaging checks
|
||||
and independent review are scoped to supplied binaries/fixtures, not release
|
||||
acceptance. Supported WSL2 import failed; a current Linux runtime is unavailable.
|
||||
The main basic/transition/history/reopen story passed exact independent model
|
||||
and four/six-source checks; supplementary native cases remain. Named creation
|
||||
passed 7/8/9-source oracles, one Undo/Redo and byte-exact reopen at actual minimum
|
||||
size. Cancellation retained its six-file baseline; SA draft recovery retained
|
||||
fields/identities, autofocus and Escape/Save As behavior. The D4D9 blank-canvas
|
||||
finding motivated the newer first-action implementation. Detailed historical
|
||||
suite and native scopes remain in [STATE.md](STATE.md) and its linked reports.
|
||||
Mounted geometry and bounded package passes do not establish full release
|
||||
acceptance. This local host has installed Ubuntu26.04 but lacks a currently
|
||||
usable guest session, installed build prerequisites or Linux application pass.
|
||||
|
||||
Current grammar supports omitted initial IDs/defaults, lexical ownership, forward
|
||||
references and preserved source. Static audit found no exposed project-wide
|
||||
reference navigation/alias refactor; current Find searches the active buffer.
|
||||
The process example has128lines/14UUIDs/31empty-default lines, while meaningful
|
||||
shorter source is possible. Two concise snippets passed26 real REST requests,
|
||||
references and preserved source. The earlier desktop audit found no exposed
|
||||
project-wide reference navigation/alias refactor; its Find searched the active
|
||||
buffer. The later CLI supplies typed alias rename. The audited process example
|
||||
had 128 lines, 14 UUIDs and 31 empty-default lines, while meaningful shorter
|
||||
source is possible. Two concise snippets passed 26 real REST requests,
|
||||
including identity-only normalization, exact name-only edits, rejected writes and
|
||||
restart persistence. Counts and this small trial prove neither improved native
|
||||
usability nor large-project performance.
|
||||
|
||||
## Assumptions
|
||||
CLI tooling is implemented, integrated and published: semantic source `01b76a1`,
|
||||
packaging assembly `a0a48e8`, release integration `27df4a3`. Author 12 tests and
|
||||
independent 183 semantic assertions pass; independent clean-package review adds
|
||||
156 assertions across 10 processes. This is a bounded source-editing tool, not
|
||||
proof of complete large-model language ergonomics.
|
||||
|
||||
GUI/server release 1 and standalone CLI release 2 are published with runnable
|
||||
Windows artifacts, full dependency notices and downloaded asset verification.
|
||||
Independent archive/source and clean runtime checks pass in their declared scopes.
|
||||
See [CLI publication evidence](release-cli-prerelease-published-20260911.md).
|
||||
The newer 13-file contextual-source slice is committed/pushed as `df6f595`,
|
||||
with its independent oracle and exact raw evidence. It passed focused 23, guide 3, strict
|
||||
Clippy, matching GUI build and formatting. The full 675 suite covers only its
|
||||
predecessor; no full 676 suite has run. Root launched the exact 52DE82 GUI in
|
||||
isolation: Lifeline Save-adoption and Chain direct Create pass complete source,
|
||||
one Undo and one Redo. Machine direct Create and one Undo pass; Redo/reopen are
|
||||
pending for Redo. An isolated postboot real Open/SA view/Save passed the complete
|
||||
machine prediction and exact six-file equality; the app closed normally.
|
||||
Later layout/caption source was independently reviewed. Postboot focused3 ran25
|
||||
passing tests and one overflowing-fixture assertion failure. The stronger
|
||||
test-only fixture is frozen; its execution and new GUI remain pending.
|
||||
|
||||
Local OA B2 now passes all four Action/State/explicitly bound Reuse/Fragment
|
||||
Create, one Undo/Save, one Redo/Save and final OA reopen/Save against independent
|
||||
complete model/two-source predictions and original exact identities. SA B2 is
|
||||
pending locally. Existing B3 OA wide/minimum and SA minimum return scopes remain
|
||||
unchanged; neither these passes nor mounted geometry tests imply broader acceptance.
|
||||
|
||||
## Assumptions
|
||||
- Contextual named creation, remaining-height canvas and local source insertion
|
||||
can reduce actual authoring detours. Native engineer tasks must establish this.
|
||||
- A bounded pure calculation/quantity model and pinned library content may fit
|
||||
the existing typed core better than a general-purpose metamodel rewrite.
|
||||
Independent designs and experiments must test this before ratification.
|
||||
Peer-ratified core staging must still be checked against independent engineering
|
||||
tasks and integrated across all required representations.
|
||||
- Existing OA/SA semantics provide a useful foundation; neither their presence
|
||||
nor old acceptance proves full conformance to the updated release objective.
|
||||
|
||||
@@ -94,21 +151,32 @@ usability nor large-project performance.
|
||||
current parent draft, then validate/adopt source and consumer atomically. This
|
||||
preserves intent and one-step history; a separate Architecture excursion or
|
||||
early source commit loses those guarantees. Initial Lifeline/process Work/
|
||||
machine-owner adapters and semantic first-action prompts are being implemented
|
||||
and independently challenged; acceptance remains pending. See the
|
||||
machine-owner adapters and semantic first-action prompts are implemented,
|
||||
reviewed and integrated with focused gates passed; scoped native results are
|
||||
above, with remaining acceptance still open. See the
|
||||
[bounded plan](release-contextual-source-creation-plan-20260911.md).
|
||||
- Scenario workspace: compact header, optional Tools/Selection shelves, actual
|
||||
remaining-height canvas/form, pinned actions, independent scrolling and
|
||||
explicit Fit/Focus. Mounted tests support this choice; native proof pending.
|
||||
- Quantities/calculations: compare additive pure analysis records, extension of
|
||||
existing fields and external evaluation. No broad schema decision yet.
|
||||
- User issue2/comment250 clarifies that diagram manipulation may respect
|
||||
sequence/domain constraints. Codex-956atf owns a shared canvas-shell redesign;
|
||||
the existing controls and supporting source-card fixes do not close the issue.
|
||||
- Quantities/calculations: peer comment 231 records ratified schema 8/local
|
||||
definitions and actual shared analysis/core types under implementation in
|
||||
isolated `codex/analysis-core-20260911`. This is not a local schema admission,
|
||||
evaluator or graphical/source/API parity pass; the local core remains descriptive.
|
||||
- Libraries: compare copied source, live references, generic definition/usages
|
||||
and pinned typed packages with explicit mappings and reviewed updates.
|
||||
[Falsification brief](release-library-candidate-review-20260910.md) records
|
||||
identity, dependency, offline and conflict requirements. No ratified winner.
|
||||
identity, dependency, offline and conflict requirements. Refined peer baseline,
|
||||
Case, full-freeze and exact-pin/equivalence contracts are ratified externally;
|
||||
their local integration and executable lifecycle acceptance remain pending.
|
||||
- Text authoring: use existing omission/default/identity contracts for concise
|
||||
experiments; compare source navigation/refactoring/patch assistance before
|
||||
grammar redesign. No namespace, hidden identity or omission change ratified.
|
||||
- Reference chooser: peer comment 224's corrected focus ownership, nested-scroll
|
||||
reveal and mounted regressions are prepared, not executed. Preserve that
|
||||
ownership and wait for its source/test handoff.
|
||||
|
||||
Long-lived decisions remain in [DECISIONS.md](DECISIONS.md). New user scope and
|
||||
normative contracts supersede older prototype exclusions.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Authoring prerelease evidence
|
||||
|
||||
Exact preserved producer, independent reviewer and root native evidence for
|
||||
Windows preview `v0.2.0-preview.authoring.20260911`. `hashes.json` records each
|
||||
copied artifact's original private path, length and SHA256. Git must retain these
|
||||
measured bytes. The executable ZIP and validation notes are release assets.
|
||||
|
||||
The preserved Python files are the original host-specific harnesses. Their
|
||||
relative paths and source/toolchain prerequisites refer to the recorded original
|
||||
`.local` layout; they are not portable standalone tests when run from this archive.
|
||||
To reproduce them, restore that layout in a disposable checkout and use fresh
|
||||
output directories. They never contain Gitea credentials. The GUI launch script
|
||||
only launches the application; native actions were performed using Computer Use.
|
||||
|
||||
See the three `harness/release-authoring-prerelease-*-20260911.md` reports for
|
||||
methods, initial GUI harness failure, passed checks, source provenance and limits.
|
||||
`published-verified.json` records the final release readback; upload records bind
|
||||
the exact downloaded assets. Publication does not complete the wider OA/SA goal.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"pid": 10800,
|
||||
"exe": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm\\arcadia-studio.exe",
|
||||
"archive_sha256": "664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa",
|
||||
"files": 559,
|
||||
"path": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm;C:\\Windows\\System32;C:\\Windows",
|
||||
"local_appdata": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\profile\\local",
|
||||
"appdata": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\profile\\roaming"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
root = Path.cwd()
|
||||
name = 'ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm'
|
||||
archive = root / '.local/authoring-prerelease-20260911/packages' / (name + '.zip')
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == '664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa'
|
||||
trial = root / '.local/authoring-prerelease-gui-20260911-v2'
|
||||
trial.mkdir(exist_ok=False)
|
||||
extract = trial / 'extracted'
|
||||
extract.mkdir()
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
names = z.namelist()
|
||||
assert len(names) == len(set(n.casefold() for n in names)) == 559
|
||||
assert all(n.startswith(name + '/') and '..' not in Path(n).parts for n in names)
|
||||
z.extractall(extract)
|
||||
package = extract / name
|
||||
for line in (package / 'SHA256SUMS.txt').read_text().splitlines():
|
||||
digest, path = line.split(' ', 1)
|
||||
assert hashlib.sha256((package / path).read_bytes()).hexdigest() == digest, path
|
||||
for folder in ['profile/local', 'profile/roaming']:
|
||||
(trial / folder).mkdir(parents=True)
|
||||
env = os.environ.copy()
|
||||
env['LOCALAPPDATA'] = str(trial / 'profile/local')
|
||||
env['APPDATA'] = str(trial / 'profile/roaming')
|
||||
env['PATH'] = os.pathsep.join([str(package), str(Path(env['SYSTEMROOT']) / 'System32'), env['SYSTEMROOT']])
|
||||
exe = package / 'arcadia-studio.exe'
|
||||
assert hashlib.sha256(exe.read_bytes()).hexdigest() == 'd4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31'
|
||||
with (trial / 'stdout.log').open('wb') as out, (trial / 'stderr.log').open('wb') as err:
|
||||
process = subprocess.Popen([str(exe)], cwd=package, env=env, stdout=out, stderr=err)
|
||||
result = {'pid': process.pid, 'exe': str(exe), 'archive_sha256': hashlib.sha256(archive.read_bytes()).hexdigest(), 'files': len(names), 'path': env['PATH'], 'local_appdata': env['LOCALAPPDATA'], 'appdata': env['APPDATA']}
|
||||
(trial / 'launch.json').write_text(json.dumps(result, indent=2), encoding='utf-8')
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -0,0 +1,64 @@
|
||||
[
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm\\arcadia-studio.exe",
|
||||
"C:\\Windows\\SYSTEM32\\ntdll.dll",
|
||||
"C:\\Windows\\System32\\KERNEL32.DLL",
|
||||
"C:\\Windows\\System32\\KERNELBASE.dll",
|
||||
"C:\\Windows\\SYSTEM32\\apphelp.dll",
|
||||
"C:\\Windows\\System32\\user32.dll",
|
||||
"C:\\Windows\\System32\\win32u.dll",
|
||||
"C:\\Windows\\System32\\GDI32.dll",
|
||||
"C:\\Windows\\System32\\gdi32full.dll",
|
||||
"C:\\Windows\\System32\\msvcp_win.dll",
|
||||
"C:\\Windows\\System32\\ucrtbase.dll",
|
||||
"C:\\Windows\\System32\\ole32.dll",
|
||||
"C:\\Windows\\System32\\combase.dll",
|
||||
"C:\\Windows\\System32\\RPCRT4.dll",
|
||||
"C:\\Windows\\System32\\advapi32.dll",
|
||||
"C:\\Windows\\System32\\msvcrt.dll",
|
||||
"C:\\Windows\\System32\\sechost.dll",
|
||||
"C:\\Windows\\System32\\bcryptprimitives.dll",
|
||||
"C:\\Windows\\System32\\shell32.dll",
|
||||
"C:\\Windows\\System32\\imm32.dll",
|
||||
"C:\\Windows\\System32\\ws2_32.dll",
|
||||
"C:\\Windows\\System32\\oleaut32.dll",
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\authoring-prerelease-gui-20260911-v2\\extracted\\ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm\\libunwind.dll",
|
||||
"C:\\Windows\\SYSTEM32\\bcrypt.dll",
|
||||
"C:\\Windows\\SYSTEM32\\dwmapi.dll",
|
||||
"C:\\Windows\\SYSTEM32\\uxtheme.dll",
|
||||
"C:\\Windows\\SYSTEM32\\opengl32.dll",
|
||||
"C:\\Windows\\SYSTEM32\\GLU32.dll",
|
||||
"C:\\Windows\\SYSTEM32\\CRYPTBASE.DLL",
|
||||
"C:\\Windows\\SYSTEM32\\dxcore.dll",
|
||||
"C:\\Windows\\System32\\MSCTF.dll",
|
||||
"C:\\Windows\\SYSTEM32\\kernel.appcore.dll",
|
||||
"C:\\Windows\\System32\\clbcatq.dll",
|
||||
"C:\\Windows\\system32\\dataexchange.dll",
|
||||
"C:\\Windows\\system32\\twinapi.appcore.dll",
|
||||
"C:\\Windows\\system32\\explorerframe.dll",
|
||||
"C:\\Windows\\System32\\SHCORE.dll",
|
||||
"C:\\Windows\\System32\\SHLWAPI.dll",
|
||||
"C:\\Windows\\System32\\Windows.Storage.dll",
|
||||
"C:\\Windows\\SYSTEM32\\powrprof.dll",
|
||||
"C:\\Windows\\SYSTEM32\\UMPDC.dll",
|
||||
"C:\\Windows\\SYSTEM32\\CFGMGR32.dll",
|
||||
"C:\\Windows\\system32\\dxgi.dll",
|
||||
"C:\\Windows\\SYSTEM32\\windows.staterepositorycore.dll",
|
||||
"C:\\Windows\\SYSTEM32\\directxdatabasehelper.dll",
|
||||
"C:\\Windows\\SYSTEM32\\d3d12.dll",
|
||||
"C:\\Windows\\SYSTEM32\\d3dcompiler_47.dll",
|
||||
"C:\\Windows\\SYSTEM32\\cryptsp.dll",
|
||||
"C:\\Windows\\System32\\DriverStore\\FileRepository\\vm3d.inf_amd64_39d3dfd189710365\\vm3dgl64.dll",
|
||||
"C:\\Windows\\System32\\DriverStore\\FileRepository\\vm3d.inf_amd64_39d3dfd189710365\\vm3dglhelper64.dll",
|
||||
"C:\\Windows\\SYSTEM32\\WINMM.dll",
|
||||
"C:\\Windows\\SYSTEM32\\D3D12Core.dll",
|
||||
"C:\\Windows\\SYSTEM32\\d3d10warp.dll",
|
||||
"C:\\Windows\\SYSTEM32\\D3DSCache.dll",
|
||||
"C:\\Windows\\SYSTEM32\\USERENV.dll",
|
||||
"C:\\Windows\\SYSTEM32\\dxilconv.dll",
|
||||
"C:\\Windows\\SYSTEM32\\dcomp.dll",
|
||||
"C:\\Windows\\SYSTEM32\\Microsoft.Internal.WarpPal.dll",
|
||||
"C:\\Windows\\SYSTEM32\\textinputframework.dll",
|
||||
"C:\\Windows\\SYSTEM32\\uiautomationcore.dll",
|
||||
"C:\\Windows\\SYSTEM32\\OLEACC.dll",
|
||||
"C:\\Windows\\System32\\threadpoolwinrt.dll"
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"matched_files": 558,
|
||||
"exact_physical_inventory": 559,
|
||||
"result": "PASS after native packaged walkthrough open and Save"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
[
|
||||
{
|
||||
"path": "producer-inventory.json",
|
||||
"original": ".local/authoring-prerelease-20260911/inventory.json",
|
||||
"bytes": 66465,
|
||||
"sha256": "3c78dea4e93dae186fd24ab826c6680178a2c7849c270a22deae2ff732f1857b"
|
||||
},
|
||||
{
|
||||
"path": "producer-build-evidence.json",
|
||||
"original": ".local/authoring-prerelease-20260911/build-evidence.json",
|
||||
"bytes": 1230,
|
||||
"sha256": "ccc1ccd087f1de9605ba23a4680e1cb6d37ce5ccf0f8db03c03c4797ae32db25"
|
||||
},
|
||||
{
|
||||
"path": "producer-package-result.json",
|
||||
"original": ".local/authoring-prerelease-20260911/package-initial-result.json",
|
||||
"bytes": 216,
|
||||
"sha256": "45f372960931fa34ca75086df5d346eab9127a7ae70b53aed86751e372c24bae"
|
||||
},
|
||||
{
|
||||
"path": "producer-roundtrip.json",
|
||||
"original": ".local/authoring-prerelease-20260911/committed-roundtrip.json",
|
||||
"bytes": 40443,
|
||||
"sha256": "27e4cd2d6130a9ced76560cee130b5d363478ee0da62925e785691c63fadb098"
|
||||
},
|
||||
{
|
||||
"path": "reviewer-artifact-result.json",
|
||||
"original": ".local/authoring-prerelease-review-20260911/artifact-result.json",
|
||||
"bytes": 338784,
|
||||
"sha256": "8b20e9d294338754ddc9b3320b59f55fc19ab979b8da18819fb47a8036db0bba"
|
||||
},
|
||||
{
|
||||
"path": "reviewer-server-result.json",
|
||||
"original": ".local/authoring-prerelease-review-20260911/server-result.json",
|
||||
"bytes": 128779,
|
||||
"sha256": "086bf21e79f3bc7deddfd3278c8ade9b470b11031f7fb278ca0ead3a75b6cfb1"
|
||||
},
|
||||
{
|
||||
"path": "reviewer.py",
|
||||
"original": ".local/authoring-prerelease-review-20260911/review.py",
|
||||
"bytes": 21434,
|
||||
"sha256": "af9eb20946dfcec78bf8959f1d5dc175e82ebb9fabba9f94cefe590fea516b92"
|
||||
},
|
||||
{
|
||||
"path": "gui-launch.json",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/launch.json",
|
||||
"bytes": 827,
|
||||
"sha256": "ade93cb1abea623f1cc63b8124857cad822e4f75e8d1c389f75fd8a9089b2c39"
|
||||
},
|
||||
{
|
||||
"path": "gui-loaded-modules.json",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/loaded-modules.json",
|
||||
"bytes": 3123,
|
||||
"sha256": "ccae3d91310644e171bf35028f1c0758ef330ebb051983e0524b8cd714652b6b"
|
||||
},
|
||||
{
|
||||
"path": "gui-post-save-exact.json",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/post-save-package-exact.json",
|
||||
"bytes": 133,
|
||||
"sha256": "e340289a2c09058c539e63de33a361326b146fc181a8bd1349a88bfc813f2420"
|
||||
},
|
||||
{
|
||||
"path": "upload-binaries-verified.json",
|
||||
"original": ".local/authoring-prerelease-20260911/upload-binaries-verified.json",
|
||||
"bytes": 685,
|
||||
"sha256": "16799aa759e7287f4c50af1068d311f6f916f97e0cfbd7aaddf7432770d281f3"
|
||||
},
|
||||
{
|
||||
"path": "gui-launch.py",
|
||||
"original": ".local/authoring-prerelease-gui-20260911.py",
|
||||
"bytes": 2001,
|
||||
"sha256": "db256498e44cbb67cd84738a1a37f0e9ab0931d130a3b8c830af2c02cff0924c"
|
||||
},
|
||||
{
|
||||
"path": "home.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/home.png",
|
||||
"bytes": 97695,
|
||||
"sha256": "e9c24489e949096347b11234d8d2d343716fd5895903c4978d139107a70e3e5a"
|
||||
},
|
||||
{
|
||||
"path": "oa-walkthrough-save.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/oa-walkthrough-save.png",
|
||||
"bytes": 131037,
|
||||
"sha256": "61b2cbdd2dddb6fa176000198ae845554147cbc80abe52fa992211157f6297b5"
|
||||
},
|
||||
{
|
||||
"path": "sa-walkthrough.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/sa-walkthrough.png",
|
||||
"bytes": 132180,
|
||||
"sha256": "7f3a2bbed9fa69f9d5d844f9a52f81fbc3928244f49d7a9ff1e475c3f2411dbf"
|
||||
},
|
||||
{
|
||||
"path": "state-workspace.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/state-workspace.png",
|
||||
"bytes": 135827,
|
||||
"sha256": "79010527e5e1bb8ccf428788a00dea80e6d8253f3d7c249089218e2df9edd6da"
|
||||
},
|
||||
{
|
||||
"path": "offline-guide.png",
|
||||
"original": ".local/authoring-prerelease-gui-20260911-v2/offline-guide.png",
|
||||
"bytes": 185496,
|
||||
"sha256": "58812d2bd0d6e73daa5187f2dd87564189960387ac4bb1e399af4b8d114c05df"
|
||||
},
|
||||
{
|
||||
"path": "published-verified.json",
|
||||
"original": ".local/authoring-prerelease-20260911/published-verified.json",
|
||||
"bytes": 374,
|
||||
"sha256": "668f28c4d8cf753386085271eb181513b0127940f324f423a2b87614ec8cc85a"
|
||||
},
|
||||
{
|
||||
"path": "upload-validation-verified.json",
|
||||
"original": ".local/authoring-prerelease-20260911/upload-validation-verified.json",
|
||||
"bytes": 277,
|
||||
"sha256": "f39859baea544662fc4bdd1fe14dbcb9bdab5639b173754a34b58b853f0d47a7"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"source_identity": "Exact-byte reconstructed input snapshot 234e01e27d6935d213097b345d91d369f808af82, derived from release base 2ec72d4241fa8971fa6fe9f626937de9dfd7c7eb by seven documented LF-to-CRLF restorations and preservation attributes. All 253 GUI manifest inputs and 120 preserved server source files matched. This derived commit is not claimed as the original build commit; GUI and server original build records are retained separately.",
|
||||
"profile": "Prebuilt Windows debug/development artifacts: GUI cargo build --locked -p oa-desktop --bin arcadia-studio, September 11 2026; preserved server from September 7 workspace bins build. Not a release-profile or reproducible-build claim.",
|
||||
"compiler": "rustc 1.98.1, commit 48a229ceaefd4985c50990b14116b6d856af0985, x86_64-pc-windows-gnullvm, LLVM 22.1.8. Current rustc -vV, all 58 prior-notice target-library hashes and both embedded compiler paths matched; original recorded build evidence retained.",
|
||||
"target": "x86_64-pc-windows-gnullvm",
|
||||
"binary_sha256": {
|
||||
"arcadia-studio.exe": "d4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31",
|
||||
"arcadia-server.exe": "efeeb89c0d2e0f93df4472cb3e9f48b2b722a37ae1043c90a5a56f2e845fe665"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"started": "2026-09-11T12:17:30.7730570Z",
|
||||
"source_commit": "234e01e27d6935d213097b345d91d369f808af82",
|
||||
"success": true,
|
||||
"finished": "2026-09-11T12:21:23.0302800Z",
|
||||
"elapsed_seconds": 232.2812442
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": 1,
|
||||
"tag": "v0.2.0-preview.authoring.20260911",
|
||||
"url": "http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.authoring.20260911",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"asset_count": 3,
|
||||
"notes_sha256": "e48399db78b18c6bd779412f7aeccf13fe78bf8c9bac22a4c418755acfab175c",
|
||||
"published_at": "2026-09-11T12:40:51Z"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
"""Independent frozen archive and packaged-server checks; never runs Cargo or GUI."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import tomllib
|
||||
import traceback
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
OUT = Path(__file__).resolve().parent
|
||||
ROOT = OUT.parents[1]
|
||||
PREP = ROOT / '.local/authoring-prerelease-20260911'
|
||||
NAME = 'ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm'
|
||||
ZIP = PREP / 'packages' / (NAME + '.zip')
|
||||
EXTRACT = OUT / 'extracted'
|
||||
PACKAGE = EXTRACT / NAME
|
||||
SOURCE = PREP / 'source'
|
||||
SOURCE_COMMIT = '234e01e27d6935d213097b345d91d369f808af82'
|
||||
BASE_COMMIT = '2ec72d4241fa8971fa6fe9f626937de9dfd7c7eb'
|
||||
EXPECTED_ZIP = '664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa'
|
||||
BINARIES = {
|
||||
'arcadia-studio.exe': 'd4d9c2bd937e301ff1009d7cb93a34ee5aa6dfc837ff7e87d468c85371b20c31',
|
||||
'arcadia-server.exe': 'efeeb89c0d2e0f93df4472cb3e9f48b2b722a37ae1043c90a5a56f2e845fe665',
|
||||
'libunwind.dll': 'f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4',
|
||||
}
|
||||
RESULT = {'checks': [], 'scope': sys.argv[1], 'started_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
|
||||
|
||||
|
||||
def check(condition, label):
|
||||
if not condition:
|
||||
raise AssertionError(label)
|
||||
RESULT['checks'].append(label)
|
||||
|
||||
|
||||
def sha(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open('x', encoding='utf-8', newline='\n') as f:
|
||||
json.dump(value, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n')
|
||||
|
||||
|
||||
def file_map(directory):
|
||||
result = {}
|
||||
for p in directory.rglob('*'):
|
||||
check(not p.is_symlink() and not (getattr(p.lstat(), 'st_file_attributes', 0) & 0x400), 'ordinary path: ' + p.relative_to(directory).as_posix())
|
||||
if p.is_file():
|
||||
result[p.relative_to(directory).as_posix()] = sha(p.read_bytes())
|
||||
return result
|
||||
|
||||
|
||||
def safe_name(name):
|
||||
p = PurePosixPath(name)
|
||||
return not p.is_absolute() and p.as_posix() == name and '\\' not in name and ':' not in name and all(c not in ('', '.', '..') and not c.endswith(('.', ' ')) and not re.match(r'(?i)^(con|nul|prn|aux|com[1-9]|lpt[1-9])(\.|$)', c) for c in p.parts)
|
||||
|
||||
|
||||
def git_blobs(ref, paths):
|
||||
raw = subprocess.run(['git', 'cat-file', '--batch'], input=''.join(ref + ':' + p + '\n' for p in paths).encode(), cwd=ROOT, capture_output=True, check=True).stdout
|
||||
result, cursor = {}, 0
|
||||
for p in paths:
|
||||
end = raw.index(b'\n', cursor)
|
||||
header = raw[cursor:end].split()
|
||||
check(len(header) == 3 and header[1] == b'blob', 'Git blob exists: ' + ref[:7] + ':' + p)
|
||||
size = int(header[2])
|
||||
result[p] = raw[end + 1:end + 1 + size]
|
||||
cursor = end + 2 + size
|
||||
check(cursor == len(raw), 'Git batch framing complete: ' + ref[:7])
|
||||
return result
|
||||
|
||||
|
||||
def artifact():
|
||||
check(not EXTRACT.exists(), 'clean extraction destination absent')
|
||||
check(ZIP.stat().st_size == 32618262 and sha(ZIP.read_bytes()) == EXPECTED_ZIP, 'frozen ZIP size and SHA256')
|
||||
with zipfile.ZipFile(ZIP) as archive:
|
||||
infos = archive.infolist()
|
||||
names = [i.filename for i in infos]
|
||||
check(len(infos) == 559, '559 ZIP members')
|
||||
check(len({n.casefold() for n in names}) == len(names), 'no duplicate or case-colliding ZIP names')
|
||||
check(all(safe_name(n) and n.startswith(NAME + '/') for n in names), 'one safe ZIP root without traversal or Windows special paths')
|
||||
check(all(not i.is_dir() and stat.S_IFMT(i.external_attr >> 16) in (0, stat.S_IFREG) for i in infos), 'ordinary file-only ZIP entries')
|
||||
check(all(i.date_time == (2000, 1, 1, 0, 0, 0) and i.flag_bits & 1 == 0 for i in infos), 'fixed timestamps and no encrypted entries')
|
||||
check(archive.testzip() is None, 'all ZIP CRCs')
|
||||
EXTRACT.mkdir()
|
||||
archive.extractall(EXTRACT)
|
||||
archive_hashes = {i.filename[len(NAME) + 1:]: sha(archive.read(i)) for i in infos}
|
||||
actual = file_map(PACKAGE)
|
||||
check(actual == archive_hashes, 'complete extracted file set and bytes equal ZIP')
|
||||
sums = {}
|
||||
for line in (PACKAGE / 'SHA256SUMS.txt').read_text().splitlines():
|
||||
match = re.fullmatch(r'([0-9a-f]{64}) (.+)', line)
|
||||
check(match is not None and safe_name(match[2]) and match[2] not in sums, 'well-formed unique checksum row')
|
||||
sums[match[2]] = match[1]
|
||||
check(sums == {p: h for p, h in actual.items() if p != 'SHA256SUMS.txt'}, 'checksum inventory covers every other file exactly')
|
||||
package = read_json(PACKAGE / 'package.json')
|
||||
inputs = package['input_sha256']
|
||||
generated = {'SHA256SUMS.txt', 'START-HERE.txt', 'THIRD-PARTY-NOTICES.txt', 'dependency-notices.json', 'documentation-check.json', 'package.json'}
|
||||
check(set(actual) - set(inputs) == generated and all(actual[p] == h.lower() for p, h in inputs.items()), '553 package input hashes plus exactly six generated files')
|
||||
check(package['version'] == '0.2.0-preview.authoring.20260911' and package['target'] == 'x86_64-pc-windows-gnullvm', 'version and target')
|
||||
check(all(actual[p] == h for p, h in BINARIES.items()), 'exact GUI, server and runtime hashes')
|
||||
for p in BINARIES:
|
||||
data = (PACKAGE / p).read_bytes()
|
||||
offset = struct.unpack_from('<I', data, 0x3c)[0]
|
||||
check(data[:2] == b'MZ' and data[offset:offset + 4] == b'PE\0\0' and struct.unpack_from('<H', data, offset + 4)[0] == 0x8664, 'ordinary PE x86_64: ' + p)
|
||||
check({p for p in actual if '/' not in p} == {'arcadia-server.exe', 'arcadia-studio.exe', 'libunwind.dll', 'LICENSE', 'README.md'} | generated, 'only declared portable top-level files')
|
||||
check({p.split('/')[0] for p in actual if '/' in p} == {'deployment', 'docs', 'examples', 'licenses'}, 'no harness, private profile, source checkout or credential-directory payload')
|
||||
for p in actual:
|
||||
if p.startswith(('docs/', 'examples/', 'deployment/')) or p == 'LICENSE':
|
||||
check((SOURCE / p).is_file() and sha((SOURCE / p).read_bytes()) == actual[p], 'public source document/example bytes: ' + p)
|
||||
RESULT['archive'] = {'path': str(ZIP), 'sha256': EXPECTED_ZIP, 'bytes': ZIP.stat().st_size, 'file_count': len(actual), 'files': actual}
|
||||
RESULT['package_claims'] = {k: package[k] for k in ('version', 'target', 'acceptance', 'build', 'build_evidence')}
|
||||
provenance()
|
||||
notices(actual)
|
||||
|
||||
|
||||
def provenance():
|
||||
gui_path = ROOT / '.local/behavior-creation-20260911/gui-verified/manifest.json'
|
||||
gui = read_json(gui_path)
|
||||
expected = {i['path']: i['sha256'].lower() for i in gui['inputs']}
|
||||
check(len(expected) == 253, 'original GUI manifest has 253 unique inputs')
|
||||
derived = git_blobs(SOURCE_COMMIT, list(expected))
|
||||
base = git_blobs(BASE_COMMIT, list(expected))
|
||||
differences = []
|
||||
for p, h in expected.items():
|
||||
check(sha(derived[p]) == h == sha((SOURCE / p).read_bytes()), 'original GUI input equals derived Git blob and checkout: ' + p)
|
||||
if sha(base[p]) != h:
|
||||
check(sha(base[p].replace(b'\r\n', b'\n').replace(b'\n', b'\r\n')) == h, 'exact LF-to-CRLF-only restoration: ' + p)
|
||||
differences.append(p)
|
||||
check(set(differences) == {'docs/openapi.json'} | {'crates/oa-desktop/src/semantic_review/' + p + '.rs' for p in ('artifacts_view', 'attachments_view', 'keyed_rows', 'keyed_tests', 'keyed', 'ordered')}, 'exact seven documented CRLF restorations')
|
||||
delta = subprocess.run(['git', 'diff', '--name-only', '--ignore-space-at-eol', BASE_COMMIT, SOURCE_COMMIT], cwd=ROOT, check=True, capture_output=True).stdout.decode().splitlines()
|
||||
check(delta == ['.gitattributes'], 'derived commit changes only preservation attributes beyond EOL bytes')
|
||||
origin = read_json(ROOT / 'harness/experiments/textual-authoring-20260911-local/provenance.json')
|
||||
tar_path = ROOT / '.local/linux-qa/runtime/source7-20260907T082753190288Z.tar.gz'
|
||||
check(sha(tar_path.read_bytes()) == 'cbb371a93a562e1e641d7629fe351e016d63e66fc9112499b3fed6d5cd171f78', 'original server source tar identity')
|
||||
server_expected = {i['path']: i['snapshot_sha256'].lower() for i in origin['source_comparisons']}
|
||||
check(len(server_expected) == 120, '120 independently recorded server source files')
|
||||
server_base = git_blobs(BASE_COMMIT, list(server_expected))
|
||||
tar_manifest = read_json(tar_path.with_suffix('').with_suffix('.tar.manifest.json'))
|
||||
with tarfile.open(tar_path) as tar:
|
||||
members = {m.name.removeprefix('./'): m for m in tar.getmembers() if m.isfile()}
|
||||
for p, h in server_expected.items():
|
||||
check(sha(tar.extractfile(members[p]).read()) == h == sha(server_base[p]) == sha((SOURCE / p).read_bytes()), 'server recorded tar equals base Git and staged input: ' + p)
|
||||
shared = [p for p in members if p in ('Cargo.toml', 'Cargo.lock', 'docs/openapi.json') or p.startswith('build/')]
|
||||
for p in shared:
|
||||
check(sha(tar.extractfile(members[p]).read()) == expected[p] == tar_manifest['files'][p].lower(), 'original server/GUI shared manifest or build input: ' + p)
|
||||
evidence = read_json(PACKAGE / 'package.json')['build_evidence']['evidence']
|
||||
check(evidence['binary_sha256'] == {k: v for k, v in BINARIES.items() if k.endswith('.exe')}, 'packaged build evidence binds both exact executables')
|
||||
check(SOURCE_COMMIT in evidence['source_identity'] and BASE_COMMIT in evidence['source_identity'], 'evidence identifies auxiliary and base sources distinctly')
|
||||
check('not claimed as the original build commit' in evidence['source_identity'] and 'Not a release-profile or reproducible-build claim' in evidence['profile'], 'packaged provenance retains explicit limitations')
|
||||
RESULT['provenance'] = {'gui_manifest_sha256': sha(gui_path.read_bytes()), 'gui_build_record': gui['build'], 'historical_gui_head': gui['head'], 'gui_inputs': 253, 'source_commit': SOURCE_COMMIT, 'base_commit': BASE_COMMIT, 'eol_restorations': differences, 'server_source_files': 120, 'shared_input_files': shared, 'tar_sha256': sha(tar_path.read_bytes())}
|
||||
|
||||
|
||||
def notices(actual):
|
||||
notice = read_json(PACKAGE / 'dependency-notices.json')
|
||||
check(notice['root_packages'] == ['oa-desktop', 'oa-server'] and len(notice['packages']) == 266, '266 notice packages and exact desktop/server roots')
|
||||
check(notice['cargo_lock_sha256'].lower() == sha((SOURCE / 'Cargo.lock').read_bytes()), 'notice lock equals exact historical source lock')
|
||||
for p, h in notice['workspace_build_inputs_sha256'].items():
|
||||
check(sha((SOURCE / p).read_bytes()) == h, 'notice common build input: ' + p)
|
||||
lock = tomllib.loads((SOURCE / 'Cargo.lock').read_text())
|
||||
locked = {(p['name'], p['version'], p.get('source')): p for p in lock['package']}
|
||||
referenced = set()
|
||||
def refs(items):
|
||||
for i in items:
|
||||
p = i['path']
|
||||
check(safe_name(p) and p.startswith('licenses/') and actual.get(p) == i['sha256'].lower(), 'bundled exact notice reference: ' + p)
|
||||
referenced.add(p)
|
||||
fonts = []
|
||||
for p in notice['packages']:
|
||||
key = (p['name'], p['version'], p['source'])
|
||||
check(key in locked and p['registry_checksum'] == locked[key].get('checksum'), 'notice identity/checksum occurs in historical lock: ' + p['name'] + '@' + p['version'])
|
||||
check(len(p['notices']) > 0, 'package has notice text: ' + p['name'])
|
||||
refs(p['notices'])
|
||||
for f in p['embedded_fonts']:
|
||||
check(len(f['notices']) > 0, 'embedded font notice present: ' + p['name'] + '/' + f['path'])
|
||||
refs(f['notices'])
|
||||
fonts.append({'package': p['name'], 'path': f['path'], 'sha256': f['sha256'], 'license': f['license']})
|
||||
if p['source'] is None:
|
||||
directory = SOURCE / 'crates' / p['name']
|
||||
files = {f.relative_to(directory).as_posix(): sha(f.read_bytes()) for f in directory.rglob('*') if f.is_file() and 'target' not in f.relative_to(directory).parts and f.name != '.cargo-ok'}
|
||||
check(files == p['local_source_sha256'], 'complete ordinary local-crate inventory: ' + p['name'])
|
||||
refs(notice['rust']['notices'])
|
||||
refs(notice['runtime']['notices'])
|
||||
check(notice['runtime']['sha256'] == BINARIES['libunwind.dll'], 'runtime notice is bound to shipped DLL')
|
||||
library_dir = ROOT / '.local/toolchain/rustup/toolchains/stable-x86_64-pc-windows-gnullvm/lib/rustlib/x86_64-pc-windows-gnullvm/lib'
|
||||
check(len(notice['rust']['target_libraries_sha256']) == 58, '58 compiler target libraries recorded')
|
||||
for name, h in notice['rust']['target_libraries_sha256'].items():
|
||||
check(sha((library_dir / name).read_bytes()) == h, 'retained compiler target-library bytes: ' + name)
|
||||
RESULT['notices'] = {'packages': len(notice['packages']), 'roots': notice['root_packages'], 'lock_sha256': notice['cargo_lock_sha256'], 'notice_reference_files': len(referenced), 'additional_license_files': sorted({p for p in actual if p.startswith('licenses/')} - referenced), 'embedded_fonts': fonts, 'rust_version': notice['rust']['version'], 'runtime_provenance': notice['runtime']['provenance'], 'scope': notice['scope']}
|
||||
|
||||
|
||||
def server():
|
||||
prior = read_json(OUT / 'artifact-result.json')
|
||||
check(prior['status'] == 'PASS' and file_map(PACKAGE) == prior['archive']['files'], 'previously verified extracted package is unchanged')
|
||||
runtime = OUT / 'server-run'
|
||||
runtime.mkdir()
|
||||
data_dir = runtime / 'data'
|
||||
temp = runtime / 'temp'
|
||||
temp.mkdir()
|
||||
windows = Path(os.environ['SystemRoot'])
|
||||
env = {'SystemRoot': str(windows), 'WINDIR': str(windows), 'PATH': str(windows / 'System32') + ';' + str(windows), 'TEMP': str(temp), 'TMP': str(temp)}
|
||||
exe = PACKAGE / 'arcadia-server.exe'
|
||||
RESULT['environment'] = env
|
||||
RESULT['processes'] = []
|
||||
for arg in ('--version', '--help'):
|
||||
output = subprocess.run([str(exe), arg], cwd=PACKAGE, env=env, capture_output=True, timeout=15, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
(runtime / (arg[2:] + '.stdout')).write_bytes(output.stdout)
|
||||
(runtime / (arg[2:] + '.stderr')).write_bytes(output.stderr)
|
||||
check(output.returncode == 0 and not output.stderr, arg + ' succeeds under clean runtime PATH')
|
||||
if arg == '--version':
|
||||
check(output.stdout.decode().strip() == 'arcadia-server 0.2.0', 'server reports underlying application version 0.2.0')
|
||||
check(not data_dir.exists(), 'help/version do not create explicit test data directory')
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
request_number = 0
|
||||
def request(port, method, path, payload=None):
|
||||
nonlocal request_number
|
||||
request_number += 1
|
||||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
|
||||
req = urllib.request.Request(f'http://127.0.0.1:{port}' + path, data=body, method=method, headers={} if body is None else {'Content-Type': 'application/json'})
|
||||
with opener.open(req, timeout=10) as response:
|
||||
raw = response.read()
|
||||
meta = {'method': method, 'path': path, 'status': response.status, 'headers': dict(response.headers), 'body_sha256': sha(raw)}
|
||||
prefix = runtime / f'http-{request_number:02d}'
|
||||
prefix.with_suffix('.body').write_bytes(raw)
|
||||
write_json(prefix.with_suffix('.json'), meta)
|
||||
return meta, raw
|
||||
def start(index):
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
port = s.getsockname()[1]
|
||||
out = (runtime / f'server-{index}.stdout').open('xb')
|
||||
err = (runtime / f'server-{index}.stderr').open('xb')
|
||||
args = [str(exe), '--bind', f'127.0.0.1:{port}', '--data-dir', str(data_dir)]
|
||||
process = subprocess.Popen(args, cwd=PACKAGE, env=env, stdout=out, stderr=err, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
out.close()
|
||||
err.close()
|
||||
RESULT['processes'].append({'pid': process.pid, 'port': port, 'arguments': args})
|
||||
deadline = time.monotonic() + 15
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise AssertionError('packaged server exited before listening')
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=.2):
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
raise AssertionError('packaged server did not listen within 15s')
|
||||
time.sleep(.05)
|
||||
return process, port
|
||||
def stop(process):
|
||||
process.terminate()
|
||||
code = process.wait(timeout=10)
|
||||
RESULT['processes'][-1]['exit_after_explicit_terminate'] = code
|
||||
process = None
|
||||
try:
|
||||
process, port = start(1)
|
||||
for path in ('/health', '/health/ready'):
|
||||
meta, raw = request(port, 'GET', path)
|
||||
check(meta['status'] == 200 and json.loads(raw) == {'status': 'ok'}, path + ' contract')
|
||||
meta, raw = request(port, 'GET', '/api/v1/openapi.json')
|
||||
check(meta['status'] == 200 and raw == (PACKAGE / 'docs/openapi.json').read_bytes(), 'served OpenAPI equals complete packaged source bytes')
|
||||
name = 'Independent package persistence — 救助 20260911'
|
||||
meta, raw = request(port, 'POST', '/api/v1/projects', {'name': name})
|
||||
created = json.loads(raw)
|
||||
check(meta['status'] == 201, 'one create returns 201')
|
||||
resource = str(uuid.UUID(created['resource_id']))
|
||||
model_id = str(uuid.UUID(created['model_id']))
|
||||
expected_summary = {'resource_id': resource, 'revision': 1, 'model_id': model_id, 'schema_version': 7, 'name': name, 'elements': 0, 'relations': 0, 'migration': None}
|
||||
check(created == expected_summary and resource != model_id, 'complete create summary with distinct fresh resource/model IDs')
|
||||
etag = f'"p-{resource}-r1"'
|
||||
headers = {k.lower(): v for k, v in meta['headers'].items()}
|
||||
path = '/api/v1/projects/' + resource
|
||||
check(headers.get('etag') == etag and headers.get('location') == path, 'create ETag and resource Location')
|
||||
meta, raw = request(port, 'GET', path)
|
||||
stored = json.loads(raw)
|
||||
model = {'schema_version': 7, 'id': model_id, 'alias': 'mission', 'name': name, 'description': '', 'elements': {}, 'relations': {}, 'diagrams': {}, 'artifact_links': {}, 'traces': {}}
|
||||
source = f'arcadia 7;\nproject mission {json.dumps(name, ensure_ascii=False)} id "{model_id}" {{\n description "";\n}}\n'
|
||||
expected = {'resource_id': resource, 'revision': 1, 'model': model, 'source_project': {'entry': 'project.arcadia', 'files': {'project.arcadia': source}}}
|
||||
check(meta['status'] == 200 and stored == expected, 'GET equals independently predicted complete Project and SourceBundle')
|
||||
check({k.lower(): v for k, v in meta['headers'].items()}.get('etag') == etag, 'GET ETag equals created revision')
|
||||
disk = data_dir / (resource + '.json')
|
||||
disk_bytes = disk.read_bytes()
|
||||
check(json.loads(disk_bytes) == expected, 'durable disk envelope equals complete expected GET')
|
||||
check({p.name for p in data_dir.iterdir()} == {'.arcadia.lock', disk.name}, 'private store contains only one project and lock')
|
||||
stop(process)
|
||||
process = None
|
||||
process, port = start(2)
|
||||
meta, raw = request(port, 'GET', '/health/ready')
|
||||
check(meta['status'] == 200 and json.loads(raw) == {'status': 'ok'}, 'restarted packaged server ready')
|
||||
meta, raw = request(port, 'GET', path)
|
||||
check(meta['status'] == 200 and json.loads(raw) == expected, 'restart retains exact complete Project and SourceBundle')
|
||||
check({k.lower(): v for k, v in meta['headers'].items()}.get('etag') == etag and disk.read_bytes() == disk_bytes, 'restart retains revision, ETag and every durable envelope byte')
|
||||
RESULT['persistence'] = {'resource_id': resource, 'model_id': model_id, 'revision': 1, 'etag': etag, 'disk_sha256': sha(disk_bytes), 'expected': expected, 'stop_kind': 'explicit process termination after acknowledged durable create; not a graceful-drain claim'}
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
stop(process)
|
||||
check(file_map(PACKAGE) == prior['archive']['files'], 'server execution leaves complete extracted package unchanged')
|
||||
check(sha(ZIP.read_bytes()) == EXPECTED_ZIP, 'frozen ZIP unchanged after independent runtime checks')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
result_path = OUT / (sys.argv[1] + '-result.json')
|
||||
if result_path.exists():
|
||||
raise SystemExit('Refusing to overwrite prior evidence: ' + str(result_path))
|
||||
try:
|
||||
{'artifact': artifact, 'server': server}[sys.argv[1]]()
|
||||
RESULT['status'] = 'PASS'
|
||||
except Exception as error:
|
||||
RESULT['status'] = 'FAIL'
|
||||
RESULT['error'] = repr(error)
|
||||
RESULT['traceback'] = traceback.format_exc()
|
||||
RESULT['finished_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
RESULT['script_sha256'] = sha(Path(__file__).read_bytes())
|
||||
write_json(result_path, RESULT)
|
||||
print(json.dumps({k: RESULT[k] for k in ('scope', 'status', 'started_utc', 'finished_utc')}, indent=2))
|
||||
print('checks:', len(RESULT['checks']))
|
||||
if RESULT['status'] == 'FAIL':
|
||||
print(RESULT['traceback'])
|
||||
sys.exit(1)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm.zip",
|
||||
"bytes": 32618262,
|
||||
"sha256": "664f30b6217689aca1492eb263b3d15777067d732fa890eeb028f435209dadfa",
|
||||
"url": "http://217.177.34.108:3000/attachments/48a283b7-5391-4677-9427-02a772e5e7e4",
|
||||
"readback": "PASS"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "ArcadiaStudio-0.2.0-preview.authoring.20260911-x86_64-pc-windows-gnullvm.zip.sha256",
|
||||
"bytes": 143,
|
||||
"sha256": "ea553a85f35d2d48f229dd4cd00ac0c3d4a6e9dafa58273004b2c49d1279f856",
|
||||
"url": "http://217.177.34.108:3000/attachments/08164a39-9292-4b2d-9856-c6ec41d9b8e2",
|
||||
"readback": "PASS"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": 3,
|
||||
"name": "VALIDATION.md",
|
||||
"bytes": 29308,
|
||||
"sha256": "c2a70be735c780d3e468d5f12643fb00be75673434211366739926536bbbe9b9",
|
||||
"url": "http://217.177.34.108:3000/attachments/9ff53361-eedf-46a0-aa4e-34f11bb0f302",
|
||||
"readback": "PASS"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Standalone CLI package evidence
|
||||
|
||||
Exact frozen author and independent reviewer evidence for the Windows
|
||||
`ArcadiaCLI-0.2.0-preview.authoring.20260911` artifact. `hashes.json` records
|
||||
original private paths, lengths and hashes; preserve these bytes through Git.
|
||||
|
||||
The original host-specific reviewer script expects its recorded `.local` layout,
|
||||
source snapshot and fresh disposable outputs. Restore that layout to reproduce
|
||||
it; running the archived file in this directory is not a portable test command.
|
||||
The production packaging tests remain under `scripts/`.
|
||||
|
||||
The author report records 15 inert checks, 13 Python tests, default-preserving
|
||||
notice selection and strict package assembly. Independent review verifies source
|
||||
equivalence/defaults, all 114 archive files and 156 assertions across 10 clean
|
||||
runtime invocations. Neither completed the wider OA/SA product or Linux acceptance.
|
||||
@@ -0,0 +1,5 @@
|
||||
.............
|
||||
----------------------------------------------------------------------
|
||||
Ran 13 tests in 1.252s
|
||||
|
||||
OK
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user