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

+38
View File
@@ -263,3 +263,41 @@ fn closing_a_tab_takes_its_export_dialog() {
// Drawn now, a dialog for the gone tab would crash.
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "the dialog went with its tab");
}
#[test]
fn a_tab_on_a_vanished_folder_takes_no_other_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
let gone = dir.path().join("volume");
std::fs::create_dir(&gone).expect("a folder");
let first = gone.join("first.docx");
with_window(&mut cx, &docs, |docs, window, cx| docs.write_as(id, first.clone(), None, window, cx));
written(&cx, &first, None);
// Its volume goes away; another file still saves.
std::fs::remove_dir_all(&gone).expect("the folder goes");
let other = dir.path().join("other.docx");
let holder = cx.update(|app| docs.read(app).holder(&other).map_err(|error| error.to_string()));
assert_eq!(holder, Ok(None));
}
#[test]
fn an_edit_after_a_save_raced_it_counts_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "a");
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.modified));
// A save's ticket landed after this edit: saved, still modified.
cx.update(|app| docs.update(app, |docs, _| docs.tab_mut(id).expect("a tab").session.reverted()));
type_into(&mut cx, &docs, "b");
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.session.state() == ely_files::SaveState::Dirty));
}
+1
View File
@@ -120,6 +120,7 @@ impl Desk {
return self.set_outside(id, Outside::Unread(why), cx);
}
tab.extra.base = disk.text.clone();
tab.mark = crate::engine::mark(&disk.text);
tab.extra.restart_autosave();
tab.doc.lock().expect("the text lock").source = disk;
let view = tab.view.clone();
+19 -6
View File
@@ -53,7 +53,9 @@ pub struct Desk {
pub keeping: crate::restore::Keeping,
/// The export dialog's choice, while it is open.
pub output: Option<crate::export::Output>,
pub printing: Option<crate::export::Printing>,
/// Pages printing to PDF, by job; several may run.
pub printing: HashMap<u64, crate::export::Printing>,
pub print_jobs: u64,
pub look: crate::look::Look,
pub look_file: PathBuf,
/// Git's word on the shown file, and which tab asked.
@@ -113,7 +115,7 @@ impl Host for Desk {
self.watch_folders(cx);
let look = serde_json::to_value(&self.look).expect("a look serializes");
view.update(cx, |view, cx| view.set_look(look, cx));
let changed = view.read(cx).changed;
let changed = view.read(cx).changed();
self.autosave(id, changed, cx);
let recheck = self.tab_mut(id).is_some_and(|tab| {
if !tab.dirty(cx) {
@@ -200,7 +202,8 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
comparing: None,
keeping,
output: None,
printing: None,
printing: Default::default(),
print_jobs: 0,
look: look.clone(),
look_file,
tracked: None,
@@ -228,8 +231,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
desk.open_path(path, cx);
}
}
for name in desk.keeping.untitled() {
for (at, name) in desk.keeping.untitled() {
// Tab numbers come from `next` as the load starts.
let id = desk.core.next;
desk.new_from_text(&name, "", cx);
if desk.core.next > id {
desk.keeping.made(at, id);
}
}
for path in asked {
desk.open_path(path, cx);
@@ -246,8 +254,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
}
})
.detach();
cx.on_app_quit(|desk, _| {
desk.keeping.close();
cx.on_app_quit(|desk, cx| {
// A system end skips asking: unsaved tabs keep points.
if desk.core.tabs.iter().any(|tab| tab.dirty(cx)) {
log::warn!("mark: quit with unsaved tabs; points stay");
} else {
desk.keeping.close();
}
async {}
})
.detach();
+1 -1
View File
@@ -132,7 +132,7 @@ impl Engine for Mark {
}
fn edited(view: &MarkView) -> u64 {
view.changed
view.changed()
}
fn edit_pending(view: &MarkView) -> bool {
+40 -23
View File
@@ -108,8 +108,7 @@ impl Desk {
let Some(id) = self.active_tab().map(|tab| tab.id) else {
return;
};
let pdf = self.core.work.join(format!("print-{id}.pdf"));
self.render_pdf(id, pdf, None, cx);
self.render_pdf(id, None, cx);
}
/// Asks where, renders the whole text, then writes it.
@@ -126,20 +125,28 @@ impl Desk {
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir);
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
cx.spawn(async move |desk, cx| {
let Ok(Ok(Some(path))) = chosen.await else {
return log::info!("mark: the export panel closed without a path");
// A cancel says nothing; a panel that failed says why.
let failed = match chosen.await {
Ok(Ok(Some(path))) => Ok(path),
Ok(Ok(None)) => return log::info!("mark: the export panel was cancelled"),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
let path = match failed {
Ok(path) => path,
Err(why) => {
if desk.update(cx, |desk, cx| desk.panel_failed(&why, cx)).is_err() {
log::error!("mark: the export panel failed after its window: {why}");
}
return;
}
};
desk.update(cx, |desk, cx| {
if let Err(error) = not_open(desk, &path) {
return desk.exported(Err(error), cx);
}
match output {
Output::Pdf => desk.render_pdf(
id,
desk.core.work.join(format!("export-{id}.pdf")),
Some(path),
cx,
),
Output::Pdf => desk.render_pdf(id, Some(path), cx),
_ => desk.write_output(id, output, path, cx),
}
})
@@ -257,7 +264,11 @@ impl Desk {
}
/// The whole text as PDF; then to `to`, else printed.
fn render_pdf(&mut self, id: u64, pdf: PathBuf, to: Option<PathBuf>, cx: &mut Context<Self>) {
fn render_pdf(&mut self, id: u64, to: Option<PathBuf>, cx: &mut Context<Self>) {
// Each job has its own files; none cancels another.
self.print_jobs += 1;
let n = self.print_jobs;
let pdf = self.core.work.join(format!("print-{n}.pdf"));
let Some(tab) = self.tab(id) else {
return;
};
@@ -273,14 +284,20 @@ impl Desk {
view.render(
"html",
move |rendered, cx: &mut App| {
let staged = work.join(format!("print-{id}.html"));
let page = rendered.map_err(io::Error::other).and_then(|rendered| {
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?);
std::fs::write(&staged, paper::page(&name, &style, &html))?;
Ok(Lost::of(images, &rendered))
});
let staged = work.join(format!("print-{n}.html"));
let writing = staged.clone();
cx.spawn(async move |cx| {
// Images inline and the page writes off the window thread.
let page = cx
.background_spawn(async move {
rendered.map_err(io::Error::other).and_then(|rendered| {
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?);
std::fs::write(&writing, paper::page(&name, &style, &html))?;
Ok(Lost::of(images, &rendered))
})
})
.await;
in_window(&desk, cx, |desk, window, cx| match page {
Ok(lost) => {
let job = Job {
@@ -289,7 +306,7 @@ impl Desk {
open,
lost,
};
desk.print_page(&staged, job, window, cx)
desk.print_page(n, &staged, job, window, cx)
}
Err(error) => desk.exported(Err(error), cx),
})
@@ -303,7 +320,7 @@ impl Desk {
}
/// Chromium prints the staged page; the PDF goes where asked.
fn print_page(&mut self, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
fn print_page(&mut self, n: u64, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
let root = page
.parent()
.expect("a staged page sits in a folder")
@@ -315,20 +332,20 @@ impl Desk {
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
Event::Loaded => view.read(cx).print_pdf(&job.pdf),
Event::Printed(path, ok) => {
desk.printing = None;
desk.printing.remove(&n);
let printed = ok
.then(|| path.clone())
.ok_or_else(|| io::Error::other("Chromium did not print the page"));
desk.printed(printed, job.clone(), cx);
}
Event::Failed(why) => {
desk.printing = None;
desk.printing.remove(&n);
desk.exported(Err(io::Error::other(why.clone())), cx);
}
Event::Query(_, reply) => reply.ok(""),
Event::Leave(_) => {}
});
self.printing = Some(Printing {
self.printing.insert(n, Printing {
_view: view,
_heard: heard,
});
+5 -1
View File
@@ -115,8 +115,12 @@ impl Desk {
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.finder = None;
if let Some(view) = self.active_view() {
// Every tab's page holds its own highlights.
let views: Vec<_> = self.core.tabs.iter().map(|tab| tab.view.clone()).collect();
for view in views {
view.update(cx, |view, cx| view.unfind(cx));
}
if let Some(view) = self.active_view() {
let focus = view.read(cx).focus.clone();
window.focus(&focus, cx);
}
+1
View File
@@ -15,6 +15,7 @@ mod places;
mod rename;
mod restore;
mod side;
mod tally;
mod text;
mod vcs;
mod view;
+2
View File
@@ -308,6 +308,8 @@ impl Desk {
return None;
}
tab.extra.base = disk.text.clone();
// The file holds the disk's text: it is saved.
tab.mark = crate::engine::mark(&disk.text);
tab.extra.restart_autosave();
// Its bytes (BOM, line ends) are what saves keep now.
tab.doc.lock().expect("the text lock").source = disk.clone();
+5
View File
@@ -75,6 +75,11 @@ pub fn open(
));
}
/// The disk's text in place; reports start a new generation.
pub fn refresh(view: &WebView, markdown: &str, load: u64) {
view.run_js(&format!("ely.refresh({}, {load})", json!(markdown)));
}
/// The same text in another mode; pending edits go first.
pub fn switch(view: &WebView, mode: Mode) {
view.run_js(&format!("ely.mode('{}')", mode.name()));
+39 -16
View File
@@ -37,6 +37,8 @@ struct Waiting {
kept: Kept,
real: Option<PathBuf>,
text: Option<(String, SystemTime)>,
/// The untitled tab made for it, once made.
tab: Option<u64>,
}
/// The session's files and what still waits to restore.
@@ -97,6 +99,7 @@ impl Keeping {
},
real: Some(real),
text: None,
tab: None,
}),
Err(error) => {
log::warn!("mark: a kept file does not open: {error}");
@@ -111,6 +114,7 @@ impl Keeping {
kept,
real: None,
text,
tab: None,
});
}
}
@@ -132,13 +136,16 @@ impl Keeping {
Ok((keeping, paths, missing))
}
/// The untitled tabs to make, with their names.
pub fn untitled(&self) -> Vec<String> {
let untitled = self
.waiting
.iter()
.filter(|waiting| waiting.kept.path.is_none());
untitled.map(|waiting| waiting.kept.name.clone()).collect()
/// The untitled tabs to make: their place here and name.
pub fn untitled(&self) -> Vec<(usize, String)> {
let waiting = self.waiting.iter().enumerate();
let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect()
}
/// Entry `at` belongs to untitled tab `tab`.
pub fn made(&mut self, at: usize, tab: u64) {
self.waiting[at].tab = Some(tab);
}
/// A clean end: no crash next time, no stale points.
@@ -177,6 +184,15 @@ fn clear(dir: &Path) -> io::Result<()> {
}
}
/// What tab `id` restores: by file, or untitled by number.
fn waiting_for(waiting: &[Waiting], real: Option<&PathBuf>, id: u64) -> Option<usize> {
waiting.iter().position(|waiting| match (&waiting.real, real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => waiting.tab == Some(id),
_ => false,
})
}
impl Desk {
/// A tab just shown takes its kept mode, place, text.
pub fn restore_shown(&mut self, id: u64, cx: &mut Context<Self>) {
@@ -190,16 +206,8 @@ impl Desk {
.session
.path()
.and_then(|path| std::fs::canonicalize(path).ok());
let untitled = tab.session.path().is_none();
let waiting = &mut self.keeping.waiting;
let Some(at) = waiting
.iter()
.position(|waiting| match (&waiting.real, &real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => untitled,
_ => false,
})
else {
let Some(at) = waiting_for(waiting, real.as_ref(), id) else {
return;
};
let Waiting {
@@ -392,4 +400,19 @@ mod tests {
let place = kept.place.expect("a place");
assert_eq!((place.line, place.column), (3, 0));
}
#[test]
fn an_untitled_copy_goes_to_the_tab_made_for_it() {
let untitled = |name: &str, tab: u64| super::Waiting {
kept: super::Kept { path: None, name: name.into(), mode: "visual".into(), place: None, slot: String::new() },
real: None,
text: None,
tab: Some(tab),
};
let waiting = [untitled("Untitled1", 4), untitled("Untitled2", 5)];
// The second tab shows first: it still takes its own.
assert_eq!(super::waiting_for(&waiting, None, 5), Some(1));
assert_eq!(super::waiting_for(&waiting, None, 4), Some(0));
assert_eq!(super::waiting_for(&waiting, None, 9), None, "a new tab takes nothing");
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Which page reports count, by page load and by version.
use std::collections::HashMap;
/// One page load's change counts and the renders it owes.
pub struct Tally<R> {
load: u64,
/// The newest change the page announced.
changed: u64,
/// The change whose text arrived.
reported: u64,
renders: HashMap<u64, R>,
asked: u64,
}
impl<R> Tally<R> {
pub fn new() -> Self {
Tally { load: 0, changed: 0, reported: 0, renders: HashMap::new(), asked: 0 }
}
pub fn load(&self) -> u64 {
self.load
}
pub fn changed(&self) -> u64 {
self.changed
}
/// Every change's text has arrived.
pub fn settled(&self) -> bool {
self.reported == self.changed
}
/// A reload or refresh: older reports lapse, owed renders end.
pub fn restart(&mut self) -> Vec<R> {
self.load += 1;
(self.changed, self.reported) = (0, 0);
self.lapse()
}
/// The renders owed, as the page fails or restarts.
pub fn lapse(&mut self) -> Vec<R> {
self.renders.drain().map(|(_, render)| render).collect()
}
/// Whether a report from page load `generation` still counts.
pub fn current(&self, generation: Option<u64>) -> bool {
generation.is_none_or(|load| load == self.load)
}
pub fn change(&mut self, version: u64) {
self.changed = self.changed.max(version);
}
/// Takes text `version` if it is the newest.
pub fn text(&mut self, version: u64) -> bool {
let newest = version >= self.reported;
if newest {
self.reported = version;
}
newest
}
/// Owes `render`; its number goes to the page.
pub fn ask(&mut self, render: R) -> u64 {
self.asked += 1;
self.renders.insert(self.asked, render);
self.asked
}
pub fn answered(&mut self, ask: u64) -> Option<R> {
self.renders.remove(&ask)
}
}
#[cfg(test)]
mod tests {
use super::Tally;
#[test]
fn a_key_from_before_a_refresh_leaves_it_settled() {
let mut tally = Tally::<()>::new();
let old = tally.load();
tally.restart();
// The renderer's change for the old text lands late.
if tally.current(Some(old)) {
tally.change(1);
}
assert!(tally.settled(), "an old change does not count");
// The refreshed page's own edits count again.
tally.change(1);
assert!(!tally.settled());
assert!(tally.text(1));
assert!(tally.settled());
}
#[test]
fn a_restart_ends_every_owed_render() {
let mut tally = Tally::new();
let ask = tally.ask("export");
assert_eq!(tally.restart(), vec!["export"]);
// Its late answer finds nobody waiting.
assert_eq!(tally.answered(ask), None);
}
#[test]
fn only_the_newest_text_counts() {
let mut tally = Tally::<()>::new();
tally.change(2);
assert!(tally.text(2));
assert!(!tally.text(1), "an older text is dropped");
assert!(tally.settled());
}
}
+70 -37
View File
@@ -43,14 +43,10 @@ pub struct Failed(pub String);
pub struct MarkView {
pub(crate) web: Entity<WebView>,
doc: Doc,
/// The newest change the page announced.
pub changed: u64,
/// The change whose text `doc` holds.
reported: u64,
/// Page loads, changes and owed renders.
tally: crate::tally::Tally<Rendered>,
/// The mark at the last load or save.
saved: String,
/// Page loads so far; a reload restarts its versions.
load: u64,
pub mode: Mode,
/// Headings as the shown editor reads them.
pub outline: Vec<Heading>,
@@ -70,9 +66,6 @@ pub struct MarkView {
pub place: Option<Place>,
/// The writing view, as the page applies it (MARK-09).
look: Value,
/// Renders asked of the page, by number.
renders: std::collections::HashMap<u64, Rendered>,
asked: u64,
pub focus: FocusHandle,
}
@@ -95,10 +88,8 @@ impl MarkView {
MarkView {
web,
doc,
changed: 0,
reported: 0,
tally: crate::tally::Tally::new(),
saved,
load: 0,
mode: if large { Mode::Source } else { Mode::Visual },
large,
outline: Vec::new(),
@@ -108,8 +99,6 @@ impl MarkView {
attachable: false,
place: None,
look: Value::Null,
renders: std::collections::HashMap::new(),
asked: 0,
folder,
focus,
}
@@ -117,7 +106,12 @@ impl MarkView {
/// Every change's text has arrived.
pub fn settled(&self) -> bool {
self.reported == self.changed
self.tally.settled()
}
/// The newest change the page announced.
pub fn changed(&self) -> u64 {
self.tally.changed()
}
/// The text's size now, in bytes.
@@ -190,8 +184,10 @@ impl MarkView {
doc.source = source;
}
self.saved = mark(&text);
(self.changed, self.reported) = (0, 0);
crate::page::call(self.web.read(cx), "refresh", &Value::from(text));
// Reports and renders from before belong to the old text.
let lapsed = self.tally.restart();
self.end_renders(lapsed, "the text was replaced from disk", cx);
crate::page::refresh(self.web.read(cx), &text, self.tally.load());
cx.emit(Reported);
}
@@ -213,9 +209,8 @@ impl MarkView {
done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static,
cx: &mut Context<Self>,
) {
self.asked += 1;
self.renders.insert(self.asked, Box::new(done));
let ask = serde_json::json!({ "kind": kind, "ask": self.asked });
let ask = self.tally.ask(Box::new(done));
let ask = serde_json::json!({ "kind": kind, "ask": ask });
crate::page::call(self.web.read(cx), "render", &ask);
}
@@ -240,7 +235,7 @@ impl MarkView {
drop(doc);
let mode = if self.large { Mode::Source } else { mode };
self.mode = mode;
let at = (mode, self.load, place);
let at = (mode, self.tally.load(), place);
crate::page::open(self.web.read(cx), &current, at, &self.folder, cx);
cx.emit(Reported);
}
@@ -262,23 +257,50 @@ impl MarkView {
match event {
// A reload shows the last text that arrived.
Event::Loaded => {
self.load += 1;
let lapsed = self.tally.restart();
self.end_renders(lapsed, "the page reloaded", cx);
self.shown = false;
(self.changed, self.reported) = (0, 0);
let text = self.doc.lock().expect("the text lock").current.clone();
let at = (self.mode, self.load, self.place);
let at = (self.mode, self.tally.load(), self.place);
crate::page::open(web.read(cx), &text, at, &self.folder, cx);
cx.emit(Reported);
}
Event::Query(said, reply) => self.query(said, reply, cx),
Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"),
Event::Failed(why) => cx.emit(Failed(why.clone())),
// A click on a link: web and mail open outside.
Event::Leave(url) if ely_office::outward(url) => {
log::info!("mark: opens a link outside");
cx.open_url(url);
}
Event::Leave(_) => {
log::info!("mark: a link of another kind was refused");
let said = ely_ui::i18n::text(cx, "office.link.refused", &[]).to_string();
cx.emit(Failed(said));
}
Event::Failed(why) => {
let lapsed = self.tally.lapse();
self.end_renders(lapsed, why, cx);
cx.emit(Failed(why.clone()));
}
Event::Printed(path, _) => {
log::error!("mark: an editor page printed {}", path.display())
}
}
}
/// Renders the old page will never answer end with `why`.
fn end_renders(&mut self, pending: Vec<Rendered>, why: &str, cx: &mut Context<Self>) {
if pending.is_empty() {
return;
}
log::warn!("mark: {} renders lost with the page", pending.len());
let why = why.to_string();
cx.defer(move |cx| {
for done in pending {
done(Err(why.clone()), cx);
}
});
}
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
let said: Value = match serde_json::from_str(said) {
Ok(said) => said,
@@ -287,10 +309,7 @@ impl MarkView {
return reply.fail("bad JSON");
}
};
if said["generation"]
.as_u64()
.is_some_and(|load| load != self.load)
{
if !self.tally.current(said["generation"].as_u64()) {
log::info!("mark: a report from an earlier page load was dropped");
return reply.ok("");
}
@@ -307,6 +326,10 @@ impl MarkView {
self.mode = mode;
cx.notify();
}
if let Some(what) = said["broke"].as_str() {
log::error!("mark: the visual editor broke ({what}); source shows");
cx.emit(Failed(ely_ui::i18n::text(cx, "mark.visual.broke", &[("what", what)]).to_string()));
}
self.shown = true;
if let Some(place) = self.waiting.take() {
crate::page::call(self.web.read(cx), "reveal", &place);
@@ -316,6 +339,14 @@ impl MarkView {
}
cx.emit(Reported);
}
(Some("failed"), ..) => {
let Some(what) = said["what"].as_str() else {
log::error!("mark: the page sent a failure without its name");
return reply.fail("bad failure");
};
log::error!("mark: the page built no editor ({what})");
cx.emit(Failed(ely_ui::i18n::text(cx, "mark.page.broke", &[("what", what)]).to_string()));
}
(Some("outline"), ..) => match headings(&said["items"]) {
Some(outline) => {
self.outline = outline;
@@ -351,7 +382,7 @@ impl MarkView {
(Some("table" | "inserted"), ..) => {}
(Some("rendered"), ..) => {
let ask = said["ask"].as_u64().unwrap_or_default();
let Some(done) = self.renders.remove(&ask) else {
let Some(done) = self.tally.answered(ask) else {
log::error!("mark: a render nobody asked for came back");
return reply.fail("unasked render");
};
@@ -379,16 +410,18 @@ impl MarkView {
}
},
(Some("changed"), Some(version), _) => {
self.changed = self.changed.max(version);
self.tally.change(version);
cx.emit(Reported);
}
// Only the newest text counts.
(Some("text"), Some(version), Some(text)) if version >= self.reported => {
self.doc.lock().expect("the text lock").current = text.to_owned();
self.reported = version;
cx.emit(Reported);
(Some("text"), Some(version), Some(text)) => {
if !self.tally.text(version) {
log::info!("mark: an older text was dropped");
} else {
self.doc.lock().expect("the text lock").current = text.to_owned();
cx.emit(Reported);
}
}
(Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"),
_ => {
log::error!("mark: the page asked something unknown");
return reply.fail("unknown request");
+22 -5
View File
@@ -81,7 +81,14 @@ async function build(text, mode) {
const place = document.createElement('div')
holder.append(place)
const edited = (write) => changed(() => front + write())
const made = await visual(place, body, mode === 'visual', texts, edited, edge)
let made
try {
made = await visual(place, body, mode === 'visual', texts, edited, edge)
} catch (error) {
// A broken editor falls back to source and says so.
console.error(`ely: the visual editor failed: ${error.name}`)
entry.broke = error.name
}
if (made) {
// Lines count from the file's top, front matter included.
const above = front ? front.split('\n').length - 1 : 0
@@ -119,13 +126,20 @@ async function show(text, want) {
asked = null
if (current) drop(current)
current = null
const entry = await build(text, want)
let entry
try {
entry = await build(text, want)
} catch (error) {
// Source failed too: tell the app, never blank.
if (turn === latest) tell({ kind: 'failed', generation, what: error.name })
return
}
if (turn !== latest) return drop(entry)
current = entry
entry.holder.hidden = false
document.getElementById('document').dataset.mode = entry.mode
entry.focus()
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback })
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback, broke: entry.broke ?? null })
// A newer request owns what is queued now.
if (turn !== latest) return
outline()
@@ -176,10 +190,12 @@ async function open(text, want, words, load, folder, place) {
}
/** Changed on disk: the caret and scroll stay near. */
async function refresh(text) {
async function refresh(text, load) {
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
clearTimeout(timer)
pending = null
generation = load
version = 0
times = [text]
at = 0
await show(text, current?.mode ?? 'visual')
@@ -243,7 +259,8 @@ function reveal(place) {
}
const shown =
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query)
if (shown === false) console.warn(`ely: nothing at ${JSON.stringify(place)}`)
// Logs keep no search text; a line number at most.
if (shown === false) console.warn('heading' in place ? 'ely: no such heading' : `ely: nothing at line ${place.line}`)
}
/** The app's find box hears where the search stands. */
+3 -8
View File
@@ -5,17 +5,12 @@ use gpui::Context;
use crate::view::{Failed, PdfView};
/// Addresses that may leave the document.
pub fn outward(uri: &str) -> bool {
["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme))
}
/// A target as typed: a page number or an address.
pub fn parse(typed: &str, pages: usize) -> Option<Target> {
let typed = typed.trim();
match typed.parse::<usize>() {
Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)),
Err(_) => outward(typed).then(|| Target::Web(typed.to_string())),
Err(_) => ely_office::outward(typed).then(|| Target::Web(typed.to_string())),
}
}
@@ -35,12 +30,12 @@ impl PdfView {
log::info!("pdf: follows a link to page {}", page + 1);
self.show(page, link.y, cx)
}
None if outward(&link.uri) => {
None if ely_office::outward(&link.uri) => {
log::info!("pdf: opens a link outside");
cx.open_url(&link.uri)
}
None => {
let text = crate::viewer::Viewer::text(cx, "pdf.link.refused", &[]);
let text = crate::viewer::Viewer::text(cx, "office.link.refused", &[]);
cx.emit(Failed(text.to_string()))
}
}
+30 -2
View File
@@ -31,7 +31,8 @@ actions!(
/// Insert and View; View checks what is on.
pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] {
let label = |key: &str| Deck::text(cx, key, &[]);
let insert = Menu::new(label("menu.insert")).items([
let media = ely_platform::media::BUILT.then(|| MenuItem::action(label("show.insert.media"), InsertMedia));
let insert = [
MenuItem::action(label("show.insert.text"), InsertTextBox),
MenuItem::separator(),
MenuItem::action(label("show.insert.rectangle"), InsertRectangle),
@@ -42,12 +43,16 @@ pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2]
MenuItem::action(label("show.insert.connector"), InsertConnector),
MenuItem::separator(),
MenuItem::action(label("show.insert.picture"), InsertPicture),
MenuItem::action(label("show.insert.media"), InsertMedia),
]
.into_iter()
.chain(media)
.chain([
MenuItem::action(label("show.insert.table"), InsertTable),
MenuItem::action(label("show.insert.chart"), InsertChart),
MenuItem::separator(),
MenuItem::action(label("show.insert.remark"), InsertRemark),
]);
let insert = Menu::new(label("menu.insert")).items(insert);
let view = Menu::new(label("menu.view")).items([
MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks),
MenuItem::separator(),
@@ -150,3 +155,26 @@ impl Deck {
self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx);
}
}
#[cfg(test)]
mod tests {
#[test]
fn insert_media_shows_only_where_it_plays() {
let cx = gpui::TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (offered, total) = cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
let media = crate::deck::Deck::text(cx, "show.insert.media", &[]);
let [insert, _] = super::menus((false, false, false), cx);
let names = insert.items.iter().filter_map(|item| match item {
gpui::MenuItem::Action { name, .. } => Some(name.clone()),
_ => None,
});
let names: Vec<_> = names.collect();
(names.iter().any(|name| *name == media), names.len())
});
assert_eq!(offered, ely_platform::media::BUILT);
assert!(total >= 11, "the rest stay: {total}");
}
}
+12
View File
@@ -128,6 +128,18 @@ pub enum Choice {
}
impl Tree {
/// Opens a read's dialog unless another opened since.
pub fn offer(&mut self, ask: Ask, cx: &mut Context<Self>) {
match (&self.ask, ask) {
(None, ask) => self.ask = Some(ask),
(Some(_), Ask::Failed { title, detail }) => {
self.toast(format!("{title}: {detail}").into(), Severity::Danger, cx)
}
(Some(_), _) => log::info!("tree: a later dialog stands; read dropped"),
}
cx.notify();
}
pub fn dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let this = cx.weak_entity();
let dismiss = {
+6 -2
View File
@@ -18,7 +18,8 @@ pub struct Ref {
/// The commit it names, through an annotated tag.
pub commit: String,
pub subject: String,
pub at: i64,
/// When made; a tag on a blob has none.
pub at: Option<i64>,
/// An annotated tag's own message.
pub message: Option<String>,
}
@@ -103,7 +104,10 @@ pub fn parse(bytes: &[u8]) -> io::Result<Vec<Ref>> {
track: (!upstream.is_empty()).then(|| track(tracking)).flatten(),
commit: if annotated { peeled } else { object }.to_string(),
subject: if annotated { peeled_subject } else { subject }.to_string(),
at: at.parse().map_err(|_| bad(record))?,
at: match at {
"" => None,
at => Some(at.parse().map_err(|_| bad(record))?),
},
message: annotated.then(|| subject.to_string()),
});
}
+5 -3
View File
@@ -215,10 +215,12 @@ impl Tree {
};
let result = self.merge_editor.read(cx).text().to_string();
let open = merging.takes.iter().filter(|taken| taken.is_none()).count();
if merging.takes.get(at).copied().flatten().is_some() {
let words = text(cx, "tree.conflict.taken", &[]);
return self.toast(words, Severity::Warning, cx);
}
// Hand edits that moved the markers end block choices.
if merging.takes.get(at).copied().flatten().is_some()
|| conflicts(&result, merging.size()).len() != open
{
if conflicts(&result, merging.size()).len() != open {
let words = text(cx, "tree.conflict.hand", &[]);
return self.toast(words, Severity::Warning, cx);
}
+10 -2
View File
@@ -29,6 +29,7 @@ pub fn sides(doing: Option<&Doing>, cx: &App) -> [SharedString; 2] {
Op::Rebase => ("tree.ours.rebase", "tree.theirs.rebase"),
Op::CherryPick => ("tree.ours.pick", "tree.theirs.pick"),
Op::Revert => ("tree.ours.revert", "tree.theirs.revert"),
Op::Am => ("tree.ours.am", "tree.theirs.am"),
};
[
text(cx, ours, &[("name", &doing.ours)]),
@@ -59,8 +60,14 @@ fn button(
impl Tree {
/// A stopped operation: continue, skip or abort.
pub fn op_alert(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let Some(Some(Ok(Some(doing)))) = self.active().map(|opened| opened.doing.as_ref()) else {
return None;
let doing = match self.active().map(|opened| opened.doing.as_ref()) {
Some(Some(Ok(Some(doing)))) => doing,
// A read that failed says so in Git's own words.
Some(Some(Err(error))) => {
let title = text(cx, "tree.op.unread", &[("error", error)]);
return Some(Alert::new("tree-op", Severity::Danger, title).into_any_element());
}
_ => return None,
};
let op = doing.op;
let key = match op {
@@ -68,6 +75,7 @@ impl Tree {
Op::Rebase => "tree.op.rebasing",
Op::CherryPick => "tree.op.picking",
Op::Revert => "tree.op.reverting",
Op::Am => "tree.op.applying",
};
let title = text(cx, key, &[("ours", &doing.ours), ("theirs", &doing.theirs)]);
let mut alert = Alert::new("tree-op", Severity::Warning, title)
+7
View File
@@ -74,6 +74,13 @@ impl Git {
checked(args, output)
}
/// HEAD names a commit; a new repository has none yet.
pub fn born(&self, dir: &Path) -> io::Result<bool> {
Ok(!self
.run_or_one(dir, &["rev-parse", "--verify", "-q", "HEAD"])?
.is_empty())
}
/// Exit 1 is an answer: files differ, key unset.
pub fn run_or_one(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
let output = self.command(dir, args).output()?;
+2
View File
@@ -95,6 +95,7 @@ fn graph(logged: &[Logged]) -> Vec<GraphRow> {
impl Tree {
pub fn set_side(&mut self, side: crate::tree::Side, cx: &mut Context<Self>) {
self.look = None;
self.side = side;
self.ensure_side(cx);
cx.notify();
@@ -278,6 +279,7 @@ impl Tree {
/// Reads a commit, or a stash with its untracked files.
pub fn pick_in(&mut self, whose: Whose, commit: &str, cx: &mut Context<Self>) {
self.look = None;
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
+10 -4
View File
@@ -34,7 +34,9 @@ pub fn bind(cx: &mut App) {
}
pub fn menus(cx: &App) -> Vec<Menu> {
vec![Menu::new(text(cx, "tree.menu.repository", &[])).items([
let terminal = crate::tools::TERMINAL
.then(|| MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal));
let items = [
MenuItem::action(text(cx, "tree.clone", &[]), CloneRepository),
MenuItem::action(text(cx, "tree.close", &[]), CloseRepository),
MenuItem::separator(),
@@ -43,11 +45,15 @@ pub fn menus(cx: &App) -> Vec<Menu> {
MenuItem::action(text(cx, "tree.net.push", &[]), Push),
MenuItem::action(text(cx, "tree.force.open", &[]), ForcePush),
MenuItem::separator(),
MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal),
MenuItem::action(text(cx, "tree.tool.reveal", &[]), Reveal),
]
.into_iter()
.chain(terminal)
.chain([
MenuItem::action(text(cx, crate::tools::REVEAL, &[]), Reveal),
MenuItem::action(text(cx, "tree.clean.open", &[]), CleanUntracked),
MenuItem::action(text(cx, "tree.tool.editor", &[]), ChooseEditor),
])]
]);
vec![Menu::new(text(cx, "tree.menu.repository", &[])).items(items)]
}
impl Tree {
+3
View File
@@ -66,6 +66,9 @@ impl Tree {
let task = cx.background_spawn(async move { ops::doing(&git, &root, &dir) });
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
if let Err(error) = &read {
log::error!("tree: what Git is doing did not read: {error}");
}
this.update(cx, |tree, cx| {
if let Some(opened) = tree
.opened_mut(id)
+12 -13
View File
@@ -123,7 +123,7 @@ impl Tree {
};
net.stopping = true;
if let Err(error) = net.stop.now() {
self.failed("tree.error.stop", &error.to_string(), cx);
self.failed("tree.error.stop_remote", &error.to_string(), cx);
}
cx.notify();
}
@@ -183,7 +183,7 @@ impl Tree {
if tree.active != Some(repo) {
return;
}
tree.ask = Some(match read {
let ask = match read {
Ok(list) if list.is_empty() => {
let words = text(cx, "tree.push.no_remote", &[]);
return tree.toast(words, Severity::Warning, cx);
@@ -198,8 +198,8 @@ impl Tree {
title: text(cx, "tree.remotes.unread", &[]),
detail: error.to_string().into(),
},
});
cx.notify();
};
tree.offer(ask, cx);
})
.ok();
})
@@ -234,14 +234,14 @@ impl Tree {
if tree.active != Some(repo) {
return;
}
tree.ask = Some(match plan {
let ask = match plan {
Ok(plan) => Ask::Force { repo, plan },
Err(error) => Ask::Failed {
title: text(cx, "tree.force.unready", &[]),
detail: error.to_string().into(),
},
});
cx.notify();
};
tree.offer(ask, cx);
})
.ok();
})
@@ -262,11 +262,10 @@ impl Tree {
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.stop_remote(id, cx)).ok();
});
Some(
Alert::new("tree-net", Severity::Info, verb)
.body(net.said.clone())
.action(stop.into_any_element())
.into_any_element(),
)
let alert = Alert::new("tree-net", Severity::Info, verb).body(net.said.clone());
if !crate::running::STOPS {
return Some(alert.into_any_element());
}
Some(alert.action(stop.into_any_element()).into_any_element())
}
}
+15 -1
View File
@@ -10,6 +10,8 @@ pub enum Op {
Rebase,
CherryPick,
Revert,
/// `git am`, applying mailed patches.
Am,
}
impl Op {
@@ -20,6 +22,7 @@ impl Op {
Op::Rebase => "rebase",
Op::CherryPick => "cherry-pick",
Op::Revert => "revert",
Op::Am => "am",
}
}
@@ -78,7 +81,18 @@ pub fn doing(git: &Git, root: &Path, git_dir: &Path) -> io::Result<Option<Doing>
let read = |name: &str| {
std::fs::read_to_string(git_dir.join(name)).map(|text| text.trim().to_string())
};
let doing = if has("rebase-merge") || has("rebase-apply") {
// `git am` keeps rebase-apply too, without an onto.
let doing = if has("rebase-apply/applying") {
Doing {
op: Op::Am,
ours: head(git, root)?,
theirs: format!(
"{}/{}",
read("rebase-apply/next")?,
read("rebase-apply/last")?
),
}
} else if has("rebase-merge") || has("rebase-apply") {
let dir = if has("rebase-merge") {
"rebase-merge"
} else {
+2
View File
@@ -45,6 +45,8 @@ impl Tree {
/// Shows `path` as listed in `section`.
pub fn show(&mut self, path: &str, section: ChangeSection, cx: &mut Context<Self>) {
let Some(repo) = self.active else { return };
// A new pick leaves any file history or blame.
self.look = None;
self.shown = Some(Shown {
repo,
section,
+4 -1
View File
@@ -94,12 +94,15 @@ impl Tree {
}
pub fn pick_place(&mut self, place: Place, cx: &mut Context<Self>) {
self.look = None;
match &place {
Place::Stash(oid) => self.pick_in(Whose::Stash, oid, cx),
Place::Reflog(moved) => self.pick_in(Whose::Reflog, &moved.oid, cx),
_ => {}
}
self.picked_place = Some(place);
if let Some(opened) = self.active.and_then(|id| self.opened_mut(id)) {
opened.picked_place = Some(place);
}
cx.notify();
}
+2 -2
View File
@@ -125,7 +125,7 @@ impl Tree {
// The picked entry itself, while it still names this commit.
let moved = match (
self.active().and_then(|o| o.places.read.as_ref()),
&self.picked_place,
self.active().and_then(|o| o.picked_place.as_ref()),
) {
(
Some(Read {
@@ -187,7 +187,7 @@ impl Tree {
let Some(opened) = self.active() else {
return none();
};
let (Some(read), Some(place)) = (&opened.places.read, &self.picked_place) else {
let (Some(read), Some(place)) = (&opened.places.read, &opened.picked_place) else {
return none();
};
let label = |key: &str| text(cx, key, &[]);
+3
View File
@@ -48,6 +48,9 @@ impl Moved {
const KEEP: usize = 200;
pub fn reflog(git: &Git, root: &Path) -> io::Result<Vec<Moved>> {
if !git.born(root)? {
return Ok(Vec::new());
}
// With a date mode, `%gd` carries when HEAD moved.
let format = "--format=%H%x00%gd%x00%gs";
let count = format!("-n{KEEP}");
+3 -3
View File
@@ -113,14 +113,14 @@ impl Tree {
if tree.active != Some(repo) {
return;
}
tree.ask = Some(match plan {
let ask = match plan {
Ok(plan) => Ask::Move { repo, plan },
Err(error) => Ask::Failed {
title: text(cx, "tree.op.failed", &[]),
detail: error.to_string().into(),
},
});
cx.notify();
};
tree.offer(ask, cx);
})
.ok();
})
+5 -2
View File
@@ -24,7 +24,7 @@ impl Tree {
pub fn refs_pane(&self, cx: &mut Context<Self>) -> AnyElement {
let found = self
.active()
.and_then(|opened| match (&opened.refs, &self.picked_ref) {
.and_then(|opened| match (&opened.refs, &opened.picked_ref) {
(Some(Ok(refs)), Some((kind, name))) => refs
.iter()
.find(|r| r.kind == *kind && r.name == *name)
@@ -50,7 +50,10 @@ impl Tree {
found.subject
),
)
.text(label("tree.ref.when"), when(found.at));
.text(
label("tree.ref.when"),
found.at.map_or_else(|| "—".into(), when),
);
if let Some(upstream) = &found.upstream {
let track = match found.track {
Some((ahead, behind)) => format!("{upstream} ↑{ahead} ↓{behind}"),
-2
View File
@@ -7,8 +7,6 @@ use std::{
use crate::{git::Git, running::Running};
/// How many repositories the recent list keeps.
/// A work tree and its Git directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Repo {
+3
View File
@@ -154,6 +154,9 @@ impl Stop {
}
}
/// Only Unix stops Git; elsewhere no stop shows.
pub const STOPS: bool = cfg!(unix);
/// Its own session: the group is Git and its helpers.
#[cfg(unix)]
fn terminate(pid: u32) -> io::Result<()> {
+23 -16
View File
@@ -89,7 +89,7 @@ pub fn places(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
let Some(read) = &opened.places.read else {
return div().into_any_element();
};
let picked = read_tree.picked_place.clone();
let picked = opened.picked_place.clone();
let label = |key: &str| text(cx, key, &[]);
let pick = |place: Place| {
let this = tree.downgrade();
@@ -124,26 +124,33 @@ pub fn places(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
Box::new(|t, _, cx| t.ask_force(cx)),
),
]);
let local = div().flex().flex_wrap().gap_1().children([
let terminal = crate::tools::TERMINAL.then(|| {
button(
tree,
"tree-terminal",
label("tree.tool.terminal"),
Box::new(|t, _, cx| t.open_terminal(cx)),
),
button(
tree,
"tree-reveal",
label("tree.tool.reveal"),
Box::new(|t, _, cx| t.reveal(cx)),
),
button(
tree,
"tree-clean",
label("tree.clean.open"),
Box::new(|t, _, cx| t.ask_clean(cx)),
),
]);
)
});
let local = div()
.flex()
.flex_wrap()
.gap_1()
.children(terminal)
.children([
button(
tree,
"tree-reveal",
label(crate::tools::REVEAL),
Box::new(|t, _, cx| t.reveal(cx)),
),
button(
tree,
"tree-clean",
label("tree.clean.open"),
Box::new(|t, _, cx| t.ask_clean(cx)),
),
]);
let mut out: Vec<AnyElement> = vec![net.into_any_element(), local.into_any_element()];
out.push(heading(
label("tree.places.stashes"),
+7 -4
View File
@@ -28,7 +28,7 @@ fn branch(found: &Ref) -> Branch {
ahead,
behind,
subject: found.subject.clone().into(),
when: when(found.at),
when: found.at.map_or_else(|| "—".into(), when),
}
}
@@ -37,7 +37,7 @@ fn tag(found: &Ref) -> GitTag {
name: found.name.clone().into(),
message: found.message.clone().map(Into::into),
commit: found.commit.clone().into(),
when: when(found.at),
when: found.at.map_or_else(|| "—".into(), when),
}
}
@@ -59,7 +59,7 @@ pub fn branches(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
}
Some(Ok(refs)) => refs,
};
let picked = read.picked_ref.clone();
let picked = opened.picked_ref.clone();
// A name shows picked only in its own kind's list.
let picked_in = move |kind: Kind| {
picked
@@ -72,7 +72,10 @@ pub fn branches(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
move |name: &gpui::SharedString, _: &mut gpui::Window, cx: &mut gpui::App| {
let name = name.to_string();
this.update(cx, |tree, cx| {
tree.picked_ref = Some((kind, name));
tree.look = None;
if let Some(opened) = tree.active.and_then(|id| tree.opened_mut(id)) {
opened.picked_ref = Some((kind, name));
}
cx.notify();
})
.ok();
+12
View File
@@ -64,6 +64,14 @@ pub fn worktrees(git: &Git, root: &Path) -> io::Result<Vec<Worktree>> {
}
}
found.extend(now);
// Real paths, as the open repository's root is.
for tree in &mut found {
match std::fs::canonicalize(&tree.path) {
Ok(real) => tree.path = real,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Ok(found)
}
@@ -135,6 +143,10 @@ pub struct LfsFile {
/// `git lfs ls-files` as JSON; `files` is null with none.
pub fn lfs(git: &Git, root: &Path) -> io::Result<Vec<LfsFile>> {
// LFS reads HEAD first; before a commit there is nothing.
if !git.born(root)? {
return Ok(Vec::new());
}
let out = git.run(root, &["lfs", "ls-files", "--long", "--json"])?;
let read: serde_json::Value = serde_json::from_slice(&out).map_err(io::Error::other)?;
let files = match &read["files"] {
+18
View File
@@ -428,3 +428,21 @@ fn marker_sizes_git_cannot_read_mean_seven() {
);
}
}
#[test]
fn a_tag_on_a_blob_has_no_date_and_spoils_no_list() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["tag", "blobtag", "HEAD:a.txt"]);
let refs = crate::branches::read(&git(), &root).expect("the list reads");
let tag = refs
.iter()
.find(|r| r.name == "blobtag")
.expect("the tag is listed");
assert_eq!(tag.at, None);
assert!(
refs.iter()
.filter(|r| r.name != "blobtag")
.all(|r| r.at.is_some())
);
}
+57
View File
@@ -182,3 +182,60 @@ fn twin_reflog_events_stay_apart() {
.collect();
assert_eq!(found, [2], "it moved one down, alone");
}
#[test]
fn a_shown_file_deleted_since_says_it_changed() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("gone.txt"), "x").expect("writes");
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
std::fs::remove_file(root.join("gone.txt")).expect("removes");
let write = Write::Clean {
files: clean.files,
dirs: clean.dirs,
ignored: false,
};
let error = run(&root, write).expect_err("refused");
assert!(
error
.to_string()
.contains("gone.txt changed since the preview"),
"{error}"
);
}
#[test]
fn a_pick_in_one_repository_stays_there() {
let (one, other) = (
tempfile::tempdir().expect("a temp dir"),
tempfile::tempdir().expect("a temp dir"),
);
let (first, second) = (committed(one.path()), committed(other.path()));
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, one.path());
for root in [&first, &second] {
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
}
settle(cx, |app| {
tree.read(app).repos.len() == 2 && branch(tree.read(app)).is_some()
});
let ids = cx.update(|_, app| {
tree.read(app)
.repos
.iter()
.map(|o| o.id)
.collect::<Vec<_>>()
});
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.activate(Some(ids[0]), cx);
tree.pick_place(crate::places::Place::Remote("origin".into()), cx);
tree.activate(Some(ids[1]), cx);
})
});
assert!(cx.update(|_, app| {
tree.read(app)
.active()
.is_some_and(|o| o.picked_place.is_none())
}));
}
+64
View File
@@ -256,3 +256,67 @@ fn a_pick_that_conflicts_stops_without_a_dialog() {
});
assert!(cx.update(|_, app| tree.read(app).ask.is_none()));
}
#[test]
fn a_git_am_stopped_on_a_conflict_says_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
let patch = sh(&root, &["format-patch", "-1", "side", "--stdout"]);
std::fs::write(dir.path().join("p.patch"), patch).expect("writes");
// `git am` stops on the conflict, as in a terminal.
let stopped = super::git().run(
&root,
&[
"am",
"--3way",
dir.path().join("p.patch").to_str().expect("utf-8"),
],
);
assert!(stopped.is_err(), "the patch conflicts");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
let op = |app: &App| match tree.read(app).active().and_then(|o| o.doing.clone()) {
Some(Ok(Some(doing))) => Some((doing.op, doing.theirs)),
_ => None,
};
settle(cx, |app| op(app).is_some());
assert_eq!(
cx.update(|_, app| op(app)),
Some((crate::ops::Op::Am, "1/1".to_string()))
);
}
#[test]
fn a_settled_conflict_says_so_when_picked_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
let shell = cx.update(|window, app| {
let shell = ely_ui::tool_root(
ely_platform::AppId::Tree,
tree.clone().into(),
Vec::new(),
window,
app,
);
tree.update(app, |tree, _| tree.root = shell.downgrade());
shell
});
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.merge("side", cx)));
settle(cx, |app| conflicts(tree.read(app)) == 1);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.show("a.txt", ChangeSection::Conflicted, cx)
})
});
settle(cx, |app| tree.read(app).merging.is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.take(0, Take::Theirs, cx)));
cx.update(|_, app| tree.update(app, |tree, cx| tree.take(0, Take::Ours, cx)));
let want = cx.update(|_, app| ely_ui::i18n::text(app, "tree.conflict.taken", &[]));
let toasts = cx.update(|_, app| shell.read(app).toasts(app));
assert!(toasts.iter().any(|(text, _)| *text == want), "{toasts:?}");
}
+38
View File
@@ -433,3 +433,41 @@ fn a_stash_with_an_empty_message_still_lists() {
assert_eq!(shown, ["", "named"]);
assert_eq!(list[0].oid, oid);
}
#[test]
fn a_new_repository_lists_no_moves_and_no_lfs_files() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("new");
crate::repo::init(&git(), &root).expect("init");
assert_eq!(
crate::recover::reflog(&git(), &root).expect("no reflog error"),
Vec::new()
);
assert_eq!(
crate::structure::lfs(&git(), &root).expect("no LFS error"),
Vec::new()
);
}
#[test]
fn a_worktree_registered_through_a_link_lists_its_real_path() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let other = dir.path().join("tree");
let path = other.to_str().expect("utf-8");
run(
&root,
Write::Git(words(&["worktree", "add", "-b", "side", "--", path])),
)
.expect("adds");
// Other tools may write the path through a link.
std::os::unix::fs::symlink(dir.path(), dir.path().join("link")).expect("links");
let gitdir = root.join(".git/worktrees/tree/gitdir");
std::fs::write(
&gitdir,
format!("{}\n", dir.path().join("link/tree/.git").display()),
)
.expect("writes");
let list = structure::worktrees(&git(), &root).expect("reads");
assert_eq!(list[1].path, std::fs::canonicalize(&other).expect("real"));
}
+32 -1
View File
@@ -343,7 +343,12 @@ fn a_picked_reflog_event_stays_itself_as_others_arrive() {
panic!("a reflog event");
};
assert!(now.same(was), "the event moved to HEAD@{{2}}");
assert!(cx.update(|_, app| tree.read(app).picked_place.as_ref() == Some(&picked)));
assert!(cx.update(|_, app| {
tree.read(app)
.active()
.and_then(|o| o.picked_place.as_ref())
== Some(&picked)
}));
}
#[test]
@@ -383,3 +388,29 @@ fn a_held_config_lock_waits_for_a_retry_too() {
String::from_utf8_lossy(&read).trim() == "Locked Out"
});
}
#[test]
fn a_read_that_lands_late_leaves_the_users_dialog_open() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let head = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.ask_move(&head, cx);
tree.ask = Some(Ask::Failed {
title: "mine".into(),
detail: "".into(),
});
})
});
cx.run_until_parked();
let ask = cx.update(|_, app| match &tree.read(app).ask {
Some(Ask::Failed { title, .. }) => title.to_string(),
_ => "replaced".into(),
});
assert_eq!(ask, "mine");
}
+141
View File
@@ -320,3 +320,144 @@ fn a_commit_from_the_box_shows_in_history_with_its_files() {
};
settle(cx, file);
}
#[test]
fn any_new_pick_leaves_a_file_history() {
use crate::tree::Side;
use ely_gpui_component::git::ChangeSection;
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let head = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
let looking = |cx: &mut VisualTestContext| cx.update(|_, app| tree.read(app).look.is_some());
let picks: [(&str, Box<dyn Fn(&mut Tree, &mut gpui::Context<Tree>)>); 3] = [
(
"a tab",
Box::new(|tree, cx| tree.set_side(Side::Branches, cx)),
),
(
"a commit",
Box::new(move |tree, cx| tree.pick_commit(&head, cx)),
),
(
"a file",
Box::new(|tree, cx| tree.show("a.txt", ChangeSection::Unstaged, cx)),
),
];
for (what, pick) in picks {
cx.update(|_, app| tree.update(app, |tree, cx| tree.file_log("a.txt", cx)));
assert!(looking(cx), "the history opens");
cx.update(|_, app| tree.update(app, |tree, cx| pick(tree, cx)));
assert!(!looking(cx), "{what} leaves the history");
}
}
#[test]
fn a_result_that_did_not_save_says_so() {
use crate::work::Write;
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
// The window's toasts live on its root.
let shell = cx.update(|window, app| {
let shell = ely_ui::tool_root(
ely_platform::AppId::Tree,
tree.clone().into(),
Vec::new(),
window,
app,
);
tree.update(app, |tree, _| tree.root = shell.downgrade());
shell
});
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let id = cx.update(|_, app| tree.read(app).active().expect("open").id);
let save = Write::Save {
path: "a.txt".into(),
text: "merged".into(),
};
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.written(
id,
&save,
Err(std::io::Error::other("disk full")),
false,
cx,
)
})
});
let want =
cx.update(|_, app| ely_ui::i18n::text(app, "tree.error.save", &[("error", "disk full")]));
let toasts = cx.update(|_, app| shell.read(app).toasts(app));
assert!(toasts.iter().any(|(text, _)| *text == want), "{toasts:?}");
}
#[test]
fn unstaging_right_after_a_first_commit_deletes_nothing() {
use crate::work::Write;
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("new");
crate::repo::init(&super::git(), &root).expect("init");
std::fs::write(root.join("a.txt"), "one\n").expect("writes");
sh(&root, &["add", "a.txt"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| {
tree.read(app)
.active()
.is_some_and(|opened| matches!(opened.status, Some(Ok(_))))
});
let id = cx.update(|_, app| tree.read(app).active().expect("open").id);
// Committed outside; the status has not read it yet.
sh(&root, &["commit", "-qm", "first"]);
cx.update(|_, app| tree.update(app, |tree, cx| tree.write(id, Write::UnstageAll, cx)));
settle(cx, |app| {
tree.read(app)
.active()
.is_some_and(|opened| !opened.writing && opened.writes.is_empty())
});
assert_eq!(
sh(&root, &["status", "--porcelain"]),
"",
"nothing staged for deletion"
);
}
#[test]
fn the_repository_menu_offers_only_what_this_platform_builds() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_tree, cx) = window(&mut cx, dir.path());
let names = cx.update(|_, app| {
crate::menu::menus(app)[0]
.items
.iter()
.filter_map(|item| match item {
gpui::MenuItem::Action { name, .. } => Some(name.to_string()),
_ => None,
})
.collect::<Vec<_>>()
});
let mut said = |key: &str| cx.update(|_, app| ely_ui::i18n::text(app, key, &[]).to_string());
let (terminal, reveal, clone) = (
said("tree.tool.terminal"),
said(crate::tools::REVEAL),
said("tree.clone"),
);
assert_eq!(
names.contains(&terminal),
crate::tools::TERMINAL,
"{names:?}"
);
assert!(
names.contains(&reveal) && names.contains(&clone),
"{names:?}"
);
}
+10
View File
@@ -97,6 +97,16 @@ fn launch(editor: &Path, file: &Path) -> io::Result<()> {
.map_err(|error| io::Error::other(format!("{}: {error}", editor.display())))
}
/// Only macOS opens a terminal; elsewhere it is hidden.
pub const TERMINAL: bool = cfg!(target_os = "macos");
/// The reveal entry, named for this platform's file browser.
pub const REVEAL: &str = if cfg!(target_os = "macos") {
"tree.tool.reveal"
} else {
"tree.tool.reveal_folder"
};
#[cfg(target_os = "macos")]
fn terminal_at(dir: &Path) -> io::Result<()> {
Command::new("open")
+5 -5
View File
@@ -54,6 +54,9 @@ pub struct Opened {
/// Identity reads started; only the latest lands.
pub me_asks: u64,
pub places: crate::places::Places,
/// The branch or tag picked in the Branches panel.
pub picked_ref: Option<(crate::branches::Kind, String)>,
pub picked_place: Option<crate::places::Place>,
pub net: Option<crate::network::Net>,
/// Kept alive; dropping it stops the watch.
_watcher: Option<RecommendedWatcher>,
@@ -103,9 +106,6 @@ pub struct Tree {
pub amend_loading: bool,
/// File history and blame opened, numbering each.
pub looks: u64,
/// The branch or tag picked in the Branches panel.
pub picked_ref: Option<(crate::branches::Kind, String)>,
pub picked_place: Option<crate::places::Place>,
/// The conflicted file being merged by hand.
pub merging: Option<crate::conflict::Merging>,
pub merge_editor: Entity<ely_gpui_component::editor::CodeEditor>,
@@ -220,8 +220,6 @@ impl Tree {
amend_ask: 0,
amend_loading: false,
looks: 0,
picked_ref: None,
picked_place: None,
merging: None,
merge_editor,
_searching: searching,
@@ -364,6 +362,8 @@ impl Tree {
me: None,
me_asks: 0,
places: Default::default(),
picked_ref: None,
picked_place: None,
net: None,
_watcher: watcher,
});
+7 -5
View File
@@ -168,16 +168,18 @@ impl Tree {
}
if let Some(cloning) = &self.cloning {
let this = cx.weak_entity();
let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]);
let state = EmptyState::new("tree-cloning", IconName::Download, title)
.body(cloning.said.clone());
if !crate::running::STOPS {
return state.into_any_element();
}
let stop = Button::new("tree-clone-stop", text(cx, "tree.clone.stop", &[]))
.disabled(cloning.stopping)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.stop_clone(cx)).ok();
});
let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]);
return EmptyState::new("tree-cloning", IconName::Download, title)
.body(cloning.said.clone())
.action(stop)
.into_any_element();
return state.action(stop).into_any_element();
}
let Some(opened) = self.active() else {
return self.start_page(cx);
+22 -5
View File
@@ -267,7 +267,17 @@ fn as_shown(root: &std::path::Path, path: &str, folder: bool) -> io::Result<()>
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
for (ix, part) in parts.iter().enumerate() {
at.push(part);
let kind = std::fs::symlink_metadata(&at)?.file_type();
// Gone since the preview is a change; others name paths.
let kind = match std::fs::symlink_metadata(&at) {
Ok(meta) => meta.file_type(),
Err(error) if error.kind() == io::ErrorKind::NotFound => return Err(changed()),
Err(error) => {
return Err(io::Error::new(
error.kind(),
format!("{}: {error}", at.display()),
));
}
};
let last = ix + 1 == parts.len();
if (!last && !kind.is_dir()) || (last && kind.is_dir() != folder) {
return Err(changed());
@@ -349,15 +359,18 @@ impl Tree {
let Some(write) = opened.writes.pop_front() else {
return;
};
let born = matches!(&opened.status, Some(Ok(status)) if status.head.oid.is_some());
opened.writing = true;
if let Write::Remote(args) = write {
return self.start_remote(id, args, cx);
}
let (root, args) = (opened.repo.root.clone(), write.args(born));
log::info!("tree: {:?} in {}", args.first(), root.display());
let root = opened.repo.root.clone();
let task = cx.background_spawn(async move {
let done = run(&git, &root, &write, &args);
// Asked now, not cached: a commit may just have landed.
let done = git.born(&root).and_then(|born| {
let args = write.args(born);
log::info!("tree: {:?} in {}", args.first(), root.display());
run(&git, &root, &write, &args)
});
let stopped = match (&write, &done) {
(Write::Git(args), Err(error)) => {
ops::stopped(&git, &root, args, &error.to_string())
@@ -411,6 +424,10 @@ impl Tree {
}
(Write::Config { .. }, Ok(())) => self.read_me(id, cx),
(Write::Save { path, text }, Ok(())) => self.merge_saved(id, path, text, cx),
// A file write, not Git: the result did not save.
(Write::Save { .. }, Err(error)) => {
self.failed("tree.error.save", &error.to_string(), cx)
}
// A conflict stop is no failure; Git says which.
(
Write::Git(_)