Fixes for M-02 to M-14, G-01 to G-14, K-02 to K-04, X-04 to X-14, not yet reviewed
Committed before review so Windows and WSL builds can start. Each fix was tested and mutation-checked here; the codex review of each item is still to come, so tasks/BUGS.md stays unticked. M-02 is mid-review: round 1 findings are fixed (apps/ely-mark/src/tally.rs). K-01 stays deferred. The ElyMark window test mark-outside.sh failed once at its crash-recovery step in the last run; not yet looked into.
This commit is contained in:
75 files changed
+1482
-354
No files matched your search
@@ -30,7 +30,7 @@ patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
|
||||
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
|
||||
assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README
|
||||
dist/ 打包产物,不入库
|
||||
docs/ 工程风险与已知限制
|
||||
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
|
||||
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
|
||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
|
||||
tasks/ TASKS.md 进度真相
|
||||
@@ -192,7 +192,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
|
||||
- 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
|
||||
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high;本机 `codex` 别名已带 `--yolo`,重复传参会报错)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。
|
||||
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定Line truncated
|
||||
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定Line truncated
|
||||
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
|
||||
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
|
||||
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ mod places;
|
||||
mod rename;
|
||||
mod restore;
|
||||
mod side;
|
||||
mod tally;
|
||||
mod text;
|
||||
mod vcs;
|
||||
mod view;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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), ¤t, 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");
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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, &[]);
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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())
|
||||
}));
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Crash {
|
||||
pub fn open(dir: &Path, pid: u32) -> io::Result<Self> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let file = dir.join("session.lock");
|
||||
std::fs::write(&file, format!("open {pid}"))?;
|
||||
write_whole(&file, &format!("open {pid}"))?;
|
||||
Ok(Self { file })
|
||||
}
|
||||
|
||||
@@ -34,28 +34,24 @@ impl Crash {
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut parts = text.split_whitespace();
|
||||
match (parts.next(), parts.next()) {
|
||||
match (parts.next(), parts.next().map(str::parse::<u32>)) {
|
||||
(Some("discarded"), _) => Ok(Marker::Discarded),
|
||||
(Some("open"), Some(pid)) => {
|
||||
let pid: u32 = pid.parse().map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "bad pid in the session lock")
|
||||
})?;
|
||||
Ok(if alive(pid) {
|
||||
Marker::Clean
|
||||
} else {
|
||||
Marker::Crashed
|
||||
})
|
||||
(Some("open"), Some(Ok(pid))) => Ok(if alive(pid) {
|
||||
Marker::Clean
|
||||
} else {
|
||||
Marker::Crashed
|
||||
}),
|
||||
// A torn lock means no clean end: offer the points.
|
||||
_ => {
|
||||
log::warn!("recovery: the session lock is unreadable; treated as a crash");
|
||||
Ok(Marker::Crashed)
|
||||
}
|
||||
_ => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"bad session lock",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit discard: never a crash next launch (§6.2).
|
||||
pub fn discard(self) -> io::Result<()> {
|
||||
std::fs::write(&self.file, "discarded 0")
|
||||
write_whole(&self.file, "discarded 0")
|
||||
}
|
||||
|
||||
/// A clean close removes the lock.
|
||||
@@ -68,6 +64,15 @@ impl Crash {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces `file` whole: a power cut leaves old or new.
|
||||
fn write_whole(file: &Path, text: &str) -> io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
let mut staging = tempfile::NamedTempFile::new_in(file.parent().unwrap_or(Path::new(".")))?;
|
||||
staging.write_all(text.as_bytes())?;
|
||||
staging.as_file().sync_all()?;
|
||||
staging.persist(file).map(drop).map_err(|error| error.error)
|
||||
}
|
||||
|
||||
/// Whether process `pid` still runs; a crash leaves none.
|
||||
#[cfg(unix)]
|
||||
pub fn alive(pid: u32) -> bool {
|
||||
|
||||
@@ -53,10 +53,13 @@ pub struct RecoveryKey {
|
||||
}
|
||||
|
||||
impl RecoveryKey {
|
||||
/// The stored key, else this session's; never a file.
|
||||
/// The stored key, else this session's; read once a run.
|
||||
pub fn load() -> Self {
|
||||
let dir = account_home().map(|home| shared_dir(&home));
|
||||
Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir)))
|
||||
static LOADED: std::sync::OnceLock<RecoveryKey> = std::sync::OnceLock::new();
|
||||
*LOADED.get_or_init(|| {
|
||||
let dir = account_home().map(|home| shared_dir(&home));
|
||||
Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The loaded key, or this session's when loading failed.
|
||||
|
||||
@@ -163,6 +163,11 @@ impl Recovery {
|
||||
|
||||
/// Every point on disk, newest first.
|
||||
pub fn points(&self) -> io::Result<Vec<RecoveryPoint>> {
|
||||
self.stamps()?.into_iter().map(|stamp| self.point(stamp)).collect()
|
||||
}
|
||||
|
||||
/// The points' stamps by name alone, newest first.
|
||||
fn stamps(&self) -> io::Result<Vec<u128>> {
|
||||
let dir = self.dir.join("points");
|
||||
let mut stamps = Vec::new();
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
@@ -183,16 +188,13 @@ impl Recovery {
|
||||
}
|
||||
stamps.sort_unstable_by(|a, b| b.cmp(a));
|
||||
stamps.dedup();
|
||||
stamps
|
||||
.into_iter()
|
||||
.map(|stamp| self.point(stamp))
|
||||
.collect::<io::Result<_>>()
|
||||
Ok(stamps)
|
||||
}
|
||||
|
||||
/// The point one stamp names.
|
||||
fn point(&self, stamp: u128) -> io::Result<RecoveryPoint> {
|
||||
let dir = self.dir.join("points");
|
||||
let at = SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64);
|
||||
let at = at_of(stamp);
|
||||
let enc = dir.join(format!("{stamp}.enc"));
|
||||
if enc.try_exists()? {
|
||||
let cursor = match self.key {
|
||||
@@ -223,26 +225,26 @@ impl Recovery {
|
||||
|
||||
/// Drops the oldest points past age, count and byte limits.
|
||||
fn prune(&self, now: SystemTime) -> io::Result<()> {
|
||||
let mut points = self.points()?;
|
||||
while points.len() > self.keep
|
||||
|| points.last().is_some_and(|point| {
|
||||
now.duration_since(point.at)
|
||||
// Names only: a sealed point is never opened here.
|
||||
let mut stamps = self.stamps()?;
|
||||
while stamps.len() > self.keep
|
||||
|| stamps.last().is_some_and(|stamp| {
|
||||
now.duration_since(at_of(*stamp))
|
||||
.map(|age| age > self.max_age)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
|| self.bytes(&points)? > self.cap
|
||||
|| self.bytes(&stamps)? > self.cap
|
||||
{
|
||||
let Some(oldest) = points.pop() else { break };
|
||||
self.drop(oldest.at)?;
|
||||
let Some(oldest) = stamps.pop() else { break };
|
||||
self.drop(oldest)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The total size of every file of every live point.
|
||||
fn bytes(&self, points: &[RecoveryPoint]) -> io::Result<u64> {
|
||||
fn bytes(&self, stamps: &[u128]) -> io::Result<u64> {
|
||||
let mut total = 0;
|
||||
for point in points {
|
||||
let stamp = stamp_of(point.at)?;
|
||||
for stamp in stamps {
|
||||
let dir = self.dir.join("points");
|
||||
for ext in ["bin", "cursor", "history", "enc"] {
|
||||
let path = dir.join(format!("{stamp}.{ext}"));
|
||||
@@ -257,8 +259,7 @@ impl Recovery {
|
||||
}
|
||||
|
||||
/// Removes a point's files, its metadata first.
|
||||
fn drop(&self, at: SystemTime) -> io::Result<()> {
|
||||
let stamp = stamp_of(at)?;
|
||||
fn drop(&self, stamp: u128) -> io::Result<()> {
|
||||
let dir = self.dir.join("points");
|
||||
for ext in ["cursor", "history", "bin", "enc"] {
|
||||
match std::fs::remove_file(dir.join(format!("{stamp}.{ext}"))) {
|
||||
@@ -272,8 +273,8 @@ impl Recovery {
|
||||
|
||||
/// An explicit discard: points go, the result stays recorded (§6.2).
|
||||
pub fn discard(&mut self) -> io::Result<()> {
|
||||
for point in self.points()? {
|
||||
self.drop(point.at)?;
|
||||
for stamp in self.stamps()? {
|
||||
self.drop(stamp)?;
|
||||
}
|
||||
std::fs::write(self.dir.join(".discarded"), "discarded")?;
|
||||
self.last_ok = None;
|
||||
@@ -304,6 +305,11 @@ impl Recovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time a stamp names.
|
||||
fn at_of(stamp: u128) -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64)
|
||||
}
|
||||
|
||||
/// The point's timestamp as its file stem.
|
||||
fn stamp_of(at: SystemTime) -> io::Result<u128> {
|
||||
Ok(at
|
||||
|
||||
@@ -272,6 +272,18 @@ fn a_discard_drops_points_and_records_it() {
|
||||
assert!(!recovery.discarded().expect("the marker clears"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_torn_lock_counts_as_a_crash() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
for torn in ["", "op", "open x"] {
|
||||
std::fs::write(dir.path().join("session.lock"), torn).expect("writes");
|
||||
assert_eq!(Crash::check(dir.path(), |_| true).expect("reads"), Marker::Crashed, "{torn:?}");
|
||||
}
|
||||
// Opening again rewrites it whole.
|
||||
Crash::open(dir.path(), 7).expect("opens");
|
||||
assert_eq!(Crash::check(dir.path(), |pid| pid == 7).expect("reads"), Marker::Clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lock_tells_crash_from_discard() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
@@ -393,3 +405,15 @@ fn the_task_status_starts_from_the_recovery() {
|
||||
assert_eq!(task.last_ok(), Some(at(100)));
|
||||
task.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_sealed_points_opens_none_of_them() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut old = Recovery::sensitive(dir.path().to_path_buf(), key()).with_limits(2, u64::MAX);
|
||||
old.write_once(b"one", Some("1:1"), at(100)).expect("lands");
|
||||
// Another run's key cannot open the older points.
|
||||
let mut now = Recovery::sensitive(dir.path().to_path_buf(), [9u8; 32]).with_limits(2, u64::MAX);
|
||||
now.write_once(b"two", Some("2:2"), at(200)).expect("pruning reads names only");
|
||||
now.write_once(b"three", None, at(300)).expect("and drops the oldest unread");
|
||||
assert_eq!(now.stamps().expect("names").len(), 2);
|
||||
}
|
||||
@@ -28,12 +28,18 @@ pub fn relay(stderr: impl Read + Send + 'static, who: &'static str) -> Relay {
|
||||
let kept = Arc::clone(&last);
|
||||
let thread = std::thread::spawn(move || {
|
||||
let mut withheld = 0u64;
|
||||
for line in BufReader::new(stderr).lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
let (mut reader, mut bytes) = (BufReader::new(stderr), Vec::new());
|
||||
loop {
|
||||
bytes.clear();
|
||||
match reader.read_until(b'\n', &mut bytes) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(error) => return log::warn!(target: who, "stderr unreadable: {error}"),
|
||||
};
|
||||
match parse(&line) {
|
||||
}
|
||||
// Engines may print other encodings; keep reading past them.
|
||||
let line = String::from_utf8_lossy(&bytes);
|
||||
let line = line.trim_end_matches(['\n', '\r']);
|
||||
match parse(line) {
|
||||
Some((level, target, message)) => {
|
||||
if level == log::Level::Error {
|
||||
*kept.lock().expect("the relay lock") = Some(message.to_string());
|
||||
@@ -41,7 +47,7 @@ pub fn relay(stderr: impl Read + Send + 'static, who: &'static str) -> Relay {
|
||||
log::log!(target: target, level, "{message}")
|
||||
}
|
||||
// A crash stack names code, never documents.
|
||||
None if frame(&line) => log::error!(target: who, "{line}"),
|
||||
None if frame(line) => log::error!(target: who, "{line}"),
|
||||
None => {
|
||||
withheld += 1;
|
||||
log::debug!(target: who, "native output withheld ({withheld} lines)");
|
||||
@@ -79,6 +85,13 @@ mod tests {
|
||||
assert_eq!(parse("warning: syntax error: unknown keyword: 'Zq'"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_in_another_encoding_stops_nothing() {
|
||||
let stderr: &[u8] = b"caf\xe9 opened\n[2026-10-08T12:00:00Z ERROR ely_pdf::worker] it broke\n";
|
||||
let mut relay = super::relay(stderr, "test");
|
||||
assert_eq!(relay.last_error().as_deref(), Some("it broke"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_frames_pass_and_engine_text_does_not() {
|
||||
assert!(frame("3 ely-lok-worker 0x0000000104f3c960 report + 52"));
|
||||
|
||||
@@ -15,8 +15,8 @@ pub use cell::{Cell, Function, Mark, Request};
|
||||
use cell::{functions_of, marks_of, request_of, usage_of};
|
||||
pub use parse::A11y;
|
||||
use parse::{
|
||||
Dialog, a11y_update, dialog_of, fonts_of, formula_text, found_of, rect_of, result_of, state_of,
|
||||
tabs_of,
|
||||
Dialog, Unreadable, a11y_update, dialog_of, fonts_of, formula_text, found_of, rect_of,
|
||||
result_of, state_of, tabs_of,
|
||||
};
|
||||
|
||||
/// LOK_CALLBACK kinds Ely reads.
|
||||
@@ -205,22 +205,36 @@ unsafe extern "C" fn on_event(kind: i32, payload: *const c_char, data: *mut c_vo
|
||||
signal().notify_all();
|
||||
}
|
||||
|
||||
/// A rectangle payload; an unreadable one is a fault.
|
||||
fn rect_in(batch: &mut Events, what: &str, text: &str) -> Option<Rect> {
|
||||
rect_of(text).unwrap_or_else(|Unreadable| {
|
||||
batch.faults.push(format!("an unreadable {what}: {text}"));
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Folds one callback in; returns a dialog to close.
|
||||
fn record(slot: &mut Slot, kind: i32, text: &str) -> Option<u32> {
|
||||
let batch = &mut slot.batch;
|
||||
match kind {
|
||||
INVALIDATE_TILES => {
|
||||
batch.generation += 1;
|
||||
match rect_of(text) {
|
||||
match rect_in(batch, "invalidation", text) {
|
||||
Some(rect) => batch.rects.push(rect),
|
||||
None => batch.all = true,
|
||||
}
|
||||
}
|
||||
CURSOR => batch.cursor = rect_of(text),
|
||||
SELECTION => batch.selection = text.split(';').filter_map(rect_of).collect(),
|
||||
CURSOR => batch.cursor = rect_in(batch, "cursor", text),
|
||||
SELECTION => {
|
||||
let parts: Vec<&str> = text.split(';').collect();
|
||||
batch.selection = parts
|
||||
.into_iter()
|
||||
.filter_map(|part| rect_in(batch, "selection", part))
|
||||
.collect();
|
||||
}
|
||||
CURSOR_VISIBLE => batch.cursor_visible = text == "true",
|
||||
GRAPHIC => {
|
||||
batch.graphic = rect_of(text);
|
||||
batch.graphic = rect_in(batch, "graphic selection", text);
|
||||
batch.cropping = batch.graphic.is_some() && text.contains("\"isCropMode\": true");
|
||||
}
|
||||
STATE => {
|
||||
@@ -247,7 +261,7 @@ fn record(slot: &mut Slot, kind: i32, text: &str) -> Option<u32> {
|
||||
.push(format!("an unreadable search result: {text}")),
|
||||
},
|
||||
MENU => batch.menu = Some(text.to_string()),
|
||||
CELL_CURSOR => batch.cell.cursor = rect_of(text),
|
||||
CELL_CURSOR => batch.cell.cursor = rect_in(batch, "cell cursor", text),
|
||||
CELL_FORMULA => batch.cell.formula = text.to_string(),
|
||||
CELL_ADDRESS => batch.cell.address = text.to_string(),
|
||||
REFERENCE_MARKS => match marks_of(text) {
|
||||
|
||||
@@ -209,7 +209,7 @@ pub fn marks_of(text: &str) -> Option<Vec<Mark>> {
|
||||
.iter()
|
||||
.map(|mark| {
|
||||
Some(Mark {
|
||||
rect: super::parse::rect_of(mark["rectangle"].as_str()?)?,
|
||||
rect: super::parse::rect_of(mark["rectangle"].as_str()?).ok()??,
|
||||
color: u32::from_str_radix(mark["color"].as_str()?, 16).ok()?,
|
||||
part: mark["part"].as_str()?.parse().ok()?,
|
||||
})
|
||||
|
||||
@@ -21,16 +21,19 @@ pub enum Dialog {
|
||||
Titled { window: u32, title: String },
|
||||
}
|
||||
|
||||
/// Parses "x, y, w, h" plus optional fields.
|
||||
pub fn rect_of(text: &str) -> Option<Rect> {
|
||||
let parts: Vec<i32> = text
|
||||
.split(',')
|
||||
.filter_map(|part| part.trim().parse().ok())
|
||||
.collect();
|
||||
match parts.as_slice() {
|
||||
[x, y, w, h, ..] => Some((*x, *y, *w, *h)),
|
||||
_ => None,
|
||||
/// A payload that is neither a rectangle nor empty.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Unreadable;
|
||||
|
||||
/// "x, y, w, h" and later fields; "EMPTY" is none.
|
||||
pub fn rect_of(text: &str) -> Result<Option<Rect>, Unreadable> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() || text.starts_with("EMPTY") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut parts = text.split(',').map(|part| part.trim().parse::<i32>());
|
||||
let mut next = || parts.next().and_then(Result::ok).ok_or(Unreadable);
|
||||
Ok(Some((next()?, next()?, next()?, next()?)))
|
||||
}
|
||||
|
||||
/// A search's matches, each by its first rectangle.
|
||||
@@ -39,7 +42,7 @@ pub fn found_of(text: &str) -> Option<Vec<Rect>> {
|
||||
json["searchResultSelection"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|found| rect_of(found["rectangles"].as_str()?.split(';').next()?))
|
||||
.map(|found| rect_of(found["rectangles"].as_str()?.split(';').next()?).ok()?)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -189,6 +192,15 @@ pub fn a11y_update(a11y: &mut A11y, kind: i32, text: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_reads_strictly() {
|
||||
assert_eq!(rect_of("1, 2, 3, 4, 0, 5"), Ok(Some((1, 2, 3, 4))));
|
||||
assert_eq!(rect_of("EMPTY, 0, 0"), Ok(None));
|
||||
assert_eq!(rect_of(""), Ok(None));
|
||||
assert_eq!(rect_of(r#"{"x": 1}, 2, 3, 4, 5"#), Err(Unreadable));
|
||||
assert_eq!(rect_of("1, 2, 3"), Err(Unreadable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dont_care_states_read_as_mixed() {
|
||||
let mixed = state_of(r#"{"commandName":".uno:Bold","dontcare":true}"#);
|
||||
|
||||
@@ -400,7 +400,12 @@ fn with_doc<T>(id: u64, call: impl FnOnce(&Document) -> T) -> io::Result<T> {
|
||||
/// The shown part, then each part's name and info.
|
||||
fn sheets(doc: &Document) -> io::Result<Vec<u8>> {
|
||||
let count = doc.parts();
|
||||
let mut pack = Pack::new().u64(doc.part() as u64).u64(count as u64);
|
||||
// -1 means no view shell: no list.
|
||||
let unread = |what| io::Error::other(format!("the kernel gives no {what}"));
|
||||
let current = u64::try_from(doc.part()).map_err(|_| unread("current part"))?;
|
||||
let mut pack = Pack::new()
|
||||
.u64(current)
|
||||
.u64(u64::try_from(count).map_err(|_| unread("part count"))?);
|
||||
for part in 0..count {
|
||||
pack = pack.str(&doc.part_name(part)?).str(&doc.part_info(part)?);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ fn invalid(what: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, what.to_string())
|
||||
}
|
||||
|
||||
/// Paints one tile after checking the geometry (LOK-03).
|
||||
/// One tile and the generation it was painted at (LOK-03).
|
||||
fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec<u8>)> {
|
||||
let id = unpack.u64()?;
|
||||
let (x, y, w, h) = (unpack.i64()?, unpack.i64()?, unpack.i64()?, unpack.i64()?);
|
||||
@@ -36,6 +36,8 @@ fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec<u8>)> {
|
||||
"bad tile geometry",
|
||||
));
|
||||
};
|
||||
// Read first: an invalidation mid-paint must outdate it.
|
||||
let generation = crate::events::generation(id);
|
||||
let tile = with_doc(id, |doc| {
|
||||
let mut tile = vec![0u8; len as usize];
|
||||
doc.paint_rect(
|
||||
@@ -45,7 +47,7 @@ fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec<u8>)> {
|
||||
);
|
||||
tile
|
||||
})?;
|
||||
Ok((id, tile))
|
||||
Ok((generation, tile))
|
||||
}
|
||||
|
||||
/// One slide's whole picture at the asked pixels.
|
||||
@@ -74,8 +76,8 @@ pub(super) fn part_painted(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
|
||||
/// A painted tile behind its generation.
|
||||
pub(super) fn painted(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
let (id, tile) = paint(unpack)?;
|
||||
let mut body = Pack::new().u64(crate::events::generation(id)).bytes();
|
||||
let (generation, tile) = paint(unpack)?;
|
||||
let mut body = Pack::new().u64(generation).bytes();
|
||||
body.extend_from_slice(&tile);
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
@@ -265,6 +265,11 @@ pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Addresses a click may open outside: web and mail.
|
||||
pub fn outward(uri: &str) -> bool {
|
||||
["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme))
|
||||
}
|
||||
|
||||
/// A localized message.
|
||||
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
|
||||
ely_ui::i18n::text(cx, key, args)
|
||||
|
||||
@@ -238,13 +238,18 @@ pub trait Opening: Host {
|
||||
|
||||
/// Engine errors become toasts; kernel edits mark the session.
|
||||
fn watch(&mut self, id: u64, view: &Entity<View<Self>>, cx: &mut Context<Self>) {
|
||||
let seen = std::cell::Cell::new(Self::Engine::edited(view.read(cx)));
|
||||
let reported = move |host: &mut Self,
|
||||
view: &Entity<View<Self>>,
|
||||
modified: bool,
|
||||
cx: &mut Context<Self>| {
|
||||
let edited = Self::Engine::edited(view.read(cx));
|
||||
let moved = seen.replace(edited) != edited;
|
||||
if let Some(tab) = host.tab_mut(id) {
|
||||
match (modified, tab.modified) {
|
||||
(true, false) => tab.session.edit(),
|
||||
// An edit landing mid-save outdates its ticket.
|
||||
(true, true) if moved => tab.session.edit(),
|
||||
(false, true) => tab.session.reverted(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ pub fn size_menu<P: Panel>(panel: &P, id: &'static str, cx: &Context<P>) -> AnyE
|
||||
if let Some(size) = size.clone().filter(|size| !sizes.contains(size)) {
|
||||
sizes.push(size);
|
||||
}
|
||||
sizes.sort_by(|a, b| points(a).total_cmp(&points(b)));
|
||||
let sizes = ordered(sizes);
|
||||
let mut menu = Select::new(
|
||||
id,
|
||||
sizes
|
||||
@@ -279,7 +279,28 @@ pub fn known(state: &str) -> bool {
|
||||
!state.is_empty() && state != ely_lok::MIXED
|
||||
}
|
||||
|
||||
/// A size label's points, for ordering.
|
||||
fn points(size: &str) -> f64 {
|
||||
size.parse().expect("sizes are numbers")
|
||||
/// Sizes by points; one the kernel words oddly shows last.
|
||||
fn ordered(sizes: Vec<SharedString>) -> Vec<SharedString> {
|
||||
let (mut sized, mut odd) = (Vec::new(), Vec::new());
|
||||
for size in sizes {
|
||||
match size.parse::<f64>() {
|
||||
Ok(points) => sized.push((points, size)),
|
||||
Err(_) => odd.push(size),
|
||||
}
|
||||
}
|
||||
sized.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut sizes: Vec<SharedString> = sized.into_iter().map(|(_, size)| size).collect();
|
||||
sizes.append(&mut odd);
|
||||
sizes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn an_odd_size_shows_last_and_panics_nothing() {
|
||||
let sizes = ["12", "disabled", "9", "10.5"]
|
||||
.map(gpui::SharedString::from)
|
||||
.to_vec();
|
||||
assert_eq!(super::ordered(sizes), ["9", "10.5", "12", "disabled"]);
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,35 @@ use std::{
|
||||
|
||||
use ely_files::{Crash, Marker, Recovery, RecoveryKey};
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use gpui::{AsyncApp, Context, WeakEntity};
|
||||
use gpui::{AppContext as _, AsyncApp, Context, WeakEntity};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Engine, Host, Opening as _, Source, Tabs as _, text};
|
||||
use crate::{Engine, Host, Tabs as _, text};
|
||||
|
||||
mod reopen;
|
||||
|
||||
/// A tab's points, and the edit the last holds.
|
||||
struct Slot {
|
||||
kept: Kept,
|
||||
written: Option<u64>,
|
||||
writing: bool,
|
||||
/// The last failure, shown once until it changes.
|
||||
failed: Option<String>,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// Notes a failed point; words to show, once each.
|
||||
fn fail(&mut self, edited: u64, error: &io::Error) -> Option<String> {
|
||||
// Too big for the cap: wait for the next edit.
|
||||
if error.kind() == io::ErrorKind::InvalidInput {
|
||||
self.written = Some(edited);
|
||||
}
|
||||
let said = error.to_string();
|
||||
(self.failed.as_ref() != Some(&said)).then(|| {
|
||||
self.failed = Some(said.clone());
|
||||
said
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What reopening a slot needs, kept beside its points.
|
||||
@@ -47,6 +66,9 @@ pub struct Keeping {
|
||||
pub(crate) last: Option<SystemTime>,
|
||||
/// The index on disk is behind; the failure shown.
|
||||
behind: Option<String>,
|
||||
/// An index write runs; another follows it if asked.
|
||||
flushing: bool,
|
||||
flush_again: bool,
|
||||
}
|
||||
|
||||
impl Keeping {
|
||||
@@ -70,7 +92,7 @@ impl Keeping {
|
||||
};
|
||||
let crash = Crash::open(&dir, std::process::id())?;
|
||||
let (reopening, held) = (Vec::new(), Vec::new());
|
||||
Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None })
|
||||
Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None, flushing: false, flush_again: false })
|
||||
}
|
||||
|
||||
/// A clean quit: the next start finds nothing to recover.
|
||||
@@ -87,17 +109,27 @@ impl Keeping {
|
||||
}
|
||||
|
||||
fn save_index(&self) -> io::Result<()> {
|
||||
let (path, bytes) = self.index()?;
|
||||
write_index(&path, &bytes)
|
||||
}
|
||||
|
||||
/// The index file and what it should hold now.
|
||||
fn index(&self) -> io::Result<(PathBuf, Vec<u8>)> {
|
||||
let waiting = self.reopening.iter().map(|(_, _, kept)| kept).chain(&self.held);
|
||||
let waiting = waiting.map(|kept| Kept { held: true, ..kept.clone() });
|
||||
let slots = self.slots.values().map(|slot| slot.kept.clone());
|
||||
let kept: Vec<Kept> = slots.chain(self.crashed.iter().cloned()).chain(waiting).collect();
|
||||
let path = self.dir.join("recovery").join("index.json");
|
||||
std::fs::create_dir_all(self.dir.join("recovery"))?;
|
||||
// Staged then renamed: a failed write keeps the last index.
|
||||
ely_files::save_atomic(&path, &serde_json::to_vec(&kept).map_err(io::Error::other)?, 0).map(|_| ())
|
||||
let bytes = serde_json::to_vec(&kept).map_err(io::Error::other)?;
|
||||
Ok((self.dir.join("recovery").join("index.json"), bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged then renamed: a failed write keeps the last index.
|
||||
fn write_index(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
std::fs::create_dir_all(path.parent().expect("the index has a folder"))?;
|
||||
ely_files::save_atomic(path, bytes, 0).map(|_| ())
|
||||
}
|
||||
|
||||
fn read_index(dir: &Path) -> io::Result<Vec<Kept>> {
|
||||
match std::fs::read(dir.join("recovery").join("index.json")) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)),
|
||||
@@ -128,6 +160,9 @@ fn remove(path: &Path) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A slot taken from a released tab, waiting to reopen.
|
||||
pub struct Taken(Kept, String);
|
||||
|
||||
/// When recovery points are written and crashes come back.
|
||||
pub trait Recovering: Host {
|
||||
/// From the app's window, never its tests.
|
||||
@@ -140,8 +175,17 @@ pub trait Recovering: Host {
|
||||
}
|
||||
}
|
||||
Self::keep_points_every(cx);
|
||||
cx.on_app_quit(|host, _| {
|
||||
if let Some(keeping) = host.core_mut().keeping.as_mut() {
|
||||
cx.on_app_quit(|host, cx| {
|
||||
// A write still in flight must not be the last.
|
||||
if let Some(keeping) = host.core().keeping.as_ref()
|
||||
&& let Err(error) = keeping.save_index()
|
||||
{
|
||||
log::error!("recovery: the index is not saved at quit: {error}");
|
||||
}
|
||||
// A system end skips asking: unsaved tabs keep points.
|
||||
if host.core().tabs.iter().any(|tab| tab.dirty(cx)) {
|
||||
log::warn!("recovery: quit with unsaved tabs; points stay");
|
||||
} else if let Some(keeping) = host.core_mut().keeping.as_mut() {
|
||||
keeping.end();
|
||||
}
|
||||
async {}
|
||||
@@ -219,6 +263,7 @@ pub trait Recovering: Host {
|
||||
match written {
|
||||
Ok(()) => {
|
||||
slot.written = Some(edited);
|
||||
slot.failed = None;
|
||||
keeping.last = Some(SystemTime::now());
|
||||
if keeping.behind.is_some() {
|
||||
host.flush_index(cx);
|
||||
@@ -226,7 +271,8 @@ pub trait Recovering: Host {
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("recovery: tab {id} point failed ({:?})", error.kind());
|
||||
let shown = text(cx, "office.recovery.failed", &[("error", &error.to_string())]);
|
||||
let Some(said) = slot.fail(edited, &error) else { return };
|
||||
let shown = text(cx, "office.recovery.failed", &[("error", &said)]);
|
||||
host.toast_unlogged(shown, Severity::Warning, cx);
|
||||
}
|
||||
}
|
||||
@@ -236,13 +282,40 @@ pub trait Recovering: Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the index; a failure shows once and retries.
|
||||
/// Writes the index in the background, one at a time.
|
||||
fn flush_index(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
let error = match keeping.save_index() {
|
||||
Ok(()) => return keeping.behind = None,
|
||||
Err(error) => error.to_string(),
|
||||
if keeping.flushing {
|
||||
return keeping.flush_again = true;
|
||||
}
|
||||
let (path, bytes) = match keeping.index() {
|
||||
Ok(index) => index,
|
||||
Err(error) => return self.index_failed(error.to_string(), cx),
|
||||
};
|
||||
keeping.flushing = true;
|
||||
let task = cx.background_spawn(async move { write_index(&path, &bytes) });
|
||||
cx.spawn(async move |this: WeakEntity<Self>, cx: &mut AsyncApp| {
|
||||
let written = task.await;
|
||||
this.update(cx, |host, cx| {
|
||||
let Some(keeping) = host.core_mut().keeping.as_mut() else { return };
|
||||
keeping.flushing = false;
|
||||
match written {
|
||||
Ok(()) => keeping.behind = None,
|
||||
Err(error) => host.index_failed(error.to_string(), cx),
|
||||
}
|
||||
let again = host.core_mut().keeping.as_mut().is_some_and(|keeping| std::mem::take(&mut keeping.flush_again));
|
||||
if again {
|
||||
host.flush_index(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// An index failure shows once and retries.
|
||||
fn index_failed(&mut self, error: String, cx: &mut Context<Self>) {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
if keeping.behind.as_ref() == Some(&error) {
|
||||
return;
|
||||
}
|
||||
@@ -261,7 +334,7 @@ pub trait Recovering: Host {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
let slot = format!("{}-{id}", std::process::id());
|
||||
let kept = Kept { slot, name: name.to_string(), format, sealed, held: false };
|
||||
keeping.slots.insert(id, Slot { kept, written: None, writing: false });
|
||||
keeping.slots.insert(id, Slot { kept, written: None, writing: false, failed: None });
|
||||
self.flush_index(cx);
|
||||
}
|
||||
|
||||
@@ -275,60 +348,50 @@ pub trait Recovering: Host {
|
||||
self.flush_index(cx);
|
||||
}
|
||||
|
||||
/// Opens each crashed slot's newest point as unsaved copy.
|
||||
/// Reopens each crashed slot's newest point as an unsaved copy.
|
||||
fn reopen_crashed(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return };
|
||||
let crashed = std::mem::take(&mut keeping.crashed);
|
||||
let crashed: Vec<(Kept, String)> = std::mem::take(&mut keeping.crashed).into_iter().map(|kept| { let name = kept.name.clone(); (kept, name) }).collect();
|
||||
if crashed.is_empty() {
|
||||
return;
|
||||
}
|
||||
for kept in crashed {
|
||||
let folder = self.core().keeping.as_ref().expect("keeping").folder(&kept.slot);
|
||||
match restore(&kept, &folder, &self.core().work, self.core().next, false) {
|
||||
Ok(Some((copy, at))) => {
|
||||
log::info!("recovery: reopening a crashed {}", kept.format);
|
||||
self.core_mut().keeping.as_mut().expect("keeping").reopening.push((copy.clone(), at, kept));
|
||||
self.load(Source::Copy(copy), cx);
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(error) = remove(&folder) {
|
||||
log::error!("recovery: an empty slot stays: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
reopen::restore_off(self, crashed, false, |host, done, cx| {
|
||||
for (name, restored) in done {
|
||||
if let Err(error) = restored {
|
||||
log::error!("recovery: a crashed copy cannot open ({:?})", error.kind());
|
||||
let shown = text(cx, "office.recovery.lost", &[("name", &kept.name), ("error", &error.to_string())]);
|
||||
self.toast_unlogged(shown, Severity::Danger, cx);
|
||||
// Its points stay for a later start.
|
||||
self.core_mut().keeping.as_mut().expect("keeping").held.push(kept);
|
||||
let shown = text(cx, "office.recovery.lost", &[("name", &name), ("error", &error.to_string())]);
|
||||
host.toast_unlogged(shown, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.flush_index(cx);
|
||||
}, cx);
|
||||
}
|
||||
|
||||
/// Opens `id`'s newest point as a copy; false if none.
|
||||
fn reopen_point(&mut self, id: u64, cx: &mut Context<Self>) -> io::Result<bool> {
|
||||
let (work, next) = (self.core().work.clone(), self.core().next);
|
||||
let Some(keeping) = self.core_mut().keeping.as_mut() else { return Ok(false) };
|
||||
// Out of the tab's hands: releasing it keeps these points.
|
||||
let Some(slot) = keeping.slots.remove(&id) else { return Ok(false) };
|
||||
let restored = restore(&slot.kept, &keeping.folder(&slot.kept.slot), &work, next, true);
|
||||
match restored {
|
||||
Ok(Some((copy, at))) => {
|
||||
keeping.reopening.push((copy.clone(), at, slot.kept));
|
||||
self.load(Source::Copy(copy), cx);
|
||||
Ok(true)
|
||||
/// Takes tab `id`'s slot; releasing it keeps the points.
|
||||
fn take_slot(&mut self, id: u64, name: String) -> Option<Taken> {
|
||||
let slot = self.core_mut().keeping.as_mut()?.slots.remove(&id)?;
|
||||
Some(Taken(slot.kept, name))
|
||||
}
|
||||
|
||||
/// Reopens taken slots as copies; names any lost.
|
||||
fn reopen_taken(&mut self, taken: Vec<Taken>, mut lost: Vec<String>, cx: &mut Context<Self>) {
|
||||
let taken = taken.into_iter().map(|Taken(kept, name)| (kept, name)).collect();
|
||||
reopen::restore_off(self, taken, true, move |host, done, cx| {
|
||||
for (name, restored) in done {
|
||||
match restored {
|
||||
Ok(true) => {}
|
||||
Ok(false) => lost.push(name),
|
||||
Err(error) => {
|
||||
log::error!("office: a point does not open ({:?})", error.kind());
|
||||
lost.push(format!("{name} ({error})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
keeping.slots.insert(id, slot);
|
||||
Ok(false)
|
||||
log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len());
|
||||
if !lost.is_empty() {
|
||||
let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]);
|
||||
host.toast_unlogged(shown, Severity::Danger, cx);
|
||||
}
|
||||
Err(error) => {
|
||||
keeping.held.push(slot.kept);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}, cx);
|
||||
}
|
||||
|
||||
/// A reopened copy takes over its old points, unsaved.
|
||||
@@ -355,28 +418,6 @@ pub trait Recovering: Host {
|
||||
|
||||
impl<H: Host> Recovering for H {}
|
||||
|
||||
/// The newest point copied to the work folder, and when.
|
||||
fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result<Option<(PathBuf, SystemTime)>> {
|
||||
let recovery = match kept.sealed {
|
||||
true => {
|
||||
let key = RecoveryKey::load();
|
||||
// This run's points open with this run's key.
|
||||
if !key.lasting && !this_run {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store"));
|
||||
}
|
||||
Recovery::sensitive(folder.to_path_buf(), key.key)
|
||||
}
|
||||
false => Recovery::new(folder.to_path_buf()),
|
||||
};
|
||||
let Some(point) = recovery.points()?.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dir = crate::files::work_dir(work, next)?;
|
||||
let copy = dir.join(&kept.name).with_extension(&kept.format);
|
||||
recovery.restore(&point, ©)?;
|
||||
Ok(Some((copy, point.at)))
|
||||
}
|
||||
|
||||
/// A recovery time as this machine's clock reads it.
|
||||
pub(crate) fn clock(at: SystemTime) -> String {
|
||||
match jiff::Timestamp::try_from(at) {
|
||||
@@ -387,7 +428,18 @@ pub(crate) fn clock(at: SystemTime) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Keeping, Kept};
|
||||
use super::{Keeping, Kept, Slot};
|
||||
|
||||
#[test]
|
||||
fn a_failing_point_says_so_once_until_the_reason_changes() {
|
||||
let mut slot = Slot { kept: kept("s", false), written: None, writing: false, failed: None };
|
||||
let full = std::io::Error::other("disk full");
|
||||
assert_eq!(slot.fail(1, &full).as_deref(), Some("disk full"));
|
||||
assert_eq!(slot.fail(2, &full), None, "the same reason stays quiet");
|
||||
let big = std::io::Error::new(std::io::ErrorKind::InvalidInput, "over the cap");
|
||||
assert!(slot.fail(3, &big).is_some(), "a new reason shows");
|
||||
assert_eq!(slot.written, Some(3), "too big waits for the next edit");
|
||||
}
|
||||
|
||||
fn kept(slot: &str, held: bool) -> Kept {
|
||||
Kept { slot: slot.into(), name: "a.docx".into(), format: "docx".into(), sealed: false, held }
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Points restored off the UI thread, then opened (§6.3).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use ely_files::{Recovery, RecoveryKey};
|
||||
use gpui::{AppContext as _, AsyncApp, Context, WeakEntity};
|
||||
|
||||
use super::{Kept, Recovering, remove};
|
||||
use crate::{Opening as _, Source};
|
||||
|
||||
/// Restores each slot's newest point, then opens the copy.
|
||||
pub(super) fn restore_off<H: Recovering>(
|
||||
host: &mut H,
|
||||
slots: Vec<(Kept, String)>,
|
||||
this_run: bool,
|
||||
then: impl FnOnce(&mut H, Vec<(String, io::Result<bool>)>, &mut Context<H>) + 'static,
|
||||
cx: &mut Context<H>,
|
||||
) {
|
||||
let Some(keeping) = host.core_mut().keeping.as_mut() else { return };
|
||||
// Held meanwhile: a crash now still finds them.
|
||||
keeping.held.extend(slots.iter().map(|(kept, _)| kept.clone()));
|
||||
let jobs: Vec<(u64, PathBuf, Kept, String)> = slots
|
||||
.into_iter()
|
||||
.map(|(kept, name)| {
|
||||
let folder = host.core().keeping.as_ref().expect("keeping").folder(&kept.slot);
|
||||
let id = host.core().next;
|
||||
// Each copy's folder is its future tab's.
|
||||
host.core_mut().next += 1;
|
||||
(id, folder, kept, name)
|
||||
})
|
||||
.collect();
|
||||
host.flush_index(cx);
|
||||
let work = host.core().work.clone();
|
||||
let task = cx.background_spawn(async move {
|
||||
jobs.into_iter()
|
||||
.map(|(id, folder, kept, name)| {
|
||||
let restored = restore(&kept, &folder, &work, id, this_run);
|
||||
// An empty slot has nothing to keep.
|
||||
let restored = match restored {
|
||||
Ok(None) => remove(&folder).map(|()| None),
|
||||
other => other,
|
||||
};
|
||||
(id, kept, name, restored)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
cx.spawn(async move |this: WeakEntity<H>, cx: &mut AsyncApp| {
|
||||
let done = task.await;
|
||||
this.update(cx, |host, cx| {
|
||||
let Some(keeping) = host.core_mut().keeping.as_mut() else { return };
|
||||
keeping.held.retain(|held| !done.iter().any(|(_, kept, ..)| kept.slot == held.slot));
|
||||
let mut said = Vec::new();
|
||||
for (id, kept, name, restored) in done {
|
||||
let keeping = host.core_mut().keeping.as_mut().expect("keeping");
|
||||
let opened = match restored {
|
||||
Ok(Some((copy, at))) => {
|
||||
log::info!("recovery: reopening a {}", kept.format);
|
||||
keeping.reopening.push((copy.clone(), at, kept));
|
||||
host.load_into(id, Source::Copy(copy), cx);
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => Ok(false),
|
||||
Err(error) => {
|
||||
// Its points stay for a later start.
|
||||
keeping.held.push(kept);
|
||||
Err(error)
|
||||
}
|
||||
};
|
||||
said.push((name, opened));
|
||||
}
|
||||
host.flush_index(cx);
|
||||
then(host, said, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The newest point copied to the work folder, and when.
|
||||
fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result<Option<(PathBuf, SystemTime)>> {
|
||||
let recovery = match kept.sealed {
|
||||
true => {
|
||||
let key = RecoveryKey::load();
|
||||
// This run's points open with this run's key.
|
||||
if !key.lasting && !this_run {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store"));
|
||||
}
|
||||
Recovery::sensitive(folder.to_path_buf(), key.key)
|
||||
}
|
||||
false => Recovery::new(folder.to_path_buf()),
|
||||
};
|
||||
let Some(point) = recovery.points()?.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dir = crate::files::work_dir(work, next)?;
|
||||
let copy = dir.join(&kept.name).with_extension(&kept.format);
|
||||
recovery.restore(&point, ©)?;
|
||||
Ok(Some((copy, point.at)))
|
||||
}
|
||||
|
||||
@@ -30,16 +30,27 @@ fn identity(path: &Path) -> io::Result<PathBuf> {
|
||||
pub trait Saving: Host {
|
||||
/// The tab whose source file is `target`.
|
||||
fn holder(&self, target: &Path) -> io::Result<Option<u64>> {
|
||||
// Target errors stand; a tab's gone volume does not.
|
||||
let target = identity(target)?;
|
||||
for tab in &self.core().tabs {
|
||||
if let Some(open) = tab.session.path()
|
||||
&& ely_files::same_file(open, target)?
|
||||
{
|
||||
return Ok(Some(tab.id));
|
||||
let Some(open) = tab.session.path() else { continue };
|
||||
match ely_files::same_file(open, &target) {
|
||||
Ok(true) => return Ok(Some(tab.id)),
|
||||
Ok(false) => {}
|
||||
Err(error) => log::warn!("office: tab {} has no file to compare: {error}", tab.id),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// A save as refused unwritten; a waiting close stops.
|
||||
fn refuse_save(&mut self, then: &Option<After>, words: gpui::SharedString, cx: &mut Context<Self>) {
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
if then.is_some() {
|
||||
self.close_stopped(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves in place in its own format.
|
||||
fn save(&mut self, id: u64, then: Option<After>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.settled(id, cx) {
|
||||
@@ -216,25 +227,29 @@ pub trait Saving: Host {
|
||||
host.write_as(id, path, then, window, cx)
|
||||
});
|
||||
}
|
||||
let name = self.tab(id).map(|tab| tab.name.clone()).unwrap_or_default();
|
||||
let unsaved = |error: io::Error, cx: &mut Context<Self>| {
|
||||
text(cx, "office.error.save", &[("name", &name), ("error", &error.to_string())])
|
||||
};
|
||||
let target = match identity(&path) {
|
||||
Ok(target) => target,
|
||||
Err(error) => return self.report(id, Err(SaveError::Io(error)), cx),
|
||||
Err(error) => return self.refuse_save(&then, unsaved(error, cx), cx),
|
||||
};
|
||||
let Some(format) = files::save_as_format(&Self::KIND, &target) else {
|
||||
let formats = Self::KIND.save_as.join(", .");
|
||||
let text = text(cx, "office.error.format", &[("formats", &formats)]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
return self.refuse_save(&then, text, cx);
|
||||
};
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return log::info!("office: tab {id} closed before its save");
|
||||
};
|
||||
let holder = match self.holder(&target) {
|
||||
Ok(holder) => holder,
|
||||
Err(error) => return self.report(id, Err(SaveError::Io(error)), cx),
|
||||
Err(error) => return self.refuse_save(&then, unsaved(error, cx), cx),
|
||||
};
|
||||
if holder.is_some_and(|other| other != id) {
|
||||
let text = text(cx, "office.error.taken", &[("name", &file_name(&target))]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
return self.refuse_save(&then, text, cx);
|
||||
}
|
||||
let own_format = tab
|
||||
.session
|
||||
@@ -242,7 +257,7 @@ pub trait Saving: Host {
|
||||
.and_then(|open| files::kept_format(&Self::KIND, open).ok());
|
||||
if holder == Some(id) && (tab.risk.is_some() || own_format != Some(format)) {
|
||||
let text = text(cx, "office.error.convert", &[]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
return self.refuse_save(&then, text, cx);
|
||||
}
|
||||
// The written file differs from the open one; reopen it.
|
||||
let reopen =
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{path::PathBuf, sync::Arc};
|
||||
use ely_gpui_component::{feedback::Banner, primitives::Severity};
|
||||
use gpui::{AppContext as _, Context};
|
||||
|
||||
use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, Tabs as _, recover::clock, text};
|
||||
use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, recover::clock, text};
|
||||
|
||||
/// Why the engine ended, and whether it is coming back.
|
||||
pub struct Stopped {
|
||||
@@ -93,7 +93,7 @@ pub trait Stopping: Host {
|
||||
.iter()
|
||||
.map(|tab| (tab.id, tab.name.to_string(), tab.session.path().map(PathBuf::from), tab.dirty(cx)))
|
||||
.collect();
|
||||
let mut lost = Vec::new();
|
||||
let (mut taken, mut lost) = (Vec::new(), Vec::new());
|
||||
for (id, name, path, dirty) in tabs {
|
||||
if !dirty {
|
||||
self.release_tab(id, cx);
|
||||
@@ -102,13 +102,9 @@ pub trait Stopping: Host {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match self.reopen_point(id, cx) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => lost.push(name),
|
||||
Err(error) => {
|
||||
log::error!("office: tab {id} point does not open ({:?})", error.kind());
|
||||
lost.push(format!("{name} ({error})"));
|
||||
}
|
||||
match self.take_slot(id, name.clone()) {
|
||||
Some(slot) => taken.push(slot),
|
||||
None => lost.push(name),
|
||||
}
|
||||
self.release_tab(id, cx);
|
||||
}
|
||||
@@ -116,11 +112,7 @@ pub trait Stopping: Host {
|
||||
if matches!(self.core().dialog, Some(Dialog::Unsaved { .. } | Dialog::Conflict { .. })) {
|
||||
self.core_mut().dialog = None;
|
||||
}
|
||||
log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len());
|
||||
if !lost.is_empty() {
|
||||
let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]);
|
||||
self.toast_unlogged(shown, Severity::Danger, cx);
|
||||
}
|
||||
self.reopen_taken(taken, lost, cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ use std::path::Path;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
/// Only macOS plays yet; elsewhere nothing offers it.
|
||||
pub const BUILT: bool = cfg!(target_os = "macos");
|
||||
|
||||
/// What the player knows of its file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum State {
|
||||
|
||||
@@ -83,6 +83,11 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mark.git.failed", "ElyTree did not open: {error}"),
|
||||
("mark.large", "This file is {size} MB: it shows as source. Visual and preview are off; saving keeps every byte."),
|
||||
("mark.large.modes", "Large files stay in source mode"),
|
||||
(
|
||||
"mark.visual.broke",
|
||||
"The visual editor failed ({what}); the file shows as source",
|
||||
),
|
||||
("mark.page.broke", "The editor did not open ({what})"),
|
||||
("mark.mode.visual", "Visual"),
|
||||
("mark.mode.source", "Source"),
|
||||
("mark.mode.preview", "Preview"),
|
||||
@@ -179,6 +184,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mark.git.failed", "ElyTree 没能打开:{error}"),
|
||||
("mark.large", "此文件 {size} MB,以源码显示。可视与预览已关闭;保存时保留全部内容。"),
|
||||
("mark.large.modes", "大文件只用源码模式"),
|
||||
(
|
||||
"mark.visual.broke",
|
||||
"可视编辑器出错({what}),文件以源码显示",
|
||||
),
|
||||
("mark.page.broke", "编辑器没有打开({what})"),
|
||||
("mark.mode.visual", "可视"),
|
||||
("mark.mode.source", "源码"),
|
||||
("mark.mode.preview", "预览"),
|
||||
|
||||
@@ -17,12 +17,31 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
),
|
||||
("office.error.changed", "The document changed meanwhile; try again"),
|
||||
("office.print.failed", "Printing failed: {error}"),
|
||||
("office.recovery.failed", "A recovery copy was not written: {error}"),
|
||||
("office.recovery.off", "Recovery copies are off for this run: {error}"),
|
||||
("office.recovery.lost", "{name} could not be recovered: {error}"),
|
||||
("office.recovery.back", "{name}: unsaved content from {at} is back; the cursor and undo history are not"),
|
||||
("office.recovery.index", "Recovery points cannot be listed, so a crash may lose them: {error}"),
|
||||
("office.stopped", "The document engine stopped ({why}). Last recovery point: {at}."),
|
||||
("office.link.refused", "Ely opens only web and mail links."),
|
||||
(
|
||||
"office.recovery.failed",
|
||||
"A recovery copy was not written: {error}",
|
||||
),
|
||||
(
|
||||
"office.recovery.off",
|
||||
"Recovery copies are off for this run: {error}",
|
||||
),
|
||||
(
|
||||
"office.recovery.lost",
|
||||
"{name} could not be recovered: {error}",
|
||||
),
|
||||
(
|
||||
"office.recovery.back",
|
||||
"{name}: unsaved content from {at} is back; the cursor and undo history are not",
|
||||
),
|
||||
(
|
||||
"office.recovery.index",
|
||||
"Recovery points cannot be listed, so a crash may lose them: {error}",
|
||||
),
|
||||
(
|
||||
"office.stopped",
|
||||
"The document engine stopped ({why}). Last recovery point: {at}.",
|
||||
),
|
||||
("office.stopped.never", "none yet"),
|
||||
("office.stopped.reopen", "Reopen documents"),
|
||||
("office.stopped.lost", "Not reopened: {names}. Their unsaved changes had no recovery point."),
|
||||
@@ -103,6 +122,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("office.error.convert", "格式转换要写成新文件,请换个名字"),
|
||||
("office.error.changed", "文档在此间已改动,请重试"),
|
||||
("office.print.failed", "打印失败:{error}"),
|
||||
("office.link.refused", "Ely 只打开网页与邮件链接。"),
|
||||
("office.recovery.failed", "恢复副本未能写入:{error}"),
|
||||
("office.recovery.off", "本次运行不写恢复副本:{error}"),
|
||||
("office.recovery.lost", "{name} 未能恢复:{error}"),
|
||||
|
||||
@@ -184,7 +184,6 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"pdf.link.bad",
|
||||
"Type a page number, or a web or mail address.",
|
||||
),
|
||||
("pdf.link.refused", "Ely opens only web and mail links."),
|
||||
("pdf.side.files", "Attachments"),
|
||||
("pdf.files.none", "No attachments"),
|
||||
("pdf.file.attach", "Attach File…"),
|
||||
@@ -391,7 +390,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("pdf.tool.link", "添加链接"),
|
||||
("pdf.link.target", "目标"),
|
||||
("pdf.link.bad", "请输入页码,或网址、邮件地址。"),
|
||||
("pdf.link.refused", "Ely 只打开网页与邮件链接。"),
|
||||
("pdf.side.files", "附件"),
|
||||
("pdf.files.none", "没有附件"),
|
||||
("pdf.file.attach", "添加附件…"),
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("tree.error.write", "Git failed: {error}"),
|
||||
("tree.error.clone", "The clone failed: {error}"),
|
||||
("tree.error.stop", "The clone did not stop: {error}"),
|
||||
("tree.error.stop_remote", "Git did not stop: {error}"),
|
||||
("tree.error.panel", "The folder panel failed: {error}"),
|
||||
("tree.space.exact", "Exact"),
|
||||
("tree.space.changes", "Ignore Space Changes"),
|
||||
@@ -179,6 +180,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.error.write", "Git 失败:{error}"),
|
||||
("tree.error.clone", "克隆失败:{error}"),
|
||||
("tree.error.stop", "克隆未能停止:{error}"),
|
||||
("tree.error.stop_remote", "Git 未能停止:{error}"),
|
||||
("tree.error.panel", "文件夹面板失败:{error}"),
|
||||
("tree.space.exact", "精确"),
|
||||
("tree.space.changes", "忽略空白变化"),
|
||||
|
||||
@@ -75,6 +75,8 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
),
|
||||
("tree.op.picking", "Cherry-picking {theirs} onto {ours}"),
|
||||
("tree.op.reverting", "Reverting {theirs} on {ours}"),
|
||||
("tree.op.applying", "Applying patch {theirs} onto {ours}"),
|
||||
("tree.op.unread", "What Git is doing did not read: {error}"),
|
||||
(
|
||||
"tree.op.next",
|
||||
"Resolve the conflicts and mark each file resolved, then continue.",
|
||||
@@ -98,6 +100,8 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("tree.theirs.pick", "Picked, {name}"),
|
||||
("tree.ours.revert", "Current, {name}"),
|
||||
("tree.theirs.revert", "Reverting, {name}"),
|
||||
("tree.ours.am", "Current, {name}"),
|
||||
("tree.theirs.am", "Patch {name}"),
|
||||
("tree.conflict.save", "Save"),
|
||||
("tree.conflict.resolved", "Mark Resolved"),
|
||||
("tree.conflict.use_ours", "Use Current File"),
|
||||
@@ -107,6 +111,10 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"tree.conflict.hand",
|
||||
"The result was edited by hand; settle the rest in the result.",
|
||||
),
|
||||
(
|
||||
"tree.conflict.taken",
|
||||
"This conflict is settled; change it in the result.",
|
||||
),
|
||||
("tree.conflict.unsaved", "Save the result first."),
|
||||
(
|
||||
"tree.conflict.kept",
|
||||
@@ -215,6 +223,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.op.rebasing", "正在变基到 {ours},应用 {theirs}"),
|
||||
("tree.op.picking", "正在把 {theirs} 拣选到 {ours}"),
|
||||
("tree.op.reverting", "正在 {ours} 上撤销 {theirs}"),
|
||||
("tree.op.applying", "正在 {ours} 上应用补丁 {theirs}"),
|
||||
("tree.op.unread", "读不出 Git 正在做什么:{error}"),
|
||||
("tree.op.next", "解决冲突并把每个文件标为已解决,然后继续。"),
|
||||
("tree.op.continue", "继续"),
|
||||
("tree.op.skip", "跳过…"),
|
||||
@@ -232,6 +242,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.theirs.pick", "拣选的 {name}"),
|
||||
("tree.ours.revert", "当前 {name}"),
|
||||
("tree.theirs.revert", "撤销的 {name}"),
|
||||
("tree.ours.am", "当前 {name}"),
|
||||
("tree.theirs.am", "补丁 {name}"),
|
||||
("tree.conflict.save", "保存"),
|
||||
("tree.conflict.resolved", "标为已解决"),
|
||||
("tree.conflict.use_ours", "用当前侧的文件"),
|
||||
@@ -241,6 +253,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
"tree.conflict.hand",
|
||||
"结果已手动改过,其余冲突请在结果里处理。",
|
||||
),
|
||||
("tree.conflict.taken", "此处冲突已选定,请在结果里修改。"),
|
||||
("tree.conflict.unsaved", "请先保存结果。"),
|
||||
("tree.conflict.kept", "文件在磁盘上变了,未保存的结果保留。"),
|
||||
("tree.conflict.marked.title", "把 {name} 标为已解决?"),
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("tree.locked.retry", "Retry"),
|
||||
("tree.tool.terminal", "Open in Terminal"),
|
||||
("tree.tool.reveal", "Show in Finder"),
|
||||
("tree.tool.reveal_folder", "Show in Folder"),
|
||||
("tree.tool.editor", "Choose Editor…"),
|
||||
("tree.tool.in_editor", "Open in Editor"),
|
||||
("tree.tool.in_mark", "Open in ElyMark"),
|
||||
@@ -283,6 +284,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("tree.locked.retry", "重试"),
|
||||
("tree.tool.terminal", "在终端中打开"),
|
||||
("tree.tool.reveal", "在访达中显示"),
|
||||
("tree.tool.reveal_folder", "在文件夹中显示"),
|
||||
("tree.tool.editor", "选择编辑器…"),
|
||||
("tree.tool.in_editor", "在编辑器中打开"),
|
||||
("tree.tool.in_mark", "在 ElyMark 中打开"),
|
||||
|
||||
@@ -349,6 +349,16 @@ wrap_life_span_handler! {
|
||||
fn on_after_created(&self, browser: Option<&mut Browser>) {
|
||||
let Some(browser) = browser.map(|b| b.clone()) else { return };
|
||||
log::info!("web: browser {} made", browser.identifier());
|
||||
// Its view went before it came: close it, keep nothing.
|
||||
if self.surface.lock().expect("the surface lock").closed {
|
||||
log::info!("web: browser {} closes: its view is gone", browser.identifier());
|
||||
crate::engine::later(move || {
|
||||
if let Some(host) = browser.host() {
|
||||
host.close_browser(true as _);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
adopt(&browser, self.surface.clone());
|
||||
self.surface.lock().expect("the surface lock").browser = Some(browser);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-mark/src/view.rs:194 — `refresh` 换代时保留 `renders` 中的回调,随后 `query` 的代号检查会直接丢弃旧代 `rendered` 回报,导出永久悬空。原生复现:发起 Plain Text 导出,在桥接处暂留已生成的 generation=1 回报;外部写入触发 generation=2 的 `refresh` 后交付该回报。日志确认旧回报被丢弃,目标文件未生成,界面没有成功或失败提示;同一标签随后再次导出成功。证据:`/tmp/ely-m02-native.log`、`/tmp/ely-m02-native-result.log`。这违反 PRD MARK-11 与规则 2。修复方向:刷新换代时结束被作废的渲染回调并向原请求显式返回错误,补充该交错的回归测试。
|
||||
|
||||
tests/scripts/mark-outside.sh:81 — 当前测试等外部重载完成并额外暂停一秒才键入,缺少 M-02 在途消息的回归断言;18 个 ElyMark 单元测试也未覆盖这条协议。真实浏览器变异探针恢复旧版 `refresh`,并按旧版 Rust 行为保持代号:旧版与修复版均通过干净重载断言;将刷新前的 `changed` 延后至 Rust 计数重置后交付,旧版稳定留下 `changed=1、reported=0`,修复版保持落定。证据:`/tmp/ely-m02-mutation.log`;本轮 `cargo check -p ely-mark`、`cargo test -p ely-mark`、`mark-outside.sh` 均通过。修复方向:增加可控消息交付顺序的测试,覆盖两端换代、版本重置、旧代编辑回报丢弃,以及刷新后保存和关闭能够完成。
|
||||
@@ -190,6 +190,17 @@ for _ in $(seq 60); do [ "$(count "web: printed")" -gt "$printed" ] && break; sl
|
||||
[ "$(count "web: printed")" -gt "$printed" ] || fail "print made no PDF"
|
||||
sleep 2
|
||||
key 'key code 53'
|
||||
# Two prints at once: the second never cancels the first.
|
||||
sleep 1
|
||||
printed=$(count "web: printed")
|
||||
osascript -e 'tell application "System Events" to keystroke "p" using command down' -e 'tell application "System Events" to keystroke "p" using command down' >/dev/null
|
||||
# Panels are modal: close one, then the next prints.
|
||||
for want in 1 2; do
|
||||
for _ in $(seq 80); do [ "$(count "web: printed")" -ge $((printed + want)) ] && break; sleep 0.25; done
|
||||
[ "$(count "web: printed")" -ge $((printed + want)) ] || fail "a second print cancelled the first: $(grep -E "web: printed|print-" "$work/log" | tail -n 6)"
|
||||
sleep 2
|
||||
key 'key code 53'
|
||||
done
|
||||
# Rich copy carries HTML; it overwrites the clipboard.
|
||||
if [ "${ELY_CLIPBOARD_TEST:-}" = 1 ]; then
|
||||
key 'keystroke "f" using command down'
|
||||
|
||||
@@ -438,4 +438,20 @@ waits 'mark: shown as "source"'
|
||||
kill "$app"
|
||||
wait "$app" 2>/dev/null || true
|
||||
app=
|
||||
# A clean tab with the disk's text closes without asking.
|
||||
clean=$work/notes/c.md
|
||||
printf 'clean\n' >"$clean"
|
||||
launch "$clean"
|
||||
waits 'mark: shown as "visual"'
|
||||
changes=$(count "unsaved false")
|
||||
printf 'outside\n' >"$clean.tmp"
|
||||
mv "$clean.tmp" "$clean"
|
||||
more "unsaved false" "$changes"
|
||||
sleep 1
|
||||
[ "$("$work/ax-press" "$app" Close AXButton)" = pressed ] || fail "no tab Close"
|
||||
sleep 2
|
||||
! seen "Don't Save" || fail "a clean tab that took the disk's text asked to save"
|
||||
kill "$app"
|
||||
wait "$app" 2>/dev/null || true
|
||||
app=
|
||||
echo "ok: mark outside"
|
||||
Reference in new issue
Block a user