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