Phase 5: Projekt- und Dokumentmodell implementieren und archivieren
This commit is contained in:
790
crates/tb-ide/src/documents.rs
Normal file
790
crates/tb-ide/src/documents.rs
Normal file
@@ -0,0 +1,790 @@
|
||||
//! Dokumentaktionen für Editor und Designer. Kein Terminal und keine zweite Quellkopie.
|
||||
use anyhow::{anyhow, bail, ensure, Context, Result};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
ops::Range,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use tb_ui::frm::{self, FormFile, FormNode};
|
||||
use tb_vm::project_io::{
|
||||
has_extension, identity, read_document, Content, Manifest, ProjectLine, ProjectSources,
|
||||
SourceLoader,
|
||||
};
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
fn next_id() -> u64 {
|
||||
NEXT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
fn loaded<T>(result: std::result::Result<T, String>) -> Result<T> {
|
||||
result.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct DocumentId(u64);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ViewId(u64);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct View {
|
||||
document: DocumentId,
|
||||
pub cursor: usize,
|
||||
pub scroll_line: usize,
|
||||
pub scroll_column: usize,
|
||||
}
|
||||
impl View {
|
||||
pub fn document(&self) -> DocumentId {
|
||||
self.document
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Edit {
|
||||
content: Content,
|
||||
views: Vec<(ViewId, View)>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Document {
|
||||
source_path: PathBuf,
|
||||
path: Option<PathBuf>,
|
||||
content: Content,
|
||||
revision: u64,
|
||||
saved: Option<Content>,
|
||||
disk: Option<Vec<u8>>,
|
||||
binary_source: Option<PathBuf>,
|
||||
warnings: Vec<frm::BinaryWarning>,
|
||||
undo: Vec<Edit>,
|
||||
}
|
||||
impl Document {
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
pub fn source_path(&self) -> &Path {
|
||||
&self.source_path
|
||||
}
|
||||
pub fn content(&self) -> &Content {
|
||||
&self.content
|
||||
}
|
||||
pub fn code(&self) -> &str {
|
||||
self.content.code()
|
||||
}
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.saved.as_ref() != Some(&self.content)
|
||||
}
|
||||
pub fn import_warnings(&self) -> &[frm::BinaryWarning] {
|
||||
&self.warnings
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Destination {
|
||||
pub path: PathBuf,
|
||||
/// Ausdrückliche Entscheidung des Benutzers für dieses Ziel, kein globaler Default.
|
||||
pub overwrite: bool,
|
||||
}
|
||||
impl Destination {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
overwrite: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SavePlan {
|
||||
pub project: Option<Destination>,
|
||||
pub files: BTreeMap<DocumentId, Destination>,
|
||||
}
|
||||
pub enum Decision<'a> {
|
||||
Save(&'a SavePlan),
|
||||
Discard,
|
||||
Cancel,
|
||||
}
|
||||
pub enum StartupRemoval {
|
||||
Default,
|
||||
Replace(DocumentId),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Project {
|
||||
base: PathBuf,
|
||||
path: Option<PathBuf>,
|
||||
manifest: Manifest,
|
||||
saved_manifest: Option<Manifest>,
|
||||
manifest_disk: Option<Vec<u8>>,
|
||||
documents: BTreeMap<DocumentId, Document>,
|
||||
views: BTreeMap<ViewId, View>,
|
||||
pub include_paths: Vec<PathBuf>,
|
||||
}
|
||||
impl Project {
|
||||
pub fn new(base: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
base: identity(base)?,
|
||||
path: None,
|
||||
manifest: Manifest::default(),
|
||||
saved_manifest: None,
|
||||
manifest_disk: None,
|
||||
documents: BTreeMap::new(),
|
||||
views: BTreeMap::new(),
|
||||
include_paths: Vec::new(),
|
||||
})
|
||||
}
|
||||
pub fn open(path: &Path, include_paths: Vec<PathBuf>) -> Result<Self> {
|
||||
let mut loader = SourceLoader::default();
|
||||
loader.include_paths = include_paths.clone();
|
||||
let path = identity(&loaded(
|
||||
loader.resolve(Path::new("."), &path.to_string_lossy()),
|
||||
)?)?;
|
||||
let sources = loaded(loader.load(&path))?; // Alle Includes prüfen, bevor das alte Projekt ersetzt wird.
|
||||
let mut project = Self::new(path.parent().unwrap_or(Path::new(".")))?;
|
||||
project.include_paths = include_paths;
|
||||
let mut manifest = sources.manifest;
|
||||
for line in &mut manifest.lines {
|
||||
if let ProjectLine::File(p) = line {
|
||||
*p = identity(p)?;
|
||||
}
|
||||
}
|
||||
manifest.startup = manifest.startup.as_deref().map(identity).transpose()?;
|
||||
let paths: Vec<_> = manifest
|
||||
.members()
|
||||
.cloned()
|
||||
.chain(
|
||||
sources
|
||||
.units
|
||||
.iter()
|
||||
.flat_map(|u| u.segments.iter().map(|s| PathBuf::from(&s.file))),
|
||||
)
|
||||
.collect();
|
||||
for member in paths {
|
||||
project.open_document(&member)?;
|
||||
}
|
||||
if has_extension(&path, "mak") {
|
||||
project.manifest_disk = Some(fs::read(&path)?);
|
||||
project.saved_manifest = Some(manifest.clone());
|
||||
project.path = Some(path);
|
||||
}
|
||||
project.manifest = manifest;
|
||||
Ok(project)
|
||||
}
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
pub fn manifest(&self) -> &Manifest {
|
||||
&self.manifest
|
||||
}
|
||||
pub fn document(&self, id: DocumentId) -> Result<&Document> {
|
||||
self.documents
|
||||
.get(&id)
|
||||
.ok_or_else(|| anyhow!("Dokument nicht geöffnet"))
|
||||
}
|
||||
pub fn documents(&self) -> impl Iterator<Item = (DocumentId, &Document)> {
|
||||
self.documents.iter().map(|(id, d)| (*id, d))
|
||||
}
|
||||
pub fn find_document(&self, path: &Path) -> Option<DocumentId> {
|
||||
let key = identity(path).ok()?;
|
||||
self.documents
|
||||
.iter()
|
||||
.find_map(|(id, d)| (d.source_path == key).then_some(*id))
|
||||
}
|
||||
pub fn members(&self) -> Vec<DocumentId> {
|
||||
self.manifest
|
||||
.members()
|
||||
.filter_map(|p| self.find_document(p))
|
||||
.collect()
|
||||
}
|
||||
pub fn startup(&self) -> Option<DocumentId> {
|
||||
self.manifest
|
||||
.startup
|
||||
.as_deref()
|
||||
.and_then(|p| self.find_document(p))
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.saved_manifest.as_ref() != Some(&self.manifest)
|
||||
|| self.documents.values().any(Document::is_dirty)
|
||||
}
|
||||
pub fn loader(&self) -> Result<SourceLoader> {
|
||||
let mut loader = SourceLoader::default();
|
||||
loader.include_paths = self.include_paths.clone();
|
||||
for doc in self.documents.values() {
|
||||
loaded(loader.insert(&doc.source_path, doc.content.clone()))?;
|
||||
}
|
||||
Ok(loader)
|
||||
}
|
||||
pub fn sources(&self) -> Result<ProjectSources> {
|
||||
loaded(self.loader()?.load_manifest(self.manifest.clone()))
|
||||
}
|
||||
|
||||
/// Auch Includes sind eigenständige Originaldokumente, niemals die Expansion.
|
||||
pub fn open_document(&mut self, path: &Path) -> Result<DocumentId> {
|
||||
let loader = self.loader()?;
|
||||
let path = identity(&loaded(
|
||||
loader.resolve(&self.base, &path.to_string_lossy()),
|
||||
)?)?;
|
||||
if let Some(id) = self.find_document(&path) {
|
||||
return Ok(id);
|
||||
}
|
||||
ensure!(!has_extension(&path, "mak"), "MAK über Open Project öffnen");
|
||||
let read = loaded(read_document(&path))?;
|
||||
let id = DocumentId(next_id());
|
||||
self.documents.insert(
|
||||
id,
|
||||
Document {
|
||||
source_path: path.clone(),
|
||||
path: Some(path.clone()),
|
||||
saved: Some(read.content.clone()),
|
||||
content: read.content,
|
||||
revision: 0,
|
||||
disk: Some(read.bytes),
|
||||
binary_source: read.binary.then_some(path),
|
||||
warnings: read.warnings,
|
||||
undo: Vec::new(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
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"
|
||||
);
|
||||
// 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))?;
|
||||
let id = self.open_document(&path)?;
|
||||
if !self.manifest.members().any(|p| p == &path) {
|
||||
self.manifest.lines.push(ProjectLine::File(path));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
fn create(&mut self, name: &str, content: Content, extension: &str) -> Result<DocumentId> {
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name.trim() == name
|
||||
&& !name.contains(['/', '\\', '\n', '\r', '\0', '"'])
|
||||
&& name != "."
|
||||
&& name != "..",
|
||||
"Ungültiger Dokumentname"
|
||||
);
|
||||
let path = identity(&self.base.join(format!("{name}.{extension}")))?;
|
||||
ensure!(
|
||||
self.loader()?
|
||||
.resolve(&self.base, &path.to_string_lossy())
|
||||
.is_err(),
|
||||
"{} existiert bereits",
|
||||
path.display()
|
||||
);
|
||||
let id = DocumentId(next_id());
|
||||
self.documents.insert(
|
||||
id,
|
||||
Document {
|
||||
source_path: path.clone(),
|
||||
path: None,
|
||||
content,
|
||||
revision: 0,
|
||||
saved: None,
|
||||
disk: None,
|
||||
binary_source: None,
|
||||
warnings: Vec::new(),
|
||||
undo: Vec::new(),
|
||||
},
|
||||
);
|
||||
self.manifest.lines.push(ProjectLine::File(path));
|
||||
Ok(id)
|
||||
}
|
||||
pub fn new_module(&mut self, name: &str) -> Result<DocumentId> {
|
||||
self.create(name, Content::Text(String::new()), "bas")
|
||||
}
|
||||
pub fn new_form(&mut self, name: &str) -> Result<DocumentId> {
|
||||
let tokens = tb_frontend::lexer::lex(name);
|
||||
ensure!(
|
||||
matches!(
|
||||
tokens.tokens.as_slice(),
|
||||
[
|
||||
tb_frontend::lexer::Token {
|
||||
kind: tb_frontend::lexer::TokenKind::Ident { suffix: None, .. },
|
||||
..
|
||||
},
|
||||
tb_frontend::lexer::Token {
|
||||
kind: tb_frontend::lexer::TokenKind::Eol,
|
||||
..
|
||||
},
|
||||
tb_frontend::lexer::Token {
|
||||
kind: tb_frontend::lexer::TokenKind::Eof,
|
||||
..
|
||||
}
|
||||
]
|
||||
),
|
||||
"Ungültiger Formularname"
|
||||
);
|
||||
let root = FormNode {
|
||||
class: tb_frontend::forms::ObjectClass::Form,
|
||||
name: name.into(),
|
||||
properties: BTreeMap::new(),
|
||||
children: Vec::new(),
|
||||
};
|
||||
self.create(
|
||||
name,
|
||||
Content::Form(Box::new(FormFile::new("1.00", root, ""))),
|
||||
"frm",
|
||||
)
|
||||
}
|
||||
pub fn set_startup(&mut self, id: Option<DocumentId>) -> Result<()> {
|
||||
let path = id
|
||||
.map(|id| self.document(id).map(|d| d.source_path.clone()))
|
||||
.transpose()?;
|
||||
if let Some(path) = &path {
|
||||
ensure!(
|
||||
self.manifest.members().any(|p| p == path),
|
||||
"Startdatei ist kein Projektmitglied"
|
||||
);
|
||||
}
|
||||
self.manifest.startup = path;
|
||||
Ok(())
|
||||
}
|
||||
pub fn remove_file(&mut self, id: DocumentId, choice: Option<StartupRemoval>) -> Result<bool> {
|
||||
let path = self.document(id)?.source_path.clone();
|
||||
ensure!(
|
||||
self.members().contains(&id),
|
||||
"Datei ist kein Projektmitglied"
|
||||
);
|
||||
if self.startup() == Some(id) {
|
||||
match choice.ok_or_else(|| {
|
||||
anyhow!("Startdatei entfernen: Ersatz, Standard oder Abbrechen wählen")
|
||||
})? {
|
||||
StartupRemoval::Cancel => return Ok(false),
|
||||
StartupRemoval::Default => self.set_startup(None)?,
|
||||
StartupRemoval::Replace(other) => {
|
||||
ensure!(other != id, "Ersatz muss ein anderes Mitglied sein");
|
||||
self.set_startup(Some(other))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.manifest
|
||||
.lines
|
||||
.retain(|line| !matches!(line,ProjectLine::File(p) if p==&path));
|
||||
// Geöffnete Ansichten und ungespeicherte Änderungen bleiben erhalten.
|
||||
Ok(true)
|
||||
}
|
||||
pub fn open_view(&mut self, document: DocumentId) -> Result<ViewId> {
|
||||
self.document(document)?;
|
||||
let id = ViewId(next_id());
|
||||
self.views.insert(
|
||||
id,
|
||||
View {
|
||||
document,
|
||||
cursor: 0,
|
||||
scroll_line: 0,
|
||||
scroll_column: 0,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
pub fn view(&self, id: ViewId) -> Result<&View> {
|
||||
self.views
|
||||
.get(&id)
|
||||
.ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))
|
||||
}
|
||||
pub fn view_mut(&mut self, id: ViewId) -> Result<&mut View> {
|
||||
self.views
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))
|
||||
}
|
||||
pub fn close_view(&mut self, id: ViewId) -> Result<()> {
|
||||
self.views
|
||||
.remove(&id)
|
||||
.ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn view_code(&self, id: ViewId) -> Result<&str> {
|
||||
Ok(self.document(self.view(id)?.document)?.code())
|
||||
}
|
||||
fn commit_edit(&mut self, id: DocumentId, content: Content) -> Result<()> {
|
||||
let old = self.document(id)?;
|
||||
if old.content == content {
|
||||
return Ok(());
|
||||
}
|
||||
let undo = Edit {
|
||||
content: old.content.clone(),
|
||||
views: self
|
||||
.views
|
||||
.iter()
|
||||
.filter(|(_, v)| v.document == id)
|
||||
.map(|(i, v)| (*i, v.clone()))
|
||||
.collect(),
|
||||
};
|
||||
let doc = self.documents.get_mut(&id).unwrap();
|
||||
doc.undo.push(undo);
|
||||
doc.content = content;
|
||||
doc.revision += 1;
|
||||
let code = doc.code();
|
||||
for view in self.views.values_mut().filter(|v| v.document == id) {
|
||||
view.cursor = boundary(code, view.cursor);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> {
|
||||
let mut content = self.document(id)?.content.clone();
|
||||
ensure!(
|
||||
range.start <= range.end && content.code().get(range.clone()).is_some(),
|
||||
"Ungültiger Textbereich"
|
||||
);
|
||||
content.code_mut().replace_range(range.clone(), text);
|
||||
let cursors: Vec<_> = self
|
||||
.views
|
||||
.iter()
|
||||
.filter(|(_, v)| v.document == id)
|
||||
.map(|(id, v)| (*id, v.cursor))
|
||||
.collect();
|
||||
self.commit_edit(id, content)?;
|
||||
for (view_id, cursor) in cursors {
|
||||
let view = self.views.get_mut(&view_id).unwrap();
|
||||
view.cursor = cursor;
|
||||
if view.cursor > range.start {
|
||||
view.cursor = if view.cursor >= range.end {
|
||||
view.cursor - range.len() + text.len()
|
||||
} else {
|
||||
range.start + text.len()
|
||||
};
|
||||
}
|
||||
}
|
||||
// Neue Positionen werden auch nach Ersetzungen mit Unicode begrenzt.
|
||||
let code = self.documents[&id].code();
|
||||
for view in self.views.values_mut().filter(|v| v.document == id) {
|
||||
view.cursor = boundary(code, view.cursor);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn edit_form(
|
||||
&mut self,
|
||||
id: DocumentId,
|
||||
edit: impl FnOnce(&mut FormFile) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let Content::Form(mut form) = self.document(id)?.content.clone() else {
|
||||
bail!("Kein Formulardokument");
|
||||
};
|
||||
edit(&mut form)?;
|
||||
frm::read_text(
|
||||
&self.document(id)?.source_path.display().to_string(),
|
||||
&frm::write_text(&form),
|
||||
)?;
|
||||
self.commit_edit(id, Content::Form(form))
|
||||
}
|
||||
pub fn undo(&mut self, id: DocumentId) -> Result<bool> {
|
||||
self.document(id)?;
|
||||
let doc = self.documents.get_mut(&id).unwrap();
|
||||
let Some(edit) = doc.undo.pop() else {
|
||||
return Ok(false);
|
||||
};
|
||||
doc.content = edit.content;
|
||||
doc.revision += 1;
|
||||
for (id, view) in edit.views {
|
||||
if let Some(current) = self.views.get_mut(&id) {
|
||||
*current = view;
|
||||
}
|
||||
}
|
||||
let code = doc.code();
|
||||
for view in self.views.values_mut().filter(|v| v.document == id) {
|
||||
view.cursor = boundary(code, view.cursor);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
pub fn load_text(&mut self, id: DocumentId, at: usize, path: &Path) -> Result<()> {
|
||||
let text = fs::read_to_string(path).with_context(|| format!("{} lesen", path.display()))?;
|
||||
self.replace_text(id, at..at, &text)
|
||||
}
|
||||
pub fn save_text(
|
||||
&self,
|
||||
id: DocumentId,
|
||||
range: Option<Range<usize>>,
|
||||
target: &Destination,
|
||||
) -> Result<()> {
|
||||
let doc = self.document(id)?;
|
||||
let text = match range {
|
||||
Some(r) => doc
|
||||
.code()
|
||||
.get(r)
|
||||
.ok_or_else(|| anyhow!("Ungültiger Textbereich"))?,
|
||||
None => doc.code(),
|
||||
};
|
||||
let path = self.target(&target.path)?;
|
||||
ensure!(
|
||||
self.documents.values().all(|d| d.source_path != path),
|
||||
"Save Text darf kein geöffnetes Dokument überschreiben; Save File verwenden"
|
||||
);
|
||||
self.protect_binary(&path)?;
|
||||
ensure!(
|
||||
self.path.as_deref() != Some(&path),
|
||||
"Save Text darf die Projektdatei nicht überschreiben"
|
||||
);
|
||||
atomic_write(&path, text.as_bytes(), None, target.overwrite)
|
||||
}
|
||||
fn target(&self, path: &Path) -> Result<PathBuf> {
|
||||
ensure!(!path.as_os_str().is_empty(), "Ausgabepfad fehlt");
|
||||
let target = identity(&self.base.join(path))?;
|
||||
ensure!(
|
||||
target
|
||||
.to_str()
|
||||
.is_some_and(|s| !s.contains(['\n', '\r', '\0'])),
|
||||
"Ausgabepfad ist nicht als UTF-8-Dateiverweis darstellbar"
|
||||
);
|
||||
Ok(target)
|
||||
}
|
||||
fn protect_binary(&self, path: &Path) -> Result<()> {
|
||||
ensure!(
|
||||
!fs::read(path).is_ok_and(|b| b.starts_with(&[0xfc, 0x08, 1, 0])),
|
||||
"{}: binäre FRM-Quelle ist geschützt; anderes Textziel wählen",
|
||||
path.display()
|
||||
);
|
||||
ensure!(
|
||||
!self
|
||||
.documents
|
||||
.values()
|
||||
.any(|d| d.binary_source.as_deref() == Some(path)),
|
||||
"{}: binäre FRM-Quelle ist geschützt; anderes Textziel wählen",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn file_target(&self, id: DocumentId, target: Option<&Destination>) -> Result<(PathBuf, bool)> {
|
||||
let doc = self.document(id)?;
|
||||
let path = match target {
|
||||
Some(t) => self.target(&t.path)?,
|
||||
None => doc.path.clone().ok_or_else(|| {
|
||||
anyhow!("{}: Save-As-Ziel erforderlich", doc.source_path.display())
|
||||
})?,
|
||||
};
|
||||
self.protect_binary(&path)?;
|
||||
ensure!(
|
||||
self.path.as_deref() != Some(&path),
|
||||
"Dokument darf die Projektdatei nicht überschreiben"
|
||||
);
|
||||
ensure!(
|
||||
!self
|
||||
.documents
|
||||
.iter()
|
||||
.any(|(other, d)| *other != id && d.source_path == path),
|
||||
"{} ist in einem anderen Dokument geöffnet",
|
||||
path.display()
|
||||
);
|
||||
match &doc.content {
|
||||
Content::Form(_) => ensure!(has_extension(&path, "frm"), "Formularziel muss .frm sein"),
|
||||
Content::Text(_) if self.members().contains(&id) => {
|
||||
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"
|
||||
),
|
||||
}
|
||||
Ok((path, target.is_some_and(|t| t.overwrite)))
|
||||
}
|
||||
pub fn save_file(&mut self, id: DocumentId, target: Option<&Destination>) -> Result<()> {
|
||||
let (path, overwrite) = self.file_target(id, target)?;
|
||||
let doc = self.document(id)?;
|
||||
let content = loaded(
|
||||
self.loader()?
|
||||
.relocate(&doc.content, &doc.source_path, &path),
|
||||
)?;
|
||||
let text = content.text();
|
||||
let expected = if doc.path.as_deref() == Some(&path) {
|
||||
doc.disk.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
atomic_write(&path, text.as_bytes(), expected, overwrite)?;
|
||||
self.commit_edit(id, content)?;
|
||||
let doc = self.documents.get_mut(&id).unwrap();
|
||||
self.manifest.rename(&doc.source_path, &path);
|
||||
if doc.source_path != path {
|
||||
doc.revision += 1;
|
||||
}
|
||||
doc.source_path = path.clone();
|
||||
doc.path = Some(path);
|
||||
doc.disk = Some(text.into_bytes());
|
||||
doc.saved = Some(doc.content.clone());
|
||||
Ok(())
|
||||
}
|
||||
pub fn save_project(&mut self, plan: &SavePlan) -> Result<()> {
|
||||
let target = match &plan.project {
|
||||
Some(t) => self.target(&t.path)?,
|
||||
None => self
|
||||
.path
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("Projekt-Save-As-Ziel erforderlich"))?,
|
||||
};
|
||||
ensure!(has_extension(&target, "mak"), "Projektziel muss .mak sein");
|
||||
self.protect_binary(&target)?;
|
||||
ensure!(
|
||||
self.documents.values().all(|d| d.source_path != target),
|
||||
"Projektziel ist ein geöffnetes Dokument"
|
||||
);
|
||||
for id in plan.files.keys() {
|
||||
self.document(*id)?;
|
||||
}
|
||||
let mut ids = self.members();
|
||||
ids.extend(
|
||||
self.documents
|
||||
.keys()
|
||||
.filter(|id| !self.members().contains(id))
|
||||
.copied(),
|
||||
);
|
||||
let ids: Vec<_> = ids
|
||||
.into_iter()
|
||||
.filter(|id| self.documents[id].is_dirty() || plan.files.contains_key(id))
|
||||
.collect();
|
||||
let mut destinations = Vec::new();
|
||||
let mut prospective = self.manifest.clone();
|
||||
for id in &ids {
|
||||
let (path, _) = self.file_target(*id, plan.files.get(id))?;
|
||||
ensure!(
|
||||
!destinations.contains(&path) && path != target,
|
||||
"Mehrere Ausgaben verwenden {}",
|
||||
path.display()
|
||||
);
|
||||
prospective.rename(&self.documents[id].source_path, &path);
|
||||
destinations.push(path);
|
||||
}
|
||||
let text = loaded(prospective.text(&target))?; // Vollständig serialisierbar, bevor eine Datei geschrieben wird.
|
||||
let expected = if self.path.as_deref() == Some(&target) {
|
||||
self.manifest_disk.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
check_destination(
|
||||
&target,
|
||||
expected,
|
||||
plan.project.as_ref().is_some_and(|d| d.overwrite),
|
||||
)?;
|
||||
for id in ids {
|
||||
self.save_file(id, plan.files.get(&id))?;
|
||||
}
|
||||
// Zweite Konfliktprüfung direkt vor Ersetzen; ein Teilfehler lässt die alte Projektidentität aktiv.
|
||||
let expected = if self.path.as_deref() == Some(&target) {
|
||||
self.manifest_disk.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
atomic_write(
|
||||
&target,
|
||||
text.as_bytes(),
|
||||
expected,
|
||||
plan.project.as_ref().is_some_and(|d| d.overwrite),
|
||||
)?;
|
||||
self.manifest_disk = Some(text.into_bytes());
|
||||
self.saved_manifest = Some(self.manifest.clone());
|
||||
self.base = target.parent().unwrap().into();
|
||||
self.path = Some(target);
|
||||
Ok(())
|
||||
}
|
||||
/// Vor Exit/Projektwechsel aufrufen; der Aufrufer schließt erst bei true.
|
||||
pub fn prepare_close(&mut self, decision: Decision<'_>) -> Result<bool> {
|
||||
match decision {
|
||||
Decision::Cancel => Ok(false),
|
||||
Decision::Discard => Ok(true),
|
||||
Decision::Save(plan) => {
|
||||
self.save_project(plan)?;
|
||||
ensure!(!self.is_dirty(), "Nicht alle Änderungen gespeichert");
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn open_project(&mut self, path: &Path, decision: Decision<'_>) -> Result<bool> {
|
||||
if matches!(decision, Decision::Cancel) {
|
||||
return Ok(false);
|
||||
}
|
||||
let path = self.base.join(path);
|
||||
let candidate = Self::open(&path, self.include_paths.clone())?;
|
||||
let saving = matches!(decision, Decision::Save(_));
|
||||
if !self.prepare_close(decision)? {
|
||||
return Ok(false);
|
||||
}
|
||||
*self = if saving {
|
||||
Self::open(&path, self.include_paths.clone())?
|
||||
} else {
|
||||
candidate
|
||||
};
|
||||
Ok(true)
|
||||
}
|
||||
pub fn new_project(&mut self, decision: Decision<'_>) -> Result<bool> {
|
||||
let mut candidate = Self::new(&self.base)?;
|
||||
candidate.include_paths = self.include_paths.clone();
|
||||
if !self.prepare_close(decision)? {
|
||||
return Ok(false);
|
||||
}
|
||||
*self = candidate;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary(text: &str, at: usize) -> usize {
|
||||
let mut at = at.min(text.len());
|
||||
while !text.is_char_boundary(at) {
|
||||
at -= 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
fn check_destination(path: &Path, expected: Option<&[u8]>, overwrite: bool) -> Result<()> {
|
||||
let actual = match fs::read(path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e).with_context(|| format!("{} lesen", path.display())),
|
||||
};
|
||||
ensure!(
|
||||
overwrite || actual.as_deref() == expected,
|
||||
"{}: Datei extern geändert oder Ziel existiert; Überschreibentscheidung erforderlich",
|
||||
path.display()
|
||||
);
|
||||
if let Ok(metadata) = fs::metadata(path) {
|
||||
ensure!(
|
||||
!metadata.permissions().readonly(),
|
||||
"{}: Datei ist schreibgeschützt",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ersetzt erst nach erfolgreichem Schreiben und flush/sync; temporäre Datei im selben Verzeichnis.
|
||||
fn atomic_write(path: &Path, bytes: &[u8], expected: Option<&[u8]>, overwrite: bool) -> Result<()> {
|
||||
check_destination(path, expected, overwrite)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("Ungültiges Dateiziel"))?;
|
||||
let (temporary, mut file) = loop {
|
||||
let p = parent.join(format!(".tb-save-{}-{}.tmp", std::process::id(), next_id()));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&p) {
|
||||
Ok(file) => break (p, file),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e).with_context(|| format!("{} speichern", path.display())),
|
||||
}
|
||||
};
|
||||
struct Cleanup(PathBuf);
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
let cleanup = Cleanup(temporary);
|
||||
let result = (|| -> Result<()> {
|
||||
file.write_all(bytes)?;
|
||||
if let Ok(metadata) = fs::metadata(path) {
|
||||
file.set_permissions(metadata.permissions())?;
|
||||
}
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
check_destination(path, expected, overwrite)?;
|
||||
fs::rename(&cleanup.0, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
result.with_context(|| format!("{} speichern", path.display()))
|
||||
}
|
||||
2
crates/tb-ide/src/lib.rs
Normal file
2
crates/tb-ide/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
//! Testbare IDE-Zustände; Terminalbedienung wird vom IDE-Rahmen angebunden.
|
||||
pub mod documents;
|
||||
Reference in New Issue
Block a user