terminal_view: Add ability to rename terminal tabs (#45800)

Closes [#ISSUE](https://github.com/zed-industries/zed/issues/11023)

Release Notes:

- Added ability to rename terminal tabs by right-click context menu and
double-click

Recording:


https://github.com/user-attachments/assets/be81a95b-1f64-4ebd-94e4-7cfe6a1e9ddb
This commit is contained in:
Rocky Shi authored and GitHub committed 2026-01-27 07:21:43 +00:00
1 parent 2b3784e804
commit 75a2b4321b
8 files changed
+545 -60

No files matched your search

Generated
+1
View File
@@ -16768,6 +16768,7 @@ dependencies = [
"itertools 0.14.0",
"language",
"log",
"menu",
"pretty_assertions",
"project",
"rand 0.9.2",
+1
View File
@@ -29,6 +29,7 @@ gpui.workspace = true
itertools.workspace = true
language.workspace = true
log.workspace = true
menu.workspace = true
pretty_assertions.workspace = true
project.workspace = true
regex.workspace = true
+37
View File
@@ -424,6 +424,9 @@ impl Domain for TerminalDb {
ALTER TABLE terminals ADD COLUMN working_directory_path TEXT;
UPDATE terminals SET working_directory_path = CAST(working_directory AS TEXT);
),
sql! (
ALTER TABLE terminals ADD COLUMN custom_title TEXT;
),
];
}
@@ -481,4 +484,38 @@ impl TerminalDb {
WHERE item_id = ? AND workspace_id = ?
}
}
pub async fn save_custom_title(
&self,
item_id: ItemId,
workspace_id: WorkspaceId,
custom_title: Option<String>,
) -> Result<()> {
log::debug!(
"Saving custom title {:?} for item {} in workspace {:?}",
custom_title,
item_id,
workspace_id
);
self.write(move |conn| {
let query = "INSERT INTO terminals (item_id, workspace_id, custom_title)
VALUES (?1, ?2, ?3)
ON CONFLICT (workspace_id, item_id) DO UPDATE SET
custom_title = excluded.custom_title";
let mut statement = Statement::prepare(conn, query)?;
let mut next_index = statement.bind(&item_id, 1)?;
next_index = statement.bind(&workspace_id, next_index)?;
statement.bind(&custom_title, next_index)?;
statement.exec()
})
.await
}
query! {
pub fn get_custom_title(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<String>> {
SELECT custom_title
FROM terminals
WHERE item_id = ? AND workspace_id = ?
}
}
}
+9 -2
View File
@@ -141,7 +141,14 @@ impl TerminalPanel {
.active_item()
.and_then(|item| item.downcast::<TerminalView>())
.map(|terminal_view| terminal_view.read(cx).focus_handle.clone());
if !pane.has_focus(window, cx) && !pane.context_menu_focused(window, cx) {
let has_focused_rename_editor = pane
.active_item()
.and_then(|item| item.downcast::<TerminalView>())
.is_some_and(|view| view.read(cx).rename_editor_is_focused(window, cx));
if !pane.has_focus(window, cx)
&& !pane.context_menu_focused(window, cx)
&& !has_focused_rename_editor
{
return (None, None);
}
let focus_handle = pane.focus_handle(cx);
@@ -1929,7 +1936,7 @@ mod tests {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.terminal.get_or_insert_default().project.shell =
Some(settings::Shell::Program("asdf".to_owned()));
Some(settings::Shell::Program("__nonexistent_shell__".to_owned()));
});
});
});
+436 -44
View File
@@ -6,22 +6,26 @@ pub mod terminal_scrollbar;
mod terminal_slash_command;
use assistant_slash_command::SlashCommandRegistry;
use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
use gpui::{
Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point,
Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred,
div,
};
use menu;
use persistence::TERMINAL_DB;
use project::{Project, search::SearchQuery};
use schemars::JsonSchema;
use serde::Deserialize;
use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
use task::TaskId;
use terminal::{
Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState,
TaskStatus, Terminal, TerminalBounds, ToggleViMode,
alacritty_terminal::{
index::Point,
index::Point as AlacPoint,
term::{TermMode, point_to_viewport, search::RegexSearch},
},
terminal_settings::{CursorShape, TerminalSettings},
@@ -46,9 +50,6 @@ use workspace::{
register_serializable_item,
searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
};
use serde::Deserialize;
use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
use zed_actions::assistant::InlineAssist;
use std::{
@@ -88,6 +89,11 @@ actions!(
]
);
/// Renames the terminal tab.
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
#[action(namespace = terminal)]
pub struct RenameTerminal;
pub fn init(cx: &mut App) {
assistant_slash_command::init(cx);
terminal_panel::init(cx);
@@ -120,12 +126,13 @@ pub struct TerminalView {
focus_handle: FocusHandle,
//Currently using iTerm bell, show bell emoji in tab until input is received
has_bell: bool,
context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
cursor_shape: CursorShape,
blink_manager: Entity<BlinkManager>,
mode: TerminalMode,
blinking_terminal_enabled: bool,
cwd_serialized: bool,
needs_serialize: bool,
custom_title: Option<String>,
hover: Option<HoverTarget>,
hover_tooltip_update: Task<()>,
workspace_id: Option<WorkspaceId>,
@@ -134,6 +141,9 @@ pub struct TerminalView {
scroll_top: Pixels,
scroll_handle: TerminalScrollHandle,
ime_state: Option<ImeState>,
self_handle: WeakEntity<Self>,
rename_editor: Option<Entity<Editor>>,
rename_editor_subscription: Option<Subscription>,
_subscriptions: Vec<Subscription>,
_terminal_subscriptions: Vec<Subscription>,
}
@@ -249,12 +259,13 @@ impl TerminalView {
)
});
let _subscriptions = vec![
let subscriptions = vec![
focus_in,
focus_out,
cx.observe(&blink_manager, |_, _, cx| cx.notify()),
cx.observe_global::<SettingsStore>(Self::settings_changed),
];
Self {
terminal,
workspace: workspace_handle,
@@ -273,9 +284,13 @@ impl TerminalView {
block_below_cursor: None,
scroll_top: Pixels::ZERO,
scroll_handle,
cwd_serialized: false,
needs_serialize: false,
custom_title: None,
ime_state: None,
_subscriptions,
self_handle: cx.entity().downgrade(),
rename_editor: None,
rename_editor_subscription: None,
_subscriptions: subscriptions,
_terminal_subscriptions: terminal_subscriptions,
}
}
@@ -370,6 +385,101 @@ impl TerminalView {
self.has_bell
}
pub fn custom_title(&self) -> Option<&str> {
self.custom_title.as_deref()
}
pub fn set_custom_title(&mut self, label: Option<String>, cx: &mut Context<Self>) {
let label = label.filter(|l| !l.trim().is_empty());
if self.custom_title != label {
self.custom_title = label;
self.needs_serialize = true;
cx.emit(ItemEvent::UpdateTab);
cx.notify();
}
}
pub fn is_renaming(&self) -> bool {
self.rename_editor.is_some()
}
pub fn rename_editor_is_focused(&self, window: &Window, cx: &App) -> bool {
self.rename_editor
.as_ref()
.is_some_and(|editor| editor.focus_handle(cx).is_focused(window))
}
fn finish_renaming(&mut self, save: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some(editor) = self.rename_editor.take() else {
return;
};
self.rename_editor_subscription = None;
if save {
let new_label = editor.read(cx).text(cx).trim().to_string();
let label = if new_label.is_empty() {
None
} else {
// Only set custom_title if the text differs from the terminal's dynamic title.
// This prevents subtle layout changes when clicking away without making changes.
let terminal_title = self.terminal.read(cx).title(true);
if new_label == terminal_title {
None
} else {
Some(new_label)
}
};
self.set_custom_title(label, cx);
}
cx.notify();
self.focus_handle.focus(window, cx);
}
pub fn rename_terminal(
&mut self,
_: &RenameTerminal,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.terminal.read(cx).task().is_some() {
return;
}
let current_label = self
.custom_title
.clone()
.unwrap_or_else(|| self.terminal.read(cx).title(true));
let rename_editor = cx.new(|cx| Editor::single_line(window, cx));
let rename_editor_subscription = cx.subscribe_in(&rename_editor, window, {
let rename_editor = rename_editor.clone();
move |_this, _, event, window, cx| {
if let editor::EditorEvent::Blurred = event {
// Defer to let focus settle (avoids canceling during double-click).
let rename_editor = rename_editor.clone();
cx.defer_in(window, move |this, window, cx| {
let still_current = this
.rename_editor
.as_ref()
.is_some_and(|current| current == &rename_editor);
if still_current && !rename_editor.focus_handle(cx).is_focused(window) {
this.finish_renaming(false, window, cx);
}
});
}
}
});
self.rename_editor = Some(rename_editor.clone());
self.rename_editor_subscription = Some(rename_editor_subscription);
rename_editor.update(cx, |editor, cx| {
editor.set_text(current_label, window, cx);
editor.select_all(&SelectAll, window, cx);
editor.focus_handle(cx).focus(window, cx);
});
cx.notify();
}
pub fn clear_bell(&mut self, cx: &mut Context<TerminalView>) {
self.has_bell = false;
cx.emit(Event::Wakeup);
@@ -377,7 +487,7 @@ impl TerminalView {
pub fn deploy_context_menu(
&mut self,
position: gpui::Point<Pixels>,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -865,7 +975,7 @@ fn subscribe_for_terminal_events(
let current_cwd = terminal.read(cx).working_directory();
if current_cwd != previous_cwd {
previous_cwd = current_cwd;
terminal_view.cwd_serialized = false;
terminal_view.needs_serialize = true;
}
match event {
@@ -971,7 +1081,7 @@ fn subscribe_for_terminal_events(
vec![terminal_subscription, terminal_events_subscription]
}
fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {
fn regex_search_for_query(query: &SearchQuery) -> Option<RegexSearch> {
let str = query.as_str();
if query.is_regex() {
if str == "." {
@@ -1070,9 +1180,9 @@ impl Render for TerminalView {
self.terminal.update(cx, |term, _| {
let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
match delta.cmp(&0) {
std::cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
std::cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
std::cmp::Ordering::Equal => {}
cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
cmp::Ordering::Equal => {}
}
});
}
@@ -1103,6 +1213,7 @@ impl Render for TerminalView {
.on_action(cx.listener(TerminalView::show_character_palette))
.on_action(cx.listener(TerminalView::select_all))
.on_action(cx.listener(TerminalView::rerun_task))
.on_action(cx.listener(TerminalView::rename_terminal))
.on_key_down(cx.listener(Self::key_down))
.on_mouse_down(
MouseButton::Right,
@@ -1186,7 +1297,12 @@ impl Item for TerminalView {
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
let terminal = self.terminal().read(cx);
let title = terminal.title(true);
let title = self
.custom_title
.as_ref()
.filter(|title| !title.trim().is_empty())
.cloned()
.unwrap_or_else(|| terminal.title(true));
let (icon, icon_color, rerun_button) = match terminal.task() {
Some(terminal_task) => match &terminal_task.status {
@@ -1235,11 +1351,48 @@ impl Item for TerminalView {
)
}),
)
.child(Label::new(title).color(params.text_color()))
.child(
div()
.relative()
.child(
Label::new(title)
.color(params.text_color())
.when(self.is_renaming(), |this| this.alpha(0.)),
)
.when_some(self.rename_editor.clone(), |this, editor| {
let self_handle = self.self_handle.clone();
let self_handle_cancel = self.self_handle.clone();
this.child(
div()
.absolute()
.top_0()
.left_0()
.size_full()
.child(editor)
.on_action(move |_: &menu::Confirm, window, cx| {
self_handle
.update(cx, |this, cx| {
this.finish_renaming(true, window, cx)
})
.ok();
})
.on_action(move |_: &menu::Cancel, window, cx| {
self_handle_cancel
.update(cx, |this, cx| {
this.finish_renaming(false, window, cx)
})
.ok();
}),
)
}),
)
.into_any()
}
fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) {
return custom_title.clone().into();
}
let terminal = self.terminal().read(cx);
terminal.title(detail == 0).into()
}
@@ -1248,6 +1401,19 @@ impl Item for TerminalView {
None
}
fn tab_extra_context_menu_actions(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<(SharedString, Box<dyn gpui::Action>)> {
let terminal = self.terminal.read(cx);
if terminal.task().is_none() {
vec![("Rename".into(), Box::new(RenameTerminal))]
} else {
Vec::new()
}
}
fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
workspace::item::ItemBufferKind::Singleton
}
@@ -1288,7 +1454,7 @@ impl Item for TerminalView {
})
}
fn is_dirty(&self, cx: &gpui::App) -> bool {
fn is_dirty(&self, cx: &App) -> bool {
match self.terminal.read(cx).task() {
Some(task) => task.status == TaskStatus::Running,
None => self.has_bell(),
@@ -1381,38 +1547,48 @@ impl SerializableItem for TerminalView {
return None;
}
if let Some((cwd, workspace_id)) = terminal.working_directory().zip(self.workspace_id) {
self.cwd_serialized = true;
Some(cx.background_spawn(async move {
if !self.needs_serialize {
return None;
}
let workspace_id = self.workspace_id?;
let cwd = terminal.working_directory();
let custom_title = self.custom_title.clone();
self.needs_serialize = false;
Some(cx.background_spawn(async move {
if let Some(cwd) = cwd {
TERMINAL_DB
.save_working_directory(item_id, workspace_id, cwd)
.await
}))
} else {
None
}
.await?;
}
TERMINAL_DB
.save_custom_title(item_id, workspace_id, custom_title)
.await?;
Ok(())
}))
}
fn should_serialize(&self, _: &Self::Event) -> bool {
!self.cwd_serialized
self.needs_serialize
}
fn deserialize(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
workspace_id: workspace::WorkspaceId,
workspace_id: WorkspaceId,
item_id: workspace::ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<anyhow::Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
let cwd = cx
let (cwd, custom_title) = cx
.update(|_window, cx| {
let from_db = TERMINAL_DB
.get_working_directory(item_id, workspace_id)
.log_err()
.flatten();
if from_db
let cwd = if from_db
.as_ref()
.is_some_and(|from_db| !from_db.as_os_str().is_empty())
{
@@ -1421,24 +1597,34 @@ impl SerializableItem for TerminalView {
workspace
.upgrade()
.and_then(|workspace| default_working_directory(workspace.read(cx), cx))
}
};
let custom_title = TERMINAL_DB
.get_custom_title(item_id, workspace_id)
.log_err()
.flatten()
.filter(|title| !title.trim().is_empty());
(cwd, custom_title)
})
.ok()
.flatten();
.unwrap_or((None, None));
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(cwd, cx))
.await?;
cx.update(|window, cx| {
cx.new(|cx| {
TerminalView::new(
let mut view = TerminalView::new(
terminal,
workspace,
Some(workspace_id),
project.downgrade(),
window,
cx,
)
);
if custom_title.is_some() {
view.custom_title = custom_title;
}
view
})
})
})
@@ -1446,7 +1632,7 @@ impl SerializableItem for TerminalView {
}
impl SearchableItem for TerminalView {
type Match = RangeInclusive<Point>;
type Match = RangeInclusive<AlacPoint>;
fn supported_options(&self) -> SearchOptions {
SearchOptions {
@@ -1683,9 +1869,9 @@ mod tests {
assert!(workspace.worktrees(cx).next().is_some());
let res = default_working_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
let res = first_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
});
}
@@ -1705,9 +1891,9 @@ mod tests {
assert!(active_entry.is_some());
let res = default_working_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
let res = first_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
});
}
@@ -1727,9 +1913,9 @@ mod tests {
assert!(active_entry.is_some());
let res = default_working_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
assert_eq!(res, Some(Path::new("/root2/").to_path_buf()));
let res = first_project_directory(workspace, cx);
assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
});
}
@@ -1808,4 +1994,210 @@ mod tests {
project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
});
}
// Terminal rename tests
#[gpui::test]
async fn test_custom_title_initially_none(cx: &mut TestAppContext) {
let (project, workspace) = init_test(cx).await;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(None, cx))
.await
.unwrap();
let terminal_view = cx
.add_window(|window, cx| {
TerminalView::new(
terminal,
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
})
.root(cx)
.unwrap();
terminal_view.update(cx, |view, _cx| {
assert!(view.custom_title().is_none());
});
}
#[gpui::test]
async fn test_set_custom_title(cx: &mut TestAppContext) {
let (project, workspace) = init_test(cx).await;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(None, cx))
.await
.unwrap();
let terminal_view = cx
.add_window(|window, cx| {
TerminalView::new(
terminal,
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
})
.root(cx)
.unwrap();
terminal_view.update(cx, |view, cx| {
view.set_custom_title(Some("frontend".to_string()), cx);
assert_eq!(view.custom_title(), Some("frontend"));
});
}
#[gpui::test]
async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) {
let (project, workspace) = init_test(cx).await;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(None, cx))
.await
.unwrap();
let terminal_view = cx
.add_window(|window, cx| {
TerminalView::new(
terminal,
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
})
.root(cx)
.unwrap();
terminal_view.update(cx, |view, cx| {
view.set_custom_title(Some("test".to_string()), cx);
assert_eq!(view.custom_title(), Some("test"));
view.set_custom_title(Some("".to_string()), cx);
assert!(view.custom_title().is_none());
view.set_custom_title(Some(" ".to_string()), cx);
assert!(view.custom_title().is_none());
});
}
#[gpui::test]
async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) {
let (project, workspace) = init_test(cx).await;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(None, cx))
.await
.unwrap();
let terminal_view = cx
.add_window(|window, cx| {
TerminalView::new(
terminal,
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
})
.root(cx)
.unwrap();
terminal_view.update(cx, |view, cx| {
view.needs_serialize = false;
view.set_custom_title(Some("new_label".to_string()), cx);
assert!(view.needs_serialize);
});
}
#[gpui::test]
async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) {
let (project, workspace) = init_test(cx).await;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(None, cx))
.await
.unwrap();
let terminal_view = cx
.add_window(|window, cx| {
TerminalView::new(
terminal,
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
})
.root(cx)
.unwrap();
terminal_view.update(cx, |view, cx| {
view.set_custom_title(Some("my-server".to_string()), cx);
let text = view.tab_content_text(0, cx);
assert_eq!(text.as_ref(), "my-server");
});
terminal_view.update(cx, |view, cx| {
view.set_custom_title(None, cx);
let text = view.tab_content_text(0, cx);
assert_ne!(text.as_ref(), "my-server");
});
}
#[gpui::test]
async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty(
cx: &mut TestAppContext,
) {
let (project, workspace) = init_test(cx).await;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(None, cx))
.await
.unwrap();
let terminal_view = cx
.add_window(|window, cx| {
TerminalView::new(
terminal,
workspace.downgrade(),
None,
project.downgrade(),
window,
cx,
)
})
.root(cx)
.unwrap();
terminal_view.update(cx, |view, cx| {
view.custom_title = Some("".to_string());
let text = view.tab_content_text(0, cx);
assert!(
!text.is_empty(),
"Tab should show terminal title, not empty string; got: '{}'",
text
);
});
terminal_view.update(cx, |view, cx| {
view.custom_title = Some(" ".to_string());
let text = view.tab_content_text(0, cx);
assert!(
!text.is_empty() && text.as_ref() != " ",
"Tab should show terminal title, not whitespace; got: '{}'",
text
);
});
}
}
+25
View File
@@ -364,6 +364,16 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
fn include_in_nav_history() -> bool {
true
}
/// Returns additional actions to add to the tab's context menu.
/// Each entry is a label and an action to dispatch.
fn tab_extra_context_menu_actions(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Vec<(SharedString, Box<dyn Action>)> {
Vec::new()
}
}
pub trait SerializableItem: Item {
@@ -534,6 +544,11 @@ pub trait ItemHandle: 'static + Send {
fn preserve_preview(&self, cx: &App) -> bool;
fn include_in_nav_history(&self) -> bool;
fn relay_action(&self, action: Box<dyn Action>, window: &mut Window, cx: &mut App);
fn tab_extra_context_menu_actions(
&self,
window: &mut Window,
cx: &mut App,
) -> Vec<(SharedString, Box<dyn Action>)>;
fn can_autosave(&self, cx: &App) -> bool {
let is_deleted = self.project_entry_ids(cx).is_empty();
self.is_dirty(cx) && !self.has_conflict(cx) && self.can_save(cx) && !is_deleted
@@ -1082,6 +1097,16 @@ impl<T: Item> ItemHandle for Entity<T> {
window.dispatch_action(action, cx);
})
}
fn tab_extra_context_menu_actions(
&self,
window: &mut Window,
cx: &mut App,
) -> Vec<(SharedString, Box<dyn Action>)> {
self.update(cx, |this, cx| {
this.tab_extra_context_menu_actions(window, cx)
})
}
}
impl From<Box<dyn ItemHandle>> for AnyView {
+35 -13
View File
@@ -21,9 +21,9 @@ use futures::{StreamExt, stream::FuturesUnordered};
use gpui::{
Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div,
DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent,
Focusable, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point,
PromptLevel, Render, ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window,
actions, anchored, deferred, prelude::*,
Focusable, KeyContext, MouseButton, NavigationDirection, Pixels, Point, PromptLevel, Render,
ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window, actions, anchored,
deferred, prelude::*,
};
use itertools::Itertools;
use language::{Capability, DiagnosticSeverity};
@@ -2783,8 +2783,28 @@ impl Pane {
ClosePosition::Right => ui::TabCloseSide::End,
})
.toggle_state(is_active)
.on_click(cx.listener(move |pane: &mut Self, _, window, cx| {
pane.activate_item(ix, true, true, window, cx)
.on_click(cx.listener({
let item_handle = item.boxed_clone();
move |pane: &mut Self, event: &ClickEvent, window, cx| {
if event.click_count() > 1 {
// On double-click, dispatch the Rename action (when available)
// instead of just activating the item.
pane.unpreview_item_if_preview(item_id);
let extra_actions = item_handle.tab_extra_context_menu_actions(window, cx);
if let Some((_, action)) = extra_actions
.into_iter()
.find(|(label, _)| label.as_ref() == "Rename")
{
// Dispatch action directly through the focus handle to avoid
// relay_action's intermediate focus step which can interfere
// with inline editors.
let focus_handle = item_handle.item_focus_handle(cx);
focus_handle.dispatch_action(&*action, window, cx);
return;
}
}
pane.activate_item(ix, true, true, window, cx)
}
}))
// TODO: This should be a click listener with the middle mouse button instead of a mouse down listener.
.on_mouse_down(
@@ -2794,14 +2814,6 @@ impl Pane {
.detach_and_log_err(cx);
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |pane, event: &MouseDownEvent, _, _| {
if event.click_count > 1 {
pane.unpreview_item_if_preview(item_id);
}
}),
)
.on_drag(
DraggedTab {
item: item.boxed_clone(),
@@ -2953,12 +2965,14 @@ impl Pane {
let pane = cx.entity().downgrade();
let menu_context = item.item_focus_handle(cx);
let item_handle = item.boxed_clone();
right_click_menu(ix)
.trigger(|_, _, _| tab)
.menu(move |window, cx| {
let pane = pane.clone();
let menu_context = menu_context.clone();
let extra_actions = item_handle.tab_extra_context_menu_actions(window, cx);
ContextMenu::build(window, cx, move |mut menu, window, cx| {
let close_active_item_action = CloseActiveItem {
save_intent: None,
@@ -3217,6 +3231,14 @@ impl Pane {
}
};
// Add custom item-specific actions
if !extra_actions.is_empty() {
menu = menu.separator();
for (label, action) in extra_actions {
menu = menu.action(label, action);
}
}
menu.context(menu_context)
})
})
+1 -1
View File
@@ -2846,9 +2846,9 @@ mod tests {
editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
})
.unwrap();
cx.run_until_parked();
assert!(window_is_edited(window, cx));
cx.run_until_parked();
// Advance the clock to make sure the workspace is serialized
cx.executor().advance_clock(Duration::from_secs(1));