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