Cargo.lock 🔗
@@ -16768,6 +16768,7 @@ dependencies = [
"itertools 0.14.0",
"language",
"log",
+ "menu",
"pretty_assertions",
"project",
"rand 0.9.2",
Rocky Shi created
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(-)
@@ -16768,6 +16768,7 @@ dependencies = [
"itertools 0.14.0",
"language",
"log",
+ "menu",
"pretty_assertions",
"project",
"rand 0.9.2",
@@ -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
@@ -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 = ?
+ }
+ }
}
@@ -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()));
});
});
});
@@ -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
+ );
+ });
+ }
}
@@ -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 {
@@ -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)
})
})
@@ -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));