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