Merge PR #342: pane naming and tray menu fixes

Integrate the reviewed pane naming, CI documentation and native tray menu fixes.
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-28 21:48:45 +08:00
commit 77e6470a9d
17 files changed
+1024 -138

No files matched your search

+10 -1
View File
@@ -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。
@@ -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.
@@ -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.
+1
View File
@@ -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",
+1
View File
@@ -963,6 +963,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,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<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;
}
}
}
@@ -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<NebulaWorkspace>,
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::<Vec<_>>();
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<String> {
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::<Vec<_>>();
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);
});
}
@@ -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: 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 {
+110 -19
View File
@@ -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 — 等待输入"
);
}
}
}
}
}
}
}
@@ -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),