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