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:
17 files changed
+1024
-138
No files matched your search
+10
-1
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -963,6 +963,7 @@
|
||||
},
|
||||
"workspace": {
|
||||
"pane": {
|
||||
"rename_hint": "双击重命名;Enter 或点击外部保存,Esc 取消。清空恢复自动标题,重新编辑可撤销已保存的名称。",
|
||||
"stop_broadcast_input": "关闭广播输入",
|
||||
"broadcast_input_tooltip": "广播输入到本标签全部分栏",
|
||||
"close": "关闭此分栏",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user