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, Font, 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::TerminalDb;
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 HighlightedText, 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 match clipboard.entries().first() {
817 Some(ClipboardEntry::Image(image)) if !image.bytes.is_empty() => {
818 self.forward_ctrl_v(cx);
819 }
820 _ => {
821 if let Some(text) = clipboard.text() {
822 self.terminal
823 .update(cx, |terminal, _cx| terminal.paste(&text));
824 }
825 }
826 }
827 }
828
829 /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
830 /// and attach images using their native workflows.
831 fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
832 self.terminal.update(cx, |term, _| {
833 term.input(vec![0x16]);
834 });
835 }
836
837 fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
838 let mut text = paths.iter().map(|path| format!(" {path:?}")).join("");
839 text.push(' ');
840 window.focus(&self.focus_handle(cx), cx);
841 self.terminal.update(cx, |terminal, _| {
842 terminal.paste(&text);
843 });
844 }
845
846 fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
847 self.clear_bell(cx);
848 self.terminal.update(cx, |term, _| {
849 term.input(text.0.to_string().into_bytes());
850 });
851 }
852
853 fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context<Self>) {
854 if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
855 self.clear_bell(cx);
856 self.process_keystroke(&keystroke, cx);
857 }
858 }
859
860 fn dispatch_context(&self, cx: &App) -> KeyContext {
861 let mut dispatch_context = KeyContext::new_with_defaults();
862 dispatch_context.add("Terminal");
863
864 if self.terminal.read(cx).vi_mode_enabled() {
865 dispatch_context.add("vi_mode");
866 }
867
868 let mode = self.terminal.read(cx).last_content.mode;
869 dispatch_context.set(
870 "screen",
871 if mode.contains(TermMode::ALT_SCREEN) {
872 "alt"
873 } else {
874 "normal"
875 },
876 );
877
878 if mode.contains(TermMode::APP_CURSOR) {
879 dispatch_context.add("DECCKM");
880 }
881 if mode.contains(TermMode::APP_KEYPAD) {
882 dispatch_context.add("DECPAM");
883 } else {
884 dispatch_context.add("DECPNM");
885 }
886 if mode.contains(TermMode::SHOW_CURSOR) {
887 dispatch_context.add("DECTCEM");
888 }
889 if mode.contains(TermMode::LINE_WRAP) {
890 dispatch_context.add("DECAWM");
891 }
892 if mode.contains(TermMode::ORIGIN) {
893 dispatch_context.add("DECOM");
894 }
895 if mode.contains(TermMode::INSERT) {
896 dispatch_context.add("IRM");
897 }
898 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
899 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
900 dispatch_context.add("LNM");
901 }
902 if mode.contains(TermMode::FOCUS_IN_OUT) {
903 dispatch_context.add("report_focus");
904 }
905 if mode.contains(TermMode::ALTERNATE_SCROLL) {
906 dispatch_context.add("alternate_scroll");
907 }
908 if mode.contains(TermMode::BRACKETED_PASTE) {
909 dispatch_context.add("bracketed_paste");
910 }
911 if mode.intersects(TermMode::MOUSE_MODE) {
912 dispatch_context.add("any_mouse_reporting");
913 }
914 {
915 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
916 "click"
917 } else if mode.contains(TermMode::MOUSE_DRAG) {
918 "drag"
919 } else if mode.contains(TermMode::MOUSE_MOTION) {
920 "motion"
921 } else {
922 "off"
923 };
924 dispatch_context.set("mouse_reporting", mouse_reporting);
925 }
926 {
927 let format = if mode.contains(TermMode::SGR_MOUSE) {
928 "sgr"
929 } else if mode.contains(TermMode::UTF8_MOUSE) {
930 "utf8"
931 } else {
932 "normal"
933 };
934 dispatch_context.set("mouse_format", format);
935 };
936
937 if self.terminal.read(cx).last_content.selection.is_some() {
938 dispatch_context.add("selection");
939 }
940
941 dispatch_context
942 }
943
944 fn set_terminal(
945 &mut self,
946 terminal: Entity<Terminal>,
947 window: &mut Window,
948 cx: &mut Context<TerminalView>,
949 ) {
950 self._terminal_subscriptions =
951 subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx);
952 self.terminal = terminal;
953 }
954
955 fn rerun_button(task: &TaskState) -> Option<IconButton> {
956 if !task.spawned_task.show_rerun {
957 return None;
958 }
959
960 let task_id = task.spawned_task.id.clone();
961 Some(
962 IconButton::new("rerun-icon", IconName::Rerun)
963 .icon_size(IconSize::Small)
964 .size(ButtonSize::Compact)
965 .icon_color(Color::Default)
966 .shape(ui::IconButtonShape::Square)
967 .tooltip(move |_window, cx| Tooltip::for_action("Rerun task", &RerunTask, cx))
968 .on_click(move |_, window, cx| {
969 window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx);
970 }),
971 )
972 }
973}
974
975fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun {
976 zed_actions::Rerun {
977 task_id: Some(task.0.clone()),
978 allow_concurrent_runs: Some(true),
979 use_new_terminal: Some(false),
980 reevaluate_context: false,
981 }
982}
983
984fn subscribe_for_terminal_events(
985 terminal: &Entity<Terminal>,
986 workspace: WeakEntity<Workspace>,
987 window: &mut Window,
988 cx: &mut Context<TerminalView>,
989) -> Vec<Subscription> {
990 let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify());
991 let mut previous_cwd = None;
992 let terminal_events_subscription = cx.subscribe_in(
993 terminal,
994 window,
995 move |terminal_view, terminal, event, window, cx| {
996 let current_cwd = terminal.read(cx).working_directory();
997 if current_cwd != previous_cwd {
998 previous_cwd = current_cwd;
999 terminal_view.needs_serialize = true;
1000 }
1001
1002 match event {
1003 Event::Wakeup => {
1004 cx.notify();
1005 cx.emit(Event::Wakeup);
1006 cx.emit(ItemEvent::UpdateTab);
1007 cx.emit(SearchEvent::MatchesInvalidated);
1008 }
1009
1010 Event::Bell => {
1011 terminal_view.has_bell = true;
1012 cx.emit(Event::Wakeup);
1013 }
1014
1015 Event::BlinkChanged(blinking) => {
1016 terminal_view.blinking_terminal_enabled = *blinking;
1017
1018 // If in terminal-controlled mode and focused, update blink manager
1019 if matches!(
1020 TerminalSettings::get_global(cx).blinking,
1021 TerminalBlink::TerminalControlled
1022 ) && terminal_view.focus_handle.is_focused(window)
1023 {
1024 terminal_view.blink_manager.update(cx, |manager, cx| {
1025 if *blinking {
1026 manager.enable(cx);
1027 } else {
1028 manager.disable(cx);
1029 }
1030 });
1031 }
1032 }
1033
1034 Event::TitleChanged => {
1035 cx.emit(ItemEvent::UpdateTab);
1036 }
1037
1038 Event::NewNavigationTarget(maybe_navigation_target) => {
1039 match maybe_navigation_target
1040 .as_ref()
1041 .zip(terminal.read(cx).last_content.last_hovered_word.as_ref())
1042 {
1043 Some((MaybeNavigationTarget::Url(url), hovered_word)) => {
1044 if Some(hovered_word)
1045 != terminal_view
1046 .hover
1047 .as_ref()
1048 .map(|hover| &hover.hovered_word)
1049 {
1050 terminal_view.hover = Some(HoverTarget {
1051 tooltip: url.clone(),
1052 hovered_word: hovered_word.clone(),
1053 });
1054 terminal_view.hover_tooltip_update = Task::ready(());
1055 cx.notify();
1056 }
1057 }
1058 Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => {
1059 if Some(hovered_word)
1060 != terminal_view
1061 .hover
1062 .as_ref()
1063 .map(|hover| &hover.hovered_word)
1064 {
1065 terminal_view.hover = None;
1066 terminal_view.hover_tooltip_update = hover_path_like_target(
1067 &workspace,
1068 hovered_word.clone(),
1069 path_like_target,
1070 cx,
1071 );
1072 cx.notify();
1073 }
1074 }
1075 None => {
1076 terminal_view.hover = None;
1077 terminal_view.hover_tooltip_update = Task::ready(());
1078 cx.notify();
1079 }
1080 }
1081 }
1082
1083 Event::Open(maybe_navigation_target) => match maybe_navigation_target {
1084 MaybeNavigationTarget::Url(url) => cx.open_url(url),
1085 MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target(
1086 &workspace,
1087 terminal_view,
1088 path_like_target,
1089 window,
1090 cx,
1091 ),
1092 },
1093 Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
1094 Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
1095 Event::SelectionsChanged => {
1096 window.invalidate_character_coordinates();
1097 cx.emit(SearchEvent::ActiveMatchChanged)
1098 }
1099 }
1100 },
1101 );
1102 vec![terminal_subscription, terminal_events_subscription]
1103}
1104
1105fn regex_search_for_query(query: &SearchQuery) -> Option<RegexSearch> {
1106 let str = query.as_str();
1107 if query.is_regex() {
1108 if str == "." {
1109 return None;
1110 }
1111 RegexSearch::new(str).ok()
1112 } else {
1113 RegexSearch::new(®ex::escape(str)).ok()
1114 }
1115}
1116
1117struct TerminalScrollbarSettingsWrapper;
1118
1119impl GlobalSetting for TerminalScrollbarSettingsWrapper {
1120 fn get_value(_cx: &App) -> &Self {
1121 &Self
1122 }
1123}
1124
1125impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper {
1126 fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar {
1127 TerminalSettings::get_global(cx)
1128 .scrollbar
1129 .show
1130 .map(Into::into)
1131 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
1132 }
1133}
1134
1135impl TerminalView {
1136 /// Attempts to process a keystroke in the terminal. Returns true if handled.
1137 ///
1138 /// In vi mode, explicitly triggers a re-render because vi navigation (like j/k)
1139 /// updates the cursor locally without sending data to the shell, so there's no
1140 /// shell output to automatically trigger a re-render.
1141 fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
1142 let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
1143 (
1144 term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta),
1145 term.vi_mode_enabled(),
1146 )
1147 });
1148
1149 if handled && vi_mode_enabled {
1150 cx.notify();
1151 }
1152
1153 handled
1154 }
1155
1156 fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1157 self.clear_bell(cx);
1158 self.pause_cursor_blinking(window, cx);
1159
1160 if self.process_keystroke(&event.keystroke, cx) {
1161 cx.stop_propagation();
1162 }
1163 }
1164
1165 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1166 self.terminal.update(cx, |terminal, _| {
1167 terminal.set_cursor_shape(self.cursor_shape);
1168 terminal.focus_in();
1169 });
1170
1171 let should_blink = match TerminalSettings::get_global(cx).blinking {
1172 TerminalBlink::Off => false,
1173 TerminalBlink::On => true,
1174 TerminalBlink::TerminalControlled => self.blinking_terminal_enabled,
1175 };
1176
1177 if should_blink {
1178 self.blink_manager.update(cx, BlinkManager::enable);
1179 }
1180
1181 window.invalidate_character_coordinates();
1182 cx.notify();
1183 }
1184
1185 fn focus_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1186 self.blink_manager.update(cx, BlinkManager::disable);
1187 self.terminal.update(cx, |terminal, _| {
1188 terminal.focus_out();
1189 terminal.set_cursor_shape(CursorShape::Hollow);
1190 });
1191 cx.notify();
1192 }
1193}
1194
1195impl Render for TerminalView {
1196 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1197 // TODO: this should be moved out of render
1198 self.scroll_handle.update(self.terminal.read(cx));
1199
1200 if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
1201 self.terminal.update(cx, |term, _| {
1202 let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
1203 match delta.cmp(&0) {
1204 cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
1205 cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
1206 cmp::Ordering::Equal => {}
1207 }
1208 });
1209 }
1210
1211 let terminal_handle = self.terminal.clone();
1212 let terminal_view_handle = cx.entity();
1213
1214 let focused = self.focus_handle.is_focused(window);
1215
1216 div()
1217 .id("terminal-view")
1218 .size_full()
1219 .relative()
1220 .track_focus(&self.focus_handle(cx))
1221 .key_context(self.dispatch_context(cx))
1222 .on_action(cx.listener(TerminalView::send_text))
1223 .on_action(cx.listener(TerminalView::send_keystroke))
1224 .on_action(cx.listener(TerminalView::copy))
1225 .on_action(cx.listener(TerminalView::paste))
1226 .on_action(cx.listener(TerminalView::clear))
1227 .on_action(cx.listener(TerminalView::scroll_line_up))
1228 .on_action(cx.listener(TerminalView::scroll_line_down))
1229 .on_action(cx.listener(TerminalView::scroll_page_up))
1230 .on_action(cx.listener(TerminalView::scroll_page_down))
1231 .on_action(cx.listener(TerminalView::scroll_to_top))
1232 .on_action(cx.listener(TerminalView::scroll_to_bottom))
1233 .on_action(cx.listener(TerminalView::toggle_vi_mode))
1234 .on_action(cx.listener(TerminalView::show_character_palette))
1235 .on_action(cx.listener(TerminalView::select_all))
1236 .on_action(cx.listener(TerminalView::rerun_task))
1237 .on_action(cx.listener(TerminalView::rename_terminal))
1238 .on_key_down(cx.listener(Self::key_down))
1239 .on_mouse_down(
1240 MouseButton::Right,
1241 cx.listener(|this, event: &MouseDownEvent, window, cx| {
1242 if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
1243 if this.terminal.read(cx).last_content.selection.is_none() {
1244 this.terminal.update(cx, |terminal, _| {
1245 terminal.select_word_at_event_position(event);
1246 });
1247 };
1248 this.deploy_context_menu(event.position, window, cx);
1249 cx.notify();
1250 }
1251 }),
1252 )
1253 .child(
1254 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
1255 div()
1256 .id("terminal-view-container")
1257 .size_full()
1258 .bg(cx.theme().colors().editor_background)
1259 .child(TerminalElement::new(
1260 terminal_handle,
1261 terminal_view_handle,
1262 self.workspace.clone(),
1263 self.focus_handle.clone(),
1264 focused,
1265 self.should_show_cursor(focused, cx),
1266 self.block_below_cursor.clone(),
1267 self.mode.clone(),
1268 ))
1269 .when(self.content_mode(window, cx).is_scrollable(), |div| {
1270 div.custom_scrollbars(
1271 Scrollbars::for_settings::<TerminalScrollbarSettingsWrapper>()
1272 .show_along(ScrollAxes::Vertical)
1273 .with_track_along(
1274 ScrollAxes::Vertical,
1275 cx.theme().colors().editor_background,
1276 )
1277 .tracked_scroll_handle(&self.scroll_handle),
1278 window,
1279 cx,
1280 )
1281 }),
1282 )
1283 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1284 deferred(
1285 anchored()
1286 .position(*position)
1287 .anchor(gpui::Corner::TopLeft)
1288 .child(menu.clone()),
1289 )
1290 .with_priority(1)
1291 }))
1292 }
1293}
1294
1295impl Item for TerminalView {
1296 type Event = ItemEvent;
1297
1298 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
1299 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
1300 let terminal = self.terminal().read(cx);
1301 let title = terminal.title(false);
1302 let pid = terminal.pid_getter()?.fallback_pid();
1303
1304 move |_, _| {
1305 v_flex()
1306 .gap_1()
1307 .child(Label::new(title.clone()))
1308 .child(h_flex().flex_grow().child(Divider::horizontal()))
1309 .child(
1310 Label::new(format!("Process ID (PID): {}", pid))
1311 .color(Color::Muted)
1312 .size(LabelSize::Small),
1313 )
1314 .into_any_element()
1315 }
1316 }))))
1317 }
1318
1319 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
1320 let terminal = self.terminal().read(cx);
1321 let title = self
1322 .custom_title
1323 .as_ref()
1324 .filter(|title| !title.trim().is_empty())
1325 .cloned()
1326 .unwrap_or_else(|| terminal.title(true));
1327
1328 let (icon, icon_color, rerun_button) = match terminal.task() {
1329 Some(terminal_task) => match &terminal_task.status {
1330 TaskStatus::Running => (
1331 IconName::PlayFilled,
1332 Color::Disabled,
1333 TerminalView::rerun_button(terminal_task),
1334 ),
1335 TaskStatus::Unknown => (
1336 IconName::Warning,
1337 Color::Warning,
1338 TerminalView::rerun_button(terminal_task),
1339 ),
1340 TaskStatus::Completed { success } => {
1341 let rerun_button = TerminalView::rerun_button(terminal_task);
1342
1343 if *success {
1344 (IconName::Check, Color::Success, rerun_button)
1345 } else {
1346 (IconName::XCircle, Color::Error, rerun_button)
1347 }
1348 }
1349 },
1350 None => (IconName::Terminal, Color::Muted, None),
1351 };
1352
1353 h_flex()
1354 .gap_1()
1355 .group("term-tab-icon")
1356 .child(
1357 h_flex()
1358 .group("term-tab-icon")
1359 .child(
1360 div()
1361 .when(rerun_button.is_some(), |this| {
1362 this.hover(|style| style.invisible().w_0())
1363 })
1364 .child(Icon::new(icon).color(icon_color)),
1365 )
1366 .when_some(rerun_button, |this, rerun_button| {
1367 this.child(
1368 div()
1369 .absolute()
1370 .visible_on_hover("term-tab-icon")
1371 .child(rerun_button),
1372 )
1373 }),
1374 )
1375 .child(
1376 div()
1377 .relative()
1378 .child(
1379 Label::new(title)
1380 .color(params.text_color())
1381 .when(self.is_renaming(), |this| this.alpha(0.)),
1382 )
1383 .when_some(self.rename_editor.clone(), |this, editor| {
1384 let self_handle = self.self_handle.clone();
1385 let self_handle_cancel = self.self_handle.clone();
1386 this.child(
1387 div()
1388 .absolute()
1389 .top_0()
1390 .left_0()
1391 .size_full()
1392 .child(editor)
1393 .on_action(move |_: &menu::Confirm, window, cx| {
1394 self_handle
1395 .update(cx, |this, cx| {
1396 this.finish_renaming(true, window, cx)
1397 })
1398 .ok();
1399 })
1400 .on_action(move |_: &menu::Cancel, window, cx| {
1401 self_handle_cancel
1402 .update(cx, |this, cx| {
1403 this.finish_renaming(false, window, cx)
1404 })
1405 .ok();
1406 }),
1407 )
1408 }),
1409 )
1410 .into_any()
1411 }
1412
1413 fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
1414 if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) {
1415 return custom_title.clone().into();
1416 }
1417 let terminal = self.terminal().read(cx);
1418 terminal.title(detail == 0).into()
1419 }
1420
1421 fn telemetry_event_text(&self) -> Option<&'static str> {
1422 None
1423 }
1424
1425 fn handle_drop(
1426 &self,
1427 active_pane: &Pane,
1428 dropped: &dyn Any,
1429 window: &mut Window,
1430 cx: &mut App,
1431 ) -> bool {
1432 let Some(project) = self.project.upgrade() else {
1433 return false;
1434 };
1435
1436 if let Some(paths) = dropped.downcast_ref::<ExternalPaths>() {
1437 let is_local = project.read(cx).is_local();
1438 if is_local {
1439 self.add_paths_to_terminal(paths.paths(), window, cx);
1440 return true;
1441 }
1442
1443 return false;
1444 } else if let Some(tab) = dropped.downcast_ref::<DraggedTab>() {
1445 let Some(self_handle) = self.self_handle.upgrade() else {
1446 return false;
1447 };
1448
1449 let Some(workspace) = self.workspace.upgrade() else {
1450 return false;
1451 };
1452
1453 let Some(this_pane) = workspace.read(cx).pane_for(&self_handle) else {
1454 return false;
1455 };
1456
1457 let item = if tab.pane == this_pane {
1458 active_pane.item_for_index(tab.ix)
1459 } else {
1460 tab.pane.read(cx).item_for_index(tab.ix)
1461 };
1462
1463 let Some(item) = item else {
1464 return false;
1465 };
1466
1467 if item.downcast::<TerminalView>().is_some() {
1468 let Some(split_direction) = active_pane.drag_split_direction() else {
1469 return false;
1470 };
1471
1472 let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
1473 return false;
1474 };
1475
1476 if !terminal_panel.read(cx).center.panes().contains(&&this_pane) {
1477 return false;
1478 }
1479
1480 let source = tab.pane.clone();
1481 let item_id_to_move = item.item_id();
1482 let is_zoomed = {
1483 let terminal_panel = terminal_panel.read(cx);
1484 if terminal_panel.active_pane == this_pane {
1485 active_pane.is_zoomed()
1486 } else {
1487 terminal_panel.active_pane.read(cx).is_zoomed()
1488 }
1489 };
1490
1491 let workspace = workspace.downgrade();
1492 let terminal_panel = terminal_panel.downgrade();
1493 // Defer the split operation to avoid re-entrancy panic.
1494 // The pane may be the one currently being updated, so we cannot
1495 // call mark_positions (via split) synchronously.
1496 window
1497 .spawn(cx, async move |cx| {
1498 cx.update(|window, cx| {
1499 let Ok(new_pane) = terminal_panel.update(cx, |terminal_panel, cx| {
1500 let new_pane = terminal_panel::new_terminal_pane(
1501 workspace, project, is_zoomed, window, cx,
1502 );
1503 terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
1504 terminal_panel.center.split(
1505 &this_pane,
1506 &new_pane,
1507 split_direction,
1508 cx,
1509 );
1510 anyhow::Ok(new_pane)
1511 }) else {
1512 return;
1513 };
1514
1515 let Some(new_pane) = new_pane.log_err() else {
1516 return;
1517 };
1518
1519 workspace::move_item(
1520 &source,
1521 &new_pane,
1522 item_id_to_move,
1523 new_pane.read(cx).active_item_index(),
1524 true,
1525 window,
1526 cx,
1527 );
1528 })
1529 .ok();
1530 })
1531 .detach();
1532
1533 return true;
1534 } else {
1535 if let Some(project_path) = item.project_path(cx)
1536 && let Some(path) = project.read(cx).absolute_path(&project_path, cx)
1537 {
1538 self.add_paths_to_terminal(&[path], window, cx);
1539 return true;
1540 }
1541 }
1542
1543 return false;
1544 } else if let Some(selection) = dropped.downcast_ref::<DraggedSelection>() {
1545 let project = project.read(cx);
1546 let paths = selection
1547 .items()
1548 .map(|selected_entry| selected_entry.entry_id)
1549 .filter_map(|entry_id| project.path_for_entry(entry_id, cx))
1550 .filter_map(|project_path| project.absolute_path(&project_path, cx))
1551 .collect::<Vec<_>>();
1552
1553 if !paths.is_empty() {
1554 self.add_paths_to_terminal(&paths, window, cx);
1555 }
1556
1557 return true;
1558 } else if let Some(&entry_id) = dropped.downcast_ref::<ProjectEntryId>() {
1559 let project = project.read(cx);
1560 if let Some(path) = project
1561 .path_for_entry(entry_id, cx)
1562 .and_then(|project_path| project.absolute_path(&project_path, cx))
1563 {
1564 self.add_paths_to_terminal(&[path], window, cx);
1565 }
1566
1567 return true;
1568 }
1569
1570 false
1571 }
1572
1573 fn tab_extra_context_menu_actions(
1574 &self,
1575 _window: &mut Window,
1576 cx: &mut Context<Self>,
1577 ) -> Vec<(SharedString, Box<dyn gpui::Action>)> {
1578 let terminal = self.terminal.read(cx);
1579 if terminal.task().is_none() {
1580 vec![("Rename".into(), Box::new(RenameTerminal))]
1581 } else {
1582 Vec::new()
1583 }
1584 }
1585
1586 fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
1587 workspace::item::ItemBufferKind::Singleton
1588 }
1589
1590 fn can_split(&self) -> bool {
1591 true
1592 }
1593
1594 fn clone_on_split(
1595 &self,
1596 workspace_id: Option<WorkspaceId>,
1597 window: &mut Window,
1598 cx: &mut Context<Self>,
1599 ) -> Task<Option<Entity<Self>>> {
1600 let Ok(terminal) = self.project.update(cx, |project, cx| {
1601 let cwd = project
1602 .active_project_directory(cx)
1603 .map(|it| it.to_path_buf());
1604 project.clone_terminal(self.terminal(), cx, cwd)
1605 }) else {
1606 return Task::ready(None);
1607 };
1608 cx.spawn_in(window, async move |this, cx| {
1609 let terminal = terminal.await.log_err()?;
1610 this.update_in(cx, |this, window, cx| {
1611 cx.new(|cx| {
1612 TerminalView::new(
1613 terminal,
1614 this.workspace.clone(),
1615 workspace_id,
1616 this.project.clone(),
1617 window,
1618 cx,
1619 )
1620 })
1621 })
1622 .ok()
1623 })
1624 }
1625
1626 fn is_dirty(&self, cx: &App) -> bool {
1627 match self.terminal.read(cx).task() {
1628 Some(task) => task.status == TaskStatus::Running,
1629 None => self.has_bell(),
1630 }
1631 }
1632
1633 fn has_conflict(&self, _cx: &App) -> bool {
1634 false
1635 }
1636
1637 fn can_save_as(&self, _cx: &App) -> bool {
1638 false
1639 }
1640
1641 fn as_searchable(
1642 &self,
1643 handle: &Entity<Self>,
1644 _: &App,
1645 ) -> Option<Box<dyn SearchableItemHandle>> {
1646 Some(Box::new(handle.clone()))
1647 }
1648
1649 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
1650 if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1651 ToolbarItemLocation::PrimaryLeft
1652 } else {
1653 ToolbarItemLocation::Hidden
1654 }
1655 }
1656
1657 fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
1658 Some((
1659 vec![HighlightedText {
1660 text: self.terminal().read(cx).breadcrumb_text.clone().into(),
1661 highlights: vec![],
1662 }],
1663 None,
1664 ))
1665 }
1666
1667 fn added_to_workspace(
1668 &mut self,
1669 workspace: &mut Workspace,
1670 _: &mut Window,
1671 cx: &mut Context<Self>,
1672 ) {
1673 if self.terminal().read(cx).task().is_none() {
1674 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1675 log::debug!(
1676 "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}",
1677 );
1678 let db = TerminalDb::global(cx);
1679 let entity_id = cx.entity_id().as_u64();
1680 cx.background_spawn(async move {
1681 db.update_workspace_id(new_id, old_id, entity_id).await
1682 })
1683 .detach();
1684 }
1685 self.workspace_id = workspace.database_id();
1686 }
1687 }
1688
1689 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
1690 f(*event)
1691 }
1692}
1693
1694impl SerializableItem for TerminalView {
1695 fn serialized_item_kind() -> &'static str {
1696 "Terminal"
1697 }
1698
1699 fn cleanup(
1700 workspace_id: WorkspaceId,
1701 alive_items: Vec<workspace::ItemId>,
1702 _window: &mut Window,
1703 cx: &mut App,
1704 ) -> Task<anyhow::Result<()>> {
1705 let db = TerminalDb::global(cx);
1706 delete_unloaded_items(alive_items, workspace_id, "terminals", &db, cx)
1707 }
1708
1709 fn serialize(
1710 &mut self,
1711 _workspace: &mut Workspace,
1712 item_id: workspace::ItemId,
1713 _closing: bool,
1714 _: &mut Window,
1715 cx: &mut Context<Self>,
1716 ) -> Option<Task<anyhow::Result<()>>> {
1717 let terminal = self.terminal().read(cx);
1718 if terminal.task().is_some() {
1719 return None;
1720 }
1721
1722 if !self.needs_serialize {
1723 return None;
1724 }
1725
1726 let workspace_id = self.workspace_id?;
1727 let cwd = terminal.working_directory();
1728 let custom_title = self.custom_title.clone();
1729 self.needs_serialize = false;
1730
1731 let db = TerminalDb::global(cx);
1732 Some(cx.background_spawn(async move {
1733 if let Some(cwd) = cwd {
1734 db.save_working_directory(item_id, workspace_id, cwd)
1735 .await?;
1736 }
1737 db.save_custom_title(item_id, workspace_id, custom_title)
1738 .await?;
1739 Ok(())
1740 }))
1741 }
1742
1743 fn should_serialize(&self, _: &Self::Event) -> bool {
1744 self.needs_serialize
1745 }
1746
1747 fn deserialize(
1748 project: Entity<Project>,
1749 workspace: WeakEntity<Workspace>,
1750 workspace_id: WorkspaceId,
1751 item_id: workspace::ItemId,
1752 window: &mut Window,
1753 cx: &mut App,
1754 ) -> Task<anyhow::Result<Entity<Self>>> {
1755 window.spawn(cx, async move |cx| {
1756 let (cwd, custom_title) = cx
1757 .update(|_window, cx| {
1758 let db = TerminalDb::global(cx);
1759 let from_db = db
1760 .get_working_directory(item_id, workspace_id)
1761 .log_err()
1762 .flatten();
1763 let cwd = if from_db
1764 .as_ref()
1765 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1766 {
1767 from_db
1768 } else {
1769 workspace
1770 .upgrade()
1771 .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1772 };
1773 let custom_title = db
1774 .get_custom_title(item_id, workspace_id)
1775 .log_err()
1776 .flatten()
1777 .filter(|title| !title.trim().is_empty());
1778 (cwd, custom_title)
1779 })
1780 .ok()
1781 .unwrap_or((None, None));
1782
1783 let terminal = project
1784 .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))
1785 .await?;
1786 cx.update(|window, cx| {
1787 cx.new(|cx| {
1788 let mut view = TerminalView::new(
1789 terminal,
1790 workspace,
1791 Some(workspace_id),
1792 project.downgrade(),
1793 window,
1794 cx,
1795 );
1796 if custom_title.is_some() {
1797 view.custom_title = custom_title;
1798 }
1799 view
1800 })
1801 })
1802 })
1803 }
1804}
1805
1806impl SearchableItem for TerminalView {
1807 type Match = RangeInclusive<AlacPoint>;
1808
1809 fn supported_options(&self) -> SearchOptions {
1810 SearchOptions {
1811 case: false,
1812 word: false,
1813 regex: true,
1814 replacement: false,
1815 selection: false,
1816 find_in_results: false,
1817 }
1818 }
1819
1820 /// Clear stored matches
1821 fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1822 self.terminal().update(cx, |term, _| term.matches.clear())
1823 }
1824
1825 /// Store matches returned from find_matches somewhere for rendering
1826 fn update_matches(
1827 &mut self,
1828 matches: &[Self::Match],
1829 _active_match_index: Option<usize>,
1830 _token: SearchToken,
1831 _window: &mut Window,
1832 cx: &mut Context<Self>,
1833 ) {
1834 self.terminal()
1835 .update(cx, |term, _| term.matches = matches.to_vec())
1836 }
1837
1838 /// Returns the selection content to pre-load into this search
1839 fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
1840 self.terminal()
1841 .read(cx)
1842 .last_content
1843 .selection_text
1844 .clone()
1845 .unwrap_or_default()
1846 }
1847
1848 /// Focus match at given index into the Vec of matches
1849 fn activate_match(
1850 &mut self,
1851 index: usize,
1852 _: &[Self::Match],
1853 _token: SearchToken,
1854 _window: &mut Window,
1855 cx: &mut Context<Self>,
1856 ) {
1857 self.terminal()
1858 .update(cx, |term, _| term.activate_match(index));
1859 cx.notify();
1860 }
1861
1862 /// Add selections for all matches given.
1863 fn select_matches(
1864 &mut self,
1865 matches: &[Self::Match],
1866 _token: SearchToken,
1867 _: &mut Window,
1868 cx: &mut Context<Self>,
1869 ) {
1870 self.terminal()
1871 .update(cx, |term, _| term.select_matches(matches));
1872 cx.notify();
1873 }
1874
1875 /// Get all of the matches for this query, should be done on the background
1876 fn find_matches(
1877 &mut self,
1878 query: Arc<SearchQuery>,
1879 _: &mut Window,
1880 cx: &mut Context<Self>,
1881 ) -> Task<Vec<Self::Match>> {
1882 if let Some(s) = regex_search_for_query(&query) {
1883 self.terminal()
1884 .update(cx, |term, cx| term.find_matches(s, cx))
1885 } else {
1886 Task::ready(vec![])
1887 }
1888 }
1889
1890 /// Reports back to the search toolbar what the active match should be (the selection)
1891 fn active_match_index(
1892 &mut self,
1893 direction: Direction,
1894 matches: &[Self::Match],
1895 _token: SearchToken,
1896 _: &mut Window,
1897 cx: &mut Context<Self>,
1898 ) -> Option<usize> {
1899 // Selection head might have a value if there's a selection that isn't
1900 // associated with a match. Therefore, if there are no matches, we should
1901 // report None, no matter the state of the terminal
1902
1903 if !matches.is_empty() {
1904 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1905 // If selection head is contained in a match. Return that match
1906 match direction {
1907 Direction::Prev => {
1908 // If no selection before selection head, return the first match
1909 Some(
1910 matches
1911 .iter()
1912 .enumerate()
1913 .rev()
1914 .find(|(_, search_match)| {
1915 search_match.contains(&selection_head)
1916 || search_match.start() < &selection_head
1917 })
1918 .map(|(ix, _)| ix)
1919 .unwrap_or(0),
1920 )
1921 }
1922 Direction::Next => {
1923 // If no selection after selection head, return the last match
1924 Some(
1925 matches
1926 .iter()
1927 .enumerate()
1928 .find(|(_, search_match)| {
1929 search_match.contains(&selection_head)
1930 || search_match.start() > &selection_head
1931 })
1932 .map(|(ix, _)| ix)
1933 .unwrap_or(matches.len().saturating_sub(1)),
1934 )
1935 }
1936 }
1937 } else {
1938 // Matches found but no active selection, return the first last one (closest to cursor)
1939 Some(matches.len().saturating_sub(1))
1940 }
1941 } else {
1942 None
1943 }
1944 }
1945 fn replace(
1946 &mut self,
1947 _: &Self::Match,
1948 _: &SearchQuery,
1949 _token: SearchToken,
1950 _window: &mut Window,
1951 _: &mut Context<Self>,
1952 ) {
1953 // Replacement is not supported in terminal view, so this is a no-op.
1954 }
1955}
1956
1957/// Gets the working directory for the given workspace, respecting the user's settings.
1958/// Falls back to home directory when no project directory is available.
1959pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1960 let directory = match &TerminalSettings::get_global(cx).working_directory {
1961 WorkingDirectory::CurrentFileDirectory => workspace
1962 .project()
1963 .read(cx)
1964 .active_entry_directory(cx)
1965 .or_else(|| current_project_directory(workspace, cx)),
1966 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
1967 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1968 WorkingDirectory::AlwaysHome => None,
1969 WorkingDirectory::Always { directory } => shellexpand::full(directory)
1970 .ok()
1971 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1972 .filter(|dir| dir.is_dir()),
1973 };
1974 directory.or_else(dirs::home_dir)
1975}
1976
1977fn current_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1978 workspace
1979 .project()
1980 .read(cx)
1981 .active_project_directory(cx)
1982 .as_deref()
1983 .map(Path::to_path_buf)
1984 .or_else(|| first_project_directory(workspace, cx))
1985}
1986
1987///Gets the first project's home directory, or the home directory
1988fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1989 let worktree = workspace.worktrees(cx).next()?.read(cx);
1990 let worktree_path = worktree.abs_path();
1991 if worktree.root_entry()?.is_dir() {
1992 Some(worktree_path.to_path_buf())
1993 } else {
1994 // If worktree is a file, return its parent directory
1995 worktree_path.parent().map(|p| p.to_path_buf())
1996 }
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001 use super::*;
2002 use gpui::TestAppContext;
2003 use project::{Entry, Project, ProjectPath, Worktree};
2004 use std::path::{Path, PathBuf};
2005 use util::paths::PathStyle;
2006 use util::rel_path::RelPath;
2007 use workspace::item::test::{TestItem, TestProjectItem};
2008 use workspace::{AppState, MultiWorkspace, SelectedEntry};
2009
2010 fn expected_drop_text(paths: &[PathBuf]) -> String {
2011 let mut text = String::new();
2012 for path in paths {
2013 text.push(' ');
2014 text.push_str(&format!("{path:?}"));
2015 }
2016 text.push(' ');
2017 text
2018 }
2019
2020 fn assert_drop_writes_to_terminal(
2021 pane: &Entity<Pane>,
2022 terminal_view_index: usize,
2023 terminal: &Entity<Terminal>,
2024 dropped: &dyn Any,
2025 expected_text: &str,
2026 window: &mut Window,
2027 cx: &mut Context<MultiWorkspace>,
2028 ) {
2029 let _ = terminal.update(cx, |terminal, _| terminal.take_input_log());
2030
2031 let handled = pane.update(cx, |pane, cx| {
2032 pane.item_for_index(terminal_view_index)
2033 .unwrap()
2034 .handle_drop(pane, dropped, window, cx)
2035 });
2036 assert!(handled, "handle_drop should return true for {:?}", dropped);
2037
2038 let mut input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
2039 assert_eq!(input_log.len(), 1, "expected exactly one write to terminal");
2040 let written =
2041 String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8");
2042 assert_eq!(written, expected_text);
2043 }
2044
2045 // Working directory calculation tests
2046
2047 // No Worktrees in project -> home_dir()
2048 #[gpui::test]
2049 async fn no_worktree(cx: &mut TestAppContext) {
2050 let (project, workspace) = init_test(cx).await;
2051 cx.read(|cx| {
2052 let workspace = workspace.read(cx);
2053 let active_entry = project.read(cx).active_entry();
2054
2055 //Make sure environment is as expected
2056 assert!(active_entry.is_none());
2057 assert!(workspace.worktrees(cx).next().is_none());
2058
2059 let res = default_working_directory(workspace, cx);
2060 assert_eq!(res, dirs::home_dir());
2061 let res = first_project_directory(workspace, cx);
2062 assert_eq!(res, None);
2063 });
2064 }
2065
2066 // No active entry, but a worktree, worktree is a file -> parent directory
2067 #[gpui::test]
2068 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
2069 let (project, workspace) = init_test(cx).await;
2070
2071 create_file_wt(project.clone(), "/root.txt", cx).await;
2072 cx.read(|cx| {
2073 let workspace = workspace.read(cx);
2074 let active_entry = project.read(cx).active_entry();
2075
2076 //Make sure environment is as expected
2077 assert!(active_entry.is_none());
2078 assert!(workspace.worktrees(cx).next().is_some());
2079
2080 let res = default_working_directory(workspace, cx);
2081 assert_eq!(res, Some(Path::new("/").to_path_buf()));
2082 let res = first_project_directory(workspace, cx);
2083 assert_eq!(res, Some(Path::new("/").to_path_buf()));
2084 });
2085 }
2086
2087 // No active entry, but a worktree, worktree is a folder -> worktree_folder
2088 #[gpui::test]
2089 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
2090 let (project, workspace) = init_test(cx).await;
2091
2092 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2093 cx.update(|cx| {
2094 let workspace = workspace.read(cx);
2095 let active_entry = project.read(cx).active_entry();
2096
2097 assert!(active_entry.is_none());
2098 assert!(workspace.worktrees(cx).next().is_some());
2099
2100 let res = default_working_directory(workspace, cx);
2101 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2102 let res = first_project_directory(workspace, cx);
2103 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2104 });
2105 }
2106
2107 // Active entry with a work tree, worktree is a file -> worktree_folder()
2108 #[gpui::test]
2109 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
2110 let (project, workspace) = init_test(cx).await;
2111
2112 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
2113 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
2114 insert_active_entry_for(wt2, entry2, project.clone(), cx);
2115
2116 cx.update(|cx| {
2117 let workspace = workspace.read(cx);
2118 let active_entry = project.read(cx).active_entry();
2119
2120 assert!(active_entry.is_some());
2121
2122 let res = default_working_directory(workspace, cx);
2123 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
2124 let res = first_project_directory(workspace, cx);
2125 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
2126 });
2127 }
2128
2129 // Active entry, with a worktree, worktree is a folder -> worktree_folder
2130 #[gpui::test]
2131 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
2132 let (project, workspace) = init_test(cx).await;
2133
2134 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
2135 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
2136 insert_active_entry_for(wt2, entry2, project.clone(), cx);
2137
2138 cx.update(|cx| {
2139 let workspace = workspace.read(cx);
2140 let active_entry = project.read(cx).active_entry();
2141
2142 assert!(active_entry.is_some());
2143
2144 let res = default_working_directory(workspace, cx);
2145 assert_eq!(res, Some(Path::new("/root2/").to_path_buf()));
2146 let res = first_project_directory(workspace, cx);
2147 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
2148 });
2149 }
2150
2151 // active_entry_directory: No active entry -> returns None (used by CurrentFileDirectory)
2152 #[gpui::test]
2153 async fn active_entry_directory_no_active_entry(cx: &mut TestAppContext) {
2154 let (project, _workspace) = init_test(cx).await;
2155
2156 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2157
2158 cx.update(|cx| {
2159 assert!(project.read(cx).active_entry().is_none());
2160
2161 let res = project.read(cx).active_entry_directory(cx);
2162 assert_eq!(res, None);
2163 });
2164 }
2165
2166 // active_entry_directory: Active entry is file -> returns parent directory (used by CurrentFileDirectory)
2167 #[gpui::test]
2168 async fn active_entry_directory_active_file(cx: &mut TestAppContext) {
2169 let (project, _workspace) = init_test(cx).await;
2170
2171 let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2172 let entry = create_file_in_worktree(wt.clone(), "src/main.rs", cx).await;
2173 insert_active_entry_for(wt, entry, project.clone(), cx);
2174
2175 cx.update(|cx| {
2176 let res = project.read(cx).active_entry_directory(cx);
2177 assert_eq!(res, Some(Path::new("/root/src").to_path_buf()));
2178 });
2179 }
2180
2181 // active_entry_directory: Active entry is directory -> returns that directory (used by CurrentFileDirectory)
2182 #[gpui::test]
2183 async fn active_entry_directory_active_dir(cx: &mut TestAppContext) {
2184 let (project, _workspace) = init_test(cx).await;
2185
2186 let (wt, entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2187 insert_active_entry_for(wt, entry, project.clone(), cx);
2188
2189 cx.update(|cx| {
2190 let res = project.read(cx).active_entry_directory(cx);
2191 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2192 });
2193 }
2194
2195 /// Creates a worktree with 1 file: /root.txt
2196 pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
2197 let (project, workspace, _) = init_test_with_window(cx).await;
2198 (project, workspace)
2199 }
2200
2201 /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle.
2202 async fn init_test_with_window(
2203 cx: &mut TestAppContext,
2204 ) -> (
2205 Entity<Project>,
2206 Entity<Workspace>,
2207 gpui::WindowHandle<MultiWorkspace>,
2208 ) {
2209 let params = cx.update(AppState::test);
2210 cx.update(|cx| {
2211 theme::init(theme::LoadThemes::JustBase, cx);
2212 });
2213
2214 let project = Project::test(params.fs.clone(), [], cx).await;
2215 let window_handle =
2216 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2217 let workspace = window_handle
2218 .read_with(cx, |mw, _| mw.workspace().clone())
2219 .unwrap();
2220
2221 (project, workspace, window_handle)
2222 }
2223
2224 /// Creates a file in the given worktree and returns its entry.
2225 async fn create_file_in_worktree(
2226 worktree: Entity<Worktree>,
2227 relative_path: impl AsRef<Path>,
2228 cx: &mut TestAppContext,
2229 ) -> Entry {
2230 cx.update(|cx| {
2231 worktree.update(cx, |worktree, cx| {
2232 worktree.create_entry(
2233 RelPath::new(relative_path.as_ref(), PathStyle::local())
2234 .unwrap()
2235 .as_ref()
2236 .into(),
2237 false,
2238 None,
2239 cx,
2240 )
2241 })
2242 })
2243 .await
2244 .unwrap()
2245 .into_included()
2246 .unwrap()
2247 }
2248
2249 /// Creates a worktree with 1 folder: /root{suffix}/
2250 async fn create_folder_wt(
2251 project: Entity<Project>,
2252 path: impl AsRef<Path>,
2253 cx: &mut TestAppContext,
2254 ) -> (Entity<Worktree>, Entry) {
2255 create_wt(project, true, path, cx).await
2256 }
2257
2258 /// Creates a worktree with 1 file: /root{suffix}.txt
2259 async fn create_file_wt(
2260 project: Entity<Project>,
2261 path: impl AsRef<Path>,
2262 cx: &mut TestAppContext,
2263 ) -> (Entity<Worktree>, Entry) {
2264 create_wt(project, false, path, cx).await
2265 }
2266
2267 async fn create_wt(
2268 project: Entity<Project>,
2269 is_dir: bool,
2270 path: impl AsRef<Path>,
2271 cx: &mut TestAppContext,
2272 ) -> (Entity<Worktree>, Entry) {
2273 let (wt, _) = project
2274 .update(cx, |project, cx| {
2275 project.find_or_create_worktree(path, true, cx)
2276 })
2277 .await
2278 .unwrap();
2279
2280 let entry = cx
2281 .update(|cx| {
2282 wt.update(cx, |wt, cx| {
2283 wt.create_entry(RelPath::empty().into(), is_dir, None, cx)
2284 })
2285 })
2286 .await
2287 .unwrap()
2288 .into_included()
2289 .unwrap();
2290
2291 (wt, entry)
2292 }
2293
2294 pub fn insert_active_entry_for(
2295 wt: Entity<Worktree>,
2296 entry: Entry,
2297 project: Entity<Project>,
2298 cx: &mut TestAppContext,
2299 ) {
2300 cx.update(|cx| {
2301 let p = ProjectPath {
2302 worktree_id: wt.read(cx).id(),
2303 path: entry.path,
2304 };
2305 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
2306 });
2307 }
2308
2309 // Terminal drag/drop test
2310
2311 #[gpui::test]
2312 async fn test_handle_drop_writes_paths_for_all_drop_types(cx: &mut TestAppContext) {
2313 let (project, _workspace, window_handle) = init_test_with_window(cx).await;
2314
2315 let (worktree, _) = create_folder_wt(project.clone(), "/root/", cx).await;
2316 let first_entry = create_file_in_worktree(worktree.clone(), "first.txt", cx).await;
2317 let second_entry = create_file_in_worktree(worktree.clone(), "second.txt", cx).await;
2318
2319 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2320 let first_path = project
2321 .read_with(cx, |project, cx| {
2322 project.absolute_path(
2323 &ProjectPath {
2324 worktree_id,
2325 path: first_entry.path.clone(),
2326 },
2327 cx,
2328 )
2329 })
2330 .unwrap();
2331 let second_path = project
2332 .read_with(cx, |project, cx| {
2333 project.absolute_path(
2334 &ProjectPath {
2335 worktree_id,
2336 path: second_entry.path.clone(),
2337 },
2338 cx,
2339 )
2340 })
2341 .unwrap();
2342
2343 let (active_pane, terminal, terminal_view, tab_item) = window_handle
2344 .update(cx, |multi_workspace, window, cx| {
2345 let workspace = multi_workspace.workspace().clone();
2346 let active_pane = workspace.read(cx).active_pane().clone();
2347
2348 let terminal = cx.new(|cx| {
2349 terminal::TerminalBuilder::new_display_only(
2350 CursorShape::default(),
2351 terminal::terminal_settings::AlternateScroll::On,
2352 None,
2353 0,
2354 cx.background_executor(),
2355 PathStyle::local(),
2356 )
2357 .unwrap()
2358 .subscribe(cx)
2359 });
2360 let terminal_view = cx.new(|cx| {
2361 TerminalView::new(
2362 terminal.clone(),
2363 workspace.downgrade(),
2364 None,
2365 project.downgrade(),
2366 window,
2367 cx,
2368 )
2369 });
2370
2371 active_pane.update(cx, |pane, cx| {
2372 pane.add_item(
2373 Box::new(terminal_view.clone()),
2374 true,
2375 false,
2376 None,
2377 window,
2378 cx,
2379 );
2380 });
2381
2382 let tab_project_item = cx.new(|_| TestProjectItem {
2383 entry_id: Some(second_entry.id),
2384 project_path: Some(ProjectPath {
2385 worktree_id,
2386 path: second_entry.path.clone(),
2387 }),
2388 is_dirty: false,
2389 });
2390 let tab_item =
2391 cx.new(|cx| TestItem::new(cx).with_project_items(&[tab_project_item]));
2392 active_pane.update(cx, |pane, cx| {
2393 pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx);
2394 });
2395
2396 (active_pane, terminal, terminal_view, tab_item)
2397 })
2398 .unwrap();
2399
2400 cx.run_until_parked();
2401
2402 window_handle
2403 .update(cx, |multi_workspace, window, cx| {
2404 let workspace = multi_workspace.workspace().clone();
2405 let terminal_view_index =
2406 active_pane.read(cx).index_for_item(&terminal_view).unwrap();
2407 let dragged_tab_index = active_pane.read(cx).index_for_item(&tab_item).unwrap();
2408
2409 assert!(
2410 workspace.read(cx).pane_for(&terminal_view).is_some(),
2411 "terminal view not registered with workspace after run_until_parked"
2412 );
2413
2414 // Dragging an external file should write its path to the terminal
2415 let external_paths = ExternalPaths(vec![first_path.clone()].into());
2416 assert_drop_writes_to_terminal(
2417 &active_pane,
2418 terminal_view_index,
2419 &terminal,
2420 &external_paths,
2421 &expected_drop_text(std::slice::from_ref(&first_path)),
2422 window,
2423 cx,
2424 );
2425
2426 // Dragging a tab should write the path of the tab's item to the terminal
2427 let dragged_tab = DraggedTab {
2428 pane: active_pane.clone(),
2429 item: Box::new(tab_item.clone()),
2430 ix: dragged_tab_index,
2431 detail: 0,
2432 is_active: false,
2433 };
2434 assert_drop_writes_to_terminal(
2435 &active_pane,
2436 terminal_view_index,
2437 &terminal,
2438 &dragged_tab,
2439 &expected_drop_text(std::slice::from_ref(&second_path)),
2440 window,
2441 cx,
2442 );
2443
2444 // Dragging multiple selections should write both paths to the terminal
2445 let dragged_selection = DraggedSelection {
2446 active_selection: SelectedEntry {
2447 worktree_id,
2448 entry_id: first_entry.id,
2449 },
2450 marked_selections: Arc::from([
2451 SelectedEntry {
2452 worktree_id,
2453 entry_id: first_entry.id,
2454 },
2455 SelectedEntry {
2456 worktree_id,
2457 entry_id: second_entry.id,
2458 },
2459 ]),
2460 };
2461 assert_drop_writes_to_terminal(
2462 &active_pane,
2463 terminal_view_index,
2464 &terminal,
2465 &dragged_selection,
2466 &expected_drop_text(&[first_path.clone(), second_path.clone()]),
2467 window,
2468 cx,
2469 );
2470
2471 // Dropping a project entry should write the entry's path to the terminal
2472 let dropped_entry_id = first_entry.id;
2473 assert_drop_writes_to_terminal(
2474 &active_pane,
2475 terminal_view_index,
2476 &terminal,
2477 &dropped_entry_id,
2478 &expected_drop_text(&[first_path]),
2479 window,
2480 cx,
2481 );
2482 })
2483 .unwrap();
2484 }
2485
2486 // Terminal rename tests
2487
2488 #[gpui::test]
2489 async fn test_custom_title_initially_none(cx: &mut TestAppContext) {
2490 cx.executor().allow_parking();
2491
2492 let (project, workspace) = init_test(cx).await;
2493
2494 let terminal = project
2495 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2496 .await
2497 .unwrap();
2498
2499 let terminal_view = cx
2500 .add_window(|window, cx| {
2501 TerminalView::new(
2502 terminal,
2503 workspace.downgrade(),
2504 None,
2505 project.downgrade(),
2506 window,
2507 cx,
2508 )
2509 })
2510 .root(cx)
2511 .unwrap();
2512
2513 terminal_view.update(cx, |view, _cx| {
2514 assert!(view.custom_title().is_none());
2515 });
2516 }
2517
2518 #[gpui::test]
2519 async fn test_set_custom_title(cx: &mut TestAppContext) {
2520 cx.executor().allow_parking();
2521
2522 let (project, workspace) = init_test(cx).await;
2523
2524 let terminal = project
2525 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2526 .await
2527 .unwrap();
2528
2529 let terminal_view = cx
2530 .add_window(|window, cx| {
2531 TerminalView::new(
2532 terminal,
2533 workspace.downgrade(),
2534 None,
2535 project.downgrade(),
2536 window,
2537 cx,
2538 )
2539 })
2540 .root(cx)
2541 .unwrap();
2542
2543 terminal_view.update(cx, |view, cx| {
2544 view.set_custom_title(Some("frontend".to_string()), cx);
2545 assert_eq!(view.custom_title(), Some("frontend"));
2546 });
2547 }
2548
2549 #[gpui::test]
2550 async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) {
2551 cx.executor().allow_parking();
2552
2553 let (project, workspace) = init_test(cx).await;
2554
2555 let terminal = project
2556 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2557 .await
2558 .unwrap();
2559
2560 let terminal_view = cx
2561 .add_window(|window, cx| {
2562 TerminalView::new(
2563 terminal,
2564 workspace.downgrade(),
2565 None,
2566 project.downgrade(),
2567 window,
2568 cx,
2569 )
2570 })
2571 .root(cx)
2572 .unwrap();
2573
2574 terminal_view.update(cx, |view, cx| {
2575 view.set_custom_title(Some("test".to_string()), cx);
2576 assert_eq!(view.custom_title(), Some("test"));
2577
2578 view.set_custom_title(Some("".to_string()), cx);
2579 assert!(view.custom_title().is_none());
2580
2581 view.set_custom_title(Some(" ".to_string()), cx);
2582 assert!(view.custom_title().is_none());
2583 });
2584 }
2585
2586 #[gpui::test]
2587 async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) {
2588 cx.executor().allow_parking();
2589
2590 let (project, workspace) = init_test(cx).await;
2591
2592 let terminal = project
2593 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2594 .await
2595 .unwrap();
2596
2597 let terminal_view = cx
2598 .add_window(|window, cx| {
2599 TerminalView::new(
2600 terminal,
2601 workspace.downgrade(),
2602 None,
2603 project.downgrade(),
2604 window,
2605 cx,
2606 )
2607 })
2608 .root(cx)
2609 .unwrap();
2610
2611 terminal_view.update(cx, |view, cx| {
2612 view.needs_serialize = false;
2613 view.set_custom_title(Some("new_label".to_string()), cx);
2614 assert!(view.needs_serialize);
2615 });
2616 }
2617
2618 #[gpui::test]
2619 async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) {
2620 cx.executor().allow_parking();
2621
2622 let (project, workspace) = init_test(cx).await;
2623
2624 let terminal = project
2625 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2626 .await
2627 .unwrap();
2628
2629 let terminal_view = cx
2630 .add_window(|window, cx| {
2631 TerminalView::new(
2632 terminal,
2633 workspace.downgrade(),
2634 None,
2635 project.downgrade(),
2636 window,
2637 cx,
2638 )
2639 })
2640 .root(cx)
2641 .unwrap();
2642
2643 terminal_view.update(cx, |view, cx| {
2644 view.set_custom_title(Some("my-server".to_string()), cx);
2645 let text = view.tab_content_text(0, cx);
2646 assert_eq!(text.as_ref(), "my-server");
2647 });
2648
2649 terminal_view.update(cx, |view, cx| {
2650 view.set_custom_title(None, cx);
2651 let text = view.tab_content_text(0, cx);
2652 assert_ne!(text.as_ref(), "my-server");
2653 });
2654 }
2655
2656 #[gpui::test]
2657 async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty(
2658 cx: &mut TestAppContext,
2659 ) {
2660 cx.executor().allow_parking();
2661
2662 let (project, workspace) = init_test(cx).await;
2663
2664 let terminal = project
2665 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2666 .await
2667 .unwrap();
2668
2669 let terminal_view = cx
2670 .add_window(|window, cx| {
2671 TerminalView::new(
2672 terminal,
2673 workspace.downgrade(),
2674 None,
2675 project.downgrade(),
2676 window,
2677 cx,
2678 )
2679 })
2680 .root(cx)
2681 .unwrap();
2682
2683 terminal_view.update(cx, |view, cx| {
2684 view.custom_title = Some("".to_string());
2685 let text = view.tab_content_text(0, cx);
2686 assert!(
2687 !text.is_empty(),
2688 "Tab should show terminal title, not empty string; got: '{}'",
2689 text
2690 );
2691 });
2692
2693 terminal_view.update(cx, |view, cx| {
2694 view.custom_title = Some(" ".to_string());
2695 let text = view.tab_content_text(0, cx);
2696 assert!(
2697 !text.is_empty() && text.as_ref() != " ",
2698 "Tab should show terminal title, not whitespace; got: '{}'",
2699 text
2700 );
2701 });
2702 }
2703}