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:
1 parent
2b3784e804
commit
75a2b4321b
8 files changed
+545
-60
No files matched your search
Generated
+1
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user