1mod persistence;
2pub mod terminal_element;
3pub mod terminal_panel;
4mod terminal_path_like_target;
5pub mod terminal_scrollbar;
6mod terminal_slash_command;
7
8use assistant_slash_command::SlashCommandRegistry;
9use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
10use gpui::{
11 Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
12 Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point,
13 Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred,
14 div,
15};
16use menu;
17use persistence::TERMINAL_DB;
18use project::{Project, search::SearchQuery};
19use schemars::JsonSchema;
20use serde::Deserialize;
21use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
22use std::{
23 cmp,
24 ops::{Range, RangeInclusive},
25 path::{Path, PathBuf},
26 rc::Rc,
27 sync::Arc,
28 time::Duration,
29};
30use task::TaskId;
31use terminal::{
32 Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
33 ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState,
34 TaskStatus, Terminal, TerminalBounds, ToggleViMode,
35 alacritty_terminal::{
36 index::Point as AlacPoint,
37 term::{TermMode, point_to_viewport, search::RegexSearch},
38 },
39 terminal_settings::{CursorShape, TerminalSettings},
40};
41use terminal_element::TerminalElement;
42use terminal_panel::TerminalPanel;
43use terminal_path_like_target::{hover_path_like_target, open_path_like_target};
44use terminal_scrollbar::TerminalScrollHandle;
45use terminal_slash_command::TerminalSlashCommand;
46use ui::{
47 ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar,
48 prelude::*,
49 scrollbars::{self, GlobalSetting, ScrollbarVisibility},
50};
51use util::ResultExt;
52use workspace::{
53 CloseActiveItem, NewCenterTerminal, NewTerminal, ToolbarItemLocation, Workspace, WorkspaceId,
54 delete_unloaded_items,
55 item::{
56 BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
57 },
58 register_serializable_item,
59 searchable::{
60 Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle,
61 },
62};
63use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist};
64
65struct ImeState {
66 marked_text: String,
67}
68
69const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
70
71/// Event to transmit the scroll from the element to the view
72#[derive(Clone, Debug, PartialEq)]
73pub struct ScrollTerminal(pub i32);
74
75/// Sends the specified text directly to the terminal.
76#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
77#[action(namespace = terminal)]
78pub struct SendText(String);
79
80/// Sends a keystroke sequence to the terminal.
81#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
82#[action(namespace = terminal)]
83pub struct SendKeystroke(String);
84
85actions!(
86 terminal,
87 [
88 /// Reruns the last executed task in the terminal.
89 RerunTask,
90 ]
91);
92
93/// Renames the terminal tab.
94#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
95#[action(namespace = terminal)]
96pub struct RenameTerminal;
97
98pub fn init(cx: &mut App) {
99 assistant_slash_command::init(cx);
100 terminal_panel::init(cx);
101
102 register_serializable_item::<TerminalView>(cx);
103
104 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
105 workspace.register_action(TerminalView::deploy);
106 })
107 .detach();
108 SlashCommandRegistry::global(cx).register_command(TerminalSlashCommand, true);
109}
110
111pub struct BlockProperties {
112 pub height: u8,
113 pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
114}
115
116pub struct BlockContext<'a, 'b> {
117 pub window: &'a mut Window,
118 pub context: &'b mut App,
119 pub dimensions: TerminalBounds,
120}
121
122///A terminal view, maintains the PTY's file handles and communicates with the terminal
123pub struct TerminalView {
124 terminal: Entity<Terminal>,
125 workspace: WeakEntity<Workspace>,
126 project: WeakEntity<Project>,
127 focus_handle: FocusHandle,
128 //Currently using iTerm bell, show bell emoji in tab until input is received
129 has_bell: bool,
130 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
131 cursor_shape: CursorShape,
132 blink_manager: Entity<BlinkManager>,
133 mode: TerminalMode,
134 blinking_terminal_enabled: bool,
135 needs_serialize: bool,
136 custom_title: Option<String>,
137 hover: Option<HoverTarget>,
138 hover_tooltip_update: Task<()>,
139 workspace_id: Option<WorkspaceId>,
140 show_breadcrumbs: bool,
141 block_below_cursor: Option<Rc<BlockProperties>>,
142 scroll_top: Pixels,
143 scroll_handle: TerminalScrollHandle,
144 ime_state: Option<ImeState>,
145 self_handle: WeakEntity<Self>,
146 rename_editor: Option<Entity<Editor>>,
147 rename_editor_subscription: Option<Subscription>,
148 _subscriptions: Vec<Subscription>,
149 _terminal_subscriptions: Vec<Subscription>,
150}
151
152#[derive(Default, Clone)]
153pub enum TerminalMode {
154 #[default]
155 Standalone,
156 Embedded {
157 max_lines_when_unfocused: Option<usize>,
158 },
159}
160
161#[derive(Clone)]
162pub enum ContentMode {
163 Scrollable,
164 Inline {
165 displayed_lines: usize,
166 total_lines: usize,
167 },
168}
169
170impl ContentMode {
171 pub fn is_limited(&self) -> bool {
172 match self {
173 ContentMode::Scrollable => false,
174 ContentMode::Inline {
175 displayed_lines,
176 total_lines,
177 } => displayed_lines < total_lines,
178 }
179 }
180
181 pub fn is_scrollable(&self) -> bool {
182 matches!(self, ContentMode::Scrollable)
183 }
184}
185
186#[derive(Debug)]
187#[cfg_attr(test, derive(Clone, Eq, PartialEq))]
188struct HoverTarget {
189 tooltip: String,
190 hovered_word: HoveredWord,
191}
192
193impl EventEmitter<Event> for TerminalView {}
194impl EventEmitter<ItemEvent> for TerminalView {}
195impl EventEmitter<SearchEvent> for TerminalView {}
196
197impl Focusable for TerminalView {
198 fn focus_handle(&self, _cx: &App) -> FocusHandle {
199 self.focus_handle.clone()
200 }
201}
202
203impl TerminalView {
204 ///Create a new Terminal in the current working directory or the user's home directory
205 pub fn deploy(
206 workspace: &mut Workspace,
207 action: &NewCenterTerminal,
208 window: &mut Window,
209 cx: &mut Context<Workspace>,
210 ) {
211 let local = action.local;
212 let working_directory = default_working_directory(workspace, cx);
213 TerminalPanel::add_center_terminal(workspace, window, cx, move |project, cx| {
214 if local {
215 project.create_local_terminal(cx)
216 } else {
217 project.create_terminal_shell(working_directory, cx)
218 }
219 })
220 .detach_and_log_err(cx);
221 }
222
223 pub fn new(
224 terminal: Entity<Terminal>,
225 workspace: WeakEntity<Workspace>,
226 workspace_id: Option<WorkspaceId>,
227 project: WeakEntity<Project>,
228 window: &mut Window,
229 cx: &mut Context<Self>,
230 ) -> Self {
231 let workspace_handle = workspace.clone();
232 let terminal_subscriptions =
233 subscribe_for_terminal_events(&terminal, workspace, window, cx);
234
235 let focus_handle = cx.focus_handle();
236 let focus_in = cx.on_focus_in(&focus_handle, window, |terminal_view, window, cx| {
237 terminal_view.focus_in(window, cx);
238 });
239 let focus_out = cx.on_focus_out(
240 &focus_handle,
241 window,
242 |terminal_view, _event, window, cx| {
243 terminal_view.focus_out(window, cx);
244 },
245 );
246 let cursor_shape = TerminalSettings::get_global(cx).cursor_shape;
247
248 let scroll_handle = TerminalScrollHandle::new(terminal.read(cx));
249
250 let blink_manager = cx.new(|cx| {
251 BlinkManager::new(
252 CURSOR_BLINK_INTERVAL,
253 |cx| {
254 !matches!(
255 TerminalSettings::get_global(cx).blinking,
256 TerminalBlink::Off
257 )
258 },
259 cx,
260 )
261 });
262
263 let subscriptions = vec![
264 focus_in,
265 focus_out,
266 cx.observe(&blink_manager, |_, _, cx| cx.notify()),
267 cx.observe_global::<SettingsStore>(Self::settings_changed),
268 ];
269
270 Self {
271 terminal,
272 workspace: workspace_handle,
273 project,
274 has_bell: false,
275 focus_handle,
276 context_menu: None,
277 cursor_shape,
278 blink_manager,
279 blinking_terminal_enabled: false,
280 hover: None,
281 hover_tooltip_update: Task::ready(()),
282 mode: TerminalMode::Standalone,
283 workspace_id,
284 show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
285 block_below_cursor: None,
286 scroll_top: Pixels::ZERO,
287 scroll_handle,
288 needs_serialize: false,
289 custom_title: None,
290 ime_state: None,
291 self_handle: cx.entity().downgrade(),
292 rename_editor: None,
293 rename_editor_subscription: None,
294 _subscriptions: subscriptions,
295 _terminal_subscriptions: terminal_subscriptions,
296 }
297 }
298
299 /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
300 pub fn set_embedded_mode(
301 &mut self,
302 max_lines_when_unfocused: Option<usize>,
303 cx: &mut Context<Self>,
304 ) {
305 self.mode = TerminalMode::Embedded {
306 max_lines_when_unfocused,
307 };
308 cx.notify();
309 }
310
311 const MAX_EMBEDDED_LINES: usize = 1_000;
312
313 /// Returns the current `ContentMode` depending on the set `TerminalMode` and the current number of lines
314 ///
315 /// Note: Even in embedded mode, the terminal will fallback to scrollable when its content exceeds `MAX_EMBEDDED_LINES`
316 pub fn content_mode(&self, window: &Window, cx: &App) -> ContentMode {
317 match &self.mode {
318 TerminalMode::Standalone => ContentMode::Scrollable,
319 TerminalMode::Embedded {
320 max_lines_when_unfocused,
321 } => {
322 let total_lines = self.terminal.read(cx).total_lines();
323
324 if total_lines > Self::MAX_EMBEDDED_LINES {
325 ContentMode::Scrollable
326 } else {
327 let mut displayed_lines = total_lines;
328
329 if !self.focus_handle.is_focused(window)
330 && let Some(max_lines) = max_lines_when_unfocused
331 {
332 displayed_lines = displayed_lines.min(*max_lines)
333 }
334
335 ContentMode::Inline {
336 displayed_lines,
337 total_lines,
338 }
339 }
340 }
341 }
342 }
343
344 /// Sets the marked (pre-edit) text from the IME.
345 pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) {
346 if text.is_empty() {
347 return self.clear_marked_text(cx);
348 }
349 self.ime_state = Some(ImeState { marked_text: text });
350 cx.notify();
351 }
352
353 /// Gets the current marked range (UTF-16).
354 pub(crate) fn marked_text_range(&self) -> Option<Range<usize>> {
355 self.ime_state
356 .as_ref()
357 .map(|state| 0..state.marked_text.encode_utf16().count())
358 }
359
360 /// Clears the marked (pre-edit) text state.
361 pub(crate) fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
362 if self.ime_state.is_some() {
363 self.ime_state = None;
364 cx.notify();
365 }
366 }
367
368 /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
369 pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
370 if !text.is_empty() {
371 self.terminal.update(cx, |term, _| {
372 term.input(text.to_string().into_bytes());
373 });
374 }
375 }
376
377 pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds {
378 self.terminal.read(cx).last_content().terminal_bounds
379 }
380
381 pub fn entity(&self) -> &Entity<Terminal> {
382 &self.terminal
383 }
384
385 pub fn has_bell(&self) -> bool {
386 self.has_bell
387 }
388
389 pub fn custom_title(&self) -> Option<&str> {
390 self.custom_title.as_deref()
391 }
392
393 pub fn set_custom_title(&mut self, label: Option<String>, cx: &mut Context<Self>) {
394 let label = label.filter(|l| !l.trim().is_empty());
395 if self.custom_title != label {
396 self.custom_title = label;
397 self.needs_serialize = true;
398 cx.emit(ItemEvent::UpdateTab);
399 cx.notify();
400 }
401 }
402
403 pub fn is_renaming(&self) -> bool {
404 self.rename_editor.is_some()
405 }
406
407 pub fn rename_editor_is_focused(&self, window: &Window, cx: &App) -> bool {
408 self.rename_editor
409 .as_ref()
410 .is_some_and(|editor| editor.focus_handle(cx).is_focused(window))
411 }
412
413 fn finish_renaming(&mut self, save: bool, window: &mut Window, cx: &mut Context<Self>) {
414 let Some(editor) = self.rename_editor.take() else {
415 return;
416 };
417 self.rename_editor_subscription = None;
418 if save {
419 let new_label = editor.read(cx).text(cx).trim().to_string();
420 let label = if new_label.is_empty() {
421 None
422 } else {
423 // Only set custom_title if the text differs from the terminal's dynamic title.
424 // This prevents subtle layout changes when clicking away without making changes.
425 let terminal_title = self.terminal.read(cx).title(true);
426 if new_label == terminal_title {
427 None
428 } else {
429 Some(new_label)
430 }
431 };
432 self.set_custom_title(label, cx);
433 }
434 cx.notify();
435 self.focus_handle.focus(window, cx);
436 }
437
438 pub fn rename_terminal(
439 &mut self,
440 _: &RenameTerminal,
441 window: &mut Window,
442 cx: &mut Context<Self>,
443 ) {
444 if self.terminal.read(cx).task().is_some() {
445 return;
446 }
447
448 let current_label = self
449 .custom_title
450 .clone()
451 .unwrap_or_else(|| self.terminal.read(cx).title(true));
452
453 let rename_editor = cx.new(|cx| Editor::single_line(window, cx));
454 let rename_editor_subscription = cx.subscribe_in(&rename_editor, window, {
455 let rename_editor = rename_editor.clone();
456 move |_this, _, event, window, cx| {
457 if let editor::EditorEvent::Blurred = event {
458 // Defer to let focus settle (avoids canceling during double-click).
459 let rename_editor = rename_editor.clone();
460 cx.defer_in(window, move |this, window, cx| {
461 let still_current = this
462 .rename_editor
463 .as_ref()
464 .is_some_and(|current| current == &rename_editor);
465 if still_current && !rename_editor.focus_handle(cx).is_focused(window) {
466 this.finish_renaming(false, window, cx);
467 }
468 });
469 }
470 }
471 });
472
473 self.rename_editor = Some(rename_editor.clone());
474 self.rename_editor_subscription = Some(rename_editor_subscription);
475
476 rename_editor.update(cx, |editor, cx| {
477 editor.set_text(current_label, window, cx);
478 editor.select_all(&SelectAll, window, cx);
479 editor.focus_handle(cx).focus(window, cx);
480 });
481 cx.notify();
482 }
483
484 pub fn clear_bell(&mut self, cx: &mut Context<TerminalView>) {
485 self.has_bell = false;
486 cx.emit(Event::Wakeup);
487 }
488
489 pub fn deploy_context_menu(
490 &mut self,
491 position: Point<Pixels>,
492 window: &mut Window,
493 cx: &mut Context<Self>,
494 ) {
495 let assistant_enabled = self
496 .workspace
497 .upgrade()
498 .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
499 .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled());
500 let has_selection = self
501 .terminal
502 .read(cx)
503 .last_content
504 .selection_text
505 .as_ref()
506 .is_some_and(|text| !text.is_empty());
507 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
508 menu.context(self.focus_handle.clone())
509 .action("New Terminal", Box::new(NewTerminal::default()))
510 .separator()
511 .action("Copy", Box::new(Copy))
512 .action("Paste", Box::new(Paste))
513 .action("Select All", Box::new(SelectAll))
514 .action("Clear", Box::new(Clear))
515 .when(assistant_enabled, |menu| {
516 menu.separator()
517 .action("Inline Assist", Box::new(InlineAssist::default()))
518 .when(has_selection, |menu| {
519 menu.action("Add to Agent Thread", Box::new(AddSelectionToThread))
520 })
521 })
522 .separator()
523 .action(
524 "Close Terminal Tab",
525 Box::new(CloseActiveItem {
526 save_intent: None,
527 close_pinned: true,
528 }),
529 )
530 });
531
532 window.focus(&context_menu.focus_handle(cx), cx);
533 let subscription = cx.subscribe_in(
534 &context_menu,
535 window,
536 |this, _, _: &DismissEvent, window, cx| {
537 if this.context_menu.as_ref().is_some_and(|context_menu| {
538 context_menu.0.focus_handle(cx).contains_focused(window, cx)
539 }) {
540 cx.focus_self(window);
541 }
542 this.context_menu.take();
543 cx.notify();
544 },
545 );
546
547 self.context_menu = Some((context_menu, position, subscription));
548 }
549
550 fn settings_changed(&mut self, cx: &mut Context<Self>) {
551 let settings = TerminalSettings::get_global(cx);
552 let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs;
553 self.show_breadcrumbs = settings.toolbar.breadcrumbs;
554
555 let should_blink = match settings.blinking {
556 TerminalBlink::Off => false,
557 TerminalBlink::On => true,
558 TerminalBlink::TerminalControlled => self.blinking_terminal_enabled,
559 };
560 let new_cursor_shape = settings.cursor_shape;
561 let old_cursor_shape = self.cursor_shape;
562 if old_cursor_shape != new_cursor_shape {
563 self.cursor_shape = new_cursor_shape;
564 self.terminal.update(cx, |term, _| {
565 term.set_cursor_shape(self.cursor_shape);
566 });
567 }
568
569 self.blink_manager.update(
570 cx,
571 if should_blink {
572 BlinkManager::enable
573 } else {
574 BlinkManager::disable
575 },
576 );
577
578 if breadcrumb_visibility_changed {
579 cx.emit(ItemEvent::UpdateBreadcrumbs);
580 }
581 cx.notify();
582 }
583
584 fn show_character_palette(
585 &mut self,
586 _: &ShowCharacterPalette,
587 window: &mut Window,
588 cx: &mut Context<Self>,
589 ) {
590 if self
591 .terminal
592 .read(cx)
593 .last_content
594 .mode
595 .contains(TermMode::ALT_SCREEN)
596 {
597 self.terminal.update(cx, |term, cx| {
598 term.try_keystroke(
599 &Keystroke::parse("ctrl-cmd-space").unwrap(),
600 TerminalSettings::get_global(cx).option_as_meta,
601 )
602 });
603 } else {
604 window.show_character_palette();
605 }
606 }
607
608 fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
609 self.terminal.update(cx, |term, _| term.select_all());
610 cx.notify();
611 }
612
613 fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) {
614 let task = self
615 .terminal
616 .read(cx)
617 .task()
618 .map(|task| terminal_rerun_override(&task.spawned_task.id))
619 .unwrap_or_default();
620 window.dispatch_action(Box::new(task), cx);
621 }
622
623 fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) {
624 self.scroll_top = px(0.);
625 self.terminal.update(cx, |term, _| term.clear());
626 cx.notify();
627 }
628
629 fn max_scroll_top(&self, cx: &App) -> Pixels {
630 let terminal = self.terminal.read(cx);
631
632 let Some(block) = self.block_below_cursor.as_ref() else {
633 return Pixels::ZERO;
634 };
635
636 let line_height = terminal.last_content().terminal_bounds.line_height;
637 let viewport_lines = terminal.viewport_lines();
638 let cursor = point_to_viewport(
639 terminal.last_content.display_offset,
640 terminal.last_content.cursor.point,
641 )
642 .unwrap_or_default();
643 let max_scroll_top_in_lines =
644 (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1));
645
646 max_scroll_top_in_lines as f32 * line_height
647 }
648
649 fn scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) {
650 let terminal_content = self.terminal.read(cx).last_content();
651
652 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
653 let line_height = terminal_content.terminal_bounds.line_height;
654 let y_delta = event.delta.pixel_delta(line_height).y;
655 if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO {
656 self.scroll_top = cmp::max(
657 Pixels::ZERO,
658 cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)),
659 );
660 cx.notify();
661 return;
662 }
663 }
664 self.terminal.update(cx, |term, cx| {
665 term.scroll_wheel(
666 event,
667 TerminalSettings::get_global(cx).scroll_multiplier.max(0.01),
668 )
669 });
670 }
671
672 fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context<Self>) {
673 let terminal_content = self.terminal.read(cx).last_content();
674 if self.block_below_cursor.is_some()
675 && terminal_content.display_offset == 0
676 && self.scroll_top > Pixels::ZERO
677 {
678 let line_height = terminal_content.terminal_bounds.line_height;
679 self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO);
680 return;
681 }
682
683 self.terminal.update(cx, |term, _| term.scroll_line_up());
684 cx.notify();
685 }
686
687 fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context<Self>) {
688 let terminal_content = self.terminal.read(cx).last_content();
689 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
690 let max_scroll_top = self.max_scroll_top(cx);
691 if self.scroll_top < max_scroll_top {
692 let line_height = terminal_content.terminal_bounds.line_height;
693 self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top);
694 }
695 return;
696 }
697
698 self.terminal.update(cx, |term, _| term.scroll_line_down());
699 cx.notify();
700 }
701
702 fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context<Self>) {
703 if self.scroll_top == Pixels::ZERO {
704 self.terminal.update(cx, |term, _| term.scroll_page_up());
705 } else {
706 let line_height = self
707 .terminal
708 .read(cx)
709 .last_content
710 .terminal_bounds
711 .line_height();
712 let visible_block_lines = (self.scroll_top / line_height) as usize;
713 let viewport_lines = self.terminal.read(cx).viewport_lines();
714 let visible_content_lines = viewport_lines - visible_block_lines;
715
716 if visible_block_lines >= viewport_lines {
717 self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height;
718 } else {
719 self.scroll_top = px(0.);
720 self.terminal
721 .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
722 }
723 }
724 cx.notify();
725 }
726
727 fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context<Self>) {
728 self.terminal.update(cx, |term, _| term.scroll_page_down());
729 let terminal = self.terminal.read(cx);
730 if terminal.last_content().display_offset < terminal.viewport_lines() {
731 self.scroll_top = self.max_scroll_top(cx);
732 }
733 cx.notify();
734 }
735
736 fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context<Self>) {
737 self.terminal.update(cx, |term, _| term.scroll_to_top());
738 cx.notify();
739 }
740
741 fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context<Self>) {
742 self.terminal.update(cx, |term, _| term.scroll_to_bottom());
743 if self.block_below_cursor.is_some() {
744 self.scroll_top = self.max_scroll_top(cx);
745 }
746 cx.notify();
747 }
748
749 fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context<Self>) {
750 self.terminal.update(cx, |term, _| term.toggle_vi_mode());
751 cx.notify();
752 }
753
754 pub fn should_show_cursor(&self, focused: bool, cx: &mut Context<Self>) -> bool {
755 // Always show cursor when not focused or in special modes
756 if !focused
757 || self
758 .terminal
759 .read(cx)
760 .last_content
761 .mode
762 .contains(TermMode::ALT_SCREEN)
763 {
764 return true;
765 }
766
767 // When focused, check blinking settings and blink manager state
768 match TerminalSettings::get_global(cx).blinking {
769 TerminalBlink::Off => true,
770 TerminalBlink::TerminalControlled => {
771 !self.blinking_terminal_enabled || self.blink_manager.read(cx).visible()
772 }
773 TerminalBlink::On => self.blink_manager.read(cx).visible(),
774 }
775 }
776
777 pub fn pause_cursor_blinking(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
778 self.blink_manager.update(cx, BlinkManager::pause_blinking);
779 }
780
781 pub fn terminal(&self) -> &Entity<Terminal> {
782 &self.terminal
783 }
784
785 pub fn set_block_below_cursor(
786 &mut self,
787 block: BlockProperties,
788 window: &mut Window,
789 cx: &mut Context<Self>,
790 ) {
791 self.block_below_cursor = Some(Rc::new(block));
792 self.scroll_to_bottom(&ScrollToBottom, window, cx);
793 cx.notify();
794 }
795
796 pub fn clear_block_below_cursor(&mut self, cx: &mut Context<Self>) {
797 self.block_below_cursor = None;
798 self.scroll_top = Pixels::ZERO;
799 cx.notify();
800 }
801
802 ///Attempt to paste the clipboard into the terminal
803 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
804 self.terminal.update(cx, |term, _| term.copy(None));
805 cx.notify();
806 }
807
808 ///Attempt to paste the clipboard into the terminal
809 fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
810 let Some(clipboard) = cx.read_from_clipboard() else {
811 return;
812 };
813
814 if clipboard.entries().iter().any(|entry| match entry {
815 ClipboardEntry::Image(image) => !image.bytes.is_empty(),
816 _ => false,
817 }) {
818 self.forward_ctrl_v(cx);
819 return;
820 }
821
822 if let Some(text) = clipboard.text() {
823 self.terminal
824 .update(cx, |terminal, _cx| terminal.paste(&text));
825 }
826 }
827
828 /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
829 /// and attach images using their native workflows.
830 fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
831 self.terminal.update(cx, |term, _| {
832 term.input(vec![0x16]);
833 });
834 }
835
836 fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
837 self.clear_bell(cx);
838 self.terminal.update(cx, |term, _| {
839 term.input(text.0.to_string().into_bytes());
840 });
841 }
842
843 fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context<Self>) {
844 if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
845 self.clear_bell(cx);
846 self.process_keystroke(&keystroke, cx);
847 }
848 }
849
850 fn dispatch_context(&self, cx: &App) -> KeyContext {
851 let mut dispatch_context = KeyContext::new_with_defaults();
852 dispatch_context.add("Terminal");
853
854 if self.terminal.read(cx).vi_mode_enabled() {
855 dispatch_context.add("vi_mode");
856 }
857
858 let mode = self.terminal.read(cx).last_content.mode;
859 dispatch_context.set(
860 "screen",
861 if mode.contains(TermMode::ALT_SCREEN) {
862 "alt"
863 } else {
864 "normal"
865 },
866 );
867
868 if mode.contains(TermMode::APP_CURSOR) {
869 dispatch_context.add("DECCKM");
870 }
871 if mode.contains(TermMode::APP_KEYPAD) {
872 dispatch_context.add("DECPAM");
873 } else {
874 dispatch_context.add("DECPNM");
875 }
876 if mode.contains(TermMode::SHOW_CURSOR) {
877 dispatch_context.add("DECTCEM");
878 }
879 if mode.contains(TermMode::LINE_WRAP) {
880 dispatch_context.add("DECAWM");
881 }
882 if mode.contains(TermMode::ORIGIN) {
883 dispatch_context.add("DECOM");
884 }
885 if mode.contains(TermMode::INSERT) {
886 dispatch_context.add("IRM");
887 }
888 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
889 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
890 dispatch_context.add("LNM");
891 }
892 if mode.contains(TermMode::FOCUS_IN_OUT) {
893 dispatch_context.add("report_focus");
894 }
895 if mode.contains(TermMode::ALTERNATE_SCROLL) {
896 dispatch_context.add("alternate_scroll");
897 }
898 if mode.contains(TermMode::BRACKETED_PASTE) {
899 dispatch_context.add("bracketed_paste");
900 }
901 if mode.intersects(TermMode::MOUSE_MODE) {
902 dispatch_context.add("any_mouse_reporting");
903 }
904 {
905 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
906 "click"
907 } else if mode.contains(TermMode::MOUSE_DRAG) {
908 "drag"
909 } else if mode.contains(TermMode::MOUSE_MOTION) {
910 "motion"
911 } else {
912 "off"
913 };
914 dispatch_context.set("mouse_reporting", mouse_reporting);
915 }
916 {
917 let format = if mode.contains(TermMode::SGR_MOUSE) {
918 "sgr"
919 } else if mode.contains(TermMode::UTF8_MOUSE) {
920 "utf8"
921 } else {
922 "normal"
923 };
924 dispatch_context.set("mouse_format", format);
925 };
926
927 if self.terminal.read(cx).last_content.selection.is_some() {
928 dispatch_context.add("selection");
929 }
930
931 dispatch_context
932 }
933
934 fn set_terminal(
935 &mut self,
936 terminal: Entity<Terminal>,
937 window: &mut Window,
938 cx: &mut Context<TerminalView>,
939 ) {
940 self._terminal_subscriptions =
941 subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx);
942 self.terminal = terminal;
943 }
944
945 fn rerun_button(task: &TaskState) -> Option<IconButton> {
946 if !task.spawned_task.show_rerun {
947 return None;
948 }
949
950 let task_id = task.spawned_task.id.clone();
951 Some(
952 IconButton::new("rerun-icon", IconName::Rerun)
953 .icon_size(IconSize::Small)
954 .size(ButtonSize::Compact)
955 .icon_color(Color::Default)
956 .shape(ui::IconButtonShape::Square)
957 .tooltip(move |_window, cx| Tooltip::for_action("Rerun task", &RerunTask, cx))
958 .on_click(move |_, window, cx| {
959 window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx);
960 }),
961 )
962 }
963}
964
965fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun {
966 zed_actions::Rerun {
967 task_id: Some(task.0.clone()),
968 allow_concurrent_runs: Some(true),
969 use_new_terminal: Some(false),
970 reevaluate_context: false,
971 }
972}
973
974fn subscribe_for_terminal_events(
975 terminal: &Entity<Terminal>,
976 workspace: WeakEntity<Workspace>,
977 window: &mut Window,
978 cx: &mut Context<TerminalView>,
979) -> Vec<Subscription> {
980 let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify());
981 let mut previous_cwd = None;
982 let terminal_events_subscription = cx.subscribe_in(
983 terminal,
984 window,
985 move |terminal_view, terminal, event, window, cx| {
986 let current_cwd = terminal.read(cx).working_directory();
987 if current_cwd != previous_cwd {
988 previous_cwd = current_cwd;
989 terminal_view.needs_serialize = true;
990 }
991
992 match event {
993 Event::Wakeup => {
994 cx.notify();
995 cx.emit(Event::Wakeup);
996 cx.emit(ItemEvent::UpdateTab);
997 cx.emit(SearchEvent::MatchesInvalidated);
998 }
999
1000 Event::Bell => {
1001 terminal_view.has_bell = true;
1002 cx.emit(Event::Wakeup);
1003 }
1004
1005 Event::BlinkChanged(blinking) => {
1006 terminal_view.blinking_terminal_enabled = *blinking;
1007
1008 // If in terminal-controlled mode and focused, update blink manager
1009 if matches!(
1010 TerminalSettings::get_global(cx).blinking,
1011 TerminalBlink::TerminalControlled
1012 ) && terminal_view.focus_handle.is_focused(window)
1013 {
1014 terminal_view.blink_manager.update(cx, |manager, cx| {
1015 if *blinking {
1016 manager.enable(cx);
1017 } else {
1018 manager.disable(cx);
1019 }
1020 });
1021 }
1022 }
1023
1024 Event::TitleChanged => {
1025 cx.emit(ItemEvent::UpdateTab);
1026 }
1027
1028 Event::NewNavigationTarget(maybe_navigation_target) => {
1029 match maybe_navigation_target
1030 .as_ref()
1031 .zip(terminal.read(cx).last_content.last_hovered_word.as_ref())
1032 {
1033 Some((MaybeNavigationTarget::Url(url), hovered_word)) => {
1034 if Some(hovered_word)
1035 != terminal_view
1036 .hover
1037 .as_ref()
1038 .map(|hover| &hover.hovered_word)
1039 {
1040 terminal_view.hover = Some(HoverTarget {
1041 tooltip: url.clone(),
1042 hovered_word: hovered_word.clone(),
1043 });
1044 terminal_view.hover_tooltip_update = Task::ready(());
1045 cx.notify();
1046 }
1047 }
1048 Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => {
1049 if Some(hovered_word)
1050 != terminal_view
1051 .hover
1052 .as_ref()
1053 .map(|hover| &hover.hovered_word)
1054 {
1055 terminal_view.hover = None;
1056 terminal_view.hover_tooltip_update = hover_path_like_target(
1057 &workspace,
1058 hovered_word.clone(),
1059 path_like_target,
1060 cx,
1061 );
1062 cx.notify();
1063 }
1064 }
1065 None => {
1066 terminal_view.hover = None;
1067 terminal_view.hover_tooltip_update = Task::ready(());
1068 cx.notify();
1069 }
1070 }
1071 }
1072
1073 Event::Open(maybe_navigation_target) => match maybe_navigation_target {
1074 MaybeNavigationTarget::Url(url) => cx.open_url(url),
1075 MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target(
1076 &workspace,
1077 terminal_view,
1078 path_like_target,
1079 window,
1080 cx,
1081 ),
1082 },
1083 Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
1084 Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
1085 Event::SelectionsChanged => {
1086 window.invalidate_character_coordinates();
1087 cx.emit(SearchEvent::ActiveMatchChanged)
1088 }
1089 }
1090 },
1091 );
1092 vec![terminal_subscription, terminal_events_subscription]
1093}
1094
1095fn regex_search_for_query(query: &SearchQuery) -> Option<RegexSearch> {
1096 let str = query.as_str();
1097 if query.is_regex() {
1098 if str == "." {
1099 return None;
1100 }
1101 RegexSearch::new(str).ok()
1102 } else {
1103 RegexSearch::new(®ex::escape(str)).ok()
1104 }
1105}
1106
1107struct TerminalScrollbarSettingsWrapper;
1108
1109impl GlobalSetting for TerminalScrollbarSettingsWrapper {
1110 fn get_value(_cx: &App) -> &Self {
1111 &Self
1112 }
1113}
1114
1115impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper {
1116 fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar {
1117 TerminalSettings::get_global(cx)
1118 .scrollbar
1119 .show
1120 .map(Into::into)
1121 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
1122 }
1123}
1124
1125impl TerminalView {
1126 /// Attempts to process a keystroke in the terminal. Returns true if handled.
1127 ///
1128 /// In vi mode, explicitly triggers a re-render because vi navigation (like j/k)
1129 /// updates the cursor locally without sending data to the shell, so there's no
1130 /// shell output to automatically trigger a re-render.
1131 fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
1132 let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
1133 (
1134 term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta),
1135 term.vi_mode_enabled(),
1136 )
1137 });
1138
1139 if handled && vi_mode_enabled {
1140 cx.notify();
1141 }
1142
1143 handled
1144 }
1145
1146 fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1147 self.clear_bell(cx);
1148 self.pause_cursor_blinking(window, cx);
1149
1150 if self.process_keystroke(&event.keystroke, cx) {
1151 cx.stop_propagation();
1152 }
1153 }
1154
1155 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1156 self.terminal.update(cx, |terminal, _| {
1157 terminal.set_cursor_shape(self.cursor_shape);
1158 terminal.focus_in();
1159 });
1160
1161 let should_blink = match TerminalSettings::get_global(cx).blinking {
1162 TerminalBlink::Off => false,
1163 TerminalBlink::On => true,
1164 TerminalBlink::TerminalControlled => self.blinking_terminal_enabled,
1165 };
1166
1167 if should_blink {
1168 self.blink_manager.update(cx, BlinkManager::enable);
1169 }
1170
1171 window.invalidate_character_coordinates();
1172 cx.notify();
1173 }
1174
1175 fn focus_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1176 self.blink_manager.update(cx, BlinkManager::disable);
1177 self.terminal.update(cx, |terminal, _| {
1178 terminal.focus_out();
1179 terminal.set_cursor_shape(CursorShape::Hollow);
1180 });
1181 cx.notify();
1182 }
1183}
1184
1185impl Render for TerminalView {
1186 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1187 // TODO: this should be moved out of render
1188 self.scroll_handle.update(self.terminal.read(cx));
1189
1190 if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
1191 self.terminal.update(cx, |term, _| {
1192 let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
1193 match delta.cmp(&0) {
1194 cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
1195 cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
1196 cmp::Ordering::Equal => {}
1197 }
1198 });
1199 }
1200
1201 let terminal_handle = self.terminal.clone();
1202 let terminal_view_handle = cx.entity();
1203
1204 let focused = self.focus_handle.is_focused(window);
1205
1206 div()
1207 .id("terminal-view")
1208 .size_full()
1209 .relative()
1210 .track_focus(&self.focus_handle(cx))
1211 .key_context(self.dispatch_context(cx))
1212 .on_action(cx.listener(TerminalView::send_text))
1213 .on_action(cx.listener(TerminalView::send_keystroke))
1214 .on_action(cx.listener(TerminalView::copy))
1215 .on_action(cx.listener(TerminalView::paste))
1216 .on_action(cx.listener(TerminalView::clear))
1217 .on_action(cx.listener(TerminalView::scroll_line_up))
1218 .on_action(cx.listener(TerminalView::scroll_line_down))
1219 .on_action(cx.listener(TerminalView::scroll_page_up))
1220 .on_action(cx.listener(TerminalView::scroll_page_down))
1221 .on_action(cx.listener(TerminalView::scroll_to_top))
1222 .on_action(cx.listener(TerminalView::scroll_to_bottom))
1223 .on_action(cx.listener(TerminalView::toggle_vi_mode))
1224 .on_action(cx.listener(TerminalView::show_character_palette))
1225 .on_action(cx.listener(TerminalView::select_all))
1226 .on_action(cx.listener(TerminalView::rerun_task))
1227 .on_action(cx.listener(TerminalView::rename_terminal))
1228 .on_key_down(cx.listener(Self::key_down))
1229 .on_mouse_down(
1230 MouseButton::Right,
1231 cx.listener(|this, event: &MouseDownEvent, window, cx| {
1232 if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
1233 if this.terminal.read(cx).last_content.selection.is_none() {
1234 this.terminal.update(cx, |terminal, _| {
1235 terminal.select_word_at_event_position(event);
1236 });
1237 };
1238 this.deploy_context_menu(event.position, window, cx);
1239 cx.notify();
1240 }
1241 }),
1242 )
1243 .child(
1244 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
1245 div()
1246 .id("terminal-view-container")
1247 .size_full()
1248 .bg(cx.theme().colors().editor_background)
1249 .child(TerminalElement::new(
1250 terminal_handle,
1251 terminal_view_handle,
1252 self.workspace.clone(),
1253 self.focus_handle.clone(),
1254 focused,
1255 self.should_show_cursor(focused, cx),
1256 self.block_below_cursor.clone(),
1257 self.mode.clone(),
1258 ))
1259 .when(self.content_mode(window, cx).is_scrollable(), |div| {
1260 div.custom_scrollbars(
1261 Scrollbars::for_settings::<TerminalScrollbarSettingsWrapper>()
1262 .show_along(ScrollAxes::Vertical)
1263 .with_track_along(
1264 ScrollAxes::Vertical,
1265 cx.theme().colors().editor_background,
1266 )
1267 .tracked_scroll_handle(&self.scroll_handle),
1268 window,
1269 cx,
1270 )
1271 }),
1272 )
1273 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1274 deferred(
1275 anchored()
1276 .position(*position)
1277 .anchor(gpui::Corner::TopLeft)
1278 .child(menu.clone()),
1279 )
1280 .with_priority(1)
1281 }))
1282 }
1283}
1284
1285impl Item for TerminalView {
1286 type Event = ItemEvent;
1287
1288 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
1289 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
1290 let terminal = self.terminal().read(cx);
1291 let title = terminal.title(false);
1292 let pid = terminal.pid_getter()?.fallback_pid();
1293
1294 move |_, _| {
1295 v_flex()
1296 .gap_1()
1297 .child(Label::new(title.clone()))
1298 .child(h_flex().flex_grow().child(Divider::horizontal()))
1299 .child(
1300 Label::new(format!("Process ID (PID): {}", pid))
1301 .color(Color::Muted)
1302 .size(LabelSize::Small),
1303 )
1304 .into_any_element()
1305 }
1306 }))))
1307 }
1308
1309 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
1310 let terminal = self.terminal().read(cx);
1311 let title = self
1312 .custom_title
1313 .as_ref()
1314 .filter(|title| !title.trim().is_empty())
1315 .cloned()
1316 .unwrap_or_else(|| terminal.title(true));
1317
1318 let (icon, icon_color, rerun_button) = match terminal.task() {
1319 Some(terminal_task) => match &terminal_task.status {
1320 TaskStatus::Running => (
1321 IconName::PlayFilled,
1322 Color::Disabled,
1323 TerminalView::rerun_button(terminal_task),
1324 ),
1325 TaskStatus::Unknown => (
1326 IconName::Warning,
1327 Color::Warning,
1328 TerminalView::rerun_button(terminal_task),
1329 ),
1330 TaskStatus::Completed { success } => {
1331 let rerun_button = TerminalView::rerun_button(terminal_task);
1332
1333 if *success {
1334 (IconName::Check, Color::Success, rerun_button)
1335 } else {
1336 (IconName::XCircle, Color::Error, rerun_button)
1337 }
1338 }
1339 },
1340 None => (IconName::Terminal, Color::Muted, None),
1341 };
1342
1343 h_flex()
1344 .gap_1()
1345 .group("term-tab-icon")
1346 .child(
1347 h_flex()
1348 .group("term-tab-icon")
1349 .child(
1350 div()
1351 .when(rerun_button.is_some(), |this| {
1352 this.hover(|style| style.invisible().w_0())
1353 })
1354 .child(Icon::new(icon).color(icon_color)),
1355 )
1356 .when_some(rerun_button, |this, rerun_button| {
1357 this.child(
1358 div()
1359 .absolute()
1360 .visible_on_hover("term-tab-icon")
1361 .child(rerun_button),
1362 )
1363 }),
1364 )
1365 .child(
1366 div()
1367 .relative()
1368 .child(
1369 Label::new(title)
1370 .color(params.text_color())
1371 .when(self.is_renaming(), |this| this.alpha(0.)),
1372 )
1373 .when_some(self.rename_editor.clone(), |this, editor| {
1374 let self_handle = self.self_handle.clone();
1375 let self_handle_cancel = self.self_handle.clone();
1376 this.child(
1377 div()
1378 .absolute()
1379 .top_0()
1380 .left_0()
1381 .size_full()
1382 .child(editor)
1383 .on_action(move |_: &menu::Confirm, window, cx| {
1384 self_handle
1385 .update(cx, |this, cx| {
1386 this.finish_renaming(true, window, cx)
1387 })
1388 .ok();
1389 })
1390 .on_action(move |_: &menu::Cancel, window, cx| {
1391 self_handle_cancel
1392 .update(cx, |this, cx| {
1393 this.finish_renaming(false, window, cx)
1394 })
1395 .ok();
1396 }),
1397 )
1398 }),
1399 )
1400 .into_any()
1401 }
1402
1403 fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
1404 if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) {
1405 return custom_title.clone().into();
1406 }
1407 let terminal = self.terminal().read(cx);
1408 terminal.title(detail == 0).into()
1409 }
1410
1411 fn telemetry_event_text(&self) -> Option<&'static str> {
1412 None
1413 }
1414
1415 fn tab_extra_context_menu_actions(
1416 &self,
1417 _window: &mut Window,
1418 cx: &mut Context<Self>,
1419 ) -> Vec<(SharedString, Box<dyn gpui::Action>)> {
1420 let terminal = self.terminal.read(cx);
1421 if terminal.task().is_none() {
1422 vec![("Rename".into(), Box::new(RenameTerminal))]
1423 } else {
1424 Vec::new()
1425 }
1426 }
1427
1428 fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
1429 workspace::item::ItemBufferKind::Singleton
1430 }
1431
1432 fn can_split(&self) -> bool {
1433 true
1434 }
1435
1436 fn clone_on_split(
1437 &self,
1438 workspace_id: Option<WorkspaceId>,
1439 window: &mut Window,
1440 cx: &mut Context<Self>,
1441 ) -> Task<Option<Entity<Self>>> {
1442 let Ok(terminal) = self.project.update(cx, |project, cx| {
1443 let cwd = project
1444 .active_project_directory(cx)
1445 .map(|it| it.to_path_buf());
1446 project.clone_terminal(self.terminal(), cx, cwd)
1447 }) else {
1448 return Task::ready(None);
1449 };
1450 cx.spawn_in(window, async move |this, cx| {
1451 let terminal = terminal.await.log_err()?;
1452 this.update_in(cx, |this, window, cx| {
1453 cx.new(|cx| {
1454 TerminalView::new(
1455 terminal,
1456 this.workspace.clone(),
1457 workspace_id,
1458 this.project.clone(),
1459 window,
1460 cx,
1461 )
1462 })
1463 })
1464 .ok()
1465 })
1466 }
1467
1468 fn is_dirty(&self, cx: &App) -> bool {
1469 match self.terminal.read(cx).task() {
1470 Some(task) => task.status == TaskStatus::Running,
1471 None => self.has_bell(),
1472 }
1473 }
1474
1475 fn has_conflict(&self, _cx: &App) -> bool {
1476 false
1477 }
1478
1479 fn can_save_as(&self, _cx: &App) -> bool {
1480 false
1481 }
1482
1483 fn as_searchable(
1484 &self,
1485 handle: &Entity<Self>,
1486 _: &App,
1487 ) -> Option<Box<dyn SearchableItemHandle>> {
1488 Some(Box::new(handle.clone()))
1489 }
1490
1491 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
1492 if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1493 ToolbarItemLocation::PrimaryLeft
1494 } else {
1495 ToolbarItemLocation::Hidden
1496 }
1497 }
1498
1499 fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
1500 Some(vec![BreadcrumbText {
1501 text: self.terminal().read(cx).breadcrumb_text.clone(),
1502 highlights: None,
1503 font: None,
1504 }])
1505 }
1506
1507 fn added_to_workspace(
1508 &mut self,
1509 workspace: &mut Workspace,
1510 _: &mut Window,
1511 cx: &mut Context<Self>,
1512 ) {
1513 if self.terminal().read(cx).task().is_none() {
1514 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1515 log::debug!(
1516 "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}",
1517 );
1518 cx.background_spawn(TERMINAL_DB.update_workspace_id(
1519 new_id,
1520 old_id,
1521 cx.entity_id().as_u64(),
1522 ))
1523 .detach();
1524 }
1525 self.workspace_id = workspace.database_id();
1526 }
1527 }
1528
1529 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
1530 f(*event)
1531 }
1532}
1533
1534impl SerializableItem for TerminalView {
1535 fn serialized_item_kind() -> &'static str {
1536 "Terminal"
1537 }
1538
1539 fn cleanup(
1540 workspace_id: WorkspaceId,
1541 alive_items: Vec<workspace::ItemId>,
1542 _window: &mut Window,
1543 cx: &mut App,
1544 ) -> Task<anyhow::Result<()>> {
1545 delete_unloaded_items(alive_items, workspace_id, "terminals", &TERMINAL_DB, cx)
1546 }
1547
1548 fn serialize(
1549 &mut self,
1550 _workspace: &mut Workspace,
1551 item_id: workspace::ItemId,
1552 _closing: bool,
1553 _: &mut Window,
1554 cx: &mut Context<Self>,
1555 ) -> Option<Task<anyhow::Result<()>>> {
1556 let terminal = self.terminal().read(cx);
1557 if terminal.task().is_some() {
1558 return None;
1559 }
1560
1561 if !self.needs_serialize {
1562 return None;
1563 }
1564
1565 let workspace_id = self.workspace_id?;
1566 let cwd = terminal.working_directory();
1567 let custom_title = self.custom_title.clone();
1568 self.needs_serialize = false;
1569
1570 Some(cx.background_spawn(async move {
1571 if let Some(cwd) = cwd {
1572 TERMINAL_DB
1573 .save_working_directory(item_id, workspace_id, cwd)
1574 .await?;
1575 }
1576 TERMINAL_DB
1577 .save_custom_title(item_id, workspace_id, custom_title)
1578 .await?;
1579 Ok(())
1580 }))
1581 }
1582
1583 fn should_serialize(&self, _: &Self::Event) -> bool {
1584 self.needs_serialize
1585 }
1586
1587 fn deserialize(
1588 project: Entity<Project>,
1589 workspace: WeakEntity<Workspace>,
1590 workspace_id: WorkspaceId,
1591 item_id: workspace::ItemId,
1592 window: &mut Window,
1593 cx: &mut App,
1594 ) -> Task<anyhow::Result<Entity<Self>>> {
1595 window.spawn(cx, async move |cx| {
1596 let (cwd, custom_title) = cx
1597 .update(|_window, cx| {
1598 let from_db = TERMINAL_DB
1599 .get_working_directory(item_id, workspace_id)
1600 .log_err()
1601 .flatten();
1602 let cwd = if from_db
1603 .as_ref()
1604 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1605 {
1606 from_db
1607 } else {
1608 workspace
1609 .upgrade()
1610 .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1611 };
1612 let custom_title = TERMINAL_DB
1613 .get_custom_title(item_id, workspace_id)
1614 .log_err()
1615 .flatten()
1616 .filter(|title| !title.trim().is_empty());
1617 (cwd, custom_title)
1618 })
1619 .ok()
1620 .unwrap_or((None, None));
1621
1622 let terminal = project
1623 .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))
1624 .await?;
1625 cx.update(|window, cx| {
1626 cx.new(|cx| {
1627 let mut view = TerminalView::new(
1628 terminal,
1629 workspace,
1630 Some(workspace_id),
1631 project.downgrade(),
1632 window,
1633 cx,
1634 );
1635 if custom_title.is_some() {
1636 view.custom_title = custom_title;
1637 }
1638 view
1639 })
1640 })
1641 })
1642 }
1643}
1644
1645impl SearchableItem for TerminalView {
1646 type Match = RangeInclusive<AlacPoint>;
1647
1648 fn supported_options(&self) -> SearchOptions {
1649 SearchOptions {
1650 case: false,
1651 word: false,
1652 regex: true,
1653 replacement: false,
1654 selection: false,
1655 find_in_results: false,
1656 }
1657 }
1658
1659 /// Clear stored matches
1660 fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1661 self.terminal().update(cx, |term, _| term.matches.clear())
1662 }
1663
1664 /// Store matches returned from find_matches somewhere for rendering
1665 fn update_matches(
1666 &mut self,
1667 matches: &[Self::Match],
1668 _active_match_index: Option<usize>,
1669 _token: SearchToken,
1670 _window: &mut Window,
1671 cx: &mut Context<Self>,
1672 ) {
1673 self.terminal()
1674 .update(cx, |term, _| term.matches = matches.to_vec())
1675 }
1676
1677 /// Returns the selection content to pre-load into this search
1678 fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
1679 self.terminal()
1680 .read(cx)
1681 .last_content
1682 .selection_text
1683 .clone()
1684 .unwrap_or_default()
1685 }
1686
1687 /// Focus match at given index into the Vec of matches
1688 fn activate_match(
1689 &mut self,
1690 index: usize,
1691 _: &[Self::Match],
1692 _token: SearchToken,
1693 _window: &mut Window,
1694 cx: &mut Context<Self>,
1695 ) {
1696 self.terminal()
1697 .update(cx, |term, _| term.activate_match(index));
1698 cx.notify();
1699 }
1700
1701 /// Add selections for all matches given.
1702 fn select_matches(
1703 &mut self,
1704 matches: &[Self::Match],
1705 _token: SearchToken,
1706 _: &mut Window,
1707 cx: &mut Context<Self>,
1708 ) {
1709 self.terminal()
1710 .update(cx, |term, _| term.select_matches(matches));
1711 cx.notify();
1712 }
1713
1714 /// Get all of the matches for this query, should be done on the background
1715 fn find_matches(
1716 &mut self,
1717 query: Arc<SearchQuery>,
1718 _: &mut Window,
1719 cx: &mut Context<Self>,
1720 ) -> Task<Vec<Self::Match>> {
1721 if let Some(s) = regex_search_for_query(&query) {
1722 self.terminal()
1723 .update(cx, |term, cx| term.find_matches(s, cx))
1724 } else {
1725 Task::ready(vec![])
1726 }
1727 }
1728
1729 /// Reports back to the search toolbar what the active match should be (the selection)
1730 fn active_match_index(
1731 &mut self,
1732 direction: Direction,
1733 matches: &[Self::Match],
1734 _token: SearchToken,
1735 _: &mut Window,
1736 cx: &mut Context<Self>,
1737 ) -> Option<usize> {
1738 // Selection head might have a value if there's a selection that isn't
1739 // associated with a match. Therefore, if there are no matches, we should
1740 // report None, no matter the state of the terminal
1741
1742 if !matches.is_empty() {
1743 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1744 // If selection head is contained in a match. Return that match
1745 match direction {
1746 Direction::Prev => {
1747 // If no selection before selection head, return the first match
1748 Some(
1749 matches
1750 .iter()
1751 .enumerate()
1752 .rev()
1753 .find(|(_, search_match)| {
1754 search_match.contains(&selection_head)
1755 || search_match.start() < &selection_head
1756 })
1757 .map(|(ix, _)| ix)
1758 .unwrap_or(0),
1759 )
1760 }
1761 Direction::Next => {
1762 // If no selection after selection head, return the last match
1763 Some(
1764 matches
1765 .iter()
1766 .enumerate()
1767 .find(|(_, search_match)| {
1768 search_match.contains(&selection_head)
1769 || search_match.start() > &selection_head
1770 })
1771 .map(|(ix, _)| ix)
1772 .unwrap_or(matches.len().saturating_sub(1)),
1773 )
1774 }
1775 }
1776 } else {
1777 // Matches found but no active selection, return the first last one (closest to cursor)
1778 Some(matches.len().saturating_sub(1))
1779 }
1780 } else {
1781 None
1782 }
1783 }
1784 fn replace(
1785 &mut self,
1786 _: &Self::Match,
1787 _: &SearchQuery,
1788 _token: SearchToken,
1789 _window: &mut Window,
1790 _: &mut Context<Self>,
1791 ) {
1792 // Replacement is not supported in terminal view, so this is a no-op.
1793 }
1794}
1795
1796/// Gets the working directory for the given workspace, respecting the user's settings.
1797/// Falls back to home directory when no project directory is available.
1798pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1799 let directory = match &TerminalSettings::get_global(cx).working_directory {
1800 WorkingDirectory::CurrentFileDirectory => workspace
1801 .project()
1802 .read(cx)
1803 .active_entry_directory(cx)
1804 .or_else(|| current_project_directory(workspace, cx)),
1805 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
1806 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1807 WorkingDirectory::AlwaysHome => None,
1808 WorkingDirectory::Always { directory } => shellexpand::full(directory)
1809 .ok()
1810 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1811 .filter(|dir| dir.is_dir()),
1812 };
1813 directory.or_else(dirs::home_dir)
1814}
1815
1816fn current_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1817 workspace
1818 .project()
1819 .read(cx)
1820 .active_project_directory(cx)
1821 .as_deref()
1822 .map(Path::to_path_buf)
1823 .or_else(|| first_project_directory(workspace, cx))
1824}
1825
1826///Gets the first project's home directory, or the home directory
1827fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1828 let worktree = workspace.worktrees(cx).next()?.read(cx);
1829 let worktree_path = worktree.abs_path();
1830 if worktree.root_entry()?.is_dir() {
1831 Some(worktree_path.to_path_buf())
1832 } else {
1833 // If worktree is a file, return its parent directory
1834 worktree_path.parent().map(|p| p.to_path_buf())
1835 }
1836}
1837
1838#[cfg(test)]
1839mod tests {
1840 use super::*;
1841 use gpui::TestAppContext;
1842 use project::{Entry, Project, ProjectPath, Worktree};
1843 use std::path::Path;
1844 use util::paths::PathStyle;
1845 use util::rel_path::RelPath;
1846 use workspace::{AppState, MultiWorkspace};
1847
1848 // Working directory calculation tests
1849
1850 // No Worktrees in project -> home_dir()
1851 #[gpui::test]
1852 async fn no_worktree(cx: &mut TestAppContext) {
1853 let (project, workspace) = init_test(cx).await;
1854 cx.read(|cx| {
1855 let workspace = workspace.read(cx);
1856 let active_entry = project.read(cx).active_entry();
1857
1858 //Make sure environment is as expected
1859 assert!(active_entry.is_none());
1860 assert!(workspace.worktrees(cx).next().is_none());
1861
1862 let res = default_working_directory(workspace, cx);
1863 assert_eq!(res, dirs::home_dir());
1864 let res = first_project_directory(workspace, cx);
1865 assert_eq!(res, None);
1866 });
1867 }
1868
1869 // No active entry, but a worktree, worktree is a file -> parent directory
1870 #[gpui::test]
1871 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
1872 let (project, workspace) = init_test(cx).await;
1873
1874 create_file_wt(project.clone(), "/root.txt", cx).await;
1875 cx.read(|cx| {
1876 let workspace = workspace.read(cx);
1877 let active_entry = project.read(cx).active_entry();
1878
1879 //Make sure environment is as expected
1880 assert!(active_entry.is_none());
1881 assert!(workspace.worktrees(cx).next().is_some());
1882
1883 let res = default_working_directory(workspace, cx);
1884 assert_eq!(res, Some(Path::new("/").to_path_buf()));
1885 let res = first_project_directory(workspace, cx);
1886 assert_eq!(res, Some(Path::new("/").to_path_buf()));
1887 });
1888 }
1889
1890 // No active entry, but a worktree, worktree is a folder -> worktree_folder
1891 #[gpui::test]
1892 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1893 let (project, workspace) = init_test(cx).await;
1894
1895 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1896 cx.update(|cx| {
1897 let workspace = workspace.read(cx);
1898 let active_entry = project.read(cx).active_entry();
1899
1900 assert!(active_entry.is_none());
1901 assert!(workspace.worktrees(cx).next().is_some());
1902
1903 let res = default_working_directory(workspace, cx);
1904 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
1905 let res = first_project_directory(workspace, cx);
1906 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
1907 });
1908 }
1909
1910 // Active entry with a work tree, worktree is a file -> worktree_folder()
1911 #[gpui::test]
1912 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
1913 let (project, workspace) = init_test(cx).await;
1914
1915 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1916 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
1917 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1918
1919 cx.update(|cx| {
1920 let workspace = workspace.read(cx);
1921 let active_entry = project.read(cx).active_entry();
1922
1923 assert!(active_entry.is_some());
1924
1925 let res = default_working_directory(workspace, cx);
1926 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1927 let res = first_project_directory(workspace, cx);
1928 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1929 });
1930 }
1931
1932 // Active entry, with a worktree, worktree is a folder -> worktree_folder
1933 #[gpui::test]
1934 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1935 let (project, workspace) = init_test(cx).await;
1936
1937 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1938 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
1939 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1940
1941 cx.update(|cx| {
1942 let workspace = workspace.read(cx);
1943 let active_entry = project.read(cx).active_entry();
1944
1945 assert!(active_entry.is_some());
1946
1947 let res = default_working_directory(workspace, cx);
1948 assert_eq!(res, Some(Path::new("/root2/").to_path_buf()));
1949 let res = first_project_directory(workspace, cx);
1950 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1951 });
1952 }
1953
1954 // active_entry_directory: No active entry -> returns None (used by CurrentFileDirectory)
1955 #[gpui::test]
1956 async fn active_entry_directory_no_active_entry(cx: &mut TestAppContext) {
1957 let (project, _workspace) = init_test(cx).await;
1958
1959 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1960
1961 cx.update(|cx| {
1962 assert!(project.read(cx).active_entry().is_none());
1963
1964 let res = project.read(cx).active_entry_directory(cx);
1965 assert_eq!(res, None);
1966 });
1967 }
1968
1969 // active_entry_directory: Active entry is file -> returns parent directory (used by CurrentFileDirectory)
1970 #[gpui::test]
1971 async fn active_entry_directory_active_file(cx: &mut TestAppContext) {
1972 let (project, _workspace) = init_test(cx).await;
1973
1974 let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1975 let entry = cx
1976 .update(|cx| {
1977 wt.update(cx, |wt, cx| {
1978 wt.create_entry(
1979 RelPath::new(Path::new("src/main.rs"), PathStyle::local())
1980 .unwrap()
1981 .as_ref()
1982 .into(),
1983 false,
1984 None,
1985 cx,
1986 )
1987 })
1988 })
1989 .await
1990 .unwrap()
1991 .into_included()
1992 .unwrap();
1993 insert_active_entry_for(wt, entry, project.clone(), cx);
1994
1995 cx.update(|cx| {
1996 let res = project.read(cx).active_entry_directory(cx);
1997 assert_eq!(res, Some(Path::new("/root/src").to_path_buf()));
1998 });
1999 }
2000
2001 // active_entry_directory: Active entry is directory -> returns that directory (used by CurrentFileDirectory)
2002 #[gpui::test]
2003 async fn active_entry_directory_active_dir(cx: &mut TestAppContext) {
2004 let (project, _workspace) = init_test(cx).await;
2005
2006 let (wt, entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2007 insert_active_entry_for(wt, entry, project.clone(), cx);
2008
2009 cx.update(|cx| {
2010 let res = project.read(cx).active_entry_directory(cx);
2011 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2012 });
2013 }
2014
2015 /// Creates a worktree with 1 file: /root.txt
2016 pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
2017 let params = cx.update(AppState::test);
2018 cx.update(|cx| {
2019 theme::init(theme::LoadThemes::JustBase, cx);
2020 });
2021
2022 let project = Project::test(params.fs.clone(), [], cx).await;
2023 let window_handle =
2024 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2025 let workspace = window_handle
2026 .read_with(cx, |mw, _| mw.workspace().clone())
2027 .unwrap();
2028
2029 (project, workspace)
2030 }
2031
2032 /// Creates a worktree with 1 folder: /root{suffix}/
2033 async fn create_folder_wt(
2034 project: Entity<Project>,
2035 path: impl AsRef<Path>,
2036 cx: &mut TestAppContext,
2037 ) -> (Entity<Worktree>, Entry) {
2038 create_wt(project, true, path, cx).await
2039 }
2040
2041 /// Creates a worktree with 1 file: /root{suffix}.txt
2042 async fn create_file_wt(
2043 project: Entity<Project>,
2044 path: impl AsRef<Path>,
2045 cx: &mut TestAppContext,
2046 ) -> (Entity<Worktree>, Entry) {
2047 create_wt(project, false, path, cx).await
2048 }
2049
2050 async fn create_wt(
2051 project: Entity<Project>,
2052 is_dir: bool,
2053 path: impl AsRef<Path>,
2054 cx: &mut TestAppContext,
2055 ) -> (Entity<Worktree>, Entry) {
2056 let (wt, _) = project
2057 .update(cx, |project, cx| {
2058 project.find_or_create_worktree(path, true, cx)
2059 })
2060 .await
2061 .unwrap();
2062
2063 let entry = cx
2064 .update(|cx| {
2065 wt.update(cx, |wt, cx| {
2066 wt.create_entry(RelPath::empty().into(), is_dir, None, cx)
2067 })
2068 })
2069 .await
2070 .unwrap()
2071 .into_included()
2072 .unwrap();
2073
2074 (wt, entry)
2075 }
2076
2077 pub fn insert_active_entry_for(
2078 wt: Entity<Worktree>,
2079 entry: Entry,
2080 project: Entity<Project>,
2081 cx: &mut TestAppContext,
2082 ) {
2083 cx.update(|cx| {
2084 let p = ProjectPath {
2085 worktree_id: wt.read(cx).id(),
2086 path: entry.path,
2087 };
2088 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
2089 });
2090 }
2091
2092 // Terminal rename tests
2093
2094 #[gpui::test]
2095 async fn test_custom_title_initially_none(cx: &mut TestAppContext) {
2096 cx.executor().allow_parking();
2097
2098 let (project, workspace) = init_test(cx).await;
2099
2100 let terminal = project
2101 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2102 .await
2103 .unwrap();
2104
2105 let terminal_view = cx
2106 .add_window(|window, cx| {
2107 TerminalView::new(
2108 terminal,
2109 workspace.downgrade(),
2110 None,
2111 project.downgrade(),
2112 window,
2113 cx,
2114 )
2115 })
2116 .root(cx)
2117 .unwrap();
2118
2119 terminal_view.update(cx, |view, _cx| {
2120 assert!(view.custom_title().is_none());
2121 });
2122 }
2123
2124 #[gpui::test]
2125 async fn test_set_custom_title(cx: &mut TestAppContext) {
2126 cx.executor().allow_parking();
2127
2128 let (project, workspace) = init_test(cx).await;
2129
2130 let terminal = project
2131 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2132 .await
2133 .unwrap();
2134
2135 let terminal_view = cx
2136 .add_window(|window, cx| {
2137 TerminalView::new(
2138 terminal,
2139 workspace.downgrade(),
2140 None,
2141 project.downgrade(),
2142 window,
2143 cx,
2144 )
2145 })
2146 .root(cx)
2147 .unwrap();
2148
2149 terminal_view.update(cx, |view, cx| {
2150 view.set_custom_title(Some("frontend".to_string()), cx);
2151 assert_eq!(view.custom_title(), Some("frontend"));
2152 });
2153 }
2154
2155 #[gpui::test]
2156 async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) {
2157 cx.executor().allow_parking();
2158
2159 let (project, workspace) = init_test(cx).await;
2160
2161 let terminal = project
2162 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2163 .await
2164 .unwrap();
2165
2166 let terminal_view = cx
2167 .add_window(|window, cx| {
2168 TerminalView::new(
2169 terminal,
2170 workspace.downgrade(),
2171 None,
2172 project.downgrade(),
2173 window,
2174 cx,
2175 )
2176 })
2177 .root(cx)
2178 .unwrap();
2179
2180 terminal_view.update(cx, |view, cx| {
2181 view.set_custom_title(Some("test".to_string()), cx);
2182 assert_eq!(view.custom_title(), Some("test"));
2183
2184 view.set_custom_title(Some("".to_string()), cx);
2185 assert!(view.custom_title().is_none());
2186
2187 view.set_custom_title(Some(" ".to_string()), cx);
2188 assert!(view.custom_title().is_none());
2189 });
2190 }
2191
2192 #[gpui::test]
2193 async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) {
2194 cx.executor().allow_parking();
2195
2196 let (project, workspace) = init_test(cx).await;
2197
2198 let terminal = project
2199 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2200 .await
2201 .unwrap();
2202
2203 let terminal_view = cx
2204 .add_window(|window, cx| {
2205 TerminalView::new(
2206 terminal,
2207 workspace.downgrade(),
2208 None,
2209 project.downgrade(),
2210 window,
2211 cx,
2212 )
2213 })
2214 .root(cx)
2215 .unwrap();
2216
2217 terminal_view.update(cx, |view, cx| {
2218 view.needs_serialize = false;
2219 view.set_custom_title(Some("new_label".to_string()), cx);
2220 assert!(view.needs_serialize);
2221 });
2222 }
2223
2224 #[gpui::test]
2225 async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) {
2226 cx.executor().allow_parking();
2227
2228 let (project, workspace) = init_test(cx).await;
2229
2230 let terminal = project
2231 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2232 .await
2233 .unwrap();
2234
2235 let terminal_view = cx
2236 .add_window(|window, cx| {
2237 TerminalView::new(
2238 terminal,
2239 workspace.downgrade(),
2240 None,
2241 project.downgrade(),
2242 window,
2243 cx,
2244 )
2245 })
2246 .root(cx)
2247 .unwrap();
2248
2249 terminal_view.update(cx, |view, cx| {
2250 view.set_custom_title(Some("my-server".to_string()), cx);
2251 let text = view.tab_content_text(0, cx);
2252 assert_eq!(text.as_ref(), "my-server");
2253 });
2254
2255 terminal_view.update(cx, |view, cx| {
2256 view.set_custom_title(None, cx);
2257 let text = view.tab_content_text(0, cx);
2258 assert_ne!(text.as_ref(), "my-server");
2259 });
2260 }
2261
2262 #[gpui::test]
2263 async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty(
2264 cx: &mut TestAppContext,
2265 ) {
2266 cx.executor().allow_parking();
2267
2268 let (project, workspace) = init_test(cx).await;
2269
2270 let terminal = project
2271 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2272 .await
2273 .unwrap();
2274
2275 let terminal_view = cx
2276 .add_window(|window, cx| {
2277 TerminalView::new(
2278 terminal,
2279 workspace.downgrade(),
2280 None,
2281 project.downgrade(),
2282 window,
2283 cx,
2284 )
2285 })
2286 .root(cx)
2287 .unwrap();
2288
2289 terminal_view.update(cx, |view, cx| {
2290 view.custom_title = Some("".to_string());
2291 let text = view.tab_content_text(0, cx);
2292 assert!(
2293 !text.is_empty(),
2294 "Tab should show terminal title, not empty string; got: '{}'",
2295 text
2296 );
2297 });
2298
2299 terminal_view.update(cx, |view, cx| {
2300 view.custom_title = Some(" ".to_string());
2301 let text = view.tab_content_text(0, cx);
2302 assert!(
2303 !text.is_empty() && text.as_ref() != " ",
2304 "Tab should show terminal title, not whitespace; got: '{}'",
2305 text
2306 );
2307 });
2308 }
2309}