diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06fad6b..a89adb0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,10 +144,18 @@ not repeat the matrix; source updates and reopen events run it again. Native CI uses pinned `cargo-nextest` for unit and integration tests, followed by `cargo test --doc` with the same workspace features; the production feature graph and release workspace are still checked separately. -PRs restore Cargo caches without uploading merge-ref snapshots. The default branch +Native-test PR jobs restore Cargo caches without uploading merge-ref snapshots. The default branch publishes reusable snapshots per dependency/toolchain configuration; a cache hit never skips current-commit tests. Downloads are shared between architectures of the same OS, while compiled targets remain isolated by architecture and SDK. + +Auxiliary UI screenshots must not add a second automatic upstream PR build matrix. +Keep contributor-owned screenshot builds and publication in the source fork; +upstream capture needs an explicitly requested run and read-only target-cache +restoration. Screenshot evidence must identify its source commit and does not +replace native regression tests. The registered upstream screenshot workflow is +disabled as recorded in the [cache ownership decision](architecture/notes/scripts/ci/2026-09-28-upstream-screenshot-cache-ownership.md). + Local hooks are convenient, but bypassable; they are not the enforcement boundary. Submitting a workflow or `CODEOWNERS` file does not configure server-side rules. @@ -162,6 +170,7 @@ feature work; there is no routine `--skip-architecture` option. - 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。 - 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。 - Draft 和 Ready PR 都先运行必需的格式检查和矩阵规划,再执行五平台原生测试及两项 macOS release 编译检查;每个 Mac 在同一 runner 完成两类检查,原有 release 检查名称由轻量结果汇总保留。十项必需检查全绿才能合并,不省略平台或 doctest。 +- 附加截图留在贡献者 fork 中生成;上游按需执行,不重复自动跑整套截图构建,也不上传 PR 专属的大型编译缓存。截图与原生回归测试不能互相替代。 - 2000 行是现有仓库的防灾上限,800 行只提示审查,不是“大厂标准”。 - 普通功能 PR 不得增加存量债务;有问题的规则可以修订,但要有反例、测试和维护者审批。 - 新增核心抽象、依赖方向、持久化或线程模型改变要先说明设计,不强迫每个小修复写 ADR。 diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-28-pane-titles.md b/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-28-pane-titles.md new file mode 100644 index 0000000..e506359 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-28-pane-titles.md @@ -0,0 +1,95 @@ +# Pane titles belong to pane identity + +## Status + +Proposed. + +## Context + +A split tab can contain several terminals with the same automatic title. Tab +renaming labels the whole group and cannot distinguish its individual panes. +Users need an optional pane label without changing the shell's reported title, +foreground-program identity, or the tab's existing custom name. + +## Evidence + +- `nebula_app/src/gpui_shell/workspace/pane_header.rs` derives each automatic + title from the foreground program, SSH destination, or current directory. +- `TerminalPane` in `workspace.rs` owns the stable pane ID and moves with its + terminal when tabs are rearranged or transferred between windows. +- `session_recovery.rs` maps layout leaves to individual pane metadata. The + tab-level `custom_name` describes the group rather than any one leaf. + +## Decision + +Store the optional custom name on `TerminalPane`, and capture it on the matching +`LayoutSession::Pane` leaf. An absent field means the existing automatic title; +the additive, optional field does not require a session version migration. +Restore trims names through the same normalization used by the editor. + +Keep title editing in `workspace/rename.rs` alongside the existing tab editor. +The pane editor resolves its target by stable pane ID, and ignores callbacks +from superseded input entities. Double-click enters editing; Enter, outside +mouse-down and blur commit; Escape discards the buffer. Outside/blur commits +preserve the user's new focus destination. On the next frame, an abandoned +input focus with no new owner falls back to the active terminal. This check and +the actual focus change happen together, without a second deferred focus call. +The outside handler also covers blank areas that do not accept focus. An empty +value means automatic naming, with the live automatic title as its placeholder; +opening and dismissing an untouched editor therefore preserves automatic mode. + +Keep up to 32 previous committed names on the live pane, including the absence +of a custom name. Reopening the editor makes these available through the input's +platform Undo/Redo actions. Once typing starts, these actions pass through to +the input's native text history for the rest of that edit. This keeps a prior +rename from merging with new typing under the component's one-second undo +grouping interval. Only changed, committed names add history; Escape does not. +History stores values, never window-bound input entities, and is not persisted. + +Transfers retain the pane object and its name. SSH retry replaces the terminal +object, so it explicitly carries the custom name and history forward and cancels stale +editing state. Removing the edited pane/tab discards its input. A sibling exit +preserves editing while a split header remains, and saves the surviving pane's +draft before collapsing to one pane hides that header. + +## Rejected alternatives + +- Reusing the tab custom name: all panes would share one label. +- Overwriting the shell title: later terminal reports could overwrite the user + label, and consumers of program identity would receive presentation text. +- Storing names by tab index or pane ordinal: moving or closing panes changes + these positions, which could attach a label to the wrong terminal. +- Saving the current automatic title on blur: merely opening and dismissing the + editor would silently disable future automatic title updates. +- Seeding the input's native undo stack with prior renames: its public API cannot + force a grouping boundary, so immediate typing could undo together with the + previous rename. Keeping old input entities would also retain window-specific + focus subscriptions across pane transfers. + +## Consequences + +Custom names override only the header text; icons and terminal identity remain +automatic. The existing split-only header visibility rule is unchanged, so a +single-pane tab retains its pane name until it is split again. + +The GPUI session path preserves the new field. Older binaries and the legacy +shell can read the additive format but do not promise to retain pane names when +writing a new snapshot. + +## Validation + +Permanent tests in `workspace/rename/tests.rs` exercise the actual pane headers +and input controls: bounded saved-name history, repeated undo/redo, the transition +to text history, cancellation, and non-empty names through snapshots, JSON and +workspace restoration. The session schema round-trip fixture also carries named +and automatic panes. Windows preview builds were used for manual interaction +testing; the PR includes the title editor screenshot. + +## Supersedes + +None. + +## Revisit when + +Pane headers become visible for single-pane tabs, another shell adopts pane +renaming, or pane identity no longer moves with the owned terminal view. diff --git a/architecture/notes/scripts/ci/2026-09-28-upstream-screenshot-cache-ownership.md b/architecture/notes/scripts/ci/2026-09-28-upstream-screenshot-cache-ownership.md new file mode 100644 index 0000000..a0bd53c --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-28-upstream-screenshot-cache-ownership.md @@ -0,0 +1,110 @@ +# Keep auxiliary screenshot work from displacing shared native caches + +## Status + +Operational change applied and read back on 2026-09-28. This note records the +upstream workflow registration and cache cleanup; committing documentation alone +does not change GitHub settings. No branch-protection rule was changed. + +## Context + +The required native suite publishes reusable default-branch snapshots and restores +them read-only on PRs. An auxiliary screenshot workflow introduced through open +fork PRs independently built complete Windows/macOS release applications and +published large PR-scoped Rust caches in the upstream repository. + +The screenshot workflow is absent from `main` at `8087306b`, but its GitHub +registration still accepted events for PR branches that supplied the file. Editing +only a workflow on `main` would not change those already-open fork branches. + +## Evidence + +- [Native run 36327240193](https://github.com/Kuddev/pebrel/actions/runs/36327240193) + took 85m29s. Its Intel Mac queued for 48m07s and executed for 36m47s. Both Mac + architectures missed their native and release target caches, with the same + keys used by previously available default-branch entries. +- The cache inventory contained four `v0-rust-native-ui-` snapshots scoped to + PR merge refs, together using 5.39 GB. The previously observed default-branch + Mac target snapshots were no longer present. These observations establish + cache pressure and misses, not an audit of who deleted each older entry. +- [Screenshot run 36325527329](https://github.com/Kuddev/pebrel/actions/runs/36325527329) + used `.github/workflows/ui-review-screenshots.yml` from a fork PR. Its source + ran on both fork pushes and upstream PR updates; its publication job was + explicitly restricted to the source fork's push events. Upstream repeated the + full application builds but did not run that publication job. +- The screenshot action used `Swatinem/rust-cache` with failure caching and no + read-only restriction. GitHub's [cache scope rules](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) + do not make one PR's merge-ref cache available to sibling PRs merely because + their visible cache keys match. + +## Decision + +- Disable only the upstream `UI review screenshots` registration, workflow ID + `366570225`, through the documented workflow API. Leave source-fork workflow + `366585525` active. No contributor branch or application code is rewritten. +- Preserve the full five-platform native suite, release-profile checks, required + contexts, assertions, feature graphs, packaging and branch-protection rules. + Auxiliary screenshot evidence remains separate from native regression coverage. +- After confirming there are no active screenshot runs, remove only identified + upstream PR-scoped `v0-rust-native-ui-` entries. Preserve every default-branch + cache, release artifact and source file. Do not install recurring broad cleanup. +- Keep contributor screenshot publication in its source repository. Any future + upstream screenshot entrypoint must be explicitly requested, restore target + caches read-only, and retain exact-source evidence before it is enabled. + +## Rejected alternatives + +- Delete all caches: would force more cold native and release checks. +- Raise storage quota or buy runners first: does not address duplicate upstream + builds or the unshared snapshots that caused the observed competition. +- Push fixes into all contributor branches immediately: creates new PR heads and + repeats their required native matrices. The upstream registration can be stopped + without editing their feature code or interrupting their fork publication. +- Add a new scanning framework or equivalent CI gate: unnecessary for retiring + this identified auxiliary registration. A source check alone also does not stop + an already-running workflow from publishing another snapshot. +- Remove native platforms, Markdown regressions or assertions: changes required + coverage rather than addressing resource ownership. + +## Consequences + +Upstream PR updates no longer launch this registered screenshot workflow. Native +tests still run normally, including for docs-only changes. Source-fork screenshot +builds and artifacts remain available for review with their source identity. + +At cleanup time only one of the identified PR screenshot entries remained. Removing +it freed 1,351,294,893 bytes; the other previously observed entries were already +absent and are not counted as deletions performed by this change. The resulting +inventory retained all ten default-branch source/target snapshots, totaling +9,023,076,490 bytes. No cache quota or runner-plan setting changed. + +This retires one upstream registration, not arbitrary future workflow files. +New auxiliary workflows and any re-enablement still need resource/ownership review. +Dependency or SDK changes can legitimately cause cold builds; cache retention does +not establish a fixed CI duration. + +## Validation + +- The disable API returned 204; a fresh GET reported `disabled_manually` for + upstream workflow `366570225` and `active` for fork workflow `366585525`. +- No screenshot run was active or queued, so no workflow run was cancelled. +- Deleting cache `8179623547` returned 204. Readback showed the selected PR cache + absent and every pre-cleanup default-branch cache ID still present. +- The main-branch rules read before and after the operation were identical. +- The preceding main native run [36362067181](https://github.com/Kuddev/pebrel/actions/runs/36362067181) + completed successfully and repopulated the shared snapshots. A subsequent PR + run measures reuse; this operation does not claim that the eight-minute target + has already been achieved. + +## Supersedes + +None. The coverage and cache boundaries in +[shared native PR caches](2026-09-25-shared-native-pr-cache.md) and +[shared macOS runners](2026-09-27-shared-macos-runners.md) remain unchanged. + +## Revisit when + +An upstream-owned screenshot workflow has an agreed opt-in trigger, source identity +and bounded read-only cache use. Re-enabling the recorded registration requires an +explicit maintainer action after that review; a documentation edit is not an +enablement operation. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 126cf9e..b2442b6 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -963,6 +963,7 @@ }, "workspace": { "pane": { + "rename_hint": "Double-click to rename; Enter or click outside to save, Esc to cancel. Clear for an automatic title. Reopen to undo a saved name.", "stop_broadcast_input": "Stop broadcasting input", "broadcast_input_tooltip": "Broadcast input to every pane in this tab", "close": "Close this pane", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 49d8796..d50a96f 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -963,6 +963,7 @@ }, "workspace": { "pane": { + "rename_hint": "双击重命名;Enter 或点击外部保存,Esc 取消。清空恢复自动标题,重新编辑可撤销已保存的名称。", "stop_broadcast_input": "关闭广播输入", "broadcast_input_tooltip": "广播输入到本标签全部分栏", "close": "关闭此分栏", diff --git a/nebula_app/src/gpui_shell/session_restore.rs b/nebula_app/src/gpui_shell/session_restore.rs index 5d11596..2bdaa38 100644 --- a/nebula_app/src/gpui_shell/session_restore.rs +++ b/nebula_app/src/gpui_shell/session_restore.rs @@ -25,12 +25,12 @@ fn direction_of(axis: SplitAxis) -> SplitDirection { /// 不被 f32 序列化噪声绊倒,旧壳同因)。 pub fn layout_from_tree( tree: &SplitTree, - leaf_data: &impl Fn(u64) -> (String, Option, Option), + leaf_data: &impl Fn(u64) -> (String, Option, Option, Option), ) -> LayoutSession { match tree { SplitTree::Leaf(id) => { - let (cwd, agent, launch) = leaf_data(*id); - LayoutSession::Pane { cwd, agent, launch } + let (cwd, agent, launch, custom_name) = leaf_data(*id); + LayoutSession::Pane { cwd, agent, launch, custom_name } }, SplitTree::Split { direction, ratio, first, second, .. } => LayoutSession::Split { axis: axis_of(*direction), @@ -92,7 +92,7 @@ mod tests { #[test] fn layout_round_trips_structure_ratio_and_leaf_order() { let tree = sample_tree(); - let layout = layout_from_tree(&tree, &|id| (format!("D:/pane-{id}"), None, None)); + let layout = layout_from_tree(&tree, &|id| (format!("D:/pane-{id}"), None, None, None)); assert_eq!(layout.pane_count(), 3); let mut next = 10u64; @@ -134,6 +134,7 @@ mod tests { session_id: Some("abc-1".into()), }), None, + None, ) }); assert!(matches!( diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 680c59b..04da1d7 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -65,6 +65,10 @@ mod quick_jump; mod quick_terminal; mod recipes; mod remote_files; +mod rename; +#[cfg(test)] +use rename::apply_cancel_rename; +use rename::{PaneRename, TabRename, apply_commit_rename}; mod residency; mod send_to_chat; mod session_persistence; @@ -177,6 +181,9 @@ pub fn init(cx: &mut App) { /// (AI hook 的 `NEBULA_PANE_ID` 同源),全工作区唯一、终生不复用。 struct TerminalPane { id: u64, + custom_name: Option, + /// Recent committed names; follows the live pane without retaining window-bound inputs. + name_history: Vec>, view: Entity, _subscription: Subscription, } @@ -621,33 +628,13 @@ struct TabMeta { has_bell: bool, } -/// 侧栏行内重命名的活动状态(旧壳 `nebula_tab_rename` 同形态:被编辑的那 -/// 一行原地变输入框,而不是弹一个对话框)。Enter 提交;Esc / 失焦取消 -/// (对照 `input/chrome.rs` 点在框外 = `CancelRename`);提交空串 = 恢复 -/// 自动标签名。 -struct TabRename { - ix: usize, - input: Entity, - _subscription: Subscription, -} - -/// 旧壳 `TabRequest::CommitRename`(`window_context.rs` ~871-880): -/// trim;空串 → `custom_name = None`(恢复自动名);非空 → `Some(trimmed)`。 -fn apply_commit_rename(meta: &mut TabMeta, buffer: &str) { - let trimmed = buffer.trim(); - meta.custom_name = if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) }; -} - -/// 旧壳 `TabRequest::CancelRename`(`window_context.rs` ~896-901): -/// 丢掉重命名缓冲,`custom_name` 保持进入编辑前的值。 -fn apply_cancel_rename(_meta: &mut TabMeta) {} - pub struct NebulaWorkspace { tabs: Vec, /// 与 `tabs` 同下标的用户元数据,见 [`TabMeta`]。 tab_meta: Vec, /// 正在行内重命名的标签,见 [`TabRename`]。 tab_rename: Option, + pane_rename: Option, next_pane_id: u64, active: usize, /// Window-level Settings surface. It intentionally lives outside `tabs`: @@ -841,6 +828,10 @@ impl NebulaWorkspace { if ix >= self.tabs.len() { return None; } + if self.pane_rename.as_ref().is_some_and(|edit| self.tab_of_pane(edit.pane_id) == Some(ix)) + { + self.pane_rename = None; + } let tab = self.tabs.remove(ix); // 长度不齐时(理论上不会)宁可给默认元数据,也不要 panic。 let meta = @@ -945,6 +936,7 @@ impl NebulaWorkspace { tabs: Vec::new(), tab_meta: Vec::new(), tab_rename: None, + pane_rename: None, // 首窗仍从 1 起,保持既有 runtime/测试身份;后续窗口用高 32 位 // 分区,AI hook 只有 pane id 时也不会撞到另一窗口的同号 pane。 next_pane_id: runtime_window_id @@ -1216,7 +1208,13 @@ impl NebulaWorkspace { if let Some(command) = command { view.update(cx, |view, cx| view.run_command(command, cx)); } - TerminalPane { id: pane_id, view, _subscription: subscription } + TerminalPane { + id: pane_id, + custom_name: None, + name_history: Vec::new(), + view, + _subscription: subscription, + } } /// 现网格(聚焦终端)或开窗反推的目标网格:让新 pane 的 PTY 出生即 @@ -1363,6 +1361,8 @@ impl NebulaWorkspace { ) { let Some(WorkspaceTab::Terminal { panes, .. }) = self.tabs.get(tab_ix) else { return }; let Some(old) = panes.iter().find(|pane| pane.id == pane_id) else { return }; + let custom_name = old.custom_name.clone(); + let name_history = old.name_history.clone(); let (grid, remote_cwd) = { let view = old.view.read(cx); if view.ssh_destination.as_deref() != Some(destination.as_str()) { @@ -1378,7 +1378,10 @@ impl NebulaWorkspace { destination: destination.clone(), cwd: remote_cwd, }; - let replacement = self.new_pane(grid, launch, None, window, cx); + let mut replacement = self.new_pane(grid, launch, None, window, cx); + replacement.custom_name = custom_name; + replacement.name_history = name_history; + self.forget_pane_rename(pane_id); let replacement_id = replacement.id; let old = { let Some(WorkspaceTab::Terminal { panes, tree, focused, .. }) = @@ -1505,6 +1508,18 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) { + if let Some(edit) = self.pane_rename.as_ref() + && self.tab_of_pane(edit.pane_id) == Some(tab_ix) + && let Some(WorkspaceTab::Terminal { panes, .. }) = self.tabs.get(tab_ix) + && panes.iter().any(|pane| pane.id == pane_id) + { + if edit.pane_id == pane_id { + self.pane_rename = None; + } else if panes.len() == 2 { + // The survivor's header disappears when the split collapses. + self.commit_pane_rename(false, window, cx); + } + } let outcome = match self.tabs.get_mut(tab_ix) { Some(WorkspaceTab::Terminal { tree, .. }) => tree.remove_leaf(pane_id), _ => return, @@ -1537,7 +1552,9 @@ impl NebulaWorkspace { self.pane_bounds.borrow_mut().remove(&pane_id); self.mark_structural_resize(tab_ix, cx); if tab_ix == self.active { - self.focus_active(window, cx); + if self.pane_rename.is_none() { + self.focus_active(window, cx); + } self.sync_side_panel_to_active(true, cx); } cx.notify(); @@ -2344,81 +2361,6 @@ impl NebulaWorkspace { cx.notify(); } - /// 进入行内重命名:对照旧壳 `TabRequest::BeginRename` - /// (`window_context.rs` ~854-868)。预填 `custom_name`,否则 - /// `chrome_tab_label`(cwd 末级,不含分屏后缀)。已在编辑别的行时丢掉 - /// 前一次缓冲(不提交),与旧壳覆盖 `nebula_tab_rename` 同合同。 - fn begin_rename(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { - if ix >= self.tabs.len() { - return; - } - // 覆盖前一次编辑:只丢缓冲,不走 Commit(否则会把当时显示的目录名 - // 冻成 custom_name)。不要调用 `cancel_rename`——它会 `focus_active` - // 把焦点延迟抢回终端,紧接着的输入框 focus 会被下一帧冲掉。 - let _ = self.tab_rename.take(); - let current = self.meta(ix).custom_name.unwrap_or_else(|| self.rename_prefill(ix, cx)); - let input = cx.new(|cx| InputState::new(window, cx)); - let subscription = cx.subscribe_in( - &input, - window, - |this: &mut Self, _: &Entity, event: &InputEvent, window, cx| { - match event { - InputEvent::PressEnter { .. } => this.commit_rename(window, cx), - // 点在框外 = CancelRename(`input/chrome.rs` ~359-368), - // 不是 Commit。Blur 提交会把自动目录名冻成 custom_name。 - InputEvent::Blur => this.cancel_rename(window, cx), - _ => {}, - } - }, - ); - // 旧壳 `nebula_tab_rename_select_all = true`:set_value 后全选再 focus。 - // `InputState::select_all` 是 `pub(super)`,对外走公开的 `SelectAll` action。 - input.update(cx, |state, cx| { - state.set_value(current, window, cx); - state.focus(window, cx); - }); - self.tab_rename = Some(TabRename { ix, input, _subscription: subscription }); - cx.on_next_frame(window, |this, window, cx| { - let Some(rename) = this.tab_rename.as_ref() else { return }; - if !rename.input.read(cx).focus_handle(cx).is_focused(window) { - return; - } - window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx); - }); - cx.notify(); - } - - /// BeginRename 预填:有 custom 用 custom,否则终端用聚焦 pane 的 - /// `tab_label()`(cwd 末级,对齐 `chrome_tab_label`),其它 tab 用标题。 - fn rename_prefill(&self, ix: usize, cx: &App) -> String { - match self.tabs.get(ix) { - Some(tab @ WorkspaceTab::Terminal { .. }) => tab - .focused_view() - .map(|view| view.read(cx).tab_label()) - .unwrap_or_else(|| self.tab_title(ix, cx).to_string()), - _ => self.tab_title(ix, cx).to_string(), - } - } - - fn commit_rename(&mut self, window: &mut Window, cx: &mut Context) { - let Some(rename) = self.tab_rename.take() else { return }; - let name = rename.input.read(cx).value(); - if let Some(meta) = self.tab_meta.get_mut(rename.ix) { - apply_commit_rename(meta, &name); - } - self.focus_active(window, cx); - cx.notify(); - } - - fn cancel_rename(&mut self, window: &mut Window, cx: &mut Context) { - let Some(rename) = self.tab_rename.take() else { return }; - if let Some(meta) = self.tab_meta.get_mut(rename.ix) { - apply_cancel_rename(meta); - } - self.focus_active(window, cx); - cx.notify(); - } - /// 标签色标:同色再点一次即取消(旧壳菜单里选中的那枚色块 = 当前色)。 fn set_tab_color(&mut self, ix: usize, color: Option, cx: &mut Context) { if let Some(meta) = self.tab_meta.get_mut(ix) { @@ -2539,6 +2481,7 @@ impl NebulaWorkspace { pane.id, ordinal, &pane.view, + pane.custom_name.as_deref(), true, *zoomed, *broadcast, @@ -2604,7 +2547,16 @@ impl NebulaWorkspace { let ordinal = order.iter().position(|other| *other == id).map_or(1, |at| at + 1); let header = pane.map(|pane| { self.render_pane_header( - tab_ix, id, ordinal, &pane.view, is_focused, false, broadcast, corners, cx, + tab_ix, + id, + ordinal, + &pane.view, + pane.custom_name.as_deref(), + is_focused, + false, + broadcast, + corners, + cx, ) }); // 布局:标题条固定高 + 终端吃剩余。canvas 探针仍量**整个叶子** diff --git a/nebula_app/src/gpui_shell/workspace/pane_header.rs b/nebula_app/src/gpui_shell/workspace/pane_header.rs index 4fe2065..db30b23 100644 --- a/nebula_app/src/gpui_shell/workspace/pane_header.rs +++ b/nebula_app/src/gpui_shell/workspace/pane_header.rs @@ -26,8 +26,9 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ App, Bounds, Context, Entity, FontWeight, Hsla, InteractiveElement as _, IntoElement, - MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, SharedString, Styled as _, - StyledImage as _, Window, canvas, div, img, px, size, + KeyDownEvent, MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, SharedString, + StatefulInteractiveElement as _, Styled as _, StyledImage as _, Window, canvas, div, img, px, + size, }; use nebula_split::{SplitDirection, SplitTree}; @@ -242,6 +243,7 @@ impl NebulaWorkspace { pane_id: u64, ordinal: usize, view: &Entity, + custom_name: Option<&str>, focused: bool, zoomed: bool, broadcast: bool, @@ -254,6 +256,7 @@ impl NebulaWorkspace { let muted = theme.muted_foreground; let ink = if focused { theme.foreground } else { muted }; let accent = theme.primary; + let editor_bg = theme.background; // 聚焦 pane 的标题条比正文亮一档,失焦的贴回卡底——veil 只盖终端区, // 标题条自己用色阶表达焦点(把标题也压暗 30% 会让四个 pane 的标题 // 全都糊成一片灰)。 @@ -265,6 +268,13 @@ impl NebulaWorkspace { let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0); let title_px = label_px * 0.78; let PaneTitle { logo, logo_pending, glyph, text } = self.pane_title(view, cx, dark); + let automatic_title = text.clone(); + let text = custom_name.map(SharedString::from).unwrap_or(text); + let renaming = self + .pane_rename + .as_ref() + .filter(|edit| edit.pane_id == pane_id) + .map(|edit| edit.input.clone()); let group: SharedString = format!("pane-header-{pane_id}").into(); let icon_ink = if focused { ink } else { muted }; @@ -296,6 +306,7 @@ impl NebulaWorkspace { // 整条:挂整条的话按住广播/关闭键再手抖 4px 就会把 pane 拖出去。 h_flex() .id(("pane-header-grip", pane_id as usize)) + .debug_selector(move || format!("pane-header-grip-{pane_id}")) .flex_1() .min_w_0() .h_full() @@ -310,6 +321,10 @@ impl NebulaWorkspace { this.begin_pane_drag(tab_ix, pane_id, event.position, cx); }), ) + .on_double_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.begin_pane_rename(pane_id, automatic_title.clone(), window, cx); + })) .child( // 序号:与 tab 上的数量胶囊同一份 pane_order 次序, // "这是第 2 个"在标题条和标签栏上指的是同一个 pane。 @@ -340,8 +355,44 @@ impl NebulaWorkspace { .child(glyph), ) }) - .child( - div() + .child(match renaming { + Some(input) => div() + .id(("pane-title-editor", pane_id as usize)) + .debug_selector(move || format!("pane-title-editor-{pane_id}")) + .flex_1() + .min_w_0() + .h_full() + .flex() + .items_center() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_down_out(cx.listener(move |this, _, window, cx| { + if this.pane_rename.as_ref().is_some_and(|edit| edit.pane_id == pane_id) { + this.commit_pane_rename(false, window, cx); + } + })) + .on_double_click(|_, _, cx| cx.stop_propagation()) + .capture_action(cx.listener(|this, _: &gpui_component::input::Undo, window, cx| { + this.restore_pane_name(false, window, cx); + })) + .capture_action(cx.listener(|this, _: &gpui_component::input::Redo, window, cx| { + this.restore_pane_name(true, window, cx); + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "escape" { + cx.stop_propagation(); + this.cancel_pane_rename(true, window, cx); + } + })) + .child(gpui::Styled::h( + Input::new(&input).small().w_full().py_0() + .focus_bordered(false).border_1().border_color(accent) + .bg(editor_bg).rounded(px(3.0)) + .text_color(ink).text_size(px(title_px)).font_family(chrome_family.clone()), + px(PANE_HEADER_H - 4.0), + )) + .into_any_element(), + None => div() + .id(("pane-title", pane_id as usize)) .flex_1() .min_w_0() .truncate() @@ -349,8 +400,11 @@ impl NebulaWorkspace { .text_size(px(title_px)) .font_weight(FontWeight::NORMAL) .text_color(ink) - .child(text), - ), + .tooltip(move |window, cx| super::tab_presentation::tooltip( + language.text(Message::WorkspacePaneRenameHint).into(), window, cx)) + .child(text) + .into_any_element(), + }), ) .child( h_flex() diff --git a/nebula_app/src/gpui_shell/workspace/recipes/store.rs b/nebula_app/src/gpui_shell/workspace/recipes/store.rs index 0e0b7be..2955d1f 100644 --- a/nebula_app/src/gpui_shell/workspace/recipes/store.rs +++ b/nebula_app/src/gpui_shell/workspace/recipes/store.rs @@ -150,6 +150,7 @@ mod tests { axis: SplitAxis::TopBottom, ratio_permille: 370, first: Box::new(LayoutSession::Pane { + custom_name: None, launch: None, cwd: "D:/api".into(), agent: Some(AgentSession { @@ -159,6 +160,7 @@ mod tests { }), }), second: Box::new(LayoutSession::Pane { + custom_name: None, launch: None, cwd: "D:/web".into(), agent: None, diff --git a/nebula_app/src/gpui_shell/workspace/rename.rs b/nebula_app/src/gpui_shell/workspace/rename.rs new file mode 100644 index 0000000..182ad0b --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/rename.rs @@ -0,0 +1,273 @@ +//! Inline title editing; pane identity is independent of tab position and shell titles. +use super::*; + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests; + +pub(super) fn normalized_name(buffer: &str) -> Option { + let trimmed = buffer.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +/// 侧栏行内重命名的活动状态(旧壳 `nebula_tab_rename` 同形态:被编辑的那 +/// 一行原地变输入框,而不是弹一个对话框)。Enter 提交;Esc / 失焦取消 +/// (对照 `input/chrome.rs` 点在框外 = `CancelRename`);提交空串 = 恢复 +/// 自动标签名。 +pub(super) struct TabRename { + pub(super) ix: usize, + pub(super) input: Entity, + _subscription: Subscription, +} + +/// 旧壳 `TabRequest::CommitRename`(`window_context.rs` ~871-880): +/// trim;空串 → `custom_name = None`(恢复自动名);非空 → `Some(trimmed)`。 +pub(super) fn apply_commit_rename(meta: &mut TabMeta, buffer: &str) { + meta.custom_name = normalized_name(buffer); +} + +/// 旧壳 `TabRequest::CancelRename`(`window_context.rs` ~896-901): +/// 丢掉重命名缓冲,`custom_name` 保持进入编辑前的值。 +pub(super) fn apply_cancel_rename(_meta: &mut TabMeta) {} + +pub(super) struct PaneRename { + pub(super) pane_id: u64, + pub(super) input: Entity, + saved_names: Vec>, + saved_name_index: usize, + /// Once typing starts, the input owns text Undo/Redo for the rest of this edit. + local_edits: bool, + _subscription: Subscription, +} + +impl NebulaWorkspace { + /// 进入行内重命名:对照旧壳 `TabRequest::BeginRename` + /// (`window_context.rs` ~854-868)。预填 `custom_name`,否则 + /// `chrome_tab_label`(cwd 末级,不含分屏后缀)。已在编辑别的行时丢掉 + /// 前一次缓冲(不提交),与旧壳覆盖 `nebula_tab_rename` 同合同。 + pub(super) fn begin_rename(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if ix >= self.tabs.len() { + return; + } + // 覆盖前一次编辑:只丢缓冲,不走 Commit(否则会把当时显示的目录名 + // 冻成 custom_name)。不要调用 `cancel_rename`——它会 `focus_active` + // 把焦点延迟抢回终端,紧接着的输入框 focus 会被下一帧冲掉。 + let _ = self.tab_rename.take(); + self.commit_pane_rename(false, window, cx); + let current = self.meta(ix).custom_name.unwrap_or_else(|| self.rename_prefill(ix, cx)); + let input = cx.new(|cx| InputState::new(window, cx)); + let subscription = cx.subscribe_in( + &input, + window, + |this: &mut Self, input: &Entity, event: &InputEvent, window, cx| { + if this.tab_rename.as_ref().is_none_or(|edit| edit.input != *input) { + return; + } + match event { + InputEvent::PressEnter { .. } => this.commit_rename(window, cx), + // 点在框外 = CancelRename(`input/chrome.rs` ~359-368), + // 不是 Commit。Blur 提交会把自动目录名冻成 custom_name。 + InputEvent::Blur => this.cancel_rename(window, cx), + _ => {}, + } + }, + ); + // 旧壳 `nebula_tab_rename_select_all = true`:set_value 后全选再 focus。 + // `InputState::select_all` 是 `pub(super)`,对外走公开的 `SelectAll` action。 + input.update(cx, |state, cx| { + state.set_value(current, window, cx); + state.focus(window, cx); + }); + self.tab_rename = Some(TabRename { ix, input, _subscription: subscription }); + cx.on_next_frame(window, |this, window, cx| { + let Some(rename) = this.tab_rename.as_ref() else { return }; + if !rename.input.read(cx).focus_handle(cx).is_focused(window) { + return; + } + window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx); + }); + cx.notify(); + } + + /// BeginRename 预填:有 custom 用 custom,否则终端用聚焦 pane 的 + /// `tab_label()`(cwd 末级,对齐 `chrome_tab_label`),其它 tab 用标题。 + pub(super) fn rename_prefill(&self, ix: usize, cx: &App) -> String { + match self.tabs.get(ix) { + Some(tab @ WorkspaceTab::Terminal { .. }) => tab + .focused_view() + .map(|view| view.read(cx).tab_label()) + .unwrap_or_else(|| self.tab_title(ix, cx).to_string()), + _ => self.tab_title(ix, cx).to_string(), + } + } + + pub(super) fn commit_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some(rename) = self.tab_rename.take() else { return }; + let name = rename.input.read(cx).value(); + if let Some(meta) = self.tab_meta.get_mut(rename.ix) { + apply_commit_rename(meta, &name); + } + self.focus_active(window, cx); + cx.notify(); + } + + pub(super) fn cancel_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some(rename) = self.tab_rename.take() else { return }; + if let Some(meta) = self.tab_meta.get_mut(rename.ix) { + apply_cancel_rename(meta); + } + self.focus_active(window, cx); + cx.notify(); + } + + pub(super) fn begin_pane_rename( + &mut self, + pane_id: u64, + automatic_title: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + self.commit_pane_rename(false, window, cx); + let Some(ix) = self.tab_of_pane(pane_id) else { return }; + let Some(WorkspaceTab::Terminal { panes, .. }) = self.tabs.get(ix) else { return }; + let Some(pane) = panes.iter().find(|pane| pane.id == pane_id) else { return }; + let current = pane.custom_name.clone().unwrap_or_default(); + let mut saved_names = pane.name_history.clone(); + saved_names.push(pane.custom_name.clone()); + let saved_name_index = saved_names.len() - 1; + // Drop subscriptions before changing focus, so an old Blur cannot cancel the new editor. + self.tab_rename = None; + self.pane_rename = None; + self.pane_drag = None; + // An empty value means automatic naming; show the live title as a placeholder. + // Merely opening and leaving the editor must not freeze a directory/program name. + let input = cx.new(|cx| InputState::new(window, cx).placeholder(automatic_title)); + input.update(cx, |state, cx| state.set_value(current, window, cx)); + let subscription = cx.subscribe_in( + &input, + window, + |this: &mut Self, input, event: &InputEvent, window, cx| { + if this.pane_rename.as_ref().is_none_or(|edit| edit.input != *input) { + return; + } + match event { + InputEvent::PressEnter { .. } => this.commit_pane_rename(true, window, cx), + // Clicking another pane/control must keep its newly acquired focus. + InputEvent::Blur => this.commit_pane_rename(false, window, cx), + InputEvent::Change => { + if let Some(edit) = this.pane_rename.as_mut() { + edit.local_edits = true; + } + }, + _ => {}, + } + }, + ); + self.pane_rename = Some(PaneRename { + pane_id, + input: input.clone(), + saved_names, + saved_name_index, + local_edits: false, + _subscription: subscription, + }); + cx.on_next_frame(window, move |this, window, cx| { + if this.pane_rename.as_ref().is_none_or(|edit| edit.input != input) { + return; + } + input.update(cx, |state, cx| state.focus(window, cx)); + window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx); + }); + cx.notify(); + } + + pub(super) fn commit_pane_rename( + &mut self, + restore_focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(edit) = self.pane_rename.take() else { return }; + let name = normalized_name(&edit.input.read(cx).value()); + let editor_focus = edit.input.read(cx).focus_handle(cx); + if let Some(ix) = self.tab_of_pane(edit.pane_id) + && let WorkspaceTab::Terminal { panes, .. } = &mut self.tabs[ix] + && let Some(pane) = panes.iter_mut().find(|pane| pane.id == edit.pane_id) + { + if pane.custom_name != name { + const SAVED_NAME_LIMIT: usize = 32; + if pane.name_history.len() == SAVED_NAME_LIMIT { + pane.name_history.remove(0); + } + pane.name_history.push(pane.custom_name.take()); + pane.custom_name = name; + } + } + if restore_focus { + self.focus_active(window, cx); + } else if editor_focus.is_focused(window) { + // A blank click has no new focus owner. Let its target run first, + // then recover only the abandoned editor's focus, never another control's. + cx.on_next_frame(window, move |this, window, cx| { + if this.pane_rename.is_none() + && this.tab_rename.is_none() + && (editor_focus.is_focused(window) || window.focused(cx).is_none()) + && let Some(view) = + this.tabs.get(this.active).and_then(WorkspaceTab::focused_view) + { + // Do not defer a second time: another callback may focus a new input. + window.focus(&view.read(cx).focus_handle(cx), cx); + } + }); + } + cx.notify(); + } + + /// Saved-name history is available on reopening. After typing, let the input + /// handle its normal text history instead of merging it with a prior rename. + pub(super) fn restore_pane_name( + &mut self, + redo: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(edit) = self.pane_rename.as_mut().filter(|edit| !edit.local_edits) else { + cx.propagate(); + return; + }; + cx.stop_propagation(); + let next = if redo { + (edit.saved_name_index + 1).min(edit.saved_names.len() - 1) + } else { + edit.saved_name_index.saturating_sub(1) + }; + if next == edit.saved_name_index { + return; + } + edit.saved_name_index = next; + let name = edit.saved_names[next].clone().unwrap_or_default(); + edit.input.update(cx, |state, cx| state.set_value(name, window, cx)); + window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx); + cx.notify(); + } + + pub(super) fn cancel_pane_rename( + &mut self, + restore_focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.pane_rename.take().is_none() { + return; + } + if restore_focus { + self.focus_active(window, cx); + } + cx.notify(); + } + + pub(super) fn forget_pane_rename(&mut self, pane_id: u64) { + if self.pane_rename.as_ref().is_some_and(|edit| edit.pane_id == pane_id) { + self.pane_rename = None; + } + } +} diff --git a/nebula_app/src/gpui_shell/workspace/rename/tests.rs b/nebula_app/src/gpui_shell/workspace/rename/tests.rs new file mode 100644 index 0000000..6653652 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/rename/tests.rs @@ -0,0 +1,258 @@ +use super::*; +use crate::gpui_shell::terminal::view::TerminalLaunch; +use gpui::{Modifiers, TestAppContext, VisualTestContext}; + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.update(|window, cx| window.simulate_next_frame(cx)); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.run_until_parked(); +} + +struct RenameFixture { + workspace: Entity, + ids: [u64; 2], + headers: [&'static str; 2], + _directory: tempfile::TempDir, +} + +impl RenameFixture { + fn open(cx: &mut TestAppContext) -> (Self, VisualTestContext) { + let directory = tempfile::tempdir().unwrap(); + // An absolute missing program avoids PATH lookup and never starts a user shell. + let program = directory.path().join("pebrel-test-missing-shell"); + let hub = crate::runtime_api::RuntimeHub::new(); + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::math_view::register(cx); + crate::gpui_shell::file_editor::init(cx); + super::super::init(cx); + windowing::initialize(cx, hub.clone()); + }); + let mut result = None; + let (_, window) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + NebulaWorkspace::new( + window, + None, + None, + 1, + hub, + windowing::WorkspaceStartup::Empty, + windowing::WindowRole::Regular, + cx, + ) + }); + let ids = workspace.update(cx, |workspace, cx| { + let panes = (0..2) + .map(|_| { + workspace.new_pane( + (80, 24), + TerminalLaunch::Local { + cwd: Some(directory.path().to_path_buf()), + shell: Some(nebula_terminal::tty::Shell::new( + program.to_string_lossy().into_owned(), + vec![], + )), + shell_name: None, + }, + None, + window, + cx, + ) + }) + .collect::>(); + let ids = [panes[0].id, panes[1].id]; + let mut tree = SplitTree::leaf(ids[0]); + assert!(tree.split_leaf(ids[0], ids[1], SplitDirection::LeftRight, 0.5)); + workspace.insert_tab_at( + 0, + WorkspaceTab::Terminal { + panes, + tree, + focused: ids[0], + zoomed: false, + broadcast: false, + }, + TabMeta { custom_name: Some("group-title".into()), ..Default::default() }, + ); + workspace.focus_active(window, cx); + cx.notify(); + ids + }); + result = Some((workspace.clone(), ids)); + Root::new(workspace, window, cx) + }); + let (workspace, ids) = result.unwrap(); + // GPUI's test selector API requires static strings; allocate once per pane. + let headers = ids.map(|id| &*Box::leak(format!("pane-header-grip-{id}").into_boxed_str())); + let mut window = window.clone(); + window.update(|window, _| window.activate_window()); + settle(&mut window); + (Self { workspace, ids, headers, _directory: directory }, window) + } + + fn edit(&self, index: usize, cx: &mut VisualTestContext) { + let position = + cx.debug_bounds(self.headers[index]).expect("pane title is visible").center(); + cx.simulate_click(position, Modifiers::default()); + cx.simulate_event(gpui::MouseDownEvent { + position, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 2, + first_mouse: false, + }); + cx.simulate_event(gpui::MouseUpEvent { + position, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 2, + }); + settle(cx); + assert_eq!( + self.workspace.read_with(cx, |w, _| w.pane_rename.as_ref().unwrap().pane_id), + self.ids[index], + ); + } + + fn save(&self, index: usize, value: &str, cx: &mut VisualTestContext) { + self.edit(index, cx); + cx.simulate_input(value); + save_outside(cx); + assert_eq!(self.name(index, cx).as_deref(), Some(value)); + } + + fn name(&self, index: usize, cx: &VisualTestContext) -> Option { + self.workspace.read_with(cx, |w, _| { + let WorkspaceTab::Terminal { panes, .. } = &w.tabs[0] else { panic!() }; + panes.iter().find(|pane| pane.id == self.ids[index]).unwrap().custom_name.clone() + }) + } + + fn value(&self, cx: &VisualTestContext) -> String { + self.workspace.read_with(cx, |w, cx| { + w.pane_rename.as_ref().unwrap().input.read(cx).value().to_string() + }) + } +} + +fn save_outside(cx: &mut VisualTestContext) { + cx.simulate_click(gpui::point(px(2.0), px(200.0)), Modifiers::default()); + settle(cx); +} + +fn undo(cx: &mut VisualTestContext) { + cx.simulate_keystrokes(match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => "cmd-z", + _ => "ctrl-z", + }); +} + +fn redo(cx: &mut VisualTestContext) { + cx.simulate_keystrokes(match crate::platform::Platform::current() { + crate::platform::Platform::MacOS => "cmd-shift-z", + _ => "ctrl-y", + }); +} + +#[gpui::test] +fn saved_names_undo_redo_then_typing_uses_native_history(cx: &mut TestAppContext) { + let (fixture, mut cx) = RenameFixture::open(cx); + for name in ["第一次命名", "第二次命名", "第三次命名"] { + fixture.save(0, name, &mut cx); + } + fixture.edit(0, &mut cx); + for name in ["第二次命名", "第一次命名", ""] { + undo(&mut cx); + assert_eq!(fixture.value(&cx), name); + } + undo(&mut cx); + assert_eq!(fixture.value(&cx), "", "the oldest value is automatic mode"); + for name in ["第一次命名", "第二次命名", "第三次命名"] { + redo(&mut cx); + assert_eq!(fixture.value(&cx), name); + } + redo(&mut cx); + assert_eq!(fixture.value(&cx), "第三次命名"); + cx.simulate_input("尚未保存"); + undo(&mut cx); + assert_eq!(fixture.value(&cx), "第三次命名"); + undo(&mut cx); + assert_eq!(fixture.value(&cx), "第三次命名", "text undo must not enter saved-name history"); + redo(&mut cx); + assert_eq!(fixture.value(&cx), "尚未保存"); + cx.simulate_keystrokes("escape"); + settle(&mut cx); + assert_eq!(fixture.name(0, &cx).as_deref(), Some("第三次命名")); + fixture.edit(0, &mut cx); + undo(&mut cx); + assert_eq!(fixture.value(&cx), "第二次命名", "cancelled edits must not add a saved name"); + assert_eq!(fixture.name(1, &cx), None); +} + +#[gpui::test] +fn saved_names_keep_only_32_previous_changes(cx: &mut TestAppContext) { + let (fixture, mut cx) = RenameFixture::open(cx); + for index in 0..35 { + fixture.save(0, &format!("name-{index}"), &mut cx); + } + // Saving an unchanged value must not consume another history slot. + fixture.edit(0, &mut cx); + save_outside(&mut cx); + fixture.edit(0, &mut cx); + for index in (2..34).rev() { + undo(&mut cx); + assert_eq!(fixture.value(&cx), format!("name-{index}")); + } + undo(&mut cx); + assert_eq!(fixture.value(&cx), "name-2", "older names have been evicted"); + for index in 3..35 { + redo(&mut cx); + assert_eq!(fixture.value(&cx), format!("name-{index}")); + } + redo(&mut cx); + assert_eq!(fixture.value(&cx), "name-34"); + assert_eq!(fixture.name(1, &cx), None); +} + +#[gpui::test] +fn custom_pane_names_survive_snapshot_json_and_workspace_restore(cx: &mut TestAppContext) { + let (fixture, mut cx) = RenameFixture::open(cx); + fixture.save(0, "前端", &mut cx); + fixture.save(1, "后端", &mut cx); + let snapshot = fixture.workspace.read_with(&cx, |w, cx| w.snapshot_session(cx)); + let json = serde_json::to_value(&snapshot).unwrap(); + // Check the actual payload, not just equality of two potentially empty snapshots. + assert_eq!(json["tabs"][0]["layout"]["first"]["custom_name"], "前端"); + assert_eq!(json["tabs"][0]["layout"]["second"]["custom_name"], "后端"); + let restored: crate::session::Session = serde_json::from_value(json).unwrap(); + assert_eq!(snapshot, restored); + cx.update(|window, cx| { + fixture.workspace.update(cx, |w, cx| { + assert!(w.restore_tab(&restored.tabs[0], false, window, cx)); + }); + }); + fixture.workspace.read_with(&cx, |w, cx| { + let WorkspaceTab::Terminal { panes, tree, .. } = &w.tabs[1] else { panic!() }; + let names = tree + .leaves() + .iter() + .map(|id| { + let pane = panes.iter().find(|pane| pane.id == *id).unwrap(); + assert!(!fixture.ids.contains(id), "restored panes have new identities"); + assert!(pane.name_history.is_empty(), "undo history is not persisted"); + pane.custom_name.as_deref() + }) + .collect::>(); + assert_eq!(names, vec![Some("前端"), Some("后端")]); + assert_eq!(w.meta(1).custom_name.as_deref(), Some("group-title")); + let again = w.snapshot_session(cx); + assert_eq!(again.tabs[1].layout, restored.tabs[0].layout); + }); +} diff --git a/nebula_app/src/gpui_shell/workspace/session_persistence.rs b/nebula_app/src/gpui_shell/workspace/session_persistence.rs index 9159a25..d262f5c 100644 --- a/nebula_app/src/gpui_shell/workspace/session_persistence.rs +++ b/nebula_app/src/gpui_shell/workspace/session_persistence.rs @@ -129,6 +129,7 @@ mod tests { fn sample_session() -> Session { let mut tab = TabSession::single("D:/work".into(), Some("Workspace".into()), None); tab.layout = Some(LayoutSession::Pane { + custom_name: None, launch: None, cwd: tab.cwd.clone(), agent: Some(AgentSession { diff --git a/nebula_app/src/gpui_shell/workspace/session_recovery.rs b/nebula_app/src/gpui_shell/workspace/session_recovery.rs index a529c7c..767ce4d 100644 --- a/nebula_app/src/gpui_shell/workspace/session_recovery.rs +++ b/nebula_app/src/gpui_shell/workspace/session_recovery.rs @@ -82,6 +82,7 @@ impl NebulaWorkspace { use crate::session::{LaunchSession, LayoutSession}; let layout = tab.layout.clone().unwrap_or(LayoutSession::Pane { + custom_name: None, launch: None, cwd: tab.cwd.clone(), agent: None, @@ -92,7 +93,7 @@ impl NebulaWorkspace { let grid = self.initial_grid; let mut panes: Vec = Vec::new(); for (index, leaf) in layout.leaves().into_iter().enumerate() { - let LayoutSession::Pane { cwd, agent, launch } = leaf else { continue }; + let LayoutSession::Pane { cwd, agent, launch, custom_name } = leaf else { continue }; let mut launch_session = launch.clone().unwrap_or_else(|| { if index == 0 { saved_launch.clone() @@ -107,7 +108,8 @@ impl NebulaWorkspace { tab_duplication::inherit_guest_directory(&mut launch_session, cwd); let local_cwd = if guest_directory { None } else { crate::session::valid_dir(cwd) }; let launch = Self::terminal_launch_from_session(&launch_session, local_cwd); - let pane = self.new_pane(grid, launch, None, window, cx); + let mut pane = self.new_pane(grid, launch, None, window, cx); + pane.custom_name = custom_name.as_deref().and_then(rename::normalized_name); if !matches!(launch_session, LaunchSession::Default) { pane.view.update(cx, |view, _| view.session_launch = launch_session); } @@ -169,13 +171,23 @@ impl NebulaWorkspace { if ix == self.active { active_out = tabs.len(); } - let leaf_data = |id: u64| -> (String, Option, Option) { + let leaf_data = |id: u64| -> ( + String, + Option, + Option, + Option, + ) { let Some(pane) = panes.iter().find(|pane| pane.id == id) else { - return (String::new(), None, None); + return (String::new(), None, None, None); }; let view = pane.view.read(cx); let agent = view.session_agent(); - (view.cwd.clone(), agent, Some(view.session_launch.clone())) + ( + view.cwd.clone(), + agent, + Some(view.session_launch.clone()), + pane.custom_name.clone(), + ) }; let layout = crate::gpui_shell::session_restore::layout_from_tree(tree, &leaf_data); let cwd = panes diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs index 5d1769a..2e87407 100644 --- a/nebula_app/src/gpui_shell/workspace/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/tests.rs @@ -909,6 +909,7 @@ mod boot_breaker_tests { color: None, launch: Some(launch.clone()), layout: Some(LayoutSession::Pane { + custom_name: None, cwd: String::new(), agent: Some(saved.clone()), launch: Some(launch), diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index e58b6f6..87117cb 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -133,6 +133,9 @@ impl AgentSession { pub enum LayoutSession { Pane { cwd: String, + /// User pane title, independent of the tab title and shell-reported title. + #[serde(default, skip_serializing_if = "Option::is_none")] + custom_name: Option, /// v4 的追加可选字段(老文件缺省、老版本忽略,无需升版):快照时 /// 该 pane 前台的 AI CLI 对话,冷恢复据此自动接续。 #[serde(default, skip_serializing_if = "Option::is_none")] @@ -467,6 +470,7 @@ mod tests { axis: SplitAxis::LeftRight, ratio_permille: 618, first: Box::new(LayoutSession::Pane { + custom_name: Some("前端".into()), launch: None, cwd: "D:/work".into(), agent: Some(AgentSession { @@ -479,11 +483,13 @@ mod tests { axis: SplitAxis::TopBottom, ratio_permille: 500, first: Box::new(LayoutSession::Pane { + custom_name: Some("后端".into()), launch: None, cwd: "D:/logs".into(), agent: None, }), second: Box::new(LayoutSession::Pane { + custom_name: None, launch: None, cwd: String::new(), agent: None, @@ -507,10 +513,16 @@ mod tests { let session = parse(json).expect("v4 without agent must parse"); assert_eq!( session.tabs[0].layout, - Some(LayoutSession::Pane { launch: None, cwd: "D:/w".into(), agent: None }) + Some(LayoutSession::Pane { + custom_name: None, + launch: None, + cwd: "D:/w".into(), + agent: None + }) ); let with_agent = LayoutSession::Pane { + custom_name: None, launch: None, cwd: "D:/w".into(), agent: Some(AgentSession { @@ -561,12 +573,23 @@ mod tests { let tree = LayoutSession::Split { axis: SplitAxis::LeftRight, ratio_permille: 500, - first: Box::new(LayoutSession::Pane { launch: None, cwd: "a".into(), agent: None }), + first: Box::new(LayoutSession::Pane { + custom_name: None, + launch: None, + cwd: "a".into(), + agent: None, + }), second: Box::new(LayoutSession::Split { axis: SplitAxis::TopBottom, ratio_permille: 500, - first: Box::new(LayoutSession::Pane { launch: None, cwd: "b".into(), agent: None }), + first: Box::new(LayoutSession::Pane { + custom_name: None, + launch: None, + cwd: "b".into(), + agent: None, + }), second: Box::new(LayoutSession::Pane { + custom_name: None, launch: None, cwd: "c".into(), agent: None, @@ -595,6 +618,7 @@ mod tests { .map(|id| { let mut tab = TabSession::single("/home/user/project".into(), None, None); tab.layout = Some(LayoutSession::Pane { + custom_name: None, launch: None, cwd: "/home/user/project".into(), agent: Some(AgentSession { diff --git a/nebula_app/src/tray.rs b/nebula_app/src/tray.rs index cc555e5..c546b34 100644 --- a/nebula_app/src/tray.rs +++ b/nebula_app/src/tray.rs @@ -341,29 +341,20 @@ mod win { } } - fn show_menu(hwnd: windows_sys::Win32::Foundation::HWND) { - use windows_sys::Win32::Foundation::POINT; + fn build_menu( + agents: &[TrayAgent], + include_quit: bool, + ) -> windows_sys::Win32::UI::WindowsAndMessaging::HMENU { use windows_sys::Win32::UI::WindowsAndMessaging::{ - AppendMenuW, CreatePopupMenu, DestroyMenu, GetCursorPos, MF_GRAYED, MF_SEPARATOR, - MF_STRING, SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, - TrackPopupMenu, WM_NULL, + AppendMenuW, CreatePopupMenu, MF_SEPARATOR, MF_STRING, }; - // 菜单期间用快照:TrackPopupMenu 是模态泵,STATE 可能被 1 Hz 更新, - // 命令 id 必须映射回打开菜单那一刻的清单。 - let agents = state().agents.clone(); - - // SAFETY: 菜单句柄本函数创建/销毁;文案缓冲活过 AppendMenuW(菜单 - // 复制内容)。SetForegroundWindow + WM_NULL 是 TrackPopupMenu 在 - // 托盘场景的官方仪式(否则菜单点外部不收合)。 + // 构建与模态展示分开,回归测试才能检查真实 HMENU,而不启动托盘线程。 + // SAFETY: 文案缓冲活过 AppendMenuW(菜单复制内容);调用方负责销毁菜单。 unsafe { let menu = CreatePopupMenu(); if menu.is_null() { - return; - } - if agents.is_empty() { - let text = wide("没有正在运行的 agent"); - AppendMenuW(menu, MF_STRING | MF_GRAYED, 0, text.as_ptr()); + return menu; } for (index, agent) in agents.iter().enumerate() { // 状态点进文案:菜单不画自定义图形,实心/空心圈已经把 @@ -373,16 +364,40 @@ mod win { let text = wide(&format!("{mark}{}{suffix}", agent.label)); AppendMenuW(menu, MF_STRING, MENU_AGENT_BASE + index, text.as_ptr()); } - AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + // 没有 agent 时整组都不存在,避免空提示或孤立分隔线占据菜单顶部。 + if !agents.is_empty() { + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + } let show = wide(&format!("显示 {}", crate::brand::NAME)); AppendMenuW(menu, MF_STRING, MENU_SHOW, show.as_ptr()); // 旧壳没有托盘「退出」:真退出是 window+detached 都空。GPUI hide // 之后可能只剩托盘,所以只在 GPUI 回调路径上加这一项。 - if GPUI_COMMAND.get().is_some() { + if include_quit { let quit = wide(&format!("退出 {}", crate::brand::NAME)); AppendMenuW(menu, MF_STRING, MENU_QUIT, quit.as_ptr()); } + menu + } + } + fn show_menu(hwnd: windows_sys::Win32::Foundation::HWND) { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + DestroyMenu, GetCursorPos, SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, + TPM_RIGHTBUTTON, TrackPopupMenu, WM_NULL, + }; + + // 菜单期间用快照:TrackPopupMenu 是模态泵,STATE 可能被 1 Hz 更新, + // 命令 id 必须映射回打开菜单那一刻的清单。 + let agents = state().agents.clone(); + + // SAFETY: 菜单由本函数持有/销毁。SetForegroundWindow + WM_NULL 保留 + // TrackPopupMenu 在托盘场景中点外部收合的行为。 + unsafe { + let menu = build_menu(&agents, GPUI_COMMAND.get().is_some()); + if menu.is_null() { + return; + } let mut point = POINT { x: 0, y: 0 }; GetCursorPos(&mut point); SetForegroundWindow(hwnd); @@ -642,4 +657,80 @@ mod win { } tip[cursor] = 0; } + + #[cfg(test)] + mod tests { + use super::*; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + DestroyMenu, GetMenuItemCount, GetMenuItemID, GetMenuState, GetMenuStringW, HMENU, + MF_BYPOSITION, MF_GRAYED, MF_SEPARATOR, + }; + + struct Menu(HMENU); + + impl Drop for Menu { + fn drop(&mut self) { + // SAFETY: 测试独占此菜单,断言失败时也要释放原生句柄。 + unsafe { DestroyMenu(self.0) }; + } + } + + #[test] + fn native_tray_menu_omits_empty_agent_group_and_preserves_commands() { + let agents = [ + TrayAgent { + window: winit::window::WindowId::dummy(), + pane: 11, + label: "Codex".into(), + needs_attention: false, + }, + TrayAgent { + window: winit::window::WindowId::dummy(), + pane: 22, + label: "Claude".into(), + needs_attention: true, + }, + ]; + // 两条菜单路径都覆盖空/非空;不修改进程级回调或真实托盘状态。 + for include_quit in [false, true] { + for entries in [&[][..], &agents[..]] { + let menu = Menu(build_menu(entries, include_quit)); + assert!(!menu.0.is_null()); + let show_index = if entries.is_empty() { 0 } else { 3 }; + // SAFETY: 句柄有效,索引受构建的菜单长度约束,字符缓冲可写。 + unsafe { + assert_eq!( + GetMenuItemCount(menu.0), + show_index + 1 + i32::from(include_quit) + ); + assert_eq!(GetMenuItemID(menu.0, show_index), MENU_SHOW as u32); + assert_eq!( + GetMenuState(menu.0, 0, MF_BYPOSITION) & (MF_SEPARATOR | MF_GRAYED), + 0 + ); + if include_quit { + assert_eq!(GetMenuItemID(menu.0, show_index + 1), MENU_QUIT as u32); + } + if !entries.is_empty() { + assert_eq!(GetMenuItemID(menu.0, 0), MENU_AGENT_BASE as u32); + assert_eq!(GetMenuItemID(menu.0, 1), (MENU_AGENT_BASE + 1) as u32); + assert_ne!(GetMenuState(menu.0, 2, MF_BYPOSITION) & MF_SEPARATOR, 0); + let mut text = [0u16; 128]; + let count = GetMenuStringW( + menu.0, + 1, + text.as_mut_ptr(), + text.len() as i32, + MF_BYPOSITION, + ); + assert_eq!( + String::from_utf16(&text[..count as usize]).unwrap(), + "● Claude — 等待输入" + ); + } + } + } + } + } + } } diff --git a/nebula_app/src/window_context/session_snapshot.rs b/nebula_app/src/window_context/session_snapshot.rs index c449b39..b779e20 100644 --- a/nebula_app/src/window_context/session_snapshot.rs +++ b/nebula_app/src/window_context/session_snapshot.rs @@ -114,6 +114,7 @@ impl WindowContext { ) -> session::LayoutSession { match layout { Layout::Leaf(id) => session::LayoutSession::Pane { + custom_name: None, launch: None, cwd: pane_cwd(*id), agent: pane_agent(*id),