diff --git a/CLAUDE.md b/CLAUDE.md index 8dfcd3a..e4dbd70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ sqlite3 ~/Library/Application\ Support/shellr/shellr.db '.schema' # 查落盘 - **状态经由 store 流动。** 面板和对话框拿到 `Entity` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 - **Dock 面板**需实现 `EventEmitter + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。 - **标签页全部可关。** `TabGroup::close_panel` 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 `CloseTerminal` / `CloseExplorer`,⌘W 派发 `CloseActiveTab`(关工作区记录的最近显示的中间标签,面板在 `set_active(true)` 时发出 `Activated` 事件告知),处理器一律用 `DockArea::remove_panel` 移除。中间区空了就显示最近连接列表:`SessionStore::recent_sessions()` 按「最近一次变为已连接」的顺序给出,`set_state` 变为 Connected 时把会话移到最前。 -- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。 +- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 `set_active(true)` 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 `both_tabs_of_one_session_stay_closable`)。 - **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。 - **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `SessionPanel` 把 `.context_menu(..)` 挂在 `#session-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。 - **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。 diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index fdce824..b191dae 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -106,11 +106,16 @@ impl BasePanel for ExplorerPanel { "ExplorerPanel" } - fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context) { + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { if active { let id = self.session_id; self.store .update(cx, |store, cx| store.set_active(Some(id), cx)); + // The tab that just went inactive stops rendering, so the focus + // it still holds leaves the dispatch tree and every + // `window.dispatch_action` (the tab's 「×」, ⌘W, the shortcuts) + // lands nowhere. Whoever becomes active has to take focus. + window.focus(&self.focus_handle, cx); cx.emit(ExplorerPanelEvent::Activated(id)); } } diff --git a/tests/ui.rs b/tests/ui.rs index a65639a..2c367e6 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -1170,3 +1170,60 @@ async fn groups_and_sessions_are_read_back_from_the_database(cx: &mut TestAppCon [session] ); } + +/// A session's terminal and SFTP tabs sit side by side in the center, and +/// only the active one renders. The tab going inactive used to keep the +/// window focus, which took its focus handle out of the dispatch tree and +/// left every 「×」 dead. +#[gpui_kit::test] +fn both_tabs_of_one_session_stay_closable(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", DB_01), cx); + }) + .unwrap(); + cx.run_until_parked(); + + // The terminal tab is active and focused; opening SFTP puts a second tab + // for the same session beside it and activates that one. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("sftp", cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find(("explorer", DB_01)).visible()); + window.click(("close-explorer", DB_01), cx); + }) + .unwrap(); + cx.run_until_parked(); + + // The SFTP tab is gone and the terminal tab, now active again, still closes. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find(("close-explorer", DB_01)).is_none()); + assert!(window.find(("terminal", DB_01)).visible()); + window.click(("close-terminal", DB_01), cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find(("close-terminal", DB_01)).is_none()); + }) + .unwrap(); + + cx.update(|cx| { + let workspace = workspace.read(cx); + assert!(workspace.explorer(SessionId(DB_01)).is_none()); + assert!(workspace.terminal(SessionId(DB_01)).is_none()); + }); +}