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:
ZacharyZhang-NY committed 2026-10-10 02:29:19 -04:00
1 parent 6505f35669
commit d59159cea2
75 files changed
+1482 -354

No files matched your search

+21 -16
View File
@@ -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 {
+6 -3
View File
@@ -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.
+25 -19
View File
@@ -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
+24
View File
@@ -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);
}
+19 -6
View File
@@ -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"));
+21 -7
View File
@@ -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) {
+1 -1
View File
@@ -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()?,
})
+22 -10
View File
@@ -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}"#);
+6 -1
View File
@@ -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)?);
}
+6 -4
View File
@@ -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)
}
+5
View File
@@ -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)
+5
View File
@@ -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(),
_ => {}
}
+25 -4
View File
@@ -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"]);
}
}
+132 -80
View File
@@ -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, &copy)?;
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 }
+104
View File
@@ -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, &copy)?;
Ok(Some((copy, point.at)))
}
+24 -9
View File
@@ -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 =
+6 -14
View File
@@ -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);
}
}
+3
View File
@@ -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 {
+10
View File
@@ -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", "预览"),
+26 -6
View File
@@ -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}"),
-2
View File
@@ -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", "添加附件…"),
+2
View File
@@ -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", "忽略空白变化"),
+13
View File
@@ -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} 标为已解决?"),
+2
View File
@@ -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 中打开"),
+10
View File
@@ -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);
}