From 58a4a84e980249570652340459cf19d0ef36c1ae Mon Sep 17 00:00:00 2001 From: Rocky Shi Date: Tue, 27 Jan 2026 20:21:43 +1300 Subject: [PATCH] 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 --- Cargo.lock | 1 + crates/terminal_view/Cargo.toml | 1 + crates/terminal_view/src/persistence.rs | 37 ++ crates/terminal_view/src/terminal_panel.rs | 11 +- crates/terminal_view/src/terminal_view.rs | 480 +++++++++++++++++++-- crates/workspace/src/item.rs | 25 ++ crates/workspace/src/pane.rs | 48 ++- crates/zed/src/zed.rs | 2 +- 8 files changed, 545 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aca65c0421f5554be408eb027066e53fcfc87b07..4ba0b3e04ac47e074b751033e210ddd2ed3f9bd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16768,6 +16768,7 @@ dependencies = [ "itertools 0.14.0", "language", "log", + "menu", "pretty_assertions", "project", "rand 0.9.2", diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index eadd00bcbbd7a5469638c2b85d2eb4f1a65b9475..dfac354ddac223109a9c5de86e5d1d040a3cf287 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -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 diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index f2df1e0eec42d2531c8c83a4e9de187b4fda6742..b601906b07f35c799dafb89932a1d0c559fbe1eb 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -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, + ) -> 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> { + SELECT custom_title + FROM terminals + WHERE item_id = ? AND workspace_id = ? + } + } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 5bddaecb8a943dd6ff8d8790c03a5e1da9c3f198..cafb7f7da81416f1b60162adc3d3b8adf39375d0 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -141,7 +141,14 @@ impl TerminalPanel { .active_item() .and_then(|item| item.downcast::()) .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::()) + .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())); }); }); }); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 3c482286f89fbc281b6f5d37be67e601d0df465f..6b1f7e6819e1be3a12be7bedd02964008ddb7d69 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -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, gpui::Point, Subscription)>, + context_menu: Option<(Entity, Point, Subscription)>, cursor_shape: CursorShape, blink_manager: Entity, mode: TerminalMode, blinking_terminal_enabled: bool, - cwd_serialized: bool, + needs_serialize: bool, + custom_title: Option, hover: Option, hover_tooltip_update: Task<()>, workspace_id: Option, @@ -134,6 +141,9 @@ pub struct TerminalView { scroll_top: Pixels, scroll_handle: TerminalScrollHandle, ime_state: Option, + self_handle: WeakEntity, + rename_editor: Option>, + rename_editor_subscription: Option, _subscriptions: Vec, _terminal_subscriptions: Vec, } @@ -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::(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, cx: &mut Context) { + 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) { + 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, + ) { + 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) { self.has_bell = false; cx.emit(Event::Wakeup); @@ -377,7 +487,7 @@ impl TerminalView { pub fn deploy_context_menu( &mut self, - position: gpui::Point, + position: Point, window: &mut Window, cx: &mut Context, ) { @@ -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 { +fn regex_search_for_query(query: &SearchQuery) -> Option { 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, + ) -> Vec<(SharedString, Box)> { + 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, workspace: WeakEntity, - workspace_id: workspace::WorkspaceId, + workspace_id: WorkspaceId, item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { 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; + type Match = RangeInclusive; 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 + ); + }); + } } diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 2d8a485432fc367eb12b85647c38e06a9624ae4d..727e0d474b8529f2eaf2c9613abd213f620be73f 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -364,6 +364,16 @@ pub trait Item: Focusable + EventEmitter + 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, + ) -> Vec<(SharedString, Box)> { + 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, window: &mut Window, cx: &mut App); + fn tab_extra_context_menu_actions( + &self, + window: &mut Window, + cx: &mut App, + ) -> Vec<(SharedString, Box)>; 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 ItemHandle for Entity { window.dispatch_action(action, cx); }) } + + fn tab_extra_context_menu_actions( + &self, + window: &mut Window, + cx: &mut App, + ) -> Vec<(SharedString, Box)> { + self.update(cx, |this, cx| { + this.tab_extra_context_menu_actions(window, cx) + }) + } } impl From> for AnyView { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 2096c1fa23bc85690260007f3c89c5fe2b839a00..82cf73c9ab7ede32baec67647e22f74dca303836 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -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) }) }) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 22bbca4f5c1962698fce01730873b3731d50fc88..68303c30f9b12744944a2ee2e555a8e4ca18e369 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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));