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..7521602 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/2026-09-28-pane-titles.md @@ -0,0 +1,93 @@ +# 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 + +Validation covers real GPUI header mouse events, keyboard input, outside saving, +saved-name and text undo/redo, cancellation, session restoration, alongside tab rename and +session regression tests. The preview build still requires user visual testing +for the native window, font rendering and display scaling. + +## 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/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 4b9a99d..3afa22b 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -961,6 +961,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 a78fd32..137966e 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -961,6 +961,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..2aef539 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/rename.rs @@ -0,0 +1,270 @@ +//! Inline title editing; pane identity is independent of tab position and shell titles. +use super::*; + +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/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..287d71c 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: None, 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: None, 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/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),