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 let colors = cx.theme().colors();
1261 div.custom_scrollbars(
1262 Scrollbars::for_settings::<TerminalScrollbarSettingsWrapper>()
1263 .show_along(ScrollAxes::Vertical)
1264 .style(ui::ScrollbarStyle::Editor)
1265 .width_editor()
1266 .with_stable_track_along(
1267 ScrollAxes::Vertical,
1268 colors.editor_background,
1269 Some(colors.border),
1270 )
1271 .tracked_scroll_handle(&self.scroll_handle),
1272 window,
1273 cx,
1274 )
1275 }),
1276 )
1277 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1278 deferred(
1279 anchored()
1280 .position(*position)
1281 .anchor(gpui::Corner::TopLeft)
1282 .child(menu.clone()),
1283 )
1284 .with_priority(1)
1285 }))
1286 }
1287}
1288
1289impl Item for TerminalView {
1290 type Event = ItemEvent;
1291
1292 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
1293 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
1294 let terminal = self.terminal().read(cx);
1295 let title = terminal.title(false);
1296 let pid = terminal.pid_getter()?.fallback_pid();
1297
1298 move |_, _| {
1299 v_flex()
1300 .gap_1()
1301 .child(Label::new(title.clone()))
1302 .child(h_flex().flex_grow().child(Divider::horizontal()))
1303 .child(
1304 Label::new(format!("Process ID (PID): {}", pid))
1305 .color(Color::Muted)
1306 .size(LabelSize::Small),
1307 )
1308 .into_any_element()
1309 }
1310 }))))
1311 }
1312
1313 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
1314 let terminal = self.terminal().read(cx);
1315 let title = self
1316 .custom_title
1317 .as_ref()
1318 .filter(|title| !title.trim().is_empty())
1319 .cloned()
1320 .unwrap_or_else(|| terminal.title(true));
1321
1322 let (icon, icon_color, rerun_button) = match terminal.task() {
1323 Some(terminal_task) => match &terminal_task.status {
1324 TaskStatus::Running => (
1325 IconName::PlayFilled,
1326 Color::Disabled,
1327 TerminalView::rerun_button(terminal_task),
1328 ),
1329 TaskStatus::Unknown => (
1330 IconName::Warning,
1331 Color::Warning,
1332 TerminalView::rerun_button(terminal_task),
1333 ),
1334 TaskStatus::Completed { success } => {
1335 let rerun_button = TerminalView::rerun_button(terminal_task);
1336
1337 if *success {
1338 (IconName::Check, Color::Success, rerun_button)
1339 } else {
1340 (IconName::XCircle, Color::Error, rerun_button)
1341 }
1342 }
1343 },
1344 None => (IconName::Terminal, Color::Muted, None),
1345 };
1346
1347 h_flex()
1348 .gap_1()
1349 .group("term-tab-icon")
1350 .child(
1351 h_flex()
1352 .group("term-tab-icon")
1353 .child(
1354 div()
1355 .when(rerun_button.is_some(), |this| {
1356 this.hover(|style| style.invisible().w_0())
1357 })
1358 .child(Icon::new(icon).color(icon_color)),
1359 )
1360 .when_some(rerun_button, |this, rerun_button| {
1361 this.child(
1362 div()
1363 .absolute()
1364 .visible_on_hover("term-tab-icon")
1365 .child(rerun_button),
1366 )
1367 }),
1368 )
1369 .child(
1370 div()
1371 .relative()
1372 .child(
1373 Label::new(title)
1374 .color(params.text_color())
1375 .when(self.is_renaming(), |this| this.alpha(0.)),
1376 )
1377 .when_some(self.rename_editor.clone(), |this, editor| {
1378 let self_handle = self.self_handle.clone();
1379 let self_handle_cancel = self.self_handle.clone();
1380 this.child(
1381 div()
1382 .absolute()
1383 .top_0()
1384 .left_0()
1385 .size_full()
1386 .child(editor)
1387 .on_action(move |_: &menu::Confirm, window, cx| {
1388 self_handle
1389 .update(cx, |this, cx| {
1390 this.finish_renaming(true, window, cx)
1391 })
1392 .ok();
1393 })
1394 .on_action(move |_: &menu::Cancel, window, cx| {
1395 self_handle_cancel
1396 .update(cx, |this, cx| {
1397 this.finish_renaming(false, window, cx)
1398 })
1399 .ok();
1400 }),
1401 )
1402 }),
1403 )
1404 .into_any()
1405 }
1406
1407 fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
1408 if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) {
1409 return custom_title.clone().into();
1410 }
1411 let terminal = self.terminal().read(cx);
1412 terminal.title(detail == 0).into()
1413 }
1414
1415 fn telemetry_event_text(&self) -> Option<&'static str> {
1416 None
1417 }
1418
1419 fn tab_extra_context_menu_actions(
1420 &self,
1421 _window: &mut Window,
1422 cx: &mut Context<Self>,
1423 ) -> Vec<(SharedString, Box<dyn gpui::Action>)> {
1424 let terminal = self.terminal.read(cx);
1425 if terminal.task().is_none() {
1426 vec![("Rename".into(), Box::new(RenameTerminal))]
1427 } else {
1428 Vec::new()
1429 }
1430 }
1431
1432 fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
1433 workspace::item::ItemBufferKind::Singleton
1434 }
1435
1436 fn can_split(&self) -> bool {
1437 true
1438 }
1439
1440 fn clone_on_split(
1441 &self,
1442 workspace_id: Option<WorkspaceId>,
1443 window: &mut Window,
1444 cx: &mut Context<Self>,
1445 ) -> Task<Option<Entity<Self>>> {
1446 let Ok(terminal) = self.project.update(cx, |project, cx| {
1447 let cwd = project
1448 .active_project_directory(cx)
1449 .map(|it| it.to_path_buf());
1450 project.clone_terminal(self.terminal(), cx, cwd)
1451 }) else {
1452 return Task::ready(None);
1453 };
1454 cx.spawn_in(window, async move |this, cx| {
1455 let terminal = terminal.await.log_err()?;
1456 this.update_in(cx, |this, window, cx| {
1457 cx.new(|cx| {
1458 TerminalView::new(
1459 terminal,
1460 this.workspace.clone(),
1461 workspace_id,
1462 this.project.clone(),
1463 window,
1464 cx,
1465 )
1466 })
1467 })
1468 .ok()
1469 })
1470 }
1471
1472 fn is_dirty(&self, cx: &App) -> bool {
1473 match self.terminal.read(cx).task() {
1474 Some(task) => task.status == TaskStatus::Running,
1475 None => self.has_bell(),
1476 }
1477 }
1478
1479 fn has_conflict(&self, _cx: &App) -> bool {
1480 false
1481 }
1482
1483 fn can_save_as(&self, _cx: &App) -> bool {
1484 false
1485 }
1486
1487 fn as_searchable(
1488 &self,
1489 handle: &Entity<Self>,
1490 _: &App,
1491 ) -> Option<Box<dyn SearchableItemHandle>> {
1492 Some(Box::new(handle.clone()))
1493 }
1494
1495 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
1496 if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1497 ToolbarItemLocation::PrimaryLeft
1498 } else {
1499 ToolbarItemLocation::Hidden
1500 }
1501 }
1502
1503 fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
1504 Some(vec![BreadcrumbText {
1505 text: self.terminal().read(cx).breadcrumb_text.clone(),
1506 highlights: None,
1507 font: None,
1508 }])
1509 }
1510
1511 fn added_to_workspace(
1512 &mut self,
1513 workspace: &mut Workspace,
1514 _: &mut Window,
1515 cx: &mut Context<Self>,
1516 ) {
1517 if self.terminal().read(cx).task().is_none() {
1518 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1519 log::debug!(
1520 "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}",
1521 );
1522 cx.background_spawn(TERMINAL_DB.update_workspace_id(
1523 new_id,
1524 old_id,
1525 cx.entity_id().as_u64(),
1526 ))
1527 .detach();
1528 }
1529 self.workspace_id = workspace.database_id();
1530 }
1531 }
1532
1533 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
1534 f(*event)
1535 }
1536}
1537
1538impl SerializableItem for TerminalView {
1539 fn serialized_item_kind() -> &'static str {
1540 "Terminal"
1541 }
1542
1543 fn cleanup(
1544 workspace_id: WorkspaceId,
1545 alive_items: Vec<workspace::ItemId>,
1546 _window: &mut Window,
1547 cx: &mut App,
1548 ) -> Task<anyhow::Result<()>> {
1549 delete_unloaded_items(alive_items, workspace_id, "terminals", &TERMINAL_DB, cx)
1550 }
1551
1552 fn serialize(
1553 &mut self,
1554 _workspace: &mut Workspace,
1555 item_id: workspace::ItemId,
1556 _closing: bool,
1557 _: &mut Window,
1558 cx: &mut Context<Self>,
1559 ) -> Option<Task<anyhow::Result<()>>> {
1560 let terminal = self.terminal().read(cx);
1561 if terminal.task().is_some() {
1562 return None;
1563 }
1564
1565 if !self.needs_serialize {
1566 return None;
1567 }
1568
1569 let workspace_id = self.workspace_id?;
1570 let cwd = terminal.working_directory();
1571 let custom_title = self.custom_title.clone();
1572 self.needs_serialize = false;
1573
1574 Some(cx.background_spawn(async move {
1575 if let Some(cwd) = cwd {
1576 TERMINAL_DB
1577 .save_working_directory(item_id, workspace_id, cwd)
1578 .await?;
1579 }
1580 TERMINAL_DB
1581 .save_custom_title(item_id, workspace_id, custom_title)
1582 .await?;
1583 Ok(())
1584 }))
1585 }
1586
1587 fn should_serialize(&self, _: &Self::Event) -> bool {
1588 self.needs_serialize
1589 }
1590
1591 fn deserialize(
1592 project: Entity<Project>,
1593 workspace: WeakEntity<Workspace>,
1594 workspace_id: WorkspaceId,
1595 item_id: workspace::ItemId,
1596 window: &mut Window,
1597 cx: &mut App,
1598 ) -> Task<anyhow::Result<Entity<Self>>> {
1599 window.spawn(cx, async move |cx| {
1600 let (cwd, custom_title) = cx
1601 .update(|_window, cx| {
1602 let from_db = TERMINAL_DB
1603 .get_working_directory(item_id, workspace_id)
1604 .log_err()
1605 .flatten();
1606 let cwd = if from_db
1607 .as_ref()
1608 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1609 {
1610 from_db
1611 } else {
1612 workspace
1613 .upgrade()
1614 .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1615 };
1616 let custom_title = TERMINAL_DB
1617 .get_custom_title(item_id, workspace_id)
1618 .log_err()
1619 .flatten()
1620 .filter(|title| !title.trim().is_empty());
1621 (cwd, custom_title)
1622 })
1623 .ok()
1624 .unwrap_or((None, None));
1625
1626 let terminal = project
1627 .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))
1628 .await?;
1629 cx.update(|window, cx| {
1630 cx.new(|cx| {
1631 let mut view = TerminalView::new(
1632 terminal,
1633 workspace,
1634 Some(workspace_id),
1635 project.downgrade(),
1636 window,
1637 cx,
1638 );
1639 if custom_title.is_some() {
1640 view.custom_title = custom_title;
1641 }
1642 view
1643 })
1644 })
1645 })
1646 }
1647}
1648
1649impl SearchableItem for TerminalView {
1650 type Match = RangeInclusive<AlacPoint>;
1651
1652 fn supported_options(&self) -> SearchOptions {
1653 SearchOptions {
1654 case: false,
1655 word: false,
1656 regex: true,
1657 replacement: false,
1658 selection: false,
1659 find_in_results: false,
1660 }
1661 }
1662
1663 /// Clear stored matches
1664 fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1665 self.terminal().update(cx, |term, _| term.matches.clear())
1666 }
1667
1668 /// Store matches returned from find_matches somewhere for rendering
1669 fn update_matches(
1670 &mut self,
1671 matches: &[Self::Match],
1672 _active_match_index: Option<usize>,
1673 _token: SearchToken,
1674 _window: &mut Window,
1675 cx: &mut Context<Self>,
1676 ) {
1677 self.terminal()
1678 .update(cx, |term, _| term.matches = matches.to_vec())
1679 }
1680
1681 /// Returns the selection content to pre-load into this search
1682 fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
1683 self.terminal()
1684 .read(cx)
1685 .last_content
1686 .selection_text
1687 .clone()
1688 .unwrap_or_default()
1689 }
1690
1691 /// Focus match at given index into the Vec of matches
1692 fn activate_match(
1693 &mut self,
1694 index: usize,
1695 _: &[Self::Match],
1696 _token: SearchToken,
1697 _window: &mut Window,
1698 cx: &mut Context<Self>,
1699 ) {
1700 self.terminal()
1701 .update(cx, |term, _| term.activate_match(index));
1702 cx.notify();
1703 }
1704
1705 /// Add selections for all matches given.
1706 fn select_matches(
1707 &mut self,
1708 matches: &[Self::Match],
1709 _token: SearchToken,
1710 _: &mut Window,
1711 cx: &mut Context<Self>,
1712 ) {
1713 self.terminal()
1714 .update(cx, |term, _| term.select_matches(matches));
1715 cx.notify();
1716 }
1717
1718 /// Get all of the matches for this query, should be done on the background
1719 fn find_matches(
1720 &mut self,
1721 query: Arc<SearchQuery>,
1722 _: &mut Window,
1723 cx: &mut Context<Self>,
1724 ) -> Task<Vec<Self::Match>> {
1725 if let Some(s) = regex_search_for_query(&query) {
1726 self.terminal()
1727 .update(cx, |term, cx| term.find_matches(s, cx))
1728 } else {
1729 Task::ready(vec![])
1730 }
1731 }
1732
1733 /// Reports back to the search toolbar what the active match should be (the selection)
1734 fn active_match_index(
1735 &mut self,
1736 direction: Direction,
1737 matches: &[Self::Match],
1738 _token: SearchToken,
1739 _: &mut Window,
1740 cx: &mut Context<Self>,
1741 ) -> Option<usize> {
1742 // Selection head might have a value if there's a selection that isn't
1743 // associated with a match. Therefore, if there are no matches, we should
1744 // report None, no matter the state of the terminal
1745
1746 if !matches.is_empty() {
1747 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1748 // If selection head is contained in a match. Return that match
1749 match direction {
1750 Direction::Prev => {
1751 // If no selection before selection head, return the first match
1752 Some(
1753 matches
1754 .iter()
1755 .enumerate()
1756 .rev()
1757 .find(|(_, search_match)| {
1758 search_match.contains(&selection_head)
1759 || search_match.start() < &selection_head
1760 })
1761 .map(|(ix, _)| ix)
1762 .unwrap_or(0),
1763 )
1764 }
1765 Direction::Next => {
1766 // If no selection after selection head, return the last match
1767 Some(
1768 matches
1769 .iter()
1770 .enumerate()
1771 .find(|(_, search_match)| {
1772 search_match.contains(&selection_head)
1773 || search_match.start() > &selection_head
1774 })
1775 .map(|(ix, _)| ix)
1776 .unwrap_or(matches.len().saturating_sub(1)),
1777 )
1778 }
1779 }
1780 } else {
1781 // Matches found but no active selection, return the first last one (closest to cursor)
1782 Some(matches.len().saturating_sub(1))
1783 }
1784 } else {
1785 None
1786 }
1787 }
1788 fn replace(
1789 &mut self,
1790 _: &Self::Match,
1791 _: &SearchQuery,
1792 _token: SearchToken,
1793 _window: &mut Window,
1794 _: &mut Context<Self>,
1795 ) {
1796 // Replacement is not supported in terminal view, so this is a no-op.
1797 }
1798}
1799
1800/// Gets the working directory for the given workspace, respecting the user's settings.
1801/// Falls back to home directory when no project directory is available.
1802pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1803 let directory = match &TerminalSettings::get_global(cx).working_directory {
1804 WorkingDirectory::CurrentFileDirectory => workspace
1805 .project()
1806 .read(cx)
1807 .active_entry_directory(cx)
1808 .or_else(|| current_project_directory(workspace, cx)),
1809 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
1810 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1811 WorkingDirectory::AlwaysHome => None,
1812 WorkingDirectory::Always { directory } => shellexpand::full(directory)
1813 .ok()
1814 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1815 .filter(|dir| dir.is_dir()),
1816 };
1817 directory.or_else(dirs::home_dir)
1818}
1819
1820fn current_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1821 workspace
1822 .project()
1823 .read(cx)
1824 .active_project_directory(cx)
1825 .as_deref()
1826 .map(Path::to_path_buf)
1827 .or_else(|| first_project_directory(workspace, cx))
1828}
1829
1830///Gets the first project's home directory, or the home directory
1831fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1832 let worktree = workspace.worktrees(cx).next()?.read(cx);
1833 let worktree_path = worktree.abs_path();
1834 if worktree.root_entry()?.is_dir() {
1835 Some(worktree_path.to_path_buf())
1836 } else {
1837 // If worktree is a file, return its parent directory
1838 worktree_path.parent().map(|p| p.to_path_buf())
1839 }
1840}
1841
1842#[cfg(test)]
1843mod tests {
1844 use super::*;
1845 use gpui::TestAppContext;
1846 use project::{Entry, Project, ProjectPath, Worktree};
1847 use std::path::Path;
1848 use util::paths::PathStyle;
1849 use util::rel_path::RelPath;
1850 use workspace::{AppState, MultiWorkspace};
1851
1852 // Working directory calculation tests
1853
1854 // No Worktrees in project -> home_dir()
1855 #[gpui::test]
1856 async fn no_worktree(cx: &mut TestAppContext) {
1857 let (project, workspace) = init_test(cx).await;
1858 cx.read(|cx| {
1859 let workspace = workspace.read(cx);
1860 let active_entry = project.read(cx).active_entry();
1861
1862 //Make sure environment is as expected
1863 assert!(active_entry.is_none());
1864 assert!(workspace.worktrees(cx).next().is_none());
1865
1866 let res = default_working_directory(workspace, cx);
1867 assert_eq!(res, dirs::home_dir());
1868 let res = first_project_directory(workspace, cx);
1869 assert_eq!(res, None);
1870 });
1871 }
1872
1873 // No active entry, but a worktree, worktree is a file -> parent directory
1874 #[gpui::test]
1875 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
1876 let (project, workspace) = init_test(cx).await;
1877
1878 create_file_wt(project.clone(), "/root.txt", cx).await;
1879 cx.read(|cx| {
1880 let workspace = workspace.read(cx);
1881 let active_entry = project.read(cx).active_entry();
1882
1883 //Make sure environment is as expected
1884 assert!(active_entry.is_none());
1885 assert!(workspace.worktrees(cx).next().is_some());
1886
1887 let res = default_working_directory(workspace, cx);
1888 assert_eq!(res, Some(Path::new("/").to_path_buf()));
1889 let res = first_project_directory(workspace, cx);
1890 assert_eq!(res, Some(Path::new("/").to_path_buf()));
1891 });
1892 }
1893
1894 // No active entry, but a worktree, worktree is a folder -> worktree_folder
1895 #[gpui::test]
1896 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1897 let (project, workspace) = init_test(cx).await;
1898
1899 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1900 cx.update(|cx| {
1901 let workspace = workspace.read(cx);
1902 let active_entry = project.read(cx).active_entry();
1903
1904 assert!(active_entry.is_none());
1905 assert!(workspace.worktrees(cx).next().is_some());
1906
1907 let res = default_working_directory(workspace, cx);
1908 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
1909 let res = first_project_directory(workspace, cx);
1910 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
1911 });
1912 }
1913
1914 // Active entry with a work tree, worktree is a file -> worktree_folder()
1915 #[gpui::test]
1916 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
1917 let (project, workspace) = init_test(cx).await;
1918
1919 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1920 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
1921 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1922
1923 cx.update(|cx| {
1924 let workspace = workspace.read(cx);
1925 let active_entry = project.read(cx).active_entry();
1926
1927 assert!(active_entry.is_some());
1928
1929 let res = default_working_directory(workspace, cx);
1930 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1931 let res = first_project_directory(workspace, cx);
1932 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1933 });
1934 }
1935
1936 // Active entry, with a worktree, worktree is a folder -> worktree_folder
1937 #[gpui::test]
1938 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1939 let (project, workspace) = init_test(cx).await;
1940
1941 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1942 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
1943 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1944
1945 cx.update(|cx| {
1946 let workspace = workspace.read(cx);
1947 let active_entry = project.read(cx).active_entry();
1948
1949 assert!(active_entry.is_some());
1950
1951 let res = default_working_directory(workspace, cx);
1952 assert_eq!(res, Some(Path::new("/root2/").to_path_buf()));
1953 let res = first_project_directory(workspace, cx);
1954 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1955 });
1956 }
1957
1958 // active_entry_directory: No active entry -> returns None (used by CurrentFileDirectory)
1959 #[gpui::test]
1960 async fn active_entry_directory_no_active_entry(cx: &mut TestAppContext) {
1961 let (project, _workspace) = init_test(cx).await;
1962
1963 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1964
1965 cx.update(|cx| {
1966 assert!(project.read(cx).active_entry().is_none());
1967
1968 let res = project.read(cx).active_entry_directory(cx);
1969 assert_eq!(res, None);
1970 });
1971 }
1972
1973 // active_entry_directory: Active entry is file -> returns parent directory (used by CurrentFileDirectory)
1974 #[gpui::test]
1975 async fn active_entry_directory_active_file(cx: &mut TestAppContext) {
1976 let (project, _workspace) = init_test(cx).await;
1977
1978 let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1979 let entry = cx
1980 .update(|cx| {
1981 wt.update(cx, |wt, cx| {
1982 wt.create_entry(
1983 RelPath::new(Path::new("src/main.rs"), PathStyle::local())
1984 .unwrap()
1985 .as_ref()
1986 .into(),
1987 false,
1988 None,
1989 cx,
1990 )
1991 })
1992 })
1993 .await
1994 .unwrap()
1995 .into_included()
1996 .unwrap();
1997 insert_active_entry_for(wt, entry, project.clone(), cx);
1998
1999 cx.update(|cx| {
2000 let res = project.read(cx).active_entry_directory(cx);
2001 assert_eq!(res, Some(Path::new("/root/src").to_path_buf()));
2002 });
2003 }
2004
2005 // active_entry_directory: Active entry is directory -> returns that directory (used by CurrentFileDirectory)
2006 #[gpui::test]
2007 async fn active_entry_directory_active_dir(cx: &mut TestAppContext) {
2008 let (project, _workspace) = init_test(cx).await;
2009
2010 let (wt, entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2011 insert_active_entry_for(wt, entry, project.clone(), cx);
2012
2013 cx.update(|cx| {
2014 let res = project.read(cx).active_entry_directory(cx);
2015 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2016 });
2017 }
2018
2019 /// Creates a worktree with 1 file: /root.txt
2020 pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
2021 let params = cx.update(AppState::test);
2022 cx.update(|cx| {
2023 theme::init(theme::LoadThemes::JustBase, cx);
2024 });
2025
2026 let project = Project::test(params.fs.clone(), [], cx).await;
2027 let window_handle =
2028 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2029 let workspace = window_handle
2030 .read_with(cx, |mw, _| mw.workspace().clone())
2031 .unwrap();
2032
2033 (project, workspace)
2034 }
2035
2036 /// Creates a worktree with 1 folder: /root{suffix}/
2037 async fn create_folder_wt(
2038 project: Entity<Project>,
2039 path: impl AsRef<Path>,
2040 cx: &mut TestAppContext,
2041 ) -> (Entity<Worktree>, Entry) {
2042 create_wt(project, true, path, cx).await
2043 }
2044
2045 /// Creates a worktree with 1 file: /root{suffix}.txt
2046 async fn create_file_wt(
2047 project: Entity<Project>,
2048 path: impl AsRef<Path>,
2049 cx: &mut TestAppContext,
2050 ) -> (Entity<Worktree>, Entry) {
2051 create_wt(project, false, path, cx).await
2052 }
2053
2054 async fn create_wt(
2055 project: Entity<Project>,
2056 is_dir: bool,
2057 path: impl AsRef<Path>,
2058 cx: &mut TestAppContext,
2059 ) -> (Entity<Worktree>, Entry) {
2060 let (wt, _) = project
2061 .update(cx, |project, cx| {
2062 project.find_or_create_worktree(path, true, cx)
2063 })
2064 .await
2065 .unwrap();
2066
2067 let entry = cx
2068 .update(|cx| {
2069 wt.update(cx, |wt, cx| {
2070 wt.create_entry(RelPath::empty().into(), is_dir, None, cx)
2071 })
2072 })
2073 .await
2074 .unwrap()
2075 .into_included()
2076 .unwrap();
2077
2078 (wt, entry)
2079 }
2080
2081 pub fn insert_active_entry_for(
2082 wt: Entity<Worktree>,
2083 entry: Entry,
2084 project: Entity<Project>,
2085 cx: &mut TestAppContext,
2086 ) {
2087 cx.update(|cx| {
2088 let p = ProjectPath {
2089 worktree_id: wt.read(cx).id(),
2090 path: entry.path,
2091 };
2092 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
2093 });
2094 }
2095
2096 // Terminal rename tests
2097
2098 #[gpui::test]
2099 async fn test_custom_title_initially_none(cx: &mut TestAppContext) {
2100 cx.executor().allow_parking();
2101
2102 let (project, workspace) = init_test(cx).await;
2103
2104 let terminal = project
2105 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2106 .await
2107 .unwrap();
2108
2109 let terminal_view = cx
2110 .add_window(|window, cx| {
2111 TerminalView::new(
2112 terminal,
2113 workspace.downgrade(),
2114 None,
2115 project.downgrade(),
2116 window,
2117 cx,
2118 )
2119 })
2120 .root(cx)
2121 .unwrap();
2122
2123 terminal_view.update(cx, |view, _cx| {
2124 assert!(view.custom_title().is_none());
2125 });
2126 }
2127
2128 #[gpui::test]
2129 async fn test_set_custom_title(cx: &mut TestAppContext) {
2130 cx.executor().allow_parking();
2131
2132 let (project, workspace) = init_test(cx).await;
2133
2134 let terminal = project
2135 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2136 .await
2137 .unwrap();
2138
2139 let terminal_view = cx
2140 .add_window(|window, cx| {
2141 TerminalView::new(
2142 terminal,
2143 workspace.downgrade(),
2144 None,
2145 project.downgrade(),
2146 window,
2147 cx,
2148 )
2149 })
2150 .root(cx)
2151 .unwrap();
2152
2153 terminal_view.update(cx, |view, cx| {
2154 view.set_custom_title(Some("frontend".to_string()), cx);
2155 assert_eq!(view.custom_title(), Some("frontend"));
2156 });
2157 }
2158
2159 #[gpui::test]
2160 async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) {
2161 cx.executor().allow_parking();
2162
2163 let (project, workspace) = init_test(cx).await;
2164
2165 let terminal = project
2166 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2167 .await
2168 .unwrap();
2169
2170 let terminal_view = cx
2171 .add_window(|window, cx| {
2172 TerminalView::new(
2173 terminal,
2174 workspace.downgrade(),
2175 None,
2176 project.downgrade(),
2177 window,
2178 cx,
2179 )
2180 })
2181 .root(cx)
2182 .unwrap();
2183
2184 terminal_view.update(cx, |view, cx| {
2185 view.set_custom_title(Some("test".to_string()), cx);
2186 assert_eq!(view.custom_title(), Some("test"));
2187
2188 view.set_custom_title(Some("".to_string()), cx);
2189 assert!(view.custom_title().is_none());
2190
2191 view.set_custom_title(Some(" ".to_string()), cx);
2192 assert!(view.custom_title().is_none());
2193 });
2194 }
2195
2196 #[gpui::test]
2197 async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) {
2198 cx.executor().allow_parking();
2199
2200 let (project, workspace) = init_test(cx).await;
2201
2202 let terminal = project
2203 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2204 .await
2205 .unwrap();
2206
2207 let terminal_view = cx
2208 .add_window(|window, cx| {
2209 TerminalView::new(
2210 terminal,
2211 workspace.downgrade(),
2212 None,
2213 project.downgrade(),
2214 window,
2215 cx,
2216 )
2217 })
2218 .root(cx)
2219 .unwrap();
2220
2221 terminal_view.update(cx, |view, cx| {
2222 view.needs_serialize = false;
2223 view.set_custom_title(Some("new_label".to_string()), cx);
2224 assert!(view.needs_serialize);
2225 });
2226 }
2227
2228 #[gpui::test]
2229 async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) {
2230 cx.executor().allow_parking();
2231
2232 let (project, workspace) = init_test(cx).await;
2233
2234 let terminal = project
2235 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2236 .await
2237 .unwrap();
2238
2239 let terminal_view = cx
2240 .add_window(|window, cx| {
2241 TerminalView::new(
2242 terminal,
2243 workspace.downgrade(),
2244 None,
2245 project.downgrade(),
2246 window,
2247 cx,
2248 )
2249 })
2250 .root(cx)
2251 .unwrap();
2252
2253 terminal_view.update(cx, |view, cx| {
2254 view.set_custom_title(Some("my-server".to_string()), cx);
2255 let text = view.tab_content_text(0, cx);
2256 assert_eq!(text.as_ref(), "my-server");
2257 });
2258
2259 terminal_view.update(cx, |view, cx| {
2260 view.set_custom_title(None, cx);
2261 let text = view.tab_content_text(0, cx);
2262 assert_ne!(text.as_ref(), "my-server");
2263 });
2264 }
2265
2266 #[gpui::test]
2267 async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty(
2268 cx: &mut TestAppContext,
2269 ) {
2270 cx.executor().allow_parking();
2271
2272 let (project, workspace) = init_test(cx).await;
2273
2274 let terminal = project
2275 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2276 .await
2277 .unwrap();
2278
2279 let terminal_view = cx
2280 .add_window(|window, cx| {
2281 TerminalView::new(
2282 terminal,
2283 workspace.downgrade(),
2284 None,
2285 project.downgrade(),
2286 window,
2287 cx,
2288 )
2289 })
2290 .root(cx)
2291 .unwrap();
2292
2293 terminal_view.update(cx, |view, cx| {
2294 view.custom_title = Some("".to_string());
2295 let text = view.tab_content_text(0, cx);
2296 assert!(
2297 !text.is_empty(),
2298 "Tab should show terminal title, not empty string; got: '{}'",
2299 text
2300 );
2301 });
2302
2303 terminal_view.update(cx, |view, cx| {
2304 view.custom_title = Some(" ".to_string());
2305 let text = view.tab_content_text(0, cx);
2306 assert!(
2307 !text.is_empty() && text.as_ref() != " ",
2308 "Tab should show terminal title, not whitespace; got: '{}'",
2309 text
2310 );
2311 });
2312 }
2313}