feat(workspace): 支持分屏窗格标题重命名

This commit is contained in:
JoyElliot committed 2026-09-28 11:20:19 +08:00
1 parent 8087306b2b
commit 6528d9f12b
13 files changed
+531 -118

No files matched your search

@@ -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.
+1
View File
@@ -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",
+1
View File
@@ -961,6 +961,7 @@
},
"workspace": {
"pane": {
"rename_hint": "双击重命名;Enter 或点击外部保存,Esc 取消。清空恢复自动标题,重新编辑可撤销已保存的名称。",
"stop_broadcast_input": "关闭广播输入",
"broadcast_input_tooltip": "广播输入到本标签全部分栏",
"close": "关闭此分栏",
+5 -4
View File
@@ -25,12 +25,12 @@ fn direction_of(axis: SplitAxis) -> SplitDirection {
/// 不被 f32 序列化噪声绊倒,旧壳同因)。
pub fn layout_from_tree(
tree: &SplitTree<u64>,
leaf_data: &impl Fn(u64) -> (String, Option<AgentSession>, Option<LaunchSession>),
leaf_data: &impl Fn(u64) -> (String, Option<AgentSession>, Option<LaunchSession>, Option<String>),
) -> 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!(
+52 -100
View File
@@ -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<String>,
/// Recent committed names; follows the live pane without retaining window-bound inputs.
name_history: Vec<Option<String>>,
view: Entity<TerminalView>,
_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<InputState>,
_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<WorkspaceTab>,
/// 与 `tabs` 同下标的用户元数据,见 [`TabMeta`]。
tab_meta: Vec<TabMeta>,
/// 正在行内重命名的标签,见 [`TabRename`]。
tab_rename: Option<TabRename>,
pane_rename: Option<PaneRename>,
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<Self>,
) {
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<Self>) {
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<InputState>, 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<Self>) {
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<Self>) {
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<Rgb>, cx: &mut Context<Self>) {
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 探针仍量**整个叶子**
@@ -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<TerminalView>,
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()
@@ -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,
@@ -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<String> {
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<InputState>,
_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<InputState>,
saved_names: Vec<Option<String>>,
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<Self>) {
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<InputState>, 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<Self>) {
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<Self>) {
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>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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;
}
}
}
@@ -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 {
@@ -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<TerminalPane> = 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<AgentSession>, Option<LaunchSession>) {
let leaf_data = |id: u64| -> (
String,
Option<AgentSession>,
Option<LaunchSession>,
Option<String>,
) {
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
@@ -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),
+27 -3
View File
@@ -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<String>,
/// 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 {
@@ -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),