feat(workspace): 支持分屏窗格标题重命名
This commit is contained in:
1 parent
8087306b2b
commit
6528d9f12b
13 files changed
+531
-118
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# Pane titles belong to pane identity
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Context
|
||||
|
||||
A split tab can contain several terminals with the same automatic title. Tab
|
||||
renaming labels the whole group and cannot distinguish its individual panes.
|
||||
Users need an optional pane label without changing the shell's reported title,
|
||||
foreground-program identity, or the tab's existing custom name.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `nebula_app/src/gpui_shell/workspace/pane_header.rs` derives each automatic
|
||||
title from the foreground program, SSH destination, or current directory.
|
||||
- `TerminalPane` in `workspace.rs` owns the stable pane ID and moves with its
|
||||
terminal when tabs are rearranged or transferred between windows.
|
||||
- `session_recovery.rs` maps layout leaves to individual pane metadata. The
|
||||
tab-level `custom_name` describes the group rather than any one leaf.
|
||||
|
||||
## Decision
|
||||
|
||||
Store the optional custom name on `TerminalPane`, and capture it on the matching
|
||||
`LayoutSession::Pane` leaf. An absent field means the existing automatic title;
|
||||
the additive, optional field does not require a session version migration.
|
||||
Restore trims names through the same normalization used by the editor.
|
||||
|
||||
Keep title editing in `workspace/rename.rs` alongside the existing tab editor.
|
||||
The pane editor resolves its target by stable pane ID, and ignores callbacks
|
||||
from superseded input entities. Double-click enters editing; Enter, outside
|
||||
mouse-down and blur commit; Escape discards the buffer. Outside/blur commits
|
||||
preserve the user's new focus destination. On the next frame, an abandoned
|
||||
input focus with no new owner falls back to the active terminal. This check and
|
||||
the actual focus change happen together, without a second deferred focus call.
|
||||
The outside handler also covers blank areas that do not accept focus. An empty
|
||||
value means automatic naming, with the live automatic title as its placeholder;
|
||||
opening and dismissing an untouched editor therefore preserves automatic mode.
|
||||
|
||||
Keep up to 32 previous committed names on the live pane, including the absence
|
||||
of a custom name. Reopening the editor makes these available through the input's
|
||||
platform Undo/Redo actions. Once typing starts, these actions pass through to
|
||||
the input's native text history for the rest of that edit. This keeps a prior
|
||||
rename from merging with new typing under the component's one-second undo
|
||||
grouping interval. Only changed, committed names add history; Escape does not.
|
||||
History stores values, never window-bound input entities, and is not persisted.
|
||||
|
||||
Transfers retain the pane object and its name. SSH retry replaces the terminal
|
||||
object, so it explicitly carries the custom name and history forward and cancels stale
|
||||
editing state. Removing the edited pane/tab discards its input. A sibling exit
|
||||
preserves editing while a split header remains, and saves the surviving pane's
|
||||
draft before collapsing to one pane hides that header.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Reusing the tab custom name: all panes would share one label.
|
||||
- Overwriting the shell title: later terminal reports could overwrite the user
|
||||
label, and consumers of program identity would receive presentation text.
|
||||
- Storing names by tab index or pane ordinal: moving or closing panes changes
|
||||
these positions, which could attach a label to the wrong terminal.
|
||||
- Saving the current automatic title on blur: merely opening and dismissing the
|
||||
editor would silently disable future automatic title updates.
|
||||
- Seeding the input's native undo stack with prior renames: its public API cannot
|
||||
force a grouping boundary, so immediate typing could undo together with the
|
||||
previous rename. Keeping old input entities would also retain window-specific
|
||||
focus subscriptions across pane transfers.
|
||||
|
||||
## Consequences
|
||||
|
||||
Custom names override only the header text; icons and terminal identity remain
|
||||
automatic. The existing split-only header visibility rule is unchanged, so a
|
||||
single-pane tab retains its pane name until it is split again.
|
||||
|
||||
The GPUI session path preserves the new field. Older binaries and the legacy
|
||||
shell can read the additive format but do not promise to retain pane names when
|
||||
writing a new snapshot.
|
||||
|
||||
## Validation
|
||||
|
||||
Validation covers real GPUI header mouse events, keyboard input, outside saving,
|
||||
saved-name and text undo/redo, cancellation, session restoration, alongside tab rename and
|
||||
session regression tests. The preview build still requires user visual testing
|
||||
for the native window, font rendering and display scaling.
|
||||
|
||||
## Supersedes
|
||||
|
||||
None.
|
||||
|
||||
## Revisit when
|
||||
|
||||
Pane headers become visible for single-pane tabs, another shell adopts pane
|
||||
renaming, or pane identity no longer moves with the owned terminal view.
|
||||
@@ -961,6 +961,7 @@
|
||||
},
|
||||
"workspace": {
|
||||
"pane": {
|
||||
"rename_hint": "Double-click to rename; Enter or click outside to save, Esc to cancel. Clear for an automatic title. Reopen to undo a saved name.",
|
||||
"stop_broadcast_input": "Stop broadcasting input",
|
||||
"broadcast_input_tooltip": "Broadcast input to every pane in this tab",
|
||||
"close": "Close this pane",
|
||||
|
||||
@@ -961,6 +961,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,270 @@
|
||||
//! Inline title editing; pane identity is independent of tab position and shell titles.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn normalized_name(buffer: &str) -> Option<String> {
|
||||
let trimmed = buffer.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
|
||||
/// 侧栏行内重命名的活动状态(旧壳 `nebula_tab_rename` 同形态:被编辑的那
|
||||
/// 一行原地变输入框,而不是弹一个对话框)。Enter 提交;Esc / 失焦取消
|
||||
/// (对照 `input/chrome.rs` 点在框外 = `CancelRename`);提交空串 = 恢复
|
||||
/// 自动标签名。
|
||||
pub(super) struct TabRename {
|
||||
pub(super) ix: usize,
|
||||
pub(super) input: Entity<InputState>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
/// 旧壳 `TabRequest::CommitRename`(`window_context.rs` ~871-880):
|
||||
/// trim;空串 → `custom_name = None`(恢复自动名);非空 → `Some(trimmed)`。
|
||||
pub(super) fn apply_commit_rename(meta: &mut TabMeta, buffer: &str) {
|
||||
meta.custom_name = normalized_name(buffer);
|
||||
}
|
||||
|
||||
/// 旧壳 `TabRequest::CancelRename`(`window_context.rs` ~896-901):
|
||||
/// 丢掉重命名缓冲,`custom_name` 保持进入编辑前的值。
|
||||
pub(super) fn apply_cancel_rename(_meta: &mut TabMeta) {}
|
||||
|
||||
pub(super) struct PaneRename {
|
||||
pub(super) pane_id: u64,
|
||||
pub(super) input: Entity<InputState>,
|
||||
saved_names: Vec<Option<String>>,
|
||||
saved_name_index: usize,
|
||||
/// Once typing starts, the input owns text Undo/Redo for the rest of this edit.
|
||||
local_edits: bool,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl NebulaWorkspace {
|
||||
/// 进入行内重命名:对照旧壳 `TabRequest::BeginRename`
|
||||
/// (`window_context.rs` ~854-868)。预填 `custom_name`,否则
|
||||
/// `chrome_tab_label`(cwd 末级,不含分屏后缀)。已在编辑别的行时丢掉
|
||||
/// 前一次缓冲(不提交),与旧壳覆盖 `nebula_tab_rename` 同合同。
|
||||
pub(super) fn begin_rename(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if ix >= self.tabs.len() {
|
||||
return;
|
||||
}
|
||||
// 覆盖前一次编辑:只丢缓冲,不走 Commit(否则会把当时显示的目录名
|
||||
// 冻成 custom_name)。不要调用 `cancel_rename`——它会 `focus_active`
|
||||
// 把焦点延迟抢回终端,紧接着的输入框 focus 会被下一帧冲掉。
|
||||
let _ = self.tab_rename.take();
|
||||
self.commit_pane_rename(false, window, cx);
|
||||
let current = self.meta(ix).custom_name.unwrap_or_else(|| self.rename_prefill(ix, cx));
|
||||
let input = cx.new(|cx| InputState::new(window, cx));
|
||||
let subscription = cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
|this: &mut Self, input: &Entity<InputState>, event: &InputEvent, window, cx| {
|
||||
if this.tab_rename.as_ref().is_none_or(|edit| edit.input != *input) {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => this.commit_rename(window, cx),
|
||||
// 点在框外 = CancelRename(`input/chrome.rs` ~359-368),
|
||||
// 不是 Commit。Blur 提交会把自动目录名冻成 custom_name。
|
||||
InputEvent::Blur => this.cancel_rename(window, cx),
|
||||
_ => {},
|
||||
}
|
||||
},
|
||||
);
|
||||
// 旧壳 `nebula_tab_rename_select_all = true`:set_value 后全选再 focus。
|
||||
// `InputState::select_all` 是 `pub(super)`,对外走公开的 `SelectAll` action。
|
||||
input.update(cx, |state, cx| {
|
||||
state.set_value(current, window, cx);
|
||||
state.focus(window, cx);
|
||||
});
|
||||
self.tab_rename = Some(TabRename { ix, input, _subscription: subscription });
|
||||
cx.on_next_frame(window, |this, window, cx| {
|
||||
let Some(rename) = this.tab_rename.as_ref() else { return };
|
||||
if !rename.input.read(cx).focus_handle(cx).is_focused(window) {
|
||||
return;
|
||||
}
|
||||
window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// BeginRename 预填:有 custom 用 custom,否则终端用聚焦 pane 的
|
||||
/// `tab_label()`(cwd 末级,对齐 `chrome_tab_label`),其它 tab 用标题。
|
||||
pub(super) fn rename_prefill(&self, ix: usize, cx: &App) -> String {
|
||||
match self.tabs.get(ix) {
|
||||
Some(tab @ WorkspaceTab::Terminal { .. }) => tab
|
||||
.focused_view()
|
||||
.map(|view| view.read(cx).tab_label())
|
||||
.unwrap_or_else(|| self.tab_title(ix, cx).to_string()),
|
||||
_ => self.tab_title(ix, cx).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(rename) = self.tab_rename.take() else { return };
|
||||
let name = rename.input.read(cx).value();
|
||||
if let Some(meta) = self.tab_meta.get_mut(rename.ix) {
|
||||
apply_commit_rename(meta, &name);
|
||||
}
|
||||
self.focus_active(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn cancel_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(rename) = self.tab_rename.take() else { return };
|
||||
if let Some(meta) = self.tab_meta.get_mut(rename.ix) {
|
||||
apply_cancel_rename(meta);
|
||||
}
|
||||
self.focus_active(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn begin_pane_rename(
|
||||
&mut self,
|
||||
pane_id: u64,
|
||||
automatic_title: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.commit_pane_rename(false, window, cx);
|
||||
let Some(ix) = self.tab_of_pane(pane_id) else { return };
|
||||
let Some(WorkspaceTab::Terminal { panes, .. }) = self.tabs.get(ix) else { return };
|
||||
let Some(pane) = panes.iter().find(|pane| pane.id == pane_id) else { return };
|
||||
let current = pane.custom_name.clone().unwrap_or_default();
|
||||
let mut saved_names = pane.name_history.clone();
|
||||
saved_names.push(pane.custom_name.clone());
|
||||
let saved_name_index = saved_names.len() - 1;
|
||||
// Drop subscriptions before changing focus, so an old Blur cannot cancel the new editor.
|
||||
self.tab_rename = None;
|
||||
self.pane_rename = None;
|
||||
self.pane_drag = None;
|
||||
// An empty value means automatic naming; show the live title as a placeholder.
|
||||
// Merely opening and leaving the editor must not freeze a directory/program name.
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder(automatic_title));
|
||||
input.update(cx, |state, cx| state.set_value(current, window, cx));
|
||||
let subscription = cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
|this: &mut Self, input, event: &InputEvent, window, cx| {
|
||||
if this.pane_rename.as_ref().is_none_or(|edit| edit.input != *input) {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
InputEvent::PressEnter { .. } => this.commit_pane_rename(true, window, cx),
|
||||
// Clicking another pane/control must keep its newly acquired focus.
|
||||
InputEvent::Blur => this.commit_pane_rename(false, window, cx),
|
||||
InputEvent::Change => {
|
||||
if let Some(edit) = this.pane_rename.as_mut() {
|
||||
edit.local_edits = true;
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
},
|
||||
);
|
||||
self.pane_rename = Some(PaneRename {
|
||||
pane_id,
|
||||
input: input.clone(),
|
||||
saved_names,
|
||||
saved_name_index,
|
||||
local_edits: false,
|
||||
_subscription: subscription,
|
||||
});
|
||||
cx.on_next_frame(window, move |this, window, cx| {
|
||||
if this.pane_rename.as_ref().is_none_or(|edit| edit.input != input) {
|
||||
return;
|
||||
}
|
||||
input.update(cx, |state, cx| state.focus(window, cx));
|
||||
window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn commit_pane_rename(
|
||||
&mut self,
|
||||
restore_focus: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(edit) = self.pane_rename.take() else { return };
|
||||
let name = normalized_name(&edit.input.read(cx).value());
|
||||
let editor_focus = edit.input.read(cx).focus_handle(cx);
|
||||
if let Some(ix) = self.tab_of_pane(edit.pane_id)
|
||||
&& let WorkspaceTab::Terminal { panes, .. } = &mut self.tabs[ix]
|
||||
&& let Some(pane) = panes.iter_mut().find(|pane| pane.id == edit.pane_id)
|
||||
{
|
||||
if pane.custom_name != name {
|
||||
const SAVED_NAME_LIMIT: usize = 32;
|
||||
if pane.name_history.len() == SAVED_NAME_LIMIT {
|
||||
pane.name_history.remove(0);
|
||||
}
|
||||
pane.name_history.push(pane.custom_name.take());
|
||||
pane.custom_name = name;
|
||||
}
|
||||
}
|
||||
if restore_focus {
|
||||
self.focus_active(window, cx);
|
||||
} else if editor_focus.is_focused(window) {
|
||||
// A blank click has no new focus owner. Let its target run first,
|
||||
// then recover only the abandoned editor's focus, never another control's.
|
||||
cx.on_next_frame(window, move |this, window, cx| {
|
||||
if this.pane_rename.is_none()
|
||||
&& this.tab_rename.is_none()
|
||||
&& (editor_focus.is_focused(window) || window.focused(cx).is_none())
|
||||
&& let Some(view) =
|
||||
this.tabs.get(this.active).and_then(WorkspaceTab::focused_view)
|
||||
{
|
||||
// Do not defer a second time: another callback may focus a new input.
|
||||
window.focus(&view.read(cx).focus_handle(cx), cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Saved-name history is available on reopening. After typing, let the input
|
||||
/// handle its normal text history instead of merging it with a prior rename.
|
||||
pub(super) fn restore_pane_name(
|
||||
&mut self,
|
||||
redo: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(edit) = self.pane_rename.as_mut().filter(|edit| !edit.local_edits) else {
|
||||
cx.propagate();
|
||||
return;
|
||||
};
|
||||
cx.stop_propagation();
|
||||
let next = if redo {
|
||||
(edit.saved_name_index + 1).min(edit.saved_names.len() - 1)
|
||||
} else {
|
||||
edit.saved_name_index.saturating_sub(1)
|
||||
};
|
||||
if next == edit.saved_name_index {
|
||||
return;
|
||||
}
|
||||
edit.saved_name_index = next;
|
||||
let name = edit.saved_names[next].clone().unwrap_or_default();
|
||||
edit.input.update(cx, |state, cx| state.set_value(name, window, cx));
|
||||
window.dispatch_action(Box::new(gpui_component::input::SelectAll), cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn cancel_pane_rename(
|
||||
&mut self,
|
||||
restore_focus: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.pane_rename.take().is_none() {
|
||||
return;
|
||||
}
|
||||
if restore_focus {
|
||||
self.focus_active(window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn forget_pane_rename(&mut self, pane_id: u64) {
|
||||
if self.pane_rename.as_ref().is_some_and(|edit| edit.pane_id == pane_id) {
|
||||
self.pane_rename = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ mod tests {
|
||||
fn sample_session() -> Session {
|
||||
let mut tab = TabSession::single("D:/work".into(), Some("Workspace".into()), None);
|
||||
tab.layout = Some(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: tab.cwd.clone(),
|
||||
agent: Some(AgentSession {
|
||||
|
||||
@@ -82,6 +82,7 @@ impl NebulaWorkspace {
|
||||
use crate::session::{LaunchSession, LayoutSession};
|
||||
|
||||
let layout = tab.layout.clone().unwrap_or(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: tab.cwd.clone(),
|
||||
agent: None,
|
||||
@@ -92,7 +93,7 @@ impl NebulaWorkspace {
|
||||
let grid = self.initial_grid;
|
||||
let mut panes: Vec<TerminalPane> = Vec::new();
|
||||
for (index, leaf) in layout.leaves().into_iter().enumerate() {
|
||||
let LayoutSession::Pane { cwd, agent, launch } = leaf else { continue };
|
||||
let LayoutSession::Pane { cwd, agent, launch, custom_name } = leaf else { continue };
|
||||
let mut launch_session = launch.clone().unwrap_or_else(|| {
|
||||
if index == 0 {
|
||||
saved_launch.clone()
|
||||
@@ -107,7 +108,8 @@ impl NebulaWorkspace {
|
||||
tab_duplication::inherit_guest_directory(&mut launch_session, cwd);
|
||||
let local_cwd = if guest_directory { None } else { crate::session::valid_dir(cwd) };
|
||||
let launch = Self::terminal_launch_from_session(&launch_session, local_cwd);
|
||||
let pane = self.new_pane(grid, launch, None, window, cx);
|
||||
let mut pane = self.new_pane(grid, launch, None, window, cx);
|
||||
pane.custom_name = custom_name.as_deref().and_then(rename::normalized_name);
|
||||
if !matches!(launch_session, LaunchSession::Default) {
|
||||
pane.view.update(cx, |view, _| view.session_launch = launch_session);
|
||||
}
|
||||
@@ -169,13 +171,23 @@ impl NebulaWorkspace {
|
||||
if ix == self.active {
|
||||
active_out = tabs.len();
|
||||
}
|
||||
let leaf_data = |id: u64| -> (String, Option<AgentSession>, Option<LaunchSession>) {
|
||||
let leaf_data = |id: u64| -> (
|
||||
String,
|
||||
Option<AgentSession>,
|
||||
Option<LaunchSession>,
|
||||
Option<String>,
|
||||
) {
|
||||
let Some(pane) = panes.iter().find(|pane| pane.id == id) else {
|
||||
return (String::new(), None, None);
|
||||
return (String::new(), None, None, None);
|
||||
};
|
||||
let view = pane.view.read(cx);
|
||||
let agent = view.session_agent();
|
||||
(view.cwd.clone(), agent, Some(view.session_launch.clone()))
|
||||
(
|
||||
view.cwd.clone(),
|
||||
agent,
|
||||
Some(view.session_launch.clone()),
|
||||
pane.custom_name.clone(),
|
||||
)
|
||||
};
|
||||
let layout = crate::gpui_shell::session_restore::layout_from_tree(tree, &leaf_data);
|
||||
let cwd = panes
|
||||
|
||||
@@ -909,6 +909,7 @@ mod boot_breaker_tests {
|
||||
color: None,
|
||||
launch: Some(launch.clone()),
|
||||
layout: Some(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
cwd: String::new(),
|
||||
agent: Some(saved.clone()),
|
||||
launch: Some(launch),
|
||||
|
||||
@@ -133,6 +133,9 @@ impl AgentSession {
|
||||
pub enum LayoutSession {
|
||||
Pane {
|
||||
cwd: String,
|
||||
/// User pane title, independent of the tab title and shell-reported title.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
custom_name: Option<String>,
|
||||
/// v4 的追加可选字段(老文件缺省、老版本忽略,无需升版):快照时
|
||||
/// 该 pane 前台的 AI CLI 对话,冷恢复据此自动接续。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -467,6 +470,7 @@ mod tests {
|
||||
axis: SplitAxis::LeftRight,
|
||||
ratio_permille: 618,
|
||||
first: Box::new(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "D:/work".into(),
|
||||
agent: Some(AgentSession {
|
||||
@@ -479,11 +483,13 @@ mod tests {
|
||||
axis: SplitAxis::TopBottom,
|
||||
ratio_permille: 500,
|
||||
first: Box::new(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "D:/logs".into(),
|
||||
agent: None,
|
||||
}),
|
||||
second: Box::new(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: String::new(),
|
||||
agent: None,
|
||||
@@ -507,10 +513,16 @@ mod tests {
|
||||
let session = parse(json).expect("v4 without agent must parse");
|
||||
assert_eq!(
|
||||
session.tabs[0].layout,
|
||||
Some(LayoutSession::Pane { launch: None, cwd: "D:/w".into(), agent: None })
|
||||
Some(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "D:/w".into(),
|
||||
agent: None
|
||||
})
|
||||
);
|
||||
|
||||
let with_agent = LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "D:/w".into(),
|
||||
agent: Some(AgentSession {
|
||||
@@ -561,12 +573,23 @@ mod tests {
|
||||
let tree = LayoutSession::Split {
|
||||
axis: SplitAxis::LeftRight,
|
||||
ratio_permille: 500,
|
||||
first: Box::new(LayoutSession::Pane { launch: None, cwd: "a".into(), agent: None }),
|
||||
first: Box::new(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "a".into(),
|
||||
agent: None,
|
||||
}),
|
||||
second: Box::new(LayoutSession::Split {
|
||||
axis: SplitAxis::TopBottom,
|
||||
ratio_permille: 500,
|
||||
first: Box::new(LayoutSession::Pane { launch: None, cwd: "b".into(), agent: None }),
|
||||
first: Box::new(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "b".into(),
|
||||
agent: None,
|
||||
}),
|
||||
second: Box::new(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "c".into(),
|
||||
agent: None,
|
||||
@@ -595,6 +618,7 @@ mod tests {
|
||||
.map(|id| {
|
||||
let mut tab = TabSession::single("/home/user/project".into(), None, None);
|
||||
tab.layout = Some(LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: "/home/user/project".into(),
|
||||
agent: Some(AgentSession {
|
||||
|
||||
@@ -114,6 +114,7 @@ impl WindowContext {
|
||||
) -> session::LayoutSession {
|
||||
match layout {
|
||||
Layout::Leaf(id) => session::LayoutSession::Pane {
|
||||
custom_name: None,
|
||||
launch: None,
|
||||
cwd: pane_cwd(*id),
|
||||
agent: pane_agent(*id),
|
||||
|
||||
Reference in new issue
Block a user