Phase 6: IDE-Exportanbindung abschließen und Change archivieren

This commit is contained in:
2026-09-07 17:00:29 +02:00
parent 25176947ba
commit 69f2002481
31 changed files with 1310 additions and 136 deletions

View File

@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
tb-frontend.workspace = true
tb-vm.workspace = true
tb-export.workspace = true
tb-runtime.workspace = true
tb-ui = { workspace = true, features = ["terminal"] }
ratatui.workspace = true

View File

@@ -301,6 +301,9 @@ pub struct App {
pub last_command: Option<Command>,
pub hits: Vec<(Rect, Hit)>,
pub basic_events: Vec<Event>,
pub export_backend: bool,
pub runtime_dir: PathBuf,
pub(crate) export_job: Option<crate::export::Job>,
pub last_export: Option<(ExportRequest, ExportStatus)>,
pub config_path: PathBuf,
pub saved_options: Options,
@@ -351,6 +354,12 @@ impl App {
last_command: None,
hits: vec![],
basic_events: vec![],
export_backend: true,
runtime_dir: std::env::current_exe()?
.parent()
.unwrap_or(base)
.join("runtimes"),
export_job: None,
last_export: None,
next_window: 1,
geometry_action: None,
@@ -674,14 +683,18 @@ impl App {
SaveAs => self.save_dialog(false, vec![id.unwrap()], AfterSave::Stay)?,
SaveProject => self.save_dialog(
true,
self.project.documents().map(|(id, _)| id).collect(),
self.project
.documents()
.filter(|(_, d)| !d.is_library())
.map(|(id, _)| id)
.collect(),
AfterSave::Stay,
)?,
RemoveFile => {
let ids = self.project.members();
let names = self.names(&ids);
let mut replacements = vec!["Bisheriger Standard".into()];
replacements.extend(names.clone());
replacements.extend(self.names(&self.project.source_members()));
self.open_dialog(
"Remove File",
DialogKind::Remove,
@@ -692,7 +705,7 @@ impl App {
);
}
Startup => {
let ids = self.project.members();
let ids = self.project.source_members();
let mut names = vec!["Bisheriger Standard".into()];
names.extend(self.names(&ids));
let selected = self
@@ -711,7 +724,10 @@ impl App {
let ids: Vec<_> = self
.project
.documents()
.filter(|(_, d)| command == Code || matches!(d.content(), Content::Form(_)))
.filter(|(_, d)| {
!d.is_library()
&& (command == Code || matches!(d.content(), Content::Form(_)))
})
.map(|(id, _)| id)
.collect();
let selected = id
@@ -880,12 +896,22 @@ impl App {
} else {
Artifact::Library
};
let systems = vec!["linux".into(), "macos".into(), "windows".into()];
let mut systems: Vec<String> = tb_export::Target::ALL
.iter()
.map(|t| crate::export::target_parts(*t).0.into())
.collect();
systems.sort();
systems.dedup();
let selected = systems
.iter()
.position(|s| s == std::env::consts::OS)
.unwrap_or(0);
let architectures = vec!["x86_64".into(), "aarch64".into()];
let mut architectures: Vec<String> = tb_export::Target::ALL
.iter()
.map(|t| crate::export::target_parts(*t).1.into())
.collect();
architectures.sort();
architectures.dedup();
let arch = architectures
.iter()
.position(|s| s == std::env::consts::ARCH)
@@ -906,12 +932,15 @@ impl App {
self.output_default(if artifact == Artifact::Executable {
"program"
} else {
"library"
"library.tbl"
}),
),
Field::toggle("Bestehendes Ziel überschreiben", false),
],
);
if artifact == Artifact::Library {
self.dialog.as_mut().unwrap().fields.drain(1..3);
}
}
_ => {}
}
@@ -968,6 +997,13 @@ impl App {
}
}
pub(crate) fn show_document(&mut self, id: DocumentId, form: bool) -> Result<()> {
if self.project.document(id)?.is_library() {
self.message = format!(
"TBL-Bibliothek: {} · Binärabhängigkeit, kein editierbarer Quelltext",
self.project.document(id)?.source_path().display()
);
return Ok(());
}
if form {
ensure!(
matches!(self.project.document(id)?.content(), Content::Form(_)),
@@ -1078,7 +1114,7 @@ impl App {
self.selected_member = 0;
self.mode = Mode::Environment;
self.properties = false;
if let Some(id) = self.project.members().first().copied() {
if let Some(id) = self.project.source_members().first().copied() {
self.show_document(id, false)?;
}
let active = self.active;
@@ -1198,7 +1234,9 @@ impl App {
let ids: Vec<_> = self
.project
.documents()
.filter(|(_, d)| !form || matches!(d.content(), Content::Form(_)))
.filter(|(_, d)| {
!d.is_library() && (!form || matches!(d.content(), Content::Form(_)))
})
.map(|(id, _)| id)
.collect();
let id = *ids
@@ -1215,7 +1253,7 @@ impl App {
Some(if replacement == 0 {
StartupRemoval::Default
} else {
StartupRemoval::Replace(ids[replacement - 1])
StartupRemoval::Replace(self.project.source_members()[replacement - 1])
}),
)?;
self.selected_member = self
@@ -1223,7 +1261,7 @@ impl App {
.min(self.project.members().len().saturating_sub(1));
}
DialogKind::Startup => {
let ids = self.project.members();
let ids = self.project.source_members();
let choice = d.fields[0].index();
self.project.set_startup(if choice == 0 {
None
@@ -1234,7 +1272,11 @@ impl App {
DialogKind::Dirty(after) => match d.fields[0].index() {
1 => self.save_dialog(
true,
self.project.documents().map(|(id, _)| id).collect(),
self.project
.documents()
.filter(|(_, d)| !d.is_library())
.map(|(id, _)| id)
.collect(),
after,
)?,
2 => self.finish_change(after)?,
@@ -1352,25 +1394,83 @@ impl App {
self.message = format!("Optionen gespeichert: {}", self.config_path.display());
}
DialogKind::Export(artifact) => {
let library = artifact == Artifact::Library;
let output = if library { 1 } else { 3 };
let request = ExportRequest::validate(
&self.project,
artifact,
&d.fields[1].string(),
&d.fields[2].string(),
&if library {
String::new()
} else {
d.fields[1].string()
},
&if library {
String::new()
} else {
d.fields[2].string()
},
&Destination {
path: d.fields[3].string().into(),
overwrite: d.fields[4].flag(),
path: d.fields[output].string().into(),
overwrite: d.fields[output + 1].flag(),
},
)?;
d.request = Some(request);
d.export_status = ExportStatus::Ready;
d.error = crate::export::UNAVAILABLE.into();
self.export_job = None;
d.request = Some(request.clone());
if self.export_backend {
match crate::export::Job::start(request, &self.project, &self.runtime_dir) {
Ok(job) => {
self.export_job = Some(job);
d.export_status = ExportStatus::Running;
d.error.clear();
}
Err(e) => {
d.export_status = ExportStatus::Failed(format!("{e:#}"));
d.error.clear();
}
}
} else {
d.export_status = ExportStatus::Ready;
d.error = crate::export::UNAVAILABLE.into();
}
return Ok(true);
}
}
Ok(false)
}
/// Die spätere Anbindung und Oberflächentests liefern denselben konkreten Auftrag zurück.
/// Der UI-Thread veröffentlicht ausschließlich den weiterhin gültigen Auftrag.
pub fn tick_export(&mut self) {
let Some(job) = &self.export_job else {
return;
};
let request = job.request.clone();
let same_dialog = self.dialog.as_ref().and_then(|d| d.request.as_ref()) == Some(&request);
if !same_dialog || request.project != crate::export::ProjectStamp::capture(&self.project) {
self.export_job = None;
if same_dialog {
if let Some(d) = &mut self.dialog {
d.export_status = ExportStatus::Failed(
"Projekt wurde seit dem Exportauftrag geändert".into(),
);
}
}
return;
}
if let Some(result) = job.take_result() {
let status = match result.and_then(|prepared| {
ensure!(
std::fs::read(&request.path).ok() == request.previous_output,
"Ziel wurde seit dem Exportauftrag verändert"
);
prepared.publish(&|| false)
}) {
Ok(()) => ExportStatus::Success,
Err(e) => ExportStatus::Failed(format!("{e:#}")),
};
self.export_job = None;
let _ = self.export_result(&request, status);
}
}
/// Oberflächentests und die geprüfte Veröffentlichung melden denselben Auftrag zurück.
pub fn export_result(&mut self, request: &ExportRequest, status: ExportStatus) -> Result<()> {
let d = self
.dialog
@@ -1639,13 +1739,16 @@ impl App {
}
}
fn cancel_dialog(&mut self) {
self.export_job = None;
if let Some(d) = self.dialog.take() {
if let DialogKind::Browse { return_to, .. } = d.kind {
self.dialog = Some(*return_to);
return;
}
if let Some(request) = d.request {
self.last_export = Some((request, ExportStatus::Cancelled));
if d.export_status != ExportStatus::Success {
self.last_export = Some((request, ExportStatus::Cancelled));
}
}
}
}
@@ -1958,7 +2061,9 @@ impl App {
| DialogKind::SaveText
| DialogKind::Print => index == 0,
DialogKind::Save { .. } => index % 2 == 0,
DialogKind::Export(_) => index == 3,
DialogKind::Export(artifact) => {
index == if artifact == Artifact::Library { 1 } else { 3 }
}
_ => false,
};
if !allowed || index >= dialog.fields.len() {

View File

@@ -427,6 +427,15 @@ impl App {
.is_ok_and(|doc| module_name(doc).eq_ignore_ascii_case(&owner_name))
});
self.session.fullscreen = false;
if self
.project
.find_document(std::path::Path::new(&file))
.is_none()
&& !std::path::Path::new(&file).is_file()
{
self.message = format!("Bibliotheksquelle nicht verfügbar: {file}:{} · Fortsetzen oder Prozedurschritt verwenden", location.line);
return Ok(());
}
self.goto_diagnostic(&Diagnostic {
file: Some(file.clone()),
pos: SourcePos {

View File

@@ -102,6 +102,9 @@ fn remap_breakpoints(
.collect()
}
impl Document {
pub fn is_library(&self) -> bool {
has_extension(&self.source_path, "tbl")
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
@@ -247,6 +250,12 @@ impl Project {
.filter_map(|p| self.find_document(p))
.collect()
}
pub fn source_members(&self) -> Vec<DocumentId> {
self.members()
.into_iter()
.filter(|id| !self.documents[id].is_library())
.collect()
}
pub fn startup(&self) -> Option<DocumentId> {
self.manifest
.startup
@@ -260,7 +269,7 @@ impl Project {
pub fn loader(&self) -> Result<SourceLoader> {
let mut loader = SourceLoader::default();
loader.include_paths = self.include_paths.clone();
for doc in self.documents.values() {
for doc in self.documents.values().filter(|d| !d.is_library()) {
loaded(loader.insert(&doc.source_path, doc.content.clone()))?;
}
Ok(loader)
@@ -279,7 +288,18 @@ impl Project {
return Ok(id);
}
ensure!(!has_extension(&path, "mak"), "MAK über Open Project öffnen");
let read = loaded(read_document(&path))?;
let read = if has_extension(&path, "tbl") {
let bytes = fs::read(&path)?;
tb_vm::library::Library::from_tbl(&bytes).map_err(|e| anyhow!(e))?;
tb_vm::project_io::ReadDocument {
content: Content::Text(String::new()),
bytes,
binary: false,
warnings: vec![],
}
} else {
loaded(read_document(&path))?
};
let id = DocumentId(next_id());
self.documents.insert(
id,
@@ -301,15 +321,17 @@ impl Project {
}
pub fn add_file(&mut self, path: &Path) -> Result<DocumentId> {
ensure!(
has_extension(path, "bas") || has_extension(path, "frm"),
"Projektmitglied muss BAS oder FRM sein"
has_extension(path, "bas") || has_extension(path, "frm") || has_extension(path, "tbl"),
"Projektmitglied muss BAS, FRM oder TBL sein"
);
// Auch die hinzugefügte Include-Kette muss vor jeder Änderung gültig sein.
let loader = self.loader()?;
let path = identity(&loaded(
loader.resolve(&self.base, &path.to_string_lossy()),
)?)?;
loaded(loader.load(&path))?;
if !has_extension(&path, "tbl") {
loaded(loader.load(&path))?;
}
let id = self.open_document(&path)?;
if !self.manifest.members().any(|p| p == &path) {
self.manifest.lines.push(ProjectLine::File(path));
@@ -403,6 +425,7 @@ impl Project {
.map(|id| self.document(id).map(|d| d.source_path.clone()))
.transpose()?;
if let Some(path) = &path {
ensure!(!has_extension(path, "tbl"), "TBL ist keine Startdatei");
ensure!(
self.manifest.members().any(|p| p == path),
"Startdatei ist kein Projektmitglied"
@@ -436,7 +459,10 @@ impl Project {
Ok(true)
}
pub fn open_view(&mut self, document: DocumentId) -> Result<ViewId> {
self.document(document)?;
ensure!(
!self.document(document)?.is_library(),
"TBL ist eine Binärbibliothek, kein editierbarer Quelltext"
);
let id = ViewId(next_id());
self.views.insert(
id,
@@ -546,6 +572,7 @@ impl Project {
}
}
fn commit_edit(&mut self, id: DocumentId, content: Content) -> Result<()> {
ensure!(!self.document(id)?.is_library(), "TBL ist schreibgeschützt");
self.design_ids(id)?;
let old = self.document(id)?;
if old.content == content {
@@ -740,6 +767,7 @@ impl Project {
target: &Destination,
) -> Result<()> {
let doc = self.document(id)?;
ensure!(!doc.is_library(), "TBL ist kein Textdokument");
let text = match range {
Some(r) => doc
.code()
@@ -806,6 +834,10 @@ impl Project {
Ok(())
}
fn file_target(&self, id: DocumentId, target: Option<&Destination>) -> Result<(PathBuf, bool)> {
ensure!(
!self.document(id)?.is_library(),
"TBL wird als Binärabhängigkeit referenziert und nicht gespeichert"
);
let doc = self.document(id)?;
let path = match target {
Some(t) => self.target(&t.path)?,
@@ -832,8 +864,10 @@ impl Project {
ensure!(has_extension(&path, "bas"), "Modulziel muss .bas sein")
}
Content::Text(_) => ensure!(
!has_extension(&path, "frm") && !has_extension(&path, "mak"),
"Textziel darf kein FRM/MAK sein"
!has_extension(&path, "frm")
&& !has_extension(&path, "mak")
&& !has_extension(&path, "tbl"),
"Textziel darf kein FRM/MAK/TBL sein"
),
}
Ok((path, target.is_some_and(|t| t.overwrite)))

View File

@@ -118,7 +118,7 @@ impl App {
let target = if project {
self.project.path().map(PathBuf::from).or_else(|| {
self.project
.members()
.source_members()
.first()
.and_then(|id| self.project.document(*id).ok())
.map(|d| d.source_path().to_path_buf())

View File

@@ -1,4 +1,4 @@
//! Konkreter Vertrag für die spätere native Erzeugung, ohne Phase-5-Dateiausgabe.
//! Revisionsgebundene Exportaufträge und abwerfbare Hintergrundresultate.
use crate::documents::{Destination, DocumentId, Project};
use anyhow::{ensure, Result};
use std::path::PathBuf;
@@ -14,7 +14,7 @@ impl Artifact {
pub fn label(self) -> &'static str {
match self {
Self::Executable => "Natives Standalone-Executable",
Self::Library => "Native Systembibliothek",
Self::Library => "Portable P-Code-Bibliothek (.tbl)",
}
}
}
@@ -22,6 +22,8 @@ impl Artifact {
pub struct ProjectStamp {
pub path: Option<PathBuf>,
pub manifest: Manifest,
pub inputs: Vec<(PathBuf, Option<Vec<u8>>)>,
pub include_paths: Vec<PathBuf>,
pub revisions: Vec<(DocumentId, u64)>,
}
impl ProjectStamp {
@@ -29,6 +31,20 @@ impl ProjectStamp {
Self {
path: project.path().map(PathBuf::from),
manifest: project.manifest().clone(),
include_paths: project.include_paths.clone(),
inputs: project
.sources()
.map(|s| {
s.protected_inputs()
.into_iter()
.filter(|p| has_extension(p, "tbl") || project.find_document(p).is_none())
.map(|p| {
let b = std::fs::read(&p).ok();
(p, b)
})
.collect()
})
.unwrap_or_default(),
revisions: project
.documents()
.map(|(id, d)| (id, d.revision()))
@@ -38,12 +54,14 @@ impl ProjectStamp {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportRequest {
pub generation: u64,
pub project: ProjectStamp,
pub artifact: Artifact,
pub system: String,
pub architecture: String,
pub path: PathBuf,
pub overwrite: bool,
pub previous_output: Option<Vec<u8>>,
}
impl ExportRequest {
pub fn validate(
@@ -53,24 +71,25 @@ impl ExportRequest {
architecture: &str,
destination: &Destination,
) -> Result<Self> {
ensure!(
["linux", "macos", "windows"].contains(&system),
"Unbekanntes Zielsystem"
);
ensure!(
["x86_64", "aarch64"].contains(&architecture),
"Unbekannte Zielarchitektur"
);
if artifact == Artifact::Executable {
target(system, architecture)?;
}
ensure!(
!has_extension(&destination.path, "tbc"),
"TBC ist kein natives Exportartefakt"
);
let path = project.validate_export_target(destination)?;
if artifact == Artifact::Library {
ensure!(has_extension(&path, "tbl"), "Library-Ausgabe benötigt .tbl");
}
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
Ok(Self {
generation: NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
project: ProjectStamp::capture(project),
artifact,
system: system.into(),
architecture: architecture.into(),
previous_output: std::fs::read(&path).ok(),
path,
overwrite: destination.overwrite,
})
@@ -95,3 +114,333 @@ impl ExportStatus {
}
}
}
/// Die unterstützten Kombinationen werden ausschließlich aus dem Exportkatalog gewählt.
pub fn target(system: &str, architecture: &str) -> Result<tb_export::Target> {
tb_export::Target::ALL
.into_iter()
.find(|t| target_parts(*t) == (system, architecture))
.ok_or_else(|| anyhow::anyhow!("Unzulässiges EXE-Ziel: {system}/{architecture}"))
}
pub fn target_parts(target: tb_export::Target) -> (&'static str, &'static str) {
let triple = target.triple();
(
if triple.contains("windows") {
"windows"
} else if triple.contains("apple") {
"macos"
} else {
"linux"
},
triple.split('-').next().unwrap(),
)
}
pub struct Job {
pub request: ExportRequest,
thread: Option<std::thread::JoinHandle<()>>,
cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
result: std::sync::mpsc::Receiver<Result<tb_export::PreparedPublication>>,
}
impl Drop for Job {
fn drop(&mut self) {
self.cancelled
.store(true, std::sync::atomic::Ordering::Release);
// Auch beim Beenden der IDE müssen kontrollierte Tools und Staging enden.
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
impl Job {
pub fn start(
request: ExportRequest,
project: &Project,
runtime_dir: &std::path::Path,
) -> Result<Self> {
let sources = project.sources()?;
let mut compiler = tb_vm::project::ProjectCompiler::default();
compiler.debug_symbols = true;
let diagnostics = |errors: Vec<tb_frontend::Diagnostic>| {
anyhow::anyhow!(errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"))
};
let bytes = match request.artifact {
Artifact::Library => sources
.compile_library(&mut compiler)
.map_err(diagnostics)?
.to_tbl()
.map_err(|e| anyhow::anyhow!(e))?,
Artifact::Executable => sources
.compile(&mut compiler, "IDE")
.map_err(diagnostics)?
.to_tbc(),
};
ensure!(
request.project == ProjectStamp::capture(project),
"Projekt während der Übersetzung geändert"
);
let mut protected = sources.protected_inputs();
protected.extend(
project
.documents()
.map(|(_, d)| d.source_path().to_path_buf()),
);
if let Some(path) = project.path() {
protected.push(path.into());
}
let runtime_dir = runtime_dir.to_path_buf();
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = cancelled.clone();
let (sender, result) = std::sync::mpsc::channel();
let work = request.clone();
let thread = std::thread::Builder::new()
.name("tb-export".into())
.spawn(move || {
let cancelled = || flag.load(std::sync::atomic::Ordering::Acquire);
let prepare = || -> Result<tb_export::PreparedPublication> {
if work.artifact == Artifact::Library {
tb_export::prepare_publication(
&work.path,
work.overwrite,
&protected,
&cancelled,
|p| {
std::fs::write(p, &bytes)?;
Ok(())
},
)
} else {
let target = target(&work.system, &work.architecture)?;
let template = runtime_dir
.join(target.triple())
.join(target.runtime_name());
let module = tb_vm::bytecode::CompiledModule::from_tbc(&bytes)
.map_err(|e| anyhow::anyhow!("{e}"))?;
tb_export::prepare_export(
tb_export::Export {
module: &module,
target,
template: &template,
output: &work.path,
overwrite: work.overwrite,
protected: &protected,
},
&cancelled,
)
}
};
// Ein geschlossener Empfänger verwirft das Staging-Ergebnis per Drop.
let _ = sender.send(prepare());
})?;
Ok(Self {
thread: Some(thread),
request,
cancelled,
result,
})
}
pub fn take_result(&self) -> Option<Result<tb_export::PreparedPublication>> {
match self.result.try_recv() {
Ok(result) => Some(result),
Err(std::sync::mpsc::TryRecvError::Empty) => None,
Err(_) => Some(Err(anyhow::anyhow!("Exportworker beendet ohne Ergebnis"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{app::App, commands::Command};
use std::{
fs,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc, Arc,
},
};
struct Dir(PathBuf);
impl Dir {
fn new() -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"tb-export-ui-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn delayed(
app: &mut App,
output: &std::path::Path,
) -> (
mpsc::Sender<Result<tb_export::PreparedPublication>>,
Arc<AtomicBool>,
) {
app.execute(Command::MakeLibrary);
let request = ExportRequest::validate(
&app.project,
Artifact::Library,
"",
"",
&Destination {
path: output.into(),
overwrite: true,
},
)
.unwrap();
let d = app.dialog.as_mut().unwrap();
d.request = Some(request.clone());
d.export_status = ExportStatus::Running;
let (tx, rx) = mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(false));
app.export_job = Some(Job {
thread: None,
request,
cancelled: cancelled.clone(),
result: rx,
});
(tx, cancelled)
}
fn prepared(output: &std::path::Path) -> tb_export::PreparedPublication {
tb_export::prepare_publication(output, true, &[], &|| false, |p| {
fs::write(p, b"new")?;
Ok(())
})
.unwrap()
}
fn no_staging(dir: &std::path::Path) {
assert!(fs::read_dir(dir).unwrap().all(|p| !p
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".tb-export-")));
}
#[test]
fn delayed_result_is_published_only_after_ui_validation_and_success_stays_success() {
let dir = Dir::new();
let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap();
let output = dir.0.join("out.tbl");
fs::write(&output, b"old").unwrap();
let (tx, _) = delayed(&mut app, &output);
let staged = prepared(&output);
app.tick_export();
app.handle(crossterm::event::Event::Resize(110, 35));
assert_eq!(app.size, (110, 35));
assert_eq!(fs::read(&output).unwrap(), b"old");
tx.send(Ok(staged)).ok().unwrap();
app.tick_export();
assert_eq!(fs::read(&output).unwrap(), b"new");
assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success);
app.handle(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
));
assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success);
no_staging(&dir.0);
}
#[test]
fn cancelled_superseded_changed_and_foreign_jobs_never_publish() {
for mode in 0..5 {
let dir = Dir::new();
let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap();
let output = dir.0.join("out.tbl");
fs::write(&output, b"old").unwrap();
let (tx, flag) = delayed(&mut app, &output);
let staged = prepared(&output);
match mode {
0 => app.handle(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
)),
1 => {
let id = app.project.members()[0];
app.project.replace_text(id, 0..0, "PRINT 2\n").unwrap();
}
2 => {
app.project = Project::new(&dir.0).unwrap();
}
3 => {
let old = app.export_job.as_ref().unwrap().request.generation;
let (_new, _) = delayed(&mut app, &output);
assert_ne!(old, app.export_job.as_ref().unwrap().request.generation);
}
_ => {
fs::write(&output, b"external").unwrap();
}
}
let _ = tx.send(Ok(staged));
app.tick_export();
assert!(flag.load(Ordering::Acquire));
assert_eq!(
fs::read(&output).unwrap(),
if mode == 4 {
b"external".as_slice()
} else {
b"old".as_slice()
}
);
assert!(!app
.last_export
.as_ref()
.is_some_and(|(_, s)| *s == ExportStatus::Success));
no_staging(&dir.0);
}
}
#[test]
fn replaced_library_invalidates_pending_export_even_with_same_length() {
let dir = Dir::new();
let source = dir.0.join("lib.bas");
fs::write(&source, "SUB Answer\nPRINT 1\nEND SUB\n").unwrap();
let mut compiler = tb_vm::project::ProjectCompiler::default();
let build = |compiler: &mut tb_vm::project::ProjectCompiler| {
tb_vm::project_io::SourceLoader::default()
.load(&source)
.unwrap()
.compile_library(compiler)
.unwrap()
.to_tbl()
.unwrap()
};
let a = build(&mut compiler);
fs::write(&source, "SUB Answer\nPRINT 2\nEND SUB\n").unwrap();
let b = build(&mut compiler);
assert_eq!(a.len(), b.len());
let lib = dir.0.join("lib.tbl");
fs::write(&lib, &a).unwrap();
fs::write(dir.0.join("main.bas"), "CALL Answer\nEND\n").unwrap();
fs::write(dir.0.join("main.mak"), "main.bas\nlib.tbl\n").unwrap();
let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap();
app.load_initial_project(dir.0.join("main.mak")).unwrap();
let before = app.compile_current().unwrap().to_tbc();
assert!(app.current_compilation().is_some());
let out = dir.0.join("out.tbl");
fs::write(&out, b"old").unwrap();
let (tx, flag) = delayed(&mut app, &out);
let staged = prepared(&out);
fs::write(&lib, &b).unwrap();
assert!(app.current_compilation().is_none());
let _ = tx.send(Ok(staged));
app.tick_export();
assert!(flag.load(Ordering::Acquire));
assert_eq!(fs::read(&out).unwrap(), b"old");
assert_ne!(before, app.compile_current().unwrap().to_tbc());
no_staging(&dir.0);
}
}

View File

@@ -58,6 +58,14 @@ const DOCUMENTS: &[(&str, &str)] = &[
"docs/tbvm-design.md",
include_str!("../../../docs/tbvm-design.md"),
),
(
"docs/pcode-bibliotheken.md",
include_str!("../../../docs/pcode-bibliotheken.md"),
),
(
"docs/native-executables.md",
include_str!("../../../docs/native-executables.md"),
),
("PLAN.md", include_str!("../../../PLAN.md")),
];
#[derive(Clone, Debug)]

View File

@@ -70,6 +70,7 @@ fn main() -> Result<()> {
}
app.handle(event::read()?);
}
app.tick_export();
app.tick_execution(start.elapsed().as_millis() as u64);
let timeout = if app.execution == Execution::Running {
std::time::Duration::ZERO

View File

@@ -661,7 +661,15 @@ impl App {
put(
f,
submit,
if export { "[Prüfen]" } else { "[OK / Enter]" },
if export {
if self.export_backend {
"[Erzeugen]"
} else {
"[Prüfen]"
}
} else {
"[OK / Enter]"
},
if d.focus == d.fields.len() {
Style::default().fg(Color::White).bg(Color::Black)
} else {
@@ -671,7 +679,7 @@ impl App {
put(f, cancel, "[Abbrechen]", st);
self.hits.push((submit, Hit::DialogSubmit));
self.hits.push((cancel, Hit::DialogCancel));
if export {
if export && !self.export_backend {
put(
f,
Rect::new(rect.x + 36, rect.bottom() - 3, width - 38, 1),
@@ -689,7 +697,11 @@ impl App {
d.error
);
} else if status.is_empty() {
status = crate::export::UNAVAILABLE.into();
status = if self.export_backend {
"Bereit zum Erzeugen".into()
} else {
crate::export::UNAVAILABLE.into()
};
}
}
f.render_widget(

View File

@@ -8,7 +8,7 @@ use std::{
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, DialogKind, Execution, Mode, WindowKind, WindowState},
app::{App, DialogKind, Execution, Hit, Mode, WindowKind, WindowState},
commands::{self, Command},
export::{ExportStatus, ProjectStamp},
options::Options,
@@ -347,23 +347,33 @@ fn status_and_project_buttons_use_the_keyboard_command_path() {
fn export_dialogs_validate_and_render_all_results_without_creating_files() {
let t = Temp::new();
let mut app = t.app();
app.export_backend = false;
let stamp = ProjectStamp::capture(&app.project);
for command in [Command::MakeExe, Command::MakeLibrary] {
menu(&mut app, command);
while app.dialog.as_ref().unwrap().focus != 1 {
plain(&mut app, K::Tab);
}
plain(&mut app, K::Right);
let system = app.dialog.as_ref().unwrap().fields[1].string();
plain(&mut app, K::Tab);
plain(&mut app, K::Right);
let architecture = app.dialog.as_ref().unwrap().fields[2].string();
let output = if command == Command::MakeLibrary {
1
} else {
3
};
let (system, architecture) = if command == Command::MakeExe {
(
app.dialog.as_ref().unwrap().fields[1].string(),
app.dialog.as_ref().unwrap().fields[2].string(),
)
} else {
(String::new(), String::new())
};
let (text, _) = draw(&mut app);
assert!(text.contains("Erzeugen: Phase 6"));
field(&mut app, 3, "");
field(&mut app, output, "");
plain(&mut app, K::Enter);
assert!(app.dialog.as_ref().unwrap().error.contains("Ausgabepfad"));
field(&mut app, 3, &t.0.join("notthere/out").display().to_string());
field(
&mut app,
output,
&t.0.join("notthere/out").display().to_string(),
);
plain(&mut app, K::Enter);
assert!(app
.dialog
@@ -371,12 +381,12 @@ fn export_dialogs_validate_and_render_all_results_without_creating_files() {
.unwrap()
.error
.contains("Zielverzeichnis"));
field(&mut app, 3, &t.0.join("bad.tbc").display().to_string());
field(&mut app, output, &t.0.join("bad.tbc").display().to_string());
plain(&mut app, K::Enter);
assert!(app.dialog.as_ref().unwrap().error.contains("TBC"));
let existing = t.0.join("existing");
let existing = t.0.join("existing.tbl");
fs::write(&existing, b"keep").unwrap();
field(&mut app, 3, &existing.display().to_string());
field(&mut app, output, &existing.display().to_string());
plain(&mut app, K::Enter);
assert!(app
.dialog
@@ -389,8 +399,8 @@ fn export_dialogs_validate_and_render_all_results_without_creating_files() {
plain(&mut app, K::Enter);
assert_eq!(fs::read(&existing).unwrap(), b"keep");
assert!(app.dialog.as_ref().unwrap().request.is_some());
let target = t.0.join("result");
field(&mut app, 3, &target.display().to_string());
let target = t.0.join("result.tbl");
field(&mut app, output, &target.display().to_string());
plain(&mut app, K::Enter);
let request = app.dialog.as_ref().unwrap().request.clone().unwrap();
assert_eq!(request.system, system);
@@ -702,3 +712,215 @@ fn options_apply_persist_across_bases_and_preserve_unsaved_values_on_error() {
assert_eq!(defaults, Options::default());
assert_eq!(errors.len(), 4);
}
fn await_export(app: &mut App) -> ExportStatus {
let start = std::time::Instant::now();
loop {
app.tick_export();
let status = app.dialog.as_ref().unwrap().export_status.clone();
if status != ExportStatus::Running {
return status;
}
assert!(start.elapsed().as_secs() < 10, "Export hängt");
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
#[test]
fn real_library_export_uses_dirty_include_and_mouse_without_runtime_template() {
let t = Temp::new();
fs::write(
t.0.join("lib.bas"),
"SUB Answer\n'$INCLUDE: 'body.bi'\nEND SUB\n",
)
.unwrap();
fs::write(t.0.join("body.bi"), "PRINT 1\n").unwrap();
let mut app = t.app();
app.load_initial_project(t.0.join("lib.bas")).unwrap();
let include = app.project.find_document(&t.0.join("body.bi")).unwrap();
app.project
.replace_text(include, 0..8, "PRINT 42\n")
.unwrap();
let stamp = ProjectStamp::capture(&app.project);
menu(&mut app, Command::MakeLibrary);
assert_eq!(app.dialog.as_ref().unwrap().fields.len(), 3);
let out = t.0.join("out.tbl");
field(&mut app, 1, &out.display().to_string());
draw(&mut app);
let r = app
.hits
.iter()
.find(|(_, h)| matches!(h, Hit::DialogSubmit))
.unwrap()
.0;
app.handle(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: r.x,
row: r.y,
modifiers: M::NONE,
}));
assert_eq!(await_export(&mut app), ExportStatus::Success);
assert_eq!(ProjectStamp::capture(&app.project), stamp);
assert!(app.project.document(include).unwrap().is_dirty());
assert_eq!(
fs::read_to_string(t.0.join("body.bi")).unwrap(),
"PRINT 1\n"
);
plain(&mut app, K::Esc);
assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success);
fs::remove_file(t.0.join("lib.bas")).unwrap();
fs::remove_file(t.0.join("body.bi")).unwrap();
fs::write(t.0.join("consumer.bas"), "CALL Answer\nEND\n").unwrap();
fs::write(t.0.join("consumer.mak"), "consumer.bas\nout.tbl\n").unwrap();
app.load_initial_project(t.0.join("consumer.mak")).unwrap();
app.start_execution().unwrap();
for n in 0..100 {
app.tick_execution(n);
if app.execution == Execution::Ended {
break;
}
}
assert_eq!(tb_runtime::snapshot::text(app.session.screen()), " 42 \n");
}
#[test]
fn executable_target_validation_and_missing_template_are_honest() {
use tb_ide::{
documents::Destination,
export::{Artifact, ExportRequest},
};
let t = Temp::new();
let mut app = t.app();
for (system, arch) in [
("windows", "aarch64"),
("macos", "x86_64"),
("unknown", "x86_64"),
] {
assert!(ExportRequest::validate(
&app.project,
Artifact::Executable,
system,
arch,
&Destination::new(t.0.join("out"))
)
.is_err());
}
app.runtime_dir = t.0.join("missing-runtimes");
menu(&mut app, Command::MakeExe);
let out = t.0.join("out");
field(&mut app, 3, &out.display().to_string());
plain(&mut app, K::Enter);
let status = await_export(&mut app);
assert!(
matches!(&status, ExportStatus::Failed(e) if e.contains("Runtime-Vorlage fehlt")),
"{status:?}"
);
assert!(!out.exists());
assert!(app.dialog.is_some());
}
#[test]
fn debugger_reports_absent_library_source_and_can_step_back_to_caller() {
let t = Temp::new();
let path = t.0.join("lib.bas");
fs::write(&path, "SUB Answer\nPRINT 42\nEND SUB\n").unwrap();
let mut compiler = tb_vm::project::ProjectCompiler::default();
compiler.debug_symbols = true;
let bytes = tb_vm::project_io::SourceLoader::default()
.load(&path)
.unwrap()
.compile_library(&mut compiler)
.unwrap()
.to_tbl()
.unwrap();
fs::write(t.0.join("lib.tbl"), bytes).unwrap();
fs::remove_file(&path).unwrap();
fs::write(t.0.join("main.bas"), "CALL Answer\nPRINT 9\nEND\n").unwrap();
fs::write(t.0.join("main.mak"), "main.bas\nlib.tbl\n").unwrap();
let mut app = t.app();
app.load_initial_project(t.0.join("main.mak")).unwrap();
let documents = app.project.documents().count();
let mut missing = false;
for _ in 0..10 {
app.execute(Command::Step);
for n in 0..100 {
app.tick_execution(n);
if app.execution != Execution::Running {
break;
}
}
if app.message.contains("Bibliotheksquelle nicht verfügbar") {
missing = true;
break;
}
}
assert!(missing, "{}", app.message);
assert_eq!(app.project.documents().count(), documents);
assert!(!path.exists());
app.execute(Command::ProcedureStep);
for n in 0..100 {
app.tick_execution(n);
if app.execution != Execution::Running {
break;
}
}
app.execute(Command::Continue);
for n in 0..100 {
app.tick_execution(n);
if app.execution == Execution::Ended {
break;
}
}
assert_eq!(app.execution, Execution::Ended, "{}", app.message);
assert_eq!(
tb_runtime::snapshot::text(app.session.screen()),
" 42 \n 9 \n"
);
}
#[test]
fn finalization_failure_is_reported_and_preserves_existing_output() {
let t = Temp::new();
let mut app = t.app();
let target = tb_export::Target::MacosArm64;
app.runtime_dir = t.0.join("runtimes");
let template = app
.runtime_dir
.join(target.triple())
.join(target.runtime_name());
fs::create_dir_all(template.parent().unwrap()).unwrap();
// Header für den negativen Finalisierungstest, kein ausführbarer Zielnachweis.
let mut header = vec![0; 256];
header[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]);
header[4..8].copy_from_slice(&0x0100000cu32.to_le_bytes());
header[12..16].copy_from_slice(&2u32.to_le_bytes());
header.extend_from_slice(&tb_export::runtime_marker(target));
fs::write(&template, header).unwrap();
tb_export::prepare_template(&template, target).unwrap();
menu(&mut app, Command::MakeExe);
for (n, value) in [(1, "macos"), (2, "aarch64")] {
while app.dialog.as_ref().unwrap().focus != n {
plain(&mut app, K::Tab);
}
while app.dialog.as_ref().unwrap().fields[n].string() != value {
plain(&mut app, K::Right);
}
}
let out = t.0.join("existing");
fs::write(&out, b"old").unwrap();
field(&mut app, 3, &out.display().to_string());
plain(&mut app, K::Tab);
plain(&mut app, K::Char(' '));
plain(&mut app, K::Enter);
let status = await_export(&mut app);
assert!(
matches!(&status,ExportStatus::Failed(e) if e.contains("Finalisierung")),
"{status:?}"
);
assert_eq!(fs::read(&out).unwrap(), b"old");
assert!(fs::read_dir(&t.0).unwrap().all(|p| !p
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".tb-export-")));
}

View File

@@ -488,3 +488,51 @@ fn cursor_after_shortening_uses_original_position_and_open_after_save_uses_fresh
.unwrap());
assert_eq!(p.document(p.members()[0]).unwrap().code(), "PRINT 7\n");
}
#[test]
fn libraries_are_binary_members_with_relative_save_as_and_no_source_views() {
let t = Temp::new();
let lib = t.write("lib.bas", "SUB Answer\nPRINT 42\nEND SUB\n");
let mut compiler = tb_vm::project::ProjectCompiler::default();
let bytes = SourceLoader::default()
.load(&lib)
.unwrap()
.compile_library(&mut compiler)
.unwrap()
.to_tbl()
.unwrap();
let tbl = t.0.join("lib.tbl");
fs::write(&tbl, &bytes).unwrap();
fs::remove_file(lib).unwrap();
let main = t.write("main.bas", "CALL Answer\nEND\n");
let mak = t.write("p.mak", "lib.tbl\nmain.bas\n'$STARTUP: \"main.bas\"\n");
let mut p = Project::open(&mak, vec![]).unwrap();
let id = p.members()[0];
assert!(p.document(id).unwrap().is_library());
assert!(p.open_view(id).is_err());
assert!(p.replace_text(id, 0..0, "bad").is_err());
assert!(p.save_file(id, None).is_err());
assert!(p.set_startup(Some(id)).is_err());
assert_eq!(p.source_members().len(), 1);
p.sources()
.unwrap()
.compile(&mut compiler, "consumer")
.unwrap();
fs::create_dir(t.0.join("sub")).unwrap();
p.save_project(&t.plan("sub/p.mak")).unwrap();
let text = fs::read_to_string(t.0.join("sub/p.mak")).unwrap();
assert!(text.contains("../lib.tbl"), "{text}");
assert_eq!(fs::read(&tbl).unwrap(), bytes);
let mut reopened = Project::open(&t.0.join("sub/p.mak"), vec![]).unwrap();
let id = reopened.members()[0];
reopened.remove_file(id, None).unwrap();
assert!(tbl.exists());
reopened.add_file(&tbl).unwrap();
assert_eq!(reopened.manifest().members().last().unwrap(), &tbl);
assert!(reopened.find_document(&main).is_some());
reopened
.sources()
.unwrap()
.compile(&mut compiler, "consumer")
.unwrap();
}

View File

@@ -305,7 +305,8 @@ fn twenty_back_steps_end_of_contents_and_all_entries() {
h.back();
assert_eq!(h.position, p);
assert!(h.notice.contains("20"));
h.visit(11, 0, None);
let (last, _) = Catalog::embedded().resolve("", "PLAN.md").unwrap();
h.visit(last, 0, None);
let p = h.position.clone();
h.next();
assert_eq!(h.position, p);