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