sidebar: Add title renaming for Terminal Threads (#63494)
The Agent Panel already lets users rename Terminal Threads. This adds the same option to the Threads Sidebar. A rename in either place updates the other. <p> <img width="321" height="89" alt="image" src="https://github.com/user-attachments/assets/189a8598-f0da-4622-a5b0-b6a18a5750b7" /> </p> <p> <img width="324" height="77" alt="image" src="https://github.com/user-attachments/assets/2fc9f7aa-c791-4568-8729-fd3a78322d63" /> </p> Release Notes: - Added support for renaming Terminal Threads from the Threads Sidebar.
This commit is contained in:
1 parent
72dc742101
commit
7402d91b26
5 files changed
+381
-96
No files matched your search
@@ -41,7 +41,7 @@ use crate::agent_connection_store::AgentConnectionStore;
|
||||
use crate::completion_provider::{AgentContextSelection, AgentContextSource};
|
||||
use crate::terminal_thread_metadata_store::{
|
||||
TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title,
|
||||
terminal_title_without_prefix,
|
||||
normalize_terminal_custom_title, terminal_title_without_prefix,
|
||||
};
|
||||
use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent};
|
||||
use crate::{
|
||||
@@ -2612,17 +2612,15 @@ impl AgentPanel {
|
||||
if initial_title.as_deref() == Some(new_title.as_str()) {
|
||||
return;
|
||||
}
|
||||
let label = if new_title.trim().is_empty()
|
||||
|| new_title == terminal_title_without_prefix(terminal_title.as_ref())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(new_title)
|
||||
};
|
||||
let custom_title = normalize_terminal_custom_title(
|
||||
terminal_title.as_ref(),
|
||||
SharedString::from(new_title),
|
||||
);
|
||||
|
||||
cx.defer(move |cx| {
|
||||
terminal_view.update(cx, |terminal_view, cx| {
|
||||
terminal_view.set_custom_title(label, cx);
|
||||
terminal_view
|
||||
.set_custom_title(custom_title.map(|title| title.to_string()), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3373,6 +3371,26 @@ impl AgentPanel {
|
||||
self.terminals.contains_key(&terminal_id)
|
||||
}
|
||||
|
||||
pub fn rename_terminal(
|
||||
&mut self,
|
||||
terminal_id: TerminalId,
|
||||
title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some(terminal) = self.terminals.get(&terminal_id) else {
|
||||
return false;
|
||||
};
|
||||
let terminal_title = terminal.terminal_title(cx);
|
||||
let custom_title = normalize_terminal_custom_title(terminal_title.as_ref(), title);
|
||||
let terminal_view = terminal.view.clone();
|
||||
cx.defer(move |cx| {
|
||||
terminal_view.update(cx, |terminal_view, cx| {
|
||||
terminal_view.set_custom_title(custom_title.map(|title| title.to_string()), cx);
|
||||
});
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
pub fn terminals(&self, cx: &App) -> Vec<AgentPanelTerminalInfo> {
|
||||
self.terminals
|
||||
.iter()
|
||||
|
||||
@@ -70,6 +70,12 @@ impl TerminalThreadMetadata {
|
||||
self.custom_title.as_ref().map(|title| title.as_ref()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn editable_title(&self) -> SharedString {
|
||||
self.custom_title.clone().unwrap_or_else(|| {
|
||||
SharedString::from(terminal_title_without_prefix(self.title.as_ref()).to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn compose_terminal_thread_title(
|
||||
@@ -93,6 +99,19 @@ pub(crate) fn terminal_title_without_prefix(title: &str) -> &str {
|
||||
.unwrap_or(title)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_terminal_custom_title(
|
||||
terminal_title: &str,
|
||||
edited_title: SharedString,
|
||||
) -> Option<SharedString> {
|
||||
if edited_title.trim().is_empty()
|
||||
|| edited_title == terminal_title_without_prefix(terminal_title)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(edited_title)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminal_title_prefix(title: &str) -> Option<&str> {
|
||||
let mut prefix_byte_len = 0;
|
||||
let mut saw_prefix_character = false;
|
||||
@@ -264,6 +283,25 @@ impl TerminalThreadMetadataStore {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn rename_terminal(
|
||||
&mut self,
|
||||
terminal_id: TerminalId,
|
||||
title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(mut metadata) = self.entry(terminal_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let custom_title = normalize_terminal_custom_title(metadata.title.as_ref(), title);
|
||||
if metadata.custom_title == custom_title {
|
||||
return;
|
||||
}
|
||||
|
||||
metadata.custom_title = custom_title;
|
||||
self.save_internal(metadata);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn change_worktree_paths(
|
||||
&mut self,
|
||||
current_folder_paths: &PathList,
|
||||
@@ -659,6 +697,47 @@ mod tests {
|
||||
assert_eq!(metadata.display_title().as_ref(), "Fix bug");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rename_terminal_updates_stored_custom_title(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let metadata = metadata(
|
||||
"⠋ Dev Server",
|
||||
WorktreePaths::from_folder_paths(&PathList::default()),
|
||||
);
|
||||
let terminal_id = metadata.terminal_id;
|
||||
|
||||
cx.update(|cx| {
|
||||
TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| {
|
||||
store.save(metadata, cx);
|
||||
store.rename_terminal(terminal_id, "Renamed Terminal".into(), cx);
|
||||
});
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
let store = TerminalThreadMetadataStore::global(cx);
|
||||
let metadata = store
|
||||
.read(cx)
|
||||
.entry(terminal_id)
|
||||
.expect("renamed terminal metadata should exist");
|
||||
assert_eq!(metadata.custom_title.as_deref(), Some("Renamed Terminal"));
|
||||
assert_eq!(metadata.display_title().as_ref(), "⠋ Renamed Terminal");
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| {
|
||||
store.rename_terminal(terminal_id, "Dev Server".into(), cx);
|
||||
});
|
||||
let store = TerminalThreadMetadataStore::global(cx);
|
||||
let metadata = store
|
||||
.read(cx)
|
||||
.entry(terminal_id)
|
||||
.expect("renamed terminal metadata should exist");
|
||||
assert_eq!(metadata.custom_title, None);
|
||||
assert_eq!(metadata.display_title().as_ref(), "⠋ Dev Server");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_change_worktree_paths_reindexes_terminal_metadata(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
+166
-65
@@ -406,6 +406,28 @@ enum ListEntry {
|
||||
Terminal(TerminalEntry),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RenameTarget {
|
||||
Thread(ThreadId),
|
||||
Terminal(TerminalId),
|
||||
}
|
||||
|
||||
impl RenameTarget {
|
||||
fn from_entry(entry: &ListEntry) -> Option<(Self, SharedString)> {
|
||||
match entry {
|
||||
ListEntry::Thread(thread) => Some((
|
||||
Self::Thread(thread.metadata.thread_id),
|
||||
thread.metadata.display_title(),
|
||||
)),
|
||||
ListEntry::Terminal(terminal) => Some((
|
||||
Self::Terminal(terminal.metadata.terminal_id),
|
||||
terminal.metadata.editable_title(),
|
||||
)),
|
||||
ListEntry::ProjectHeader { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ActivatableEntry {
|
||||
Thread {
|
||||
@@ -743,7 +765,7 @@ pub struct Sidebar {
|
||||
width: Pixels,
|
||||
focus_handle: FocusHandle,
|
||||
filter_editor: Entity<Editor>,
|
||||
thread_rename_editor: Entity<Editor>,
|
||||
rename_editor: Entity<Editor>,
|
||||
list_state: ListState,
|
||||
contents: SidebarContents,
|
||||
/// The index of the list item that currently has the keyboard focus
|
||||
@@ -753,11 +775,11 @@ pub struct Sidebar {
|
||||
/// Tracks which sidebar entry is currently active (highlighted).
|
||||
active_entry: Option<ActiveEntry>,
|
||||
hovered_thread_index: Option<usize>,
|
||||
renaming_thread_id: Option<ThreadId>,
|
||||
rename_target: Option<RenameTarget>,
|
||||
/// Threads in the database-backed regeneration path need their own loading
|
||||
/// state because they do not have a live `agent::Thread` to report it.
|
||||
regenerating_titles: HashSet<ThreadId>,
|
||||
/// start_renaming_thread must seed current title into the title editor
|
||||
/// Starting a rename must seed the current title into the title editor,
|
||||
/// so this prevents that BufferEdited event from being interpreted as user input.
|
||||
suppress_next_rename_edit: bool,
|
||||
|
||||
@@ -815,7 +837,7 @@ impl Sidebar {
|
||||
editor.set_placeholder_text("Search threads…", window, cx);
|
||||
editor
|
||||
});
|
||||
let thread_rename_editor = cx.new(|cx| Editor::single_line(window, cx));
|
||||
let rename_editor = cx.new(|cx| Editor::single_line(window, cx));
|
||||
|
||||
cx.subscribe_in(
|
||||
&multi_workspace,
|
||||
@@ -850,10 +872,10 @@ impl Sidebar {
|
||||
.detach();
|
||||
|
||||
cx.subscribe_in(
|
||||
&thread_rename_editor,
|
||||
&rename_editor,
|
||||
window,
|
||||
|this, title_editor, event, window, cx| {
|
||||
this.handle_thread_rename_editor_event(title_editor, event, window, cx);
|
||||
this.handle_rename_editor_event(title_editor, event, window, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
@@ -898,13 +920,13 @@ impl Sidebar {
|
||||
width: DEFAULT_WIDTH,
|
||||
focus_handle,
|
||||
filter_editor,
|
||||
thread_rename_editor,
|
||||
rename_editor,
|
||||
list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
|
||||
contents: SidebarContents::default(),
|
||||
selection: None,
|
||||
active_entry: None,
|
||||
hovered_thread_index: None,
|
||||
renaming_thread_id: None,
|
||||
rename_target: None,
|
||||
regenerating_titles: HashSet::new(),
|
||||
suppress_next_rename_edit: false,
|
||||
|
||||
@@ -3251,16 +3273,13 @@ impl Sidebar {
|
||||
|
||||
let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx));
|
||||
|
||||
let is_renaming_thread = self
|
||||
.thread_rename_editor
|
||||
.focus_handle(cx)
|
||||
.is_focused(window);
|
||||
let is_renaming = self.rename_editor.focus_handle(cx).is_focused(window);
|
||||
|
||||
let identifier = if self.filter_editor.focus_handle(cx).is_focused(window)
|
||||
|| is_archived_search_focused
|
||||
{
|
||||
"searching"
|
||||
} else if is_renaming_thread {
|
||||
} else if is_renaming {
|
||||
"editing"
|
||||
} else {
|
||||
"not_searching"
|
||||
@@ -3286,8 +3305,8 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.renaming_thread_id.is_some() {
|
||||
self.finish_thread_rename(window, cx);
|
||||
if self.rename_target.is_some() {
|
||||
self.finish_entry_rename(window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3351,23 +3370,30 @@ impl Sidebar {
|
||||
!self.filter_editor.read(cx).text(cx).is_empty()
|
||||
}
|
||||
|
||||
fn start_renaming_thread(
|
||||
fn start_renaming_entry(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
thread_id: ThreadId,
|
||||
target: RenameTarget,
|
||||
title: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.renaming_thread_id.is_some() && self.renaming_thread_id != Some(thread_id) {
|
||||
self.finish_thread_rename(window, cx);
|
||||
if self.rename_target == Some(target) {
|
||||
self.rename_editor.update(cx, |editor, cx| {
|
||||
editor.select_all(&editor::actions::SelectAll, window, cx);
|
||||
editor.focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if self.rename_target.is_some() {
|
||||
self.finish_entry_rename(window, cx);
|
||||
}
|
||||
|
||||
self.selection = Some(ix);
|
||||
self.renaming_thread_id = Some(thread_id);
|
||||
self.rename_target = Some(target);
|
||||
self.suppress_next_rename_edit = true;
|
||||
self.list_state.scroll_to_reveal_item(ix);
|
||||
self.thread_rename_editor.update(cx, |editor, cx| {
|
||||
self.rename_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(title, window, cx);
|
||||
editor.select_all(&editor::actions::SelectAll, window, cx);
|
||||
editor.focus_handle(cx).focus(window, cx);
|
||||
@@ -3375,7 +3401,7 @@ impl Sidebar {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_thread_rename_editor_event(
|
||||
fn handle_rename_editor_event(
|
||||
&mut self,
|
||||
title_editor: &Entity<Editor>,
|
||||
event: &editor::EditorEvent,
|
||||
@@ -3392,16 +3418,24 @@ impl Sidebar {
|
||||
return;
|
||||
}
|
||||
let new_title = title_editor.read(cx).text(cx);
|
||||
if new_title.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(thread_id) = self.renaming_thread_id else {
|
||||
let Some(target) = self.rename_target else {
|
||||
return;
|
||||
};
|
||||
self.apply_thread_rename(thread_id, SharedString::from(new_title), window, cx);
|
||||
if matches!(target, RenameTarget::Thread(_)) && new_title.is_empty() {
|
||||
return;
|
||||
}
|
||||
let new_title = SharedString::from(new_title);
|
||||
match target {
|
||||
RenameTarget::Thread(thread_id) => {
|
||||
self.apply_thread_rename(thread_id, new_title, window, cx);
|
||||
}
|
||||
RenameTarget::Terminal(terminal_id) => {
|
||||
self.apply_terminal_rename(terminal_id, new_title, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
editor::EditorEvent::Blurred => {
|
||||
self.finish_thread_rename(window, cx);
|
||||
self.finish_entry_rename(window, cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -3440,8 +3474,35 @@ impl Sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_thread_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
if self.renaming_thread_id.take().is_none() {
|
||||
fn apply_terminal_rename(
|
||||
&mut self,
|
||||
terminal_id: TerminalId,
|
||||
title: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut found = false;
|
||||
if let Some(multi_workspace) = self.multi_workspace.upgrade() {
|
||||
let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect();
|
||||
for workspace in workspaces {
|
||||
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx)
|
||||
&& agent_panel.update(cx, |agent_panel, cx| {
|
||||
agent_panel.rename_terminal(terminal_id, title.clone(), cx)
|
||||
})
|
||||
{
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| {
|
||||
store.rename_terminal(terminal_id, title, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_entry_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
if self.rename_target.take().is_none() {
|
||||
return false;
|
||||
}
|
||||
self.focus_handle.focus(window, cx);
|
||||
@@ -3523,7 +3584,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.finish_thread_rename(window, cx) {
|
||||
if self.finish_entry_rename(window, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5684,12 +5745,15 @@ impl Sidebar {
|
||||
let Some(ix) = self.selection else {
|
||||
return;
|
||||
};
|
||||
let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
|
||||
let Some((target, title)) = self
|
||||
.contents
|
||||
.entries
|
||||
.get(ix)
|
||||
.and_then(RenameTarget::from_entry)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let thread_id = thread.metadata.thread_id;
|
||||
let title = thread.metadata.display_title();
|
||||
self.start_renaming_thread(ix, thread_id, title, window, cx);
|
||||
self.start_renaming_entry(ix, target, title, window, cx);
|
||||
}
|
||||
|
||||
fn record_thread_access(&mut self, id: &ThreadId) {
|
||||
@@ -6107,6 +6171,28 @@ impl Sidebar {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
|
||||
fn render_rename_title_editor(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
div()
|
||||
.h_full()
|
||||
.min_w_0()
|
||||
.flex_1()
|
||||
.capture_action(
|
||||
cx.listener(|this, _: &editor::actions::Newline, window, cx| {
|
||||
this.finish_entry_rename(window, cx);
|
||||
}),
|
||||
)
|
||||
.on_action(cx.listener(|this, _: &Confirm, window, cx| {
|
||||
this.finish_entry_rename(window, cx);
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|this, _: &editor::actions::Cancel, window, cx| {
|
||||
this.finish_entry_rename(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(self.rename_editor.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_thread(
|
||||
&self,
|
||||
ix: usize,
|
||||
@@ -6129,12 +6215,13 @@ impl Sidebar {
|
||||
thread.status,
|
||||
AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
|
||||
);
|
||||
let is_renaming = self.renaming_thread_id == Some(thread.metadata.thread_id);
|
||||
let is_renaming =
|
||||
self.rename_target == Some(RenameTarget::Thread(thread.metadata.thread_id));
|
||||
|
||||
let thread_id_for_actions = thread.metadata.thread_id;
|
||||
let session_id_for_delete = thread.metadata.session_id.clone();
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
let title_editor = self.thread_rename_editor.clone();
|
||||
let rename_title_editor = is_renaming.then(|| self.render_rename_title_editor(cx));
|
||||
|
||||
let id = SharedString::from(format!("thread-entry-{}", ix));
|
||||
|
||||
@@ -6200,27 +6287,8 @@ impl Sidebar {
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
.when(is_renaming, |this| {
|
||||
this.is_truncated(false).title_slot(
|
||||
div()
|
||||
.h_full()
|
||||
.min_w_0()
|
||||
.flex_1()
|
||||
.capture_action(cx.listener(
|
||||
|this, _: &editor::actions::Newline, window, cx| {
|
||||
this.finish_thread_rename(window, cx);
|
||||
},
|
||||
))
|
||||
.on_action(cx.listener(|this, _: &Confirm, window, cx| {
|
||||
this.finish_thread_rename(window, cx);
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|this, _: &editor::actions::Cancel, window, cx| {
|
||||
this.finish_thread_rename(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(title_editor),
|
||||
)
|
||||
.when_some(rename_title_editor, |this, title_editor| {
|
||||
this.is_truncated(false).title_slot(title_editor)
|
||||
})
|
||||
.when(is_hovered && !is_renaming, |this| {
|
||||
let rename_button = IconButton::new(("rename-thread", ix), IconName::Pencil)
|
||||
@@ -6239,9 +6307,9 @@ impl Sidebar {
|
||||
.on_click({
|
||||
let title = title.clone();
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.start_renaming_thread(
|
||||
this.start_renaming_entry(
|
||||
ix,
|
||||
thread_id_for_actions,
|
||||
RenameTarget::Thread(thread_id_for_actions),
|
||||
title.clone(),
|
||||
window,
|
||||
cx,
|
||||
@@ -6381,9 +6449,9 @@ impl Sidebar {
|
||||
move |window, cx| {
|
||||
sidebar
|
||||
.update(cx, |sidebar, cx| {
|
||||
sidebar.start_renaming_thread(
|
||||
sidebar.start_renaming_entry(
|
||||
ix,
|
||||
thread_id,
|
||||
RenameTarget::Thread(thread_id),
|
||||
rename_title.clone(),
|
||||
window,
|
||||
cx,
|
||||
@@ -6492,6 +6560,9 @@ impl Sidebar {
|
||||
cx.flag_value::<AgentThreadWorktreeLabelFlag>(),
|
||||
);
|
||||
let is_remote = terminal.workspace.is_remote(cx);
|
||||
let is_renaming =
|
||||
self.rename_target == Some(RenameTarget::Terminal(terminal.metadata.terminal_id));
|
||||
let rename_title_editor = is_renaming.then(|| self.render_rename_title_editor(cx));
|
||||
|
||||
let display_title = terminal.metadata.display_title();
|
||||
let (icon_char, title, highlight_positions) =
|
||||
@@ -6500,7 +6571,7 @@ impl Sidebar {
|
||||
None => (None, display_title, terminal.highlight_positions.clone()),
|
||||
};
|
||||
|
||||
ThreadItem::new(id, title)
|
||||
let terminal_item = ThreadItem::new(id, title)
|
||||
.base_bg(sidebar_bg)
|
||||
.icon(IconName::Terminal)
|
||||
.when_some(icon_char, |this, icon_char| this.icon_char(icon_char))
|
||||
@@ -6520,7 +6591,10 @@ impl Sidebar {
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
.when(is_hovered, |this| {
|
||||
.when_some(rename_title_editor, |this, title_editor| {
|
||||
this.is_truncated(false).title_slot(title_editor)
|
||||
})
|
||||
.when(is_hovered && !is_renaming, |this| {
|
||||
this.action_slot(
|
||||
IconButton::new("close-terminal", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
@@ -6553,7 +6627,34 @@ impl Sidebar {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
let context_menu_id = SharedString::from(format!("terminal-context-menu-{ix}"));
|
||||
let terminal_id = terminal.metadata.terminal_id;
|
||||
let rename_title = terminal.metadata.editable_title();
|
||||
let sidebar = cx.weak_entity();
|
||||
|
||||
right_click_menu(context_menu_id)
|
||||
.trigger(move |_, _, _| terminal_item)
|
||||
.menu(move |window, cx| {
|
||||
let sidebar = sidebar.clone();
|
||||
let rename_title = rename_title.clone();
|
||||
ContextMenu::build(window, cx, move |menu, _window, _cx| {
|
||||
menu.entry("Rename Title", None, move |window, cx| {
|
||||
sidebar
|
||||
.update(cx, |sidebar, cx| {
|
||||
sidebar.start_renaming_entry(
|
||||
ix,
|
||||
RenameTarget::Terminal(terminal_id),
|
||||
rename_title.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,23 @@ use std::{
|
||||
};
|
||||
use util::{path_list::PathList, rel_path::rel_path};
|
||||
|
||||
fn use_unique_metadata_databases(cx: &mut TestAppContext) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static NEXT_TEST_DATABASE: AtomicUsize = AtomicUsize::new(0);
|
||||
let test_database_id = NEXT_TEST_DATABASE.fetch_add(1, Ordering::SeqCst);
|
||||
cx.update(|cx| {
|
||||
cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName(
|
||||
format!("SIDEBAR_THREAD_METADATA_{test_database_id}"),
|
||||
));
|
||||
cx.set_global(TestTerminalMetadataDbName(format!(
|
||||
"SIDEBAR_TERMINAL_THREAD_METADATA_{test_database_id}"
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
use_unique_metadata_databases(cx);
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
@@ -536,6 +552,27 @@ fn focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn enter_renamed_title(
|
||||
sidebar: &Entity<Sidebar>,
|
||||
target: RenameTarget,
|
||||
renamed_title: &str,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) {
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(sidebar.rename_target, Some(target));
|
||||
});
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.rename_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(renamed_title, window, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.finish_entry_rename(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn request_test_tool_authorization(
|
||||
thread: &Entity<AcpThread>,
|
||||
tool_call_id: &str,
|
||||
@@ -1765,6 +1802,7 @@ async fn init_test_project_with_agent_panel(
|
||||
worktree_path: &str,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Entity<project::Project> {
|
||||
use_unique_metadata_databases(cx);
|
||||
agent_ui::test_support::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
|
||||
@@ -5124,17 +5162,17 @@ async fn test_rename_thread_from_sidebar_updates_title_override(cx: &mut TestApp
|
||||
|
||||
let renamed_title = "abcdefghijklmnopqrstuvwxyé renamed";
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.start_renaming_thread(entry_ix, thread_id, title, window, cx);
|
||||
sidebar.start_renaming_entry(entry_ix, RenameTarget::Thread(thread_id), title, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.thread_rename_editor.update(cx, |editor, cx| {
|
||||
sidebar.rename_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(renamed_title, window, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.finish_thread_rename(window, cx);
|
||||
sidebar.finish_entry_rename(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
@@ -5254,25 +5292,8 @@ async fn test_rename_selected_thread_action_renames_selected_thread(cx: &mut Tes
|
||||
cx.dispatch_action(RenameSelectedThread);
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.renaming_thread_id,
|
||||
Some(thread_id),
|
||||
"dispatching RenameSelectedThread should start renaming the selected thread"
|
||||
);
|
||||
});
|
||||
|
||||
let renamed_title = "Renamed via action";
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.thread_rename_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(renamed_title, window, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.finish_thread_rename(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
enter_renamed_title(&sidebar, RenameTarget::Thread(thread_id), renamed_title, cx);
|
||||
|
||||
let metadata = cx.update(|_, cx| {
|
||||
ThreadMetadataStore::global(cx)
|
||||
@@ -5284,6 +5305,72 @@ async fn test_rename_selected_thread_action_renames_selected_thread(cx: &mut Tes
|
||||
assert_eq!(metadata.title_override.as_deref(), Some(renamed_title));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rename_selected_thread_action_renames_terminal(cx: &mut TestAppContext) {
|
||||
let project = init_test_project_with_agent_panel("/my-project", cx).await;
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
let terminal_id = panel
|
||||
.update_in(cx, |panel, window, cx| {
|
||||
panel.insert_test_terminal("Dev Server", true, window, cx)
|
||||
})
|
||||
.expect("test terminal should be inserted");
|
||||
cx.run_until_parked();
|
||||
|
||||
let entry_ix = sidebar.read_with(cx, |sidebar, _cx| {
|
||||
sidebar
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
ListEntry::Terminal(terminal)
|
||||
if terminal.metadata.terminal_id == terminal_id
|
||||
)
|
||||
})
|
||||
.expect("sidebar should have a terminal entry")
|
||||
});
|
||||
|
||||
focus_sidebar(&sidebar, cx);
|
||||
sidebar.update_in(cx, |sidebar, _window, _cx| {
|
||||
sidebar.selection = Some(entry_ix);
|
||||
});
|
||||
cx.dispatch_action(RenameSelectedThread);
|
||||
cx.run_until_parked();
|
||||
|
||||
let renamed_title = "Renamed Terminal";
|
||||
enter_renamed_title(
|
||||
&sidebar,
|
||||
RenameTarget::Terminal(terminal_id),
|
||||
renamed_title,
|
||||
cx,
|
||||
);
|
||||
|
||||
panel.read_with(cx, |panel, cx| {
|
||||
let terminal = panel
|
||||
.terminals(cx)
|
||||
.into_iter()
|
||||
.find(|terminal| terminal.id == terminal_id)
|
||||
.expect("terminal should remain open after renaming");
|
||||
assert_eq!(terminal.custom_title.as_deref(), Some(renamed_title));
|
||||
});
|
||||
sidebar.read_with(cx, |_sidebar, cx| {
|
||||
let metadata = TerminalThreadMetadataStore::global(cx)
|
||||
.read(cx)
|
||||
.entry(terminal_id)
|
||||
.cloned()
|
||||
.expect("renamed terminal metadata should exist");
|
||||
assert_eq!(metadata.custom_title.as_deref(), Some(renamed_title));
|
||||
});
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [my-project]", " Renamed Terminal <== selected"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
|
||||
|
||||
@@ -54,7 +54,7 @@ You can also configure this from the Settings UI under **AI**, via the "Terminal
|
||||
|
||||
## Terminal Thread Titles {#terminal-thread-titles}
|
||||
|
||||
The terminal title in the toolbar updates automatically to reflect the running shell or process. You can also set a custom name by clicking the title or the pencil icon that appears on hover.
|
||||
The terminal title in the toolbar updates automatically to reflect the running shell or process. You can set a custom name by clicking the title or the pencil icon that appears on hover. In the Threads Sidebar, right-click a Terminal Thread and select **Rename Title**, or select it and press {#kb agent::RenameSelectedThread}.
|
||||
|
||||
## Notifications {#terminal-thread-notifications}
|
||||
|
||||
|
||||
Reference in new issue
Block a user