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