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