Fixes for M-02 to M-14, G-01 to G-14, K-02 to K-04, X-04 to X-14, not yet reviewed
Committed before review so Windows and WSL builds can start. Each fix was tested and mutation-checked here; the codex review of each item is still to come, so tasks/BUGS.md stays unticked. M-02 is mid-review: round 1 findings are fixed (apps/ely-mark/src/tally.rs). K-01 stays deferred. The ElyMark window test mark-outside.sh failed once at its crash-recovery step in the last run; not yet looked into.
This commit is contained in:
75 files changed
+1482
-354
No files matched your search
@@ -21,7 +21,7 @@ impl Crash {
|
||||
pub fn open(dir: &Path, pid: u32) -> io::Result<Self> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let file = dir.join("session.lock");
|
||||
std::fs::write(&file, format!("open {pid}"))?;
|
||||
write_whole(&file, &format!("open {pid}"))?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
@@ -34,28 +34,24 @@ impl Crash {
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut parts = text.split_whitespace();
|
||||
match (parts.next(), parts.next()) {
|
||||
match (parts.next(), parts.next().map(str::parse::<u32>)) {
|
||||
(Some("discarded"), _) => Ok(Marker::Discarded),
|
||||
(Some("open"), Some(pid)) => {
|
||||
let pid: u32 = pid.parse().map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "bad pid in the session lock")
|
||||
})?;
|
||||
Ok(if alive(pid) {
|
||||
Marker::Clean
|
||||
} else {
|
||||
Marker::Crashed
|
||||
})
|
||||
(Some("open"), Some(Ok(pid))) => Ok(if alive(pid) {
|
||||
Marker::Clean
|
||||
} else {
|
||||
Marker::Crashed
|
||||
}),
|
||||
// A torn lock means no clean end: offer the points.
|
||||
_ => {
|
||||
log::warn!("recovery: the session lock is unreadable; treated as a crash");
|
||||
Ok(Marker::Crashed)
|
||||
}
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"bad session lock",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit discard: never a crash next launch (§6.2).
|
||||
pub fn discard(self) -> io::Result<()> {
|
||||
std::fs::write(&self.file, "discarded 0")
|
||||
write_whole(&self.file, "discarded 0")
|
||||
}
|
||||
|
||||
/// A clean close removes the lock.
|
||||
@@ -68,6 +64,15 @@ impl Crash {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces `file` whole: a power cut leaves old or new.
|
||||
fn write_whole(file: &Path, text: &str) -> io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
let mut staging = tempfile::NamedTempFile::new_in(file.parent().unwrap_or(Path::new(".")))?;
|
||||
staging.write_all(text.as_bytes())?;
|
||||
staging.as_file().sync_all()?;
|
||||
staging.persist(file).map(drop).map_err(|error| error.error)
|
||||
}
|
||||
|
||||
/// Whether process `pid` still runs; a crash leaves none.
|
||||
#[cfg(unix)]
|
||||
pub fn alive(pid: u32) -> bool {
|
||||
|
||||
@@ -53,10 +53,13 @@ pub struct RecoveryKey {
|
||||
}
|
||||
|
||||
impl RecoveryKey {
|
||||
/// The stored key, else this session's; never a file.
|
||||
/// The stored key, else this session's; read once a run.
|
||||
pub fn load() -> Self {
|
||||
let dir = account_home().map(|home| shared_dir(&home));
|
||||
Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir)))
|
||||
static LOADED: std::sync::OnceLock<RecoveryKey> = std::sync::OnceLock::new();
|
||||
*LOADED.get_or_init(|| {
|
||||
let dir = account_home().map(|home| shared_dir(&home));
|
||||
Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The loaded key, or this session's when loading failed.
|
||||
|
||||
@@ -163,6 +163,11 @@ impl Recovery {
|
||||
|
||||
/// Every point on disk, newest first.
|
||||
pub fn points(&self) -> io::Result<Vec<RecoveryPoint>> {
|
||||
self.stamps()?.into_iter().map(|stamp| self.point(stamp)).collect()
|
||||
}
|
||||
|
||||
/// The points' stamps by name alone, newest first.
|
||||
fn stamps(&self) -> io::Result<Vec<u128>> {
|
||||
let dir = self.dir.join("points");
|
||||
let mut stamps = Vec::new();
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
@@ -183,16 +188,13 @@ impl Recovery {
|
||||
}
|
||||
stamps.sort_unstable_by(|a, b| b.cmp(a));
|
||||
stamps.dedup();
|
||||
stamps
|
||||
.into_iter()
|
||||
.map(|stamp| self.point(stamp))
|
||||
.collect::<io::Result<_>>()
|
||||
Ok(stamps)
|
||||
}
|
||||
|
||||
/// The point one stamp names.
|
||||
fn point(&self, stamp: u128) -> io::Result<RecoveryPoint> {
|
||||
let dir = self.dir.join("points");
|
||||
let at = SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64);
|
||||
let at = at_of(stamp);
|
||||
let enc = dir.join(format!("{stamp}.enc"));
|
||||
if enc.try_exists()? {
|
||||
let cursor = match self.key {
|
||||
@@ -223,26 +225,26 @@ impl Recovery {
|
||||
|
||||
/// Drops the oldest points past age, count and byte limits.
|
||||
fn prune(&self, now: SystemTime) -> io::Result<()> {
|
||||
let mut points = self.points()?;
|
||||
while points.len() > self.keep
|
||||
|| points.last().is_some_and(|point| {
|
||||
now.duration_since(point.at)
|
||||
// Names only: a sealed point is never opened here.
|
||||
let mut stamps = self.stamps()?;
|
||||
while stamps.len() > self.keep
|
||||
|| stamps.last().is_some_and(|stamp| {
|
||||
now.duration_since(at_of(*stamp))
|
||||
.map(|age| age > self.max_age)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
|| self.bytes(&points)? > self.cap
|
||||
|| self.bytes(&stamps)? > self.cap
|
||||
{
|
||||
let Some(oldest) = points.pop() else { break };
|
||||
self.drop(oldest.at)?;
|
||||
let Some(oldest) = stamps.pop() else { break };
|
||||
self.drop(oldest)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The total size of every file of every live point.
|
||||
fn bytes(&self, points: &[RecoveryPoint]) -> io::Result<u64> {
|
||||
fn bytes(&self, stamps: &[u128]) -> io::Result<u64> {
|
||||
let mut total = 0;
|
||||
for point in points {
|
||||
let stamp = stamp_of(point.at)?;
|
||||
for stamp in stamps {
|
||||
let dir = self.dir.join("points");
|
||||
for ext in ["bin", "cursor", "history", "enc"] {
|
||||
let path = dir.join(format!("{stamp}.{ext}"));
|
||||
@@ -257,8 +259,7 @@ impl Recovery {
|
||||
}
|
||||
|
||||
/// Removes a point's files, its metadata first.
|
||||
fn drop(&self, at: SystemTime) -> io::Result<()> {
|
||||
let stamp = stamp_of(at)?;
|
||||
fn drop(&self, stamp: u128) -> io::Result<()> {
|
||||
let dir = self.dir.join("points");
|
||||
for ext in ["cursor", "history", "bin", "enc"] {
|
||||
match std::fs::remove_file(dir.join(format!("{stamp}.{ext}"))) {
|
||||
@@ -272,8 +273,8 @@ impl Recovery {
|
||||
|
||||
/// An explicit discard: points go, the result stays recorded (§6.2).
|
||||
pub fn discard(&mut self) -> io::Result<()> {
|
||||
for point in self.points()? {
|
||||
self.drop(point.at)?;
|
||||
for stamp in self.stamps()? {
|
||||
self.drop(stamp)?;
|
||||
}
|
||||
std::fs::write(self.dir.join(".discarded"), "discarded")?;
|
||||
self.last_ok = None;
|
||||
@@ -304,6 +305,11 @@ impl Recovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time a stamp names.
|
||||
fn at_of(stamp: u128) -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64)
|
||||
}
|
||||
|
||||
/// The point's timestamp as its file stem.
|
||||
fn stamp_of(at: SystemTime) -> io::Result<u128> {
|
||||
Ok(at
|
||||
|
||||
@@ -272,6 +272,18 @@ fn a_discard_drops_points_and_records_it() {
|
||||
assert!(!recovery.discarded().expect("the marker clears"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_torn_lock_counts_as_a_crash() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
for torn in ["", "op", "open x"] {
|
||||
std::fs::write(dir.path().join("session.lock"), torn).expect("writes");
|
||||
assert_eq!(Crash::check(dir.path(), |_| true).expect("reads"), Marker::Crashed, "{torn:?}");
|
||||
}
|
||||
// Opening again rewrites it whole.
|
||||
Crash::open(dir.path(), 7).expect("opens");
|
||||
assert_eq!(Crash::check(dir.path(), |pid| pid == 7).expect("reads"), Marker::Clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lock_tells_crash_from_discard() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
@@ -393,3 +405,15 @@ fn the_task_status_starts_from_the_recovery() {
|
||||
assert_eq!(task.last_ok(), Some(at(100)));
|
||||
task.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_sealed_points_opens_none_of_them() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut old = Recovery::sensitive(dir.path().to_path_buf(), key()).with_limits(2, u64::MAX);
|
||||
old.write_once(b"one", Some("1:1"), at(100)).expect("lands");
|
||||
// Another run's key cannot open the older points.
|
||||
let mut now = Recovery::sensitive(dir.path().to_path_buf(), [9u8; 32]).with_limits(2, u64::MAX);
|
||||
now.write_once(b"two", Some("2:2"), at(200)).expect("pruning reads names only");
|
||||
now.write_once(b"three", None, at(300)).expect("and drops the oldest unread");
|
||||
assert_eq!(now.stamps().expect("names").len(), 2);
|
||||
}
|
||||
@@ -28,12 +28,18 @@ pub fn relay(stderr: impl Read + Send + 'static, who: &'static str) -> Relay {
|
||||
let kept = Arc::clone(&last);
|
||||
let thread = std::thread::spawn(move || {
|
||||
let mut withheld = 0u64;
|
||||
for line in BufReader::new(stderr).lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
let (mut reader, mut bytes) = (BufReader::new(stderr), Vec::new());
|
||||
loop {
|
||||
bytes.clear();
|
||||
match reader.read_until(b'\n', &mut bytes) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(error) => return log::warn!(target: who, "stderr unreadable: {error}"),
|
||||
};
|
||||
match parse(&line) {
|
||||
}
|
||||
// Engines may print other encodings; keep reading past them.
|
||||
let line = String::from_utf8_lossy(&bytes);
|
||||
let line = line.trim_end_matches(['\n', '\r']);
|
||||
match parse(line) {
|
||||
Some((level, target, message)) => {
|
||||
if level == log::Level::Error {
|
||||
*kept.lock().expect("the relay lock") = Some(message.to_string());
|
||||
@@ -41,7 +47,7 @@ pub fn relay(stderr: impl Read + Send + 'static, who: &'static str) -> Relay {
|
||||
log::log!(target: target, level, "{message}")
|
||||
}
|
||||
// A crash stack names code, never documents.
|
||||
None if frame(&line) => log::error!(target: who, "{line}"),
|
||||
None if frame(line) => log::error!(target: who, "{line}"),
|
||||
None => {
|
||||
withheld += 1;
|
||||
log::debug!(target: who, "native output withheld ({withheld} lines)");
|
||||
@@ -79,6 +85,13 @@ mod tests {
|
||||
assert_eq!(parse("warning: syntax error: unknown keyword: 'Zq'"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_in_another_encoding_stops_nothing() {
|
||||
let stderr: &[u8] = b"caf\xe9 opened\n[2026-10-08T12:00:00Z ERROR ely_pdf::worker] it broke\n";
|
||||
let mut relay = super::relay(stderr, "test");
|
||||
assert_eq!(relay.last_error().as_deref(), Some("it broke"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_frames_pass_and_engine_text_does_not() {
|
||||
assert!(frame("3 ely-lok-worker 0x0000000104f3c960 report + 52"));
|
||||
|
||||
@@ -15,8 +15,8 @@ pub use cell::{Cell, Function, Mark, Request};
|
||||
use cell::{functions_of, marks_of, request_of, usage_of};
|
||||
pub use parse::A11y;
|
||||
use parse::{
|
||||
Dialog, a11y_update, dialog_of, fonts_of, formula_text, found_of, rect_of, result_of, state_of,
|
||||
tabs_of,
|
||||
Dialog, Unreadable, a11y_update, dialog_of, fonts_of, formula_text, found_of, rect_of,
|
||||
result_of, state_of, tabs_of,
|
||||
};
|
||||
|
||||
/// LOK_CALLBACK kinds Ely reads.
|
||||
@@ -205,22 +205,36 @@ unsafe extern "C" fn on_event(kind: i32, payload: *const c_char, data: *mut c_vo
|
||||
signal().notify_all();
|
||||
}
|
||||
|
||||
/// A rectangle payload; an unreadable one is a fault.
|
||||
fn rect_in(batch: &mut Events, what: &str, text: &str) -> Option<Rect> {
|
||||
rect_of(text).unwrap_or_else(|Unreadable| {
|
||||
batch.faults.push(format!("an unreadable {what}: {text}"));
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Folds one callback in; returns a dialog to close.
|
||||
fn record(slot: &mut Slot, kind: i32, text: &str) -> Option<u32> {
|
||||
let batch = &mut slot.batch;
|
||||
match kind {
|
||||
INVALIDATE_TILES => {
|
||||
batch.generation += 1;
|
||||
match rect_of(text) {
|
||||
match rect_in(batch, "invalidation", text) {
|
||||
Some(rect) => batch.rects.push(rect),
|
||||
None => batch.all = true,
|
||||
}
|
||||
}
|
||||
CURSOR => batch.cursor = rect_of(text),
|
||||
SELECTION => batch.selection = text.split(';').filter_map(rect_of).collect(),
|
||||
CURSOR => batch.cursor = rect_in(batch, "cursor", text),
|
||||
SELECTION => {
|
||||
let parts: Vec<&str> = text.split(';').collect();
|
||||
batch.selection = parts
|
||||
.into_iter()
|
||||
.filter_map(|part| rect_in(batch, "selection", part))
|
||||
.collect();
|
||||
}
|
||||
CURSOR_VISIBLE => batch.cursor_visible = text == "true",
|
||||
GRAPHIC => {
|
||||
batch.graphic = rect_of(text);
|
||||
batch.graphic = rect_in(batch, "graphic selection", text);
|
||||
batch.cropping = batch.graphic.is_some() && text.contains("\"isCropMode\": true");
|
||||
}
|
||||
STATE => {
|
||||
@@ -247,7 +261,7 @@ fn record(slot: &mut Slot, kind: i32, text: &str) -> Option<u32> {
|
||||
.push(format!("an unreadable search result: {text}")),
|
||||
},
|
||||
MENU => batch.menu = Some(text.to_string()),
|
||||
CELL_CURSOR => batch.cell.cursor = rect_of(text),
|
||||
CELL_CURSOR => batch.cell.cursor = rect_in(batch, "cell cursor", text),
|
||||
CELL_FORMULA => batch.cell.formula = text.to_string(),
|
||||
CELL_ADDRESS => batch.cell.address = text.to_string(),
|
||||
REFERENCE_MARKS => match marks_of(text) {
|
||||
|
||||
@@ -209,7 +209,7 @@ pub fn marks_of(text: &str) -> Option<Vec<Mark>> {
|
||||
.iter()
|
||||
.map(|mark| {
|
||||
Some(Mark {
|
||||
rect: super::parse::rect_of(mark["rectangle"].as_str()?)?,
|
||||
rect: super::parse::rect_of(mark["rectangle"].as_str()?).ok()??,
|
||||
color: u32::from_str_radix(mark["color"].as_str()?, 16).ok()?,
|
||||
part: mark["part"].as_str()?.parse().ok()?,
|
||||
})
|
||||
|
||||
@@ -21,16 +21,19 @@ pub enum Dialog {
|
||||
Titled { window: u32, title: String },
|
||||
}
|
||||
|
||||
/// Parses "x, y, w, h" plus optional fields.
|
||||
pub fn rect_of(text: &str) -> Option<Rect> {
|
||||
let parts: Vec<i32> = text
|
||||
.split(',')
|
||||
.filter_map(|part| part.trim().parse().ok())
|
||||
.collect();
|
||||
match parts.as_slice() {
|
||||
[x, y, w, h, ..] => Some((*x, *y, *w, *h)),
|
||||
_ => None,
|
||||
/// A payload that is neither a rectangle nor empty.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Unreadable;
|
||||
|
||||
/// "x, y, w, h" and later fields; "EMPTY" is none.
|
||||
pub fn rect_of(text: &str) -> Result<Option<Rect>, Unreadable> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() || text.starts_with("EMPTY") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut parts = text.split(',').map(|part| part.trim().parse::<i32>());
|
||||
let mut next = || parts.next().and_then(Result::ok).ok_or(Unreadable);
|
||||
Ok(Some((next()?, next()?, next()?, next()?)))
|
||||
}
|
||||
|
||||
/// A search's matches, each by its first rectangle.
|
||||
@@ -39,7 +42,7 @@ pub fn found_of(text: &str) -> Option<Vec<Rect>> {
|
||||
json["searchResultSelection"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|found| rect_of(found["rectangles"].as_str()?.split(';').next()?))
|
||||
.map(|found| rect_of(found["rectangles"].as_str()?.split(';').next()?).ok()?)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -189,6 +192,15 @@ pub fn a11y_update(a11y: &mut A11y, kind: i32, text: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_reads_strictly() {
|
||||
assert_eq!(rect_of("1, 2, 3, 4, 0, 5"), Ok(Some((1, 2, 3, 4))));
|
||||
assert_eq!(rect_of("EMPTY, 0, 0"), Ok(None));
|
||||
assert_eq!(rect_of(""), Ok(None));
|
||||
assert_eq!(rect_of(r#"{"x": 1}, 2, 3, 4, 5"#), Err(Unreadable));
|
||||
assert_eq!(rect_of("1, 2, 3"), Err(Unreadable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dont_care_states_read_as_mixed() {
|
||||
let mixed = state_of(r#"{"commandName":".uno:Bold","dontcare":true}"#);
|
||||
|
||||
@@ -400,7 +400,12 @@ fn with_doc<T>(id: u64, call: impl FnOnce(&Document) -> T) -> io::Result<T> {
|
||||
/// The shown part, then each part's name and info.
|
||||
fn sheets(doc: &Document) -> io::Result<Vec<u8>> {
|
||||
let count = doc.parts();
|
||||
let mut pack = Pack::new().u64(doc.part() as u64).u64(count as u64);
|
||||
// -1 means no view shell: no list.
|
||||
let unread = |what| io::Error::other(format!("the kernel gives no {what}"));
|
||||
let current = u64::try_from(doc.part()).map_err(|_| unread("current part"))?;
|
||||
let mut pack = Pack::new()
|
||||
.u64(current)
|
||||
.u64(u64::try_from(count).map_err(|_| unread("part count"))?);
|
||||
for part in 0..count {
|
||||
pack = pack.str(&doc.part_name(part)?).str(&doc.part_info(part)?);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ fn invalid(what: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, what.to_string())
|
||||
}
|
||||
|
||||
/// Paints one tile after checking the geometry (LOK-03).
|
||||
/// One tile and the generation it was painted at (LOK-03).
|
||||
fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec<u8>)> {
|
||||
let id = unpack.u64()?;
|
||||
let (x, y, w, h) = (unpack.i64()?, unpack.i64()?, unpack.i64()?, unpack.i64()?);
|
||||
@@ -36,6 +36,8 @@ fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec<u8>)> {
|
||||
"bad tile geometry",
|
||||
));
|
||||
};
|
||||
// Read first: an invalidation mid-paint must outdate it.
|
||||
let generation = crate::events::generation(id);
|
||||
let tile = with_doc(id, |doc| {
|
||||
let mut tile = vec![0u8; len as usize];
|
||||
doc.paint_rect(
|
||||
@@ -45,7 +47,7 @@ fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec<u8>)> {
|
||||
);
|
||||
tile
|
||||
})?;
|
||||
Ok((id, tile))
|
||||
Ok((generation, tile))
|
||||
}
|
||||
|
||||
/// One slide's whole picture at the asked pixels.
|
||||
@@ -74,8 +76,8 @@ pub(super) fn part_painted(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
|
||||
/// A painted tile behind its generation.
|
||||
pub(super) fn painted(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
let (id, tile) = paint(unpack)?;
|
||||
let mut body = Pack::new().u64(crate::events::generation(id)).bytes();
|
||||
let (generation, tile) = paint(unpack)?;
|
||||
let mut body = Pack::new().u64(generation).bytes();
|
||||
body.extend_from_slice(&tile);
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -265,6 +265,11 @@ pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Addresses a click may open outside: web and mail.
|
||||
pub fn outward(uri: &str) -> bool {
|
||||
["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme))
|
||||
}
|
||||
|
||||
/// A localized message.
|
||||
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
|
||||
ely_ui::i18n::text(cx, key, args)
|
||||
|
||||
@@ -238,13 +238,18 @@ pub trait Opening: Host {
|
||||
|
||||
/// Engine errors become toasts; kernel edits mark the session.
|
||||
fn watch(&mut self, id: u64, view: &Entity<View<Self>>, cx: &mut Context<Self>) {
|
||||
let seen = std::cell::Cell::new(Self::Engine::edited(view.read(cx)));
|
||||
let reported = move |host: &mut Self,
|
||||
view: &Entity<View<Self>>,
|
||||
modified: bool,
|
||||
cx: &mut Context<Self>| {
|
||||
let edited = Self::Engine::edited(view.read(cx));
|
||||
let moved = seen.replace(edited) != edited;
|
||||
if let Some(tab) = host.tab_mut(id) {
|
||||
match (modified, tab.modified) {
|
||||
(true, false) => tab.session.edit(),
|
||||
// An edit landing mid-save outdates its ticket.
|
||||
(true, true) if moved => tab.session.edit(),
|
||||
(false, true) => tab.session.reverted(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ pub fn size_menu<P: Panel>(panel: &P, id: &'static str, cx: &Context<P>) -> AnyE
|
||||
if let Some(size) = size.clone().filter(|size| !sizes.contains(size)) {
|
||||
sizes.push(size);
|
||||
}
|
||||
sizes.sort_by(|a, b| points(a).total_cmp(&points(b)));
|
||||
let sizes = ordered(sizes);
|
||||
let mut menu = Select::new(
|
||||
id,
|
||||
sizes
|
||||
@@ -279,7 +279,28 @@ pub fn known(state: &str) -> bool {
|
||||
!state.is_empty() && state != ely_lok::MIXED
|
||||
}
|
||||
|
||||
/// A size label's points, for ordering.
|
||||
fn points(size: &str) -> f64 {
|
||||
size.parse().expect("sizes are numbers")
|
||||
/// Sizes by points; one the kernel words oddly shows last.
|
||||
fn ordered(sizes: Vec<SharedString>) -> Vec<SharedString> {
|
||||
let (mut sized, mut odd) = (Vec::new(), Vec::new());
|
||||
for size in sizes {
|
||||
match size.parse::<f64>() {
|
||||
Ok(points) => sized.push((points, size)),
|
||||
Err(_) => odd.push(size),
|
||||
}
|
||||
}
|
||||
sized.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut sizes: Vec<SharedString> = sized.into_iter().map(|(_, size)| size).collect();
|
||||
sizes.append(&mut odd);
|
||||
sizes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn an_odd_size_shows_last_and_panics_nothing() {
|
||||
let sizes = ["12", "disabled", "9", "10.5"]
|
||||
.map(gpui::SharedString::from)
|
||||
.to_vec();
|
||||
assert_eq!(super::ordered(sizes), ["9", "10.5", "12", "disabled"]);
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,35 @@ use std::{
|
||||
|
||||
use ely_files::{Crash, Marker, Recovery, RecoveryKey};
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use gpui::{AsyncApp, Context, WeakEntity};
|
||||
use gpui::{AppContext as _, AsyncApp, Context, WeakEntity};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Engine, Host, Opening as _, Source, Tabs as _, text};
|
||||
use crate::{Engine, Host, Tabs as _, text};
|
||||
|
||||
mod reopen;
|
||||
|
||||
/// A tab's points, and the edit the last holds.
|
||||
struct Slot {
|
||||
kept: Kept,
|
||||
written: Option<u64>,
|
||||
writing: bool,
|
||||
/// The last failure, shown once until it changes.
|
||||
failed: Option<String>,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Notes a failed point; words to show, once each.
|
||||
fn fail(&mut self, edited: u64, error: &io::Error) -> Option<String> {
|
||||
// Too big for the cap: wait for the next edit.
|
||||
if error.kind() == io::ErrorKind::InvalidInput {
|
||||
self.written = Some(edited);
|
||||
}
|
||||
let said = error.to_string();
|
||||
(self.failed.as_ref() != Some(&said)).then(|| {
|
||||
self.failed = Some(said.clone());
|
||||
said
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What reopening a slot needs, kept beside its points.
|
||||
@@ -47,6 +66,9 @@ pub struct Keeping {
|
||||
pub(crate) last: Option<SystemTime>,
|
||||
/// The index on disk is behind; the failure shown.
|
||||
behind: Option<String>,
|
||||
/// An index write runs; another follows it if asked.
|
||||
flushing: bool,
|
||||
flush_again: bool,
|
||||
}
|
||||
|
||||
impl Keeping {
|
||||
@@ -70,7 +92,7 @@ impl Keeping {
|
||||
};
|
||||
let crash = Crash::open(&dir, std::process::id())?;
|
||||
let (reopening, held) = (Vec::new(), Vec::new());
|
||||
Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None })
|
||||
Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None, flushing: false, flush_again: false })
|
||||
}
|
||||
|
||||
/// A clean quit: the next start finds nothing to recover.
|
||||
@@ -87,17 +109,27 @@ impl Keeping {
|
||||
}
|
||||
|
||||
fn save_index(&self) -> io::Result<()> {
|
||||
let (path, bytes) = self.index()?;
|
||||
write_index(&path, &bytes)
|
||||
}
|
||||
|
||||
/// The index file and what it should hold now.
|
||||
fn index(&self) -> io::Result<(PathBuf, Vec<u8>)> {
|
||||
let waiting = self.reopening.iter().map(|(_, _, kept)| kept).chain(&self.held);
|
||||
let waiting = waiting.map(|kept| Kept { held: true, ..kept.clone() });
|
||||
let slots = self.slots.values().map(|slot| slot.kept.clone());
|
||||
let kept: Vec<Kept> = slots.chain(self.crashed.iter().cloned()).chain(waiting).collect();
|
||||
let path = self.dir.join("recovery").join("index.json");
|
||||
std::fs::create_dir_all(self.dir.join("recovery"))?;
|
||||
// Staged then renamed: a failed write keeps the last index.
|
||||
ely_files::save_atomic(&path, &serde_json::to_vec(&kept).map_err(io::Error::other)?, 0).map(|_| ())
|
||||
let bytes = serde_json::to_vec(&kept).map_err(io::Error::other)?;
|
||||
Ok((self.dir.join("recovery").join("index.json"), bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged then renamed: a failed write keeps the last index.
|
||||
fn write_index(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
std::fs::create_dir_all(path.parent().expect("the index has a folder"))?;
|
||||
ely_files::save_atomic(path, bytes, 0).map(|_| ())
|
||||
}
|
||||
|
||||
fn read_index(dir: &Path) -> io::Result<Vec<Kept>> {
|
||||
match std::fs::read(dir.join("recovery").join("index.json")) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)),
|
||||
@@ -128,6 +160,9 @@ fn remove(path: &Path) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A slot taken from a released tab, waiting to reopen.
|
||||
pub struct Taken(Kept, String);
|
||||
|
||||
/// When recovery points are written and crashes come back.
|
||||
pub trait Recovering: Host {
|
||||
/// From the app's window, never its tests.
|
||||
@@ -140,8 +175,17 @@ pub trait Recovering: Host {
|
||||
}
|
||||
}
|
||||
Self::keep_points_every(cx);
|
||||
cx.on_app_quit(|host, _| {
|
||||
if let Some(keeping) = host.core_mut().keeping.as_mut() {
|
||||
cx.on_app_quit(|host, cx| {
|
||||
// A write still in flight must not be the last.
|
||||
if let Some(keeping) = host.core().keeping.as_ref()
|
||||
&& let Err(error) = keeping.save_index()
|
||||
{
|
||||
log::error!("recovery: the index is not saved at quit: {error}");
|
||||
}
|
||||
// A system end skips asking: unsaved tabs keep points.
|
||||
if host.core().tabs.iter().any(|tab| tab.dirty(cx)) {
|
||||
log::warn!("recovery: quit with unsaved tabs; points stay");
|
||||
} else if let Some(keeping) = host.core_mut().keeping.as_mut() {
|
||||
keeping.end();
|
||||
}
|
||||
async {}
|
||||
@@ -219,6 +263,7 @@ pub trait Recovering: Host {
|
||||
match written {
|
||||
Ok(()) => {
|
||||
slot.written = Some(edited);
|
||||
slot.failed = None;
|
||||
keeping.last = Some(SystemTime::now());
|
||||
if keeping.behind.is_some() {
|
||||
host.flush_index(cx);
|
||||
@@ -226,7 +271,8 @@ pub trait Recovering: Host {
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("recovery: tab {id} point failed ({:?})", error.kind());
|
||||
let shown = text(cx, "office.recovery.failed", &[("error", &error.to_string())]);
|
||||
let Some(said) = slot.fail(edited, &error) else { return };
|
||||
let shown = text(cx, "office.recovery.failed", &[("error", &said)]);
|
||||
host.toast_unlogged(shown, Severity::Warning, cx);
|
||||
}
|
||||
}
|
||||
@@ -236,13 +282,40 @@ pub trait Recovering: Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the index; a failure shows once and retries.
|
||||
/// Writes the index in the background, one at a time.
|
||||
fn flush_index(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
let error = match keeping.save_index() {
|
||||
Ok(()) => return keeping.behind = None,
|
||||
Err(error) => error.to_string(),
|
||||
if keeping.flushing {
|
||||
return keeping.flush_again = true;
|
||||
}
|
||||
let (path, bytes) = match keeping.index() {
|
||||
Ok(index) => index,
|
||||
Err(error) => return self.index_failed(error.to_string(), cx),
|
||||
};
|
||||
keeping.flushing = true;
|
||||
let task = cx.background_spawn(async move { write_index(&path, &bytes) });
|
||||
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
|
||||
let written = task.await;
|
||||
this.update(cx, |host, cx| {
|
||||
let Some(keeping) = host.core_mut().keeping.as_mut() else { return };
|
||||
keeping.flushing = false;
|
||||
match written {
|
||||
Ok(()) => keeping.behind = None,
|
||||
Err(error) => host.index_failed(error.to_string(), cx),
|
||||
}
|
||||
let again = host.core_mut().keeping.as_mut().is_some_and(|keeping| std::mem::take(&mut keeping.flush_again));
|
||||
if again {
|
||||
host.flush_index(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// An index failure shows once and retries.
|
||||
fn index_failed(&mut self, error: String, cx: &mut Context<Self>) {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
if keeping.behind.as_ref() == Some(&error) {
|
||||
return;
|
||||
}
|
||||
@@ -261,7 +334,7 @@ pub trait Recovering: Host {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
let slot = format!("{}-{id}", std::process::id());
|
||||
let kept = Kept { slot, name: name.to_string(), format, sealed, held: false };
|
||||
keeping.slots.insert(id, Slot { kept, written: None, writing: false });
|
||||
keeping.slots.insert(id, Slot { kept, written: None, writing: false, failed: None });
|
||||
self.flush_index(cx);
|
||||
}
|
||||
|
||||
@@ -275,60 +348,50 @@ pub trait Recovering: Host {
|
||||
self.flush_index(cx);
|
||||
}
|
||||
|
||||
/// Opens each crashed slot's newest point as unsaved copy.
|
||||
/// Reopens each crashed slot's newest point as an unsaved copy.
|
||||
fn reopen_crashed(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
let crashed = std::mem::take(&mut keeping.crashed);
|
||||
let crashed: Vec<(Kept, String)> = std::mem::take(&mut keeping.crashed).into_iter().map(|kept| { let name = kept.name.clone(); (kept, name) }).collect();
|
||||
if crashed.is_empty() {
|
||||
return;
|
||||
}
|
||||
for kept in crashed {
|
||||
let folder = self.core().keeping.as_ref().expect("keeping").folder(&kept.slot);
|
||||
match restore(&kept, &folder, &self.core().work, self.core().next, false) {
|
||||
Ok(Some((copy, at))) => {
|
||||
log::info!("recovery: reopening a crashed {}", kept.format);
|
||||
self.core_mut().keeping.as_mut().expect("keeping").reopening.push((copy.clone(), at, kept));
|
||||
self.load(Source::Copy(copy), cx);
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(error) = remove(&folder) {
|
||||
log::error!("recovery: an empty slot stays: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
reopen::restore_off(self, crashed, false, |host, done, cx| {
|
||||
for (name, restored) in done {
|
||||
if let Err(error) = restored {
|
||||
log::error!("recovery: a crashed copy cannot open ({:?})", error.kind());
|
||||
let shown = text(cx, "office.recovery.lost", &[("name", &kept.name), ("error", &error.to_string())]);
|
||||
self.toast_unlogged(shown, Severity::Danger, cx);
|
||||
// Its points stay for a later start.
|
||||
self.core_mut().keeping.as_mut().expect("keeping").held.push(kept);
|
||||
let shown = text(cx, "office.recovery.lost", &[("name", &name), ("error", &error.to_string())]);
|
||||
host.toast_unlogged(shown, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.flush_index(cx);
|
||||
}, cx);
|
||||
}
|
||||
|
||||
/// Opens `id`'s newest point as a copy; false if none.
|
||||
fn reopen_point(&mut self, id: u64, cx: &mut Context<Self>) -> io::Result<bool> {
|
||||
let (work, next) = (self.core().work.clone(), self.core().next);
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return Ok(false) };
|
||||
// Out of the tab's hands: releasing it keeps these points.
|
||||
let Some(slot) = keeping.slots.remove(&id) else { return Ok(false) };
|
||||
let restored = restore(&slot.kept, &keeping.folder(&slot.kept.slot), &work, next, true);
|
||||
match restored {
|
||||
Ok(Some((copy, at))) => {
|
||||
keeping.reopening.push((copy.clone(), at, slot.kept));
|
||||
self.load(Source::Copy(copy), cx);
|
||||
Ok(true)
|
||||
/// Takes tab `id`'s slot; releasing it keeps the points.
|
||||
fn take_slot(&mut self, id: u64, name: String) -> Option<Taken> {
|
||||
let slot = self.core_mut().keeping.as_mut()?.slots.remove(&id)?;
|
||||
Some(Taken(slot.kept, name))
|
||||
}
|
||||
|
||||
/// Reopens taken slots as copies; names any lost.
|
||||
fn reopen_taken(&mut self, taken: Vec<Taken>, mut lost: Vec<String>, cx: &mut Context<Self>) {
|
||||
let taken = taken.into_iter().map(|Taken(kept, name)| (kept, name)).collect();
|
||||
reopen::restore_off(self, taken, true, move |host, done, cx| {
|
||||
for (name, restored) in done {
|
||||
match restored {
|
||||
Ok(true) => {}
|
||||
Ok(false) => lost.push(name),
|
||||
Err(error) => {
|
||||
log::error!("office: a point does not open ({:?})", error.kind());
|
||||
lost.push(format!("{name} ({error})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
keeping.slots.insert(id, slot);
|
||||
Ok(false)
|
||||
log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len());
|
||||
if !lost.is_empty() {
|
||||
let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]);
|
||||
host.toast_unlogged(shown, Severity::Danger, cx);
|
||||
}
|
||||
Err(error) => {
|
||||
keeping.held.push(slot.kept);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}, cx);
|
||||
}
|
||||
|
||||
/// A reopened copy takes over its old points, unsaved.
|
||||
@@ -355,28 +418,6 @@ pub trait Recovering: Host {
|
||||
|
||||
impl<H: Host> Recovering for H {}
|
||||
|
||||
/// The newest point copied to the work folder, and when.
|
||||
fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result<Option<(PathBuf, SystemTime)>> {
|
||||
let recovery = match kept.sealed {
|
||||
true => {
|
||||
let key = RecoveryKey::load();
|
||||
// This run's points open with this run's key.
|
||||
if !key.lasting && !this_run {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store"));
|
||||
}
|
||||
Recovery::sensitive(folder.to_path_buf(), key.key)
|
||||
}
|
||||
false => Recovery::new(folder.to_path_buf()),
|
||||
};
|
||||
let Some(point) = recovery.points()?.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dir = crate::files::work_dir(work, next)?;
|
||||
let copy = dir.join(&kept.name).with_extension(&kept.format);
|
||||
recovery.restore(&point, ©)?;
|
||||
Ok(Some((copy, point.at)))
|
||||
}
|
||||
|
||||
/// A recovery time as this machine's clock reads it.
|
||||
pub(crate) fn clock(at: SystemTime) -> String {
|
||||
match jiff::Timestamp::try_from(at) {
|
||||
@@ -387,7 +428,18 @@ pub(crate) fn clock(at: SystemTime) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Keeping, Kept};
|
||||
use super::{Keeping, Kept, Slot};
|
||||
|
||||
#[test]
|
||||
fn a_failing_point_says_so_once_until_the_reason_changes() {
|
||||
let mut slot = Slot { kept: kept("s", false), written: None, writing: false, failed: None };
|
||||
let full = std::io::Error::other("disk full");
|
||||
assert_eq!(slot.fail(1, &full).as_deref(), Some("disk full"));
|
||||
assert_eq!(slot.fail(2, &full), None, "the same reason stays quiet");
|
||||
let big = std::io::Error::new(std::io::ErrorKind::InvalidInput, "over the cap");
|
||||
assert!(slot.fail(3, &big).is_some(), "a new reason shows");
|
||||
assert_eq!(slot.written, Some(3), "too big waits for the next edit");
|
||||
}
|
||||
|
||||
fn kept(slot: &str, held: bool) -> Kept {
|
||||
Kept { slot: slot.into(), name: "a.docx".into(), format: "docx".into(), sealed: false, held }
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Points restored off the UI thread, then opened (§6.3).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use ely_files::{Recovery, RecoveryKey};
|
||||
use gpui::{AppContext as _, AsyncApp, Context, WeakEntity};
|
||||
|
||||
use super::{Kept, Recovering, remove};
|
||||
use crate::{Opening as _, Source};
|
||||
|
||||
/// Restores each slot's newest point, then opens the copy.
|
||||
pub(super) fn restore_off<H: Recovering>(
|
||||
host: &mut H,
|
||||
slots: Vec<(Kept, String)>,
|
||||
this_run: bool,
|
||||
then: impl FnOnce(&mut H, Vec<(String, io::Result<bool>)>, &mut Context<H>) + 'static,
|
||||
cx: &mut Context<H>,
|
||||
) {
|
||||
let Some(keeping) = host.core_mut().keeping.as_mut() else { return };
|
||||
// Held meanwhile: a crash now still finds them.
|
||||
keeping.held.extend(slots.iter().map(|(kept, _)| kept.clone()));
|
||||
let jobs: Vec<(u64, PathBuf, Kept, String)> = slots
|
||||
.into_iter()
|
||||
.map(|(kept, name)| {
|
||||
let folder = host.core().keeping.as_ref().expect("keeping").folder(&kept.slot);
|
||||
let id = host.core().next;
|
||||
// Each copy's folder is its future tab's.
|
||||
host.core_mut().next += 1;
|
||||
(id, folder, kept, name)
|
||||
})
|
||||
.collect();
|
||||
host.flush_index(cx);
|
||||
let work = host.core().work.clone();
|
||||
let task = cx.background_spawn(async move {
|
||||
jobs.into_iter()
|
||||
.map(|(id, folder, kept, name)| {
|
||||
let restored = restore(&kept, &folder, &work, id, this_run);
|
||||
// An empty slot has nothing to keep.
|
||||
let restored = match restored {
|
||||
Ok(None) => remove(&folder).map(|()| None),
|
||||
other => other,
|
||||
};
|
||||
(id, kept, name, restored)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
cx.spawn(async move |this: WeakEntity<H>, cx: &mut AsyncApp| {
|
||||
let done = task.await;
|
||||
this.update(cx, |host, cx| {
|
||||
let Some(keeping) = host.core_mut().keeping.as_mut() else { return };
|
||||
keeping.held.retain(|held| !done.iter().any(|(_, kept, ..)| kept.slot == held.slot));
|
||||
let mut said = Vec::new();
|
||||
for (id, kept, name, restored) in done {
|
||||
let keeping = host.core_mut().keeping.as_mut().expect("keeping");
|
||||
let opened = match restored {
|
||||
Ok(Some((copy, at))) => {
|
||||
log::info!("recovery: reopening a {}", kept.format);
|
||||
keeping.reopening.push((copy.clone(), at, kept));
|
||||
host.load_into(id, Source::Copy(copy), cx);
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => Ok(false),
|
||||
Err(error) => {
|
||||
// Its points stay for a later start.
|
||||
keeping.held.push(kept);
|
||||
Err(error)
|
||||
}
|
||||
};
|
||||
said.push((name, opened));
|
||||
}
|
||||
host.flush_index(cx);
|
||||
then(host, said, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The newest point copied to the work folder, and when.
|
||||
fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result<Option<(PathBuf, SystemTime)>> {
|
||||
let recovery = match kept.sealed {
|
||||
true => {
|
||||
let key = RecoveryKey::load();
|
||||
// This run's points open with this run's key.
|
||||
if !key.lasting && !this_run {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store"));
|
||||
}
|
||||
Recovery::sensitive(folder.to_path_buf(), key.key)
|
||||
}
|
||||
false => Recovery::new(folder.to_path_buf()),
|
||||
};
|
||||
let Some(point) = recovery.points()?.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dir = crate::files::work_dir(work, next)?;
|
||||
let copy = dir.join(&kept.name).with_extension(&kept.format);
|
||||
recovery.restore(&point, ©)?;
|
||||
Ok(Some((copy, point.at)))
|
||||
}
|
||||
|
||||
@@ -30,16 +30,27 @@ fn identity(path: &Path) -> io::Result<PathBuf> {
|
||||
pub trait Saving: Host {
|
||||
/// The tab whose source file is `target`.
|
||||
fn holder(&self, target: &Path) -> io::Result<Option<u64>> {
|
||||
// Target errors stand; a tab's gone volume does not.
|
||||
let target = identity(target)?;
|
||||
for tab in &self.core().tabs {
|
||||
if let Some(open) = tab.session.path()
|
||||
&& ely_files::same_file(open, target)?
|
||||
{
|
||||
return Ok(Some(tab.id));
|
||||
let Some(open) = tab.session.path() else { continue };
|
||||
match ely_files::same_file(open, &target) {
|
||||
Ok(true) => return Ok(Some(tab.id)),
|
||||
Ok(false) => {}
|
||||
Err(error) => log::warn!("office: tab {} has no file to compare: {error}", tab.id),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// A save as refused unwritten; a waiting close stops.
|
||||
fn refuse_save(&mut self, then: &Option<After>, words: gpui::SharedString, cx: &mut Context<Self>) {
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
if then.is_some() {
|
||||
self.close_stopped(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves in place in its own format.
|
||||
fn save(&mut self, id: u64, then: Option<After>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.settled(id, cx) {
|
||||
@@ -216,25 +227,29 @@ pub trait Saving: Host {
|
||||
host.write_as(id, path, then, window, cx)
|
||||
});
|
||||
}
|
||||
let name = self.tab(id).map(|tab| tab.name.clone()).unwrap_or_default();
|
||||
let unsaved = |error: io::Error, cx: &mut Context<Self>| {
|
||||
text(cx, "office.error.save", &[("name", &name), ("error", &error.to_string())])
|
||||
};
|
||||
let target = match identity(&path) {
|
||||
Ok(target) => target,
|
||||
Err(error) => return self.report(id, Err(SaveError::Io(error)), cx),
|
||||
Err(error) => return self.refuse_save(&then, unsaved(error, cx), cx),
|
||||
};
|
||||
let Some(format) = files::save_as_format(&Self::KIND, &target) else {
|
||||
let formats = Self::KIND.save_as.join(", .");
|
||||
let text = text(cx, "office.error.format", &[("formats", &formats)]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
return self.refuse_save(&then, text, cx);
|
||||
};
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return log::info!("office: tab {id} closed before its save");
|
||||
};
|
||||
let holder = match self.holder(&target) {
|
||||
Ok(holder) => holder,
|
||||
Err(error) => return self.report(id, Err(SaveError::Io(error)), cx),
|
||||
Err(error) => return self.refuse_save(&then, unsaved(error, cx), cx),
|
||||
};
|
||||
if holder.is_some_and(|other| other != id) {
|
||||
let text = text(cx, "office.error.taken", &[("name", &file_name(&target))]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
return self.refuse_save(&then, text, cx);
|
||||
}
|
||||
let own_format = tab
|
||||
.session
|
||||
@@ -242,7 +257,7 @@ pub trait Saving: Host {
|
||||
.and_then(|open| files::kept_format(&Self::KIND, open).ok());
|
||||
if holder == Some(id) && (tab.risk.is_some() || own_format != Some(format)) {
|
||||
let text = text(cx, "office.error.convert", &[]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
return self.refuse_save(&then, text, cx);
|
||||
}
|
||||
// The written file differs from the open one; reopen it.
|
||||
let reopen =
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{path::PathBuf, sync::Arc};
|
||||
use ely_gpui_component::{feedback::Banner, primitives::Severity};
|
||||
use gpui::{AppContext as _, Context};
|
||||
|
||||
use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, Tabs as _, recover::clock, text};
|
||||
use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, recover::clock, text};
|
||||
|
||||
/// Why the engine ended, and whether it is coming back.
|
||||
pub struct Stopped {
|
||||
@@ -93,7 +93,7 @@ pub trait Stopping: Host {
|
||||
.iter()
|
||||
.map(|tab| (tab.id, tab.name.to_string(), tab.session.path().map(PathBuf::from), tab.dirty(cx)))
|
||||
.collect();
|
||||
let mut lost = Vec::new();
|
||||
let (mut taken, mut lost) = (Vec::new(), Vec::new());
|
||||
for (id, name, path, dirty) in tabs {
|
||||
if !dirty {
|
||||
self.release_tab(id, cx);
|
||||
@@ -102,13 +102,9 @@ pub trait Stopping: Host {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match self.reopen_point(id, cx) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => lost.push(name),
|
||||
Err(error) => {
|
||||
log::error!("office: tab {id} point does not open ({:?})", error.kind());
|
||||
lost.push(format!("{name} ({error})"));
|
||||
}
|
||||
match self.take_slot(id, name.clone()) {
|
||||
Some(slot) => taken.push(slot),
|
||||
None => lost.push(name),
|
||||
}
|
||||
self.release_tab(id, cx);
|
||||
}
|
||||
@@ -116,11 +112,7 @@ pub trait Stopping: Host {
|
||||
if matches!(self.core().dialog, Some(Dialog::Unsaved { .. } | Dialog::Conflict { .. })) {
|
||||
self.core_mut().dialog = None;
|
||||
}
|
||||
log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len());
|
||||
if !lost.is_empty() {
|
||||
let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]);
|
||||
self.toast_unlogged(shown, Severity::Danger, cx);
|
||||
}
|
||||
self.reopen_taken(taken, lost, cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ use std::path::Path;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
/// Only macOS plays yet; elsewhere nothing offers it.
|
||||
pub const BUILT: bool = cfg!(target_os = "macos");
|
||||
|
||||
/// What the player knows of its file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum State {
|
||||
|
||||
@@ -83,6 +83,11 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mark.git.failed", "ElyTree did not open: {error}"),
|
||||
("mark.large", "This file is {size} MB: it shows as source. Visual and preview are off; saving keeps every byte."),
|
||||
("mark.large.modes", "Large files stay in source mode"),
|
||||
(
|
||||
"mark.visual.broke",
|
||||
"The visual editor failed ({what}); the file shows as source",
|
||||
),
|
||||
("mark.page.broke", "The editor did not open ({what})"),
|
||||
("mark.mode.visual", "Visual"),
|
||||
("mark.mode.source", "Source"),
|
||||
("mark.mode.preview", "Preview"),
|
||||
@@ -179,6 +184,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mark.git.failed", "ElyTree 没能打开:{error}"),
|
||||
("mark.large", "此文件 {size} MB,以源码显示。可视与预览已关闭;保存时保留全部内容。"),
|
||||
("mark.large.modes", "大文件只用源码模式"),
|
||||
(
|
||||
"mark.visual.broke",
|
||||
"可视编辑器出错({what}),文件以源码显示",
|
||||
),
|
||||
("mark.page.broke", "编辑器没有打开({what})"),
|
||||
("mark.mode.visual", "可视"),
|
||||
("mark.mode.source", "源码"),
|
||||
("mark.mode.preview", "预览"),
|
||||
|
||||
@@ -17,12 +17,31 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
),
|
||||
("office.error.changed", "The document changed meanwhile; try again"),
|
||||
("office.print.failed", "Printing failed: {error}"),
|
||||
("office.recovery.failed", "A recovery copy was not written: {error}"),
|
||||
("office.recovery.off", "Recovery copies are off for this run: {error}"),
|
||||
("office.recovery.lost", "{name} could not be recovered: {error}"),
|
||||
("office.recovery.back", "{name}: unsaved content from {at} is back; the cursor and undo history are not"),
|
||||
("office.recovery.index", "Recovery points cannot be listed, so a crash may lose them: {error}"),
|
||||
("office.stopped", "The document engine stopped ({why}). Last recovery point: {at}."),
|
||||
("office.link.refused", "Ely opens only web and mail links."),
|
||||
(
|
||||
"office.recovery.failed",
|
||||
"A recovery copy was not written: {error}",
|
||||
),
|
||||
(
|
||||
"office.recovery.off",
|
||||
"Recovery copies are off for this run: {error}",
|
||||
),
|
||||
(
|
||||
"office.recovery.lost",
|
||||
"{name} could not be recovered: {error}",
|
||||
),
|
||||
(
|
||||
"office.recovery.back",
|
||||
"{name}: unsaved content from {at} is back; the cursor and undo history are not",
|
||||
),
|
||||
(
|
||||
"office.recovery.index",
|
||||
"Recovery points cannot be listed, so a crash may lose them: {error}",
|
||||
),
|
||||
(
|
||||
"office.stopped",
|
||||
"The document engine stopped ({why}). Last recovery point: {at}.",
|
||||
),
|
||||
("office.stopped.never", "none yet"),
|
||||
("office.stopped.reopen", "Reopen documents"),
|
||||
("office.stopped.lost", "Not reopened: {names}. Their unsaved changes had no recovery point."),
|
||||
@@ -103,6 +122,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("office.error.convert", "格式转换要写成新文件,请换个名字"),
|
||||
("office.error.changed", "文档在此间已改动,请重试"),
|
||||
("office.print.failed", "打印失败:{error}"),
|
||||
("office.link.refused", "Ely 只打开网页与邮件链接。"),
|
||||
("office.recovery.failed", "恢复副本未能写入:{error}"),
|
||||
("office.recovery.off", "本次运行不写恢复副本:{error}"),
|
||||
("office.recovery.lost", "{name} 未能恢复:{error}"),
|
||||
|
||||
@@ -184,7 +184,6 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"pdf.link.bad",
|
||||
"Type a page number, or a web or mail address.",
|
||||
),
|
||||
("pdf.link.refused", "Ely opens only web and mail links."),
|
||||
("pdf.side.files", "Attachments"),
|
||||
("pdf.files.none", "No attachments"),
|
||||
("pdf.file.attach", "Attach File…"),
|
||||
@@ -391,7 +390,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("pdf.tool.link", "添加链接"),
|
||||
("pdf.link.target", "目标"),
|
||||
("pdf.link.bad", "请输入页码,或网址、邮件地址。"),
|
||||
("pdf.link.refused", "Ely 只打开网页与邮件链接。"),
|
||||
("pdf.side.files", "附件"),
|
||||
("pdf.files.none", "没有附件"),
|
||||
("pdf.file.attach", "添加附件…"),
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("tree.error.write", "Git failed: {error}"),
|
||||
("tree.error.clone", "The clone failed: {error}"),
|
||||
("tree.error.stop", "The clone did not stop: {error}"),
|
||||
("tree.error.stop_remote", "Git did not stop: {error}"),
|
||||
("tree.error.panel", "The folder panel failed: {error}"),
|
||||
("tree.space.exact", "Exact"),
|
||||
("tree.space.changes", "Ignore Space Changes"),
|
||||
@@ -179,6 +180,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.error.write", "Git 失败:{error}"),
|
||||
("tree.error.clone", "克隆失败:{error}"),
|
||||
("tree.error.stop", "克隆未能停止:{error}"),
|
||||
("tree.error.stop_remote", "Git 未能停止:{error}"),
|
||||
("tree.error.panel", "文件夹面板失败:{error}"),
|
||||
("tree.space.exact", "精确"),
|
||||
("tree.space.changes", "忽略空白变化"),
|
||||
|
||||
@@ -75,6 +75,8 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
),
|
||||
("tree.op.picking", "Cherry-picking {theirs} onto {ours}"),
|
||||
("tree.op.reverting", "Reverting {theirs} on {ours}"),
|
||||
("tree.op.applying", "Applying patch {theirs} onto {ours}"),
|
||||
("tree.op.unread", "What Git is doing did not read: {error}"),
|
||||
(
|
||||
"tree.op.next",
|
||||
"Resolve the conflicts and mark each file resolved, then continue.",
|
||||
@@ -98,6 +100,8 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("tree.theirs.pick", "Picked, {name}"),
|
||||
("tree.ours.revert", "Current, {name}"),
|
||||
("tree.theirs.revert", "Reverting, {name}"),
|
||||
("tree.ours.am", "Current, {name}"),
|
||||
("tree.theirs.am", "Patch {name}"),
|
||||
("tree.conflict.save", "Save"),
|
||||
("tree.conflict.resolved", "Mark Resolved"),
|
||||
("tree.conflict.use_ours", "Use Current File"),
|
||||
@@ -107,6 +111,10 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"tree.conflict.hand",
|
||||
"The result was edited by hand; settle the rest in the result.",
|
||||
),
|
||||
(
|
||||
"tree.conflict.taken",
|
||||
"This conflict is settled; change it in the result.",
|
||||
),
|
||||
("tree.conflict.unsaved", "Save the result first."),
|
||||
(
|
||||
"tree.conflict.kept",
|
||||
@@ -215,6 +223,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.op.rebasing", "正在变基到 {ours},应用 {theirs}"),
|
||||
("tree.op.picking", "正在把 {theirs} 拣选到 {ours}"),
|
||||
("tree.op.reverting", "正在 {ours} 上撤销 {theirs}"),
|
||||
("tree.op.applying", "正在 {ours} 上应用补丁 {theirs}"),
|
||||
("tree.op.unread", "读不出 Git 正在做什么:{error}"),
|
||||
("tree.op.next", "解决冲突并把每个文件标为已解决,然后继续。"),
|
||||
("tree.op.continue", "继续"),
|
||||
("tree.op.skip", "跳过…"),
|
||||
@@ -232,6 +242,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.theirs.pick", "拣选的 {name}"),
|
||||
("tree.ours.revert", "当前 {name}"),
|
||||
("tree.theirs.revert", "撤销的 {name}"),
|
||||
("tree.ours.am", "当前 {name}"),
|
||||
("tree.theirs.am", "补丁 {name}"),
|
||||
("tree.conflict.save", "保存"),
|
||||
("tree.conflict.resolved", "标为已解决"),
|
||||
("tree.conflict.use_ours", "用当前侧的文件"),
|
||||
@@ -241,6 +253,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
"tree.conflict.hand",
|
||||
"结果已手动改过,其余冲突请在结果里处理。",
|
||||
),
|
||||
("tree.conflict.taken", "此处冲突已选定,请在结果里修改。"),
|
||||
("tree.conflict.unsaved", "请先保存结果。"),
|
||||
("tree.conflict.kept", "文件在磁盘上变了,未保存的结果保留。"),
|
||||
("tree.conflict.marked.title", "把 {name} 标为已解决?"),
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("tree.locked.retry", "Retry"),
|
||||
("tree.tool.terminal", "Open in Terminal"),
|
||||
("tree.tool.reveal", "Show in Finder"),
|
||||
("tree.tool.reveal_folder", "Show in Folder"),
|
||||
("tree.tool.editor", "Choose Editor…"),
|
||||
("tree.tool.in_editor", "Open in Editor"),
|
||||
("tree.tool.in_mark", "Open in ElyMark"),
|
||||
@@ -283,6 +284,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.locked.retry", "重试"),
|
||||
("tree.tool.terminal", "在终端中打开"),
|
||||
("tree.tool.reveal", "在访达中显示"),
|
||||
("tree.tool.reveal_folder", "在文件夹中显示"),
|
||||
("tree.tool.editor", "选择编辑器…"),
|
||||
("tree.tool.in_editor", "在编辑器中打开"),
|
||||
("tree.tool.in_mark", "在 ElyMark 中打开"),
|
||||
|
||||
@@ -349,6 +349,16 @@ wrap_life_span_handler! {
|
||||
fn on_after_created(&self, browser: Option<&mut Browser>) {
|
||||
let Some(browser) = browser.map(|b| b.clone()) else { return };
|
||||
log::info!("web: browser {} made", browser.identifier());
|
||||
// Its view went before it came: close it, keep nothing.
|
||||
if self.surface.lock().expect("the surface lock").closed {
|
||||
log::info!("web: browser {} closes: its view is gone", browser.identifier());
|
||||
crate::engine::later(move || {
|
||||
if let Some(host) = browser.host() {
|
||||
host.close_browser(true as _);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
adopt(&browser, self.surface.clone());
|
||||
self.surface.lock().expect("the surface lock").browser = Some(browser);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user