diff --git a/agents.md b/agents.md index 010a43a..1ce9232 100644 --- a/agents.md +++ b/agents.md @@ -158,6 +158,7 @@ tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久 tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限) tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴) tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错 +tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖 tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限) tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限) tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限) @@ -271,3 +272,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁、并挤回当前可见区;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表压在数据上或旁边的另一块数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:LOK 下有请求区域时,图表放进数据区右侧(隔一列)第一块够大、无合并格的空单元格(逐列向右找,跨过旁边的数据块),右侧没有依次找下方、左侧、上方,再按行带找全表第一块够大的空处(尺寸从候选格起累加,表尾也快),不受可见区限制;空处按图表所在的工作表查,数据在别的表时从光标起找;整张表没有空处即插入失败、文档不动;`LokView` 在新选中对象(原先无选中)时把它滚入视口,拖动中的对象不滚动。 - 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。 - 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。 +- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。 +- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。 diff --git a/apps/ely-doc/src/docs.rs b/apps/ely-doc/src/docs.rs index 7370d59..61c3585 100644 --- a/apps/ely-doc/src/docs.rs +++ b/apps/ely-doc/src/docs.rs @@ -126,7 +126,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx); docs.update(cx, |docs, cx| { docs.core.root = root.downgrade(); - docs.core.window = Some(window.window_handle()); + docs.own_window(window, cx); window.focus(&docs.core.focus, cx); }); let launched = ely_ui::launch_files(cx); @@ -209,7 +209,6 @@ impl Render for Docs { .on_action(cx.listener(Self::on_open)) .on_action(cx.listener(Self::on_save)) .on_action(cx.listener(Self::on_save_as)) - .on_action(cx.listener(Self::on_quit)) .on_action(cx.listener(Self::on_close_tab)) .child(shell) .children(self.core_dialog(cx)) diff --git a/apps/ely-doc/src/tests/more.rs b/apps/ely-doc/src/tests/more.rs index b23c49f..f7c0929 100644 --- a/apps/ely-doc/src/tests/more.rs +++ b/apps/ely-doc/src/tests/more.rs @@ -215,3 +215,34 @@ fn an_edit_that_just_landed_still_asks_and_selection_does_not() { }); settle(&cx, |app| docs.read(app).tab(id).is_none()); } + +#[test] +fn a_quit_from_either_window_asks_first() { + 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); + commit(&mut cx, &docs, "Unsaved"); + let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone()); + settle(&cx, |app| view.read(app).settled()); + cx.update(ely_ui::commands::bind_quit); + let home = cx.add_window(|_, _| Blank); + cx.update_window(home.into(), |_, window, app| docs.update(app, |docs, cx| docs.own_window(window, cx))) + .expect("the window lives"); + // The settings window has no quit of its own. + let settings = cx.add_window(|_, _| Blank); + for from in [home.into(), settings.into()] { + cx.dispatch_action(from, ely_ui::commands::Quit); + settle(&cx, |app| matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. }))); + assert!(cx.update(|app| docs.read(app).tab(id).is_some()), "the tab waits for an answer"); + assert_eq!(cx.update(|app| app.active_window()), Some(home.into()), "the question's window comes forward"); + // Cancel, as the dialog's button does. + cx.update(|app| docs.update(app, |docs, cx| { + docs.close_cancelled(cx); + docs.core.dialog = None; + })); + } +} diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs index 60fc829..5aa45cf 100644 --- a/apps/ely-mark/src/desk.rs +++ b/apps/ely-mark/src/desk.rs @@ -81,6 +81,11 @@ impl Host for Desk { &mut self.core } + /// A quit records the open tabs first. + fn quitting(&mut self, cx: &mut Context) { + self.leave(cx); + } + /// A quit or close stopped: the kept list follows again. fn close_cancelled(&mut self, cx: &mut Context) { self.keeping.leaving = false; @@ -215,7 +220,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let asked = ely_ui::launch_files(cx); desk.update(cx, |desk, cx| { desk.core.root = root.downgrade(); - desk.core.window = Some(window.window_handle()); + desk.own_window(window, cx); window.focus(&desk.core.focus, cx); // Launch files stand alone; unsaved text still returns. if asked.is_empty() { @@ -403,10 +408,6 @@ impl Render for Desk { .on_action(cx.listener(Self::on_export)) .on_action(cx.listener(Self::on_print)) .on_action(cx.listener(Self::on_close_tab)) - .on_action(cx.listener(|desk, quit, window, cx| { - desk.leave(cx); - desk.on_quit(quit, window, cx) - })) .on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx))) .on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx))) .on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx))) diff --git a/apps/ely-pdf/src/viewer.rs b/apps/ely-pdf/src/viewer.rs index 3688347..d8cc5c6 100644 --- a/apps/ely-pdf/src/viewer.rs +++ b/apps/ely-pdf/src/viewer.rs @@ -115,7 +115,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let root = ely_ui::document_root(app, viewer.clone().into(), &commands::FILE, crate::keys::menus(cx), window, cx); viewer.update(cx, |viewer, cx| { viewer.core.root = root.downgrade(); - viewer.core.window = Some(window.window_handle()); + viewer.own_window(window, cx); window.focus(&viewer.core.focus, cx); }); for path in ely_ui::launch_files(cx) { @@ -247,7 +247,6 @@ impl Render for Viewer { .on_action(cx.listener(Self::on_save_as)) .on_action(cx.listener(Self::on_export)) .on_action(cx.listener(Self::on_print)) - .on_action(cx.listener(Self::on_quit)) .on_action(cx.listener(Self::on_close_tab)) .child(shell) .children(self.core_dialog(cx)) diff --git a/apps/ely-show/src/deck.rs b/apps/ely-show/src/deck.rs index b8355b3..1940ed4 100644 --- a/apps/ely-show/src/deck.rs +++ b/apps/ely-show/src/deck.rs @@ -133,7 +133,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let root = ely_ui::document_root(app, deck.clone().into(), &ely_ui::commands::FILE, menus, window, cx); deck.update(cx, |deck, cx| { deck.core.root = root.downgrade(); - deck.core.window = Some(window.window_handle()); + deck.own_window(window, cx); window.focus(&deck.core.focus, cx); }); let launched = ely_ui::launch_files(cx); @@ -448,7 +448,6 @@ impl Render for Deck { .on_action(cx.listener(Self::on_save_as)) .on_action(cx.listener(Self::on_export)) .on_action(cx.listener(Self::on_print)) - .on_action(cx.listener(Self::on_quit)) .on_action(cx.listener(Self::on_close_tab)) .child(shell) .children(self.core_dialog(cx)) diff --git a/apps/ely-table/src/book.rs b/apps/ely-table/src/book.rs index cd434f2..8a22e2c 100644 --- a/apps/ely-table/src/book.rs +++ b/apps/ely-table/src/book.rs @@ -143,7 +143,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { let root = ely_ui::document_root(app, book.clone().into(), &ely_ui::commands::FILE, menus, window, cx); book.update(cx, |book, cx| { book.core.root = root.downgrade(); - book.core.window = Some(window.window_handle()); + book.own_window(window, cx); window.focus(&book.core.focus, cx); }); let launched = ely_ui::launch_files(cx); @@ -372,7 +372,6 @@ impl Render for Book { .on_action(cx.listener(Self::on_open_file)) .on_action(cx.listener(Self::on_save)) .on_action(cx.listener(Self::on_save_as)) - .on_action(cx.listener(Self::on_quit)) .on_action(cx.listener(Self::on_close_tab)) .on_action(cx.listener(Self::on_export)) .on_action(cx.listener(Self::on_print)) diff --git a/crates/ely-office/src/close.rs b/crates/ely-office/src/close.rs index 27ade2c..5ba7fa5 100644 --- a/crates/ely-office/src/close.rs +++ b/crates/ely-office/src/close.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use ely_gpui_component::primitives::Severity; -use ely_ui::commands::{CloseTab, Open, Quit, Save, SaveAs}; +use ely_ui::commands::{CloseTab, Open, Save, SaveAs}; use gpui::{App, AppContext as _, Context, PathPromptOptions, TaskExt as _, Window}; use crate::{Dialog, Engine, Host, Opening, Saving, Tabs, in_window, text}; @@ -41,8 +41,31 @@ pub trait Closing: Host { } } - fn on_quit(&mut self, _: &Quit, window: &mut Window, cx: &mut Context) { - self.proceed(After::Quit, window, cx); + /// Takes `window` as home; quit anywhere asks here first. + fn own_window(&mut self, window: &Window, cx: &mut Context) { + let handle = window.window_handle(); + self.core_mut().window = Some(handle); + let host = cx.entity().downgrade(); + ely_ui::commands::route_quit(cx, move |cx| { + let host = host.clone(); + // The asking window is busy until its action ends. + cx.defer(move |cx| { + let asked = handle.update(cx, |_, window, cx| { + // The question shows in front, with the keys. + window.activate_window(); + host.update(cx, |host, cx| { + host.quitting(cx); + host.proceed(After::Quit, window, cx); + }) + }); + match asked { + Ok(Ok(())) => {} + // No home window is left to ask in. + _ if handle.update(cx, |_, _, _| ()).is_err() => cx.quit(), + Ok(Err(error)) | Err(error) => log::error!("office: the quit was not asked: {error}"), + } + }); + }); } fn no_document(&self, cx: &mut Context) { diff --git a/crates/ely-office/src/lib.rs b/crates/ely-office/src/lib.rs index bb04d66..3e5c472 100644 --- a/crates/ely-office/src/lib.rs +++ b/crates/ely-office/src/lib.rs @@ -72,6 +72,8 @@ pub trait Host: Sized + 'static { fn observed(&mut self, id: u64, view: &Entity>, cx: &mut Context); /// Runs before a search first moves the cursor. fn search_leaves(&mut self, _: &mut Context) {} + /// Runs as a quit starts, before any tab closes. + fn quitting(&mut self, _: &mut Context) {} /// Runs when a close stops: question or save cancelled. fn close_cancelled(&mut self, _: &mut Context) {} /// An outside change met a save; true if shown. diff --git a/crates/ely-ui/src/commands.rs b/crates/ely-ui/src/commands.rs index 766f6b5..a6d11f6 100644 --- a/crates/ely-ui/src/commands.rs +++ b/crates/ely-ui/src/commands.rs @@ -1,5 +1,7 @@ use ely_platform::AppId; -use gpui::{Action, App, KeyBinding, Menu, MenuItem, SharedString, Window, actions}; +use std::rc::Rc; + +use gpui::{Action, App, Global, KeyBinding, Menu, MenuItem, SharedString, Window, actions}; use crate::i18n; @@ -25,6 +27,29 @@ actions!( [OpenElyDoc, OpenElyTable, OpenElyShow, OpenElyPdf, OpenElyMail, OpenElyTree, OpenElyMark, OpenElyAgent] ); +/// The main window's own quit, run from any window. +struct QuitRoute(Rc); + +impl Global for QuitRoute {} + +/// Quit from every window, settings too, runs `quit`. +pub fn route_quit(cx: &mut App, quit: impl Fn(&mut App) + 'static) { + cx.set_global(QuitRoute(Rc::new(quit))); +} + +/// Quit, from any window, goes through `quit`. +pub fn bind_quit(cx: &mut App) { + cx.on_action(|_: &Quit, cx| quit(cx)); +} + +/// Quits through the main window's route, if one is set. +fn quit(cx: &mut App) { + match cx.try_global::().map(|route| Rc::clone(&route.0)) { + Some(route) => route(cx), + None => cx.quit(), + } +} + /// The action that starts `app` (§16 app switching). pub fn switch_to(app: AppId) -> Box { match app { diff --git a/crates/ely-ui/src/run.rs b/crates/ely-ui/src/run.rs index 1a6570a..7672b7a 100644 --- a/crates/ely-ui/src/run.rs +++ b/crates/ely-ui/src/run.rs @@ -74,7 +74,7 @@ fn open( theme::install(cx); crate::i18n::install(cx); crate::commands::bind_keys(cx); - cx.on_action(|_: &crate::commands::Quit, cx| cx.quit()); + crate::commands::bind_quit(cx); cx.on_action(move |_: &crate::commands::OpenSettings, cx| crate::settings::open(app, cx)); let mut options = WindowOptions { focus: smoke == Smoke::Off, diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 965e693..3f7eee3 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -87,7 +87,7 @@ - [ ] K-02 图块的代际在画完之后才取,画到一半的作废被盖掉 - [ ] K-03 `rect_of` 宽容解析把读不出的载荷悄悄吞掉 - [ ] K-04 `Sheets.current` 用 `as u64` 转有符号的 part -- [ ] X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点 +- [x] X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点 — 退出一律经主窗口宿主的流程(route_quit),延后到动作结束、先把宿主窗口提到前面;R1 主窗口 Cmd+Q 不问;R2 询问被设置窗口挡住;R3 PASS - [ ] X-02 共用 `settings.json` 读不出时 Doc、Table、Show 根本起不来 - [ ] X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点 - [ ] X-04 属性栏字号菜单的 `expect("sizes are numbers")` 是脆弱点,但当前不可达 diff --git a/tasks/reviews/X-01-r1.md b/tasks/reviews/X-01-r1.md new file mode 100644 index 0000000..5b7abd3 --- /dev/null +++ b/tasks/reviews/X-01-r1.md @@ -0,0 +1,3 @@ +FAIL + +crates/ely-office/src/close.rs:50 — 主窗口的 Cmd+Q 和应用菜单“退出”会直接丢失未保存内容。GPUI 在窗口动作派发期间暂时取出当前窗口,此处同步 `handle.update` 返回 `window not found`,第 57–58 行随即调用 `cx.quit()`,跳过 `Host::quitting` 和未保存询问,并将恢复会话记为干净结束。实测:ElyDoc 新建文档,键入 `ZqxQuitProbe`,等待生成恢复点,保持主窗口聚焦后按 Cmd+Q;进程以 0 退出,询问未出现,`session.lock` 被删除;重新启动后恢复点从 1 个变成 0 个,文字丢失。独立点击应用菜单“退出”同样直接退出。设置窗口的两个入口均出现询问,取消后文字和恢复点保留。证据保存在 `/tmp/ely-x01-native-evidence.log`,违反 PRD §6.2 的保存、放弃、取消要求。新增测试 `apps/ely-doc/src/tests/more.rs:236` 直接在 App 更新中调用退出函数,绕过窗口动作派发,因此该测试通过。修复方向:将退出转发延后至当前窗口更新结束,确认宿主窗口确已关闭时才直接退出;回归测试通过主窗口和设置窗口实际派发退出动作,验证询问、取消及恢复点保留。 diff --git a/tasks/reviews/X-01-r2.md b/tasks/reviews/X-01-r2.md new file mode 100644 index 0000000..a4cbf1e --- /dev/null +++ b/tasks/reviews/X-01-r2.md @@ -0,0 +1,3 @@ +FAIL + +crates/ely-office/src/close.rs:53 — 从设置窗口退出时,保存询问被完全遮挡,键盘焦点仍留在设置窗口。复现:ElyDoc 新建文档并键入文字,Cmd+, 打开居中的设置窗口,再按 Cmd+Q 或点击应用菜单“退出”;延后路由在后台主窗口生成询问,设置窗口继续位于最前,遮住保存、放弃与取消按钮;按 Esc 后询问仍然存在。两个入口均已在真实窗口复现并检查截图与 AX,日志 `/tmp/ely-x01-r2-focus-evidence.log` 记录前台窗口为 Settings、`escape_leaves_question=true`,违反 PRD §6.2 和 UX-03。新增回归测试仅检查 Dialog 状态与标签存活,遗漏前台窗口和键盘取消。修复方向:转发退出时先激活并前置宿主窗口,再启动关闭流程;回归测试覆盖询问可见、焦点进入询问及 Esc 取消。 diff --git a/tasks/reviews/X-01-r3.md b/tasks/reviews/X-01-r3.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/X-01-r3.md @@ -0,0 +1 @@ +PASS