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

@@ -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(