1mod persistence;
2pub mod terminal_element;
3pub mod terminal_panel;
4pub mod terminal_tab_tooltip;
5
6use collections::HashSet;
7use editor::{actions::SelectAll, scroll::Autoscroll, Editor};
8use futures::{stream::FuturesUnordered, StreamExt};
9use gpui::{
10 anchored, deferred, div, impl_actions, AnyElement, AppContext, DismissEvent, EventEmitter,
11 FocusHandle, FocusableView, KeyContext, KeyDownEvent, Keystroke, Model, MouseButton,
12 MouseDownEvent, Pixels, Render, ScrollWheelEvent, Styled, Subscription, Task, View,
13 VisualContext, WeakModel, WeakView,
14};
15use language::Bias;
16use persistence::TERMINAL_DB;
17use project::{search::SearchQuery, terminals::TerminalKind, Fs, Metadata, Project};
18use schemars::JsonSchema;
19use terminal::{
20 alacritty_terminal::{
21 index::Point,
22 term::{search::RegexSearch, TermMode},
23 },
24 terminal_settings::{CursorShape, TerminalBlink, TerminalSettings, WorkingDirectory},
25 Clear, Copy, Event, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown,
26 ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskStatus, Terminal,
27 TerminalSize, ToggleViMode,
28};
29use terminal_element::{is_blank, TerminalElement};
30use terminal_panel::TerminalPanel;
31use terminal_tab_tooltip::TerminalTooltip;
32use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label, Tooltip};
33use util::{
34 paths::{PathWithPosition, SanitizedPath},
35 ResultExt,
36};
37use workspace::{
38 item::{
39 BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
40 },
41 register_serializable_item,
42 searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
43 CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, ToolbarItemLocation, Workspace,
44 WorkspaceId,
45};
46
47use anyhow::Context;
48use serde::Deserialize;
49use settings::{Settings, SettingsStore};
50use smol::Timer;
51use zed_actions::InlineAssist;
52
53use std::{
54 cmp,
55 ops::RangeInclusive,
56 path::{Path, PathBuf},
57 rc::Rc,
58 sync::Arc,
59 time::Duration,
60};
61
62const REGEX_SPECIAL_CHARS: &[char] = &[
63 '\\', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '^', '$',
64];
65
66const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
67
68const GIT_DIFF_PATH_PREFIXES: &[char] = &['a', 'b'];
69
70/// Event to transmit the scroll from the element to the view
71#[derive(Clone, Debug, PartialEq)]
72pub struct ScrollTerminal(pub i32);
73
74#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)]
75pub struct SendText(String);
76
77#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)]
78pub struct SendKeystroke(String);
79
80impl_actions!(terminal, [SendText, SendKeystroke]);
81
82pub fn init(cx: &mut AppContext) {
83 terminal_panel::init(cx);
84 terminal::init(cx);
85
86 register_serializable_item::<TerminalView>(cx);
87
88 cx.observe_new_views(|workspace: &mut Workspace, _cx| {
89 workspace.register_action(TerminalView::deploy);
90 })
91 .detach();
92}
93
94pub struct BlockProperties {
95 pub height: u8,
96 pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
97}
98
99pub struct BlockContext<'a, 'b> {
100 pub context: &'b mut WindowContext<'a>,
101 pub dimensions: TerminalSize,
102}
103
104///A terminal view, maintains the PTY's file handles and communicates with the terminal
105pub struct TerminalView {
106 terminal: Model<Terminal>,
107 workspace: WeakView<Workspace>,
108 project: WeakModel<Project>,
109 focus_handle: FocusHandle,
110 //Currently using iTerm bell, show bell emoji in tab until input is received
111 has_bell: bool,
112 context_menu: Option<(View<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
113 cursor_shape: CursorShape,
114 blink_state: bool,
115 blinking_terminal_enabled: bool,
116 blinking_paused: bool,
117 blink_epoch: usize,
118 can_navigate_to_selected_word: bool,
119 workspace_id: Option<WorkspaceId>,
120 show_breadcrumbs: bool,
121 block_below_cursor: Option<Rc<BlockProperties>>,
122 scroll_top: Pixels,
123 _subscriptions: Vec<Subscription>,
124 _terminal_subscriptions: Vec<Subscription>,
125}
126
127impl EventEmitter<Event> for TerminalView {}
128impl EventEmitter<ItemEvent> for TerminalView {}
129impl EventEmitter<SearchEvent> for TerminalView {}
130
131impl FocusableView for TerminalView {
132 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
133 self.focus_handle.clone()
134 }
135}
136
137impl TerminalView {
138 ///Create a new Terminal in the current working directory or the user's home directory
139 pub fn deploy(
140 workspace: &mut Workspace,
141 _: &NewCenterTerminal,
142 cx: &mut ViewContext<Workspace>,
143 ) {
144 let working_directory = default_working_directory(workspace, cx);
145 TerminalPanel::add_center_terminal(workspace, TerminalKind::Shell(working_directory), cx)
146 .detach_and_log_err(cx);
147 }
148
149 pub fn new(
150 terminal: Model<Terminal>,
151 workspace: WeakView<Workspace>,
152 workspace_id: Option<WorkspaceId>,
153 project: WeakModel<Project>,
154 cx: &mut ViewContext<Self>,
155 ) -> Self {
156 let workspace_handle = workspace.clone();
157 let terminal_subscriptions = subscribe_for_terminal_events(&terminal, workspace, cx);
158
159 let focus_handle = cx.focus_handle();
160 let focus_in = cx.on_focus_in(&focus_handle, |terminal_view, cx| {
161 terminal_view.focus_in(cx);
162 });
163 let focus_out = cx.on_focus_out(&focus_handle, |terminal_view, _event, cx| {
164 terminal_view.focus_out(cx);
165 });
166 let cursor_shape = TerminalSettings::get_global(cx)
167 .cursor_shape
168 .unwrap_or_default();
169
170 Self {
171 terminal,
172 workspace: workspace_handle,
173 project,
174 has_bell: false,
175 focus_handle,
176 context_menu: None,
177 cursor_shape,
178 blink_state: true,
179 blinking_terminal_enabled: false,
180 blinking_paused: false,
181 blink_epoch: 0,
182 can_navigate_to_selected_word: false,
183 workspace_id,
184 show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
185 block_below_cursor: None,
186 scroll_top: Pixels::ZERO,
187 _subscriptions: vec![
188 focus_in,
189 focus_out,
190 cx.observe_global::<SettingsStore>(Self::settings_changed),
191 ],
192 _terminal_subscriptions: terminal_subscriptions,
193 }
194 }
195
196 pub fn model(&self) -> &Model<Terminal> {
197 &self.terminal
198 }
199
200 pub fn has_bell(&self) -> bool {
201 self.has_bell
202 }
203
204 pub fn clear_bell(&mut self, cx: &mut ViewContext<TerminalView>) {
205 self.has_bell = false;
206 cx.emit(Event::Wakeup);
207 }
208
209 pub fn deploy_context_menu(
210 &mut self,
211 position: gpui::Point<Pixels>,
212 cx: &mut ViewContext<Self>,
213 ) {
214 let assistant_enabled = self
215 .workspace
216 .upgrade()
217 .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
218 .map_or(false, |terminal_panel| {
219 terminal_panel.read(cx).assistant_enabled()
220 });
221 let context_menu = ContextMenu::build(cx, |menu, _| {
222 menu.context(self.focus_handle.clone())
223 .action("New Terminal", Box::new(NewTerminal))
224 .separator()
225 .action("Copy", Box::new(Copy))
226 .action("Paste", Box::new(Paste))
227 .action("Select All", Box::new(SelectAll))
228 .action("Clear", Box::new(Clear))
229 .when(assistant_enabled, |menu| {
230 menu.separator()
231 .action("Inline Assist", Box::new(InlineAssist::default()))
232 })
233 .separator()
234 .action("Close", Box::new(CloseActiveItem { save_intent: None }))
235 });
236
237 cx.focus_view(&context_menu);
238 let subscription =
239 cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
240 if this.context_menu.as_ref().is_some_and(|context_menu| {
241 context_menu.0.focus_handle(cx).contains_focused(cx)
242 }) {
243 cx.focus_self();
244 }
245 this.context_menu.take();
246 cx.notify();
247 });
248
249 self.context_menu = Some((context_menu, position, subscription));
250 }
251
252 fn settings_changed(&mut self, cx: &mut ViewContext<Self>) {
253 let settings = TerminalSettings::get_global(cx);
254 self.show_breadcrumbs = settings.toolbar.breadcrumbs;
255
256 let new_cursor_shape = settings.cursor_shape.unwrap_or_default();
257 let old_cursor_shape = self.cursor_shape;
258 if old_cursor_shape != new_cursor_shape {
259 self.cursor_shape = new_cursor_shape;
260 self.terminal.update(cx, |term, _| {
261 term.set_cursor_shape(self.cursor_shape);
262 });
263 }
264
265 cx.notify();
266 }
267
268 fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
269 if self
270 .terminal
271 .read(cx)
272 .last_content
273 .mode
274 .contains(TermMode::ALT_SCREEN)
275 {
276 self.terminal.update(cx, |term, cx| {
277 term.try_keystroke(
278 &Keystroke::parse("ctrl-cmd-space").unwrap(),
279 TerminalSettings::get_global(cx).option_as_meta,
280 )
281 });
282 } else {
283 cx.show_character_palette();
284 }
285 }
286
287 fn select_all(&mut self, _: &SelectAll, cx: &mut ViewContext<Self>) {
288 self.terminal.update(cx, |term, _| term.select_all());
289 cx.notify();
290 }
291
292 fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
293 self.scroll_top = px(0.);
294 self.terminal.update(cx, |term, _| term.clear());
295 cx.notify();
296 }
297
298 fn max_scroll_top(&self, cx: &AppContext) -> Pixels {
299 let terminal = self.terminal.read(cx);
300
301 let Some(block) = self.block_below_cursor.as_ref() else {
302 return Pixels::ZERO;
303 };
304
305 let line_height = terminal.last_content().size.line_height;
306 let mut terminal_lines = terminal.total_lines();
307 let viewport_lines = terminal.viewport_lines();
308 if terminal.total_lines() == terminal.viewport_lines() {
309 let mut last_line = None;
310 for cell in terminal.last_content.cells.iter().rev() {
311 if !is_blank(cell) {
312 break;
313 }
314
315 let last_line = last_line.get_or_insert(cell.point.line);
316 if *last_line != cell.point.line {
317 terminal_lines -= 1;
318 }
319 *last_line = cell.point.line;
320 }
321 }
322
323 let max_scroll_top_in_lines =
324 (block.height as usize).saturating_sub(viewport_lines.saturating_sub(terminal_lines));
325
326 max_scroll_top_in_lines as f32 * line_height
327 }
328
329 fn scroll_wheel(
330 &mut self,
331 event: &ScrollWheelEvent,
332 origin: gpui::Point<Pixels>,
333 cx: &mut ViewContext<Self>,
334 ) {
335 let terminal_content = self.terminal.read(cx).last_content();
336
337 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
338 let line_height = terminal_content.size.line_height;
339 let y_delta = event.delta.pixel_delta(line_height).y;
340 if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO {
341 self.scroll_top = cmp::max(
342 Pixels::ZERO,
343 cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)),
344 );
345 cx.notify();
346 return;
347 }
348 }
349
350 self.terminal
351 .update(cx, |term, _| term.scroll_wheel(event, origin));
352 }
353
354 fn scroll_line_up(&mut self, _: &ScrollLineUp, cx: &mut ViewContext<Self>) {
355 let terminal_content = self.terminal.read(cx).last_content();
356 if self.block_below_cursor.is_some()
357 && terminal_content.display_offset == 0
358 && self.scroll_top > Pixels::ZERO
359 {
360 let line_height = terminal_content.size.line_height;
361 self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO);
362 return;
363 }
364
365 self.terminal.update(cx, |term, _| term.scroll_line_up());
366 cx.notify();
367 }
368
369 fn scroll_line_down(&mut self, _: &ScrollLineDown, cx: &mut ViewContext<Self>) {
370 let terminal_content = self.terminal.read(cx).last_content();
371 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
372 let max_scroll_top = self.max_scroll_top(cx);
373 if self.scroll_top < max_scroll_top {
374 let line_height = terminal_content.size.line_height;
375 self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top);
376 }
377 return;
378 }
379
380 self.terminal.update(cx, |term, _| term.scroll_line_down());
381 cx.notify();
382 }
383
384 fn scroll_page_up(&mut self, _: &ScrollPageUp, cx: &mut ViewContext<Self>) {
385 if self.scroll_top == Pixels::ZERO {
386 self.terminal.update(cx, |term, _| term.scroll_page_up());
387 } else {
388 let line_height = self.terminal.read(cx).last_content.size.line_height();
389 let visible_block_lines = (self.scroll_top / line_height) as usize;
390 let viewport_lines = self.terminal.read(cx).viewport_lines();
391 let visible_content_lines = viewport_lines - visible_block_lines;
392
393 if visible_block_lines >= viewport_lines {
394 self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height;
395 } else {
396 self.scroll_top = px(0.);
397 self.terminal
398 .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
399 }
400 }
401 cx.notify();
402 }
403
404 fn scroll_page_down(&mut self, _: &ScrollPageDown, cx: &mut ViewContext<Self>) {
405 self.terminal.update(cx, |term, _| term.scroll_page_down());
406 let terminal = self.terminal.read(cx);
407 if terminal.last_content().display_offset < terminal.viewport_lines() {
408 self.scroll_top = self.max_scroll_top(cx);
409 }
410 cx.notify();
411 }
412
413 fn scroll_to_top(&mut self, _: &ScrollToTop, cx: &mut ViewContext<Self>) {
414 self.terminal.update(cx, |term, _| term.scroll_to_top());
415 cx.notify();
416 }
417
418 fn scroll_to_bottom(&mut self, _: &ScrollToBottom, cx: &mut ViewContext<Self>) {
419 self.terminal.update(cx, |term, _| term.scroll_to_bottom());
420 if self.block_below_cursor.is_some() {
421 self.scroll_top = self.max_scroll_top(cx);
422 }
423 cx.notify();
424 }
425
426 fn toggle_vi_mode(&mut self, _: &ToggleViMode, cx: &mut ViewContext<Self>) {
427 self.terminal.update(cx, |term, _| term.toggle_vi_mode());
428 cx.notify();
429 }
430
431 pub fn should_show_cursor(&self, focused: bool, cx: &mut ViewContext<Self>) -> bool {
432 //Don't blink the cursor when not focused, blinking is disabled, or paused
433 if !focused
434 || self.blinking_paused
435 || self
436 .terminal
437 .read(cx)
438 .last_content
439 .mode
440 .contains(TermMode::ALT_SCREEN)
441 {
442 return true;
443 }
444
445 match TerminalSettings::get_global(cx).blinking {
446 //If the user requested to never blink, don't blink it.
447 TerminalBlink::Off => true,
448 //If the terminal is controlling it, check terminal mode
449 TerminalBlink::TerminalControlled => {
450 !self.blinking_terminal_enabled || self.blink_state
451 }
452 TerminalBlink::On => self.blink_state,
453 }
454 }
455
456 fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
457 if epoch == self.blink_epoch && !self.blinking_paused {
458 self.blink_state = !self.blink_state;
459 cx.notify();
460
461 let epoch = self.next_blink_epoch();
462 cx.spawn(|this, mut cx| async move {
463 Timer::after(CURSOR_BLINK_INTERVAL).await;
464 this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx))
465 .ok();
466 })
467 .detach();
468 }
469 }
470
471 pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
472 self.blink_state = true;
473 cx.notify();
474
475 let epoch = self.next_blink_epoch();
476 cx.spawn(|this, mut cx| async move {
477 Timer::after(CURSOR_BLINK_INTERVAL).await;
478 this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
479 .ok();
480 })
481 .detach();
482 }
483
484 pub fn terminal(&self) -> &Model<Terminal> {
485 &self.terminal
486 }
487
488 pub fn set_block_below_cursor(&mut self, block: BlockProperties, cx: &mut ViewContext<Self>) {
489 self.block_below_cursor = Some(Rc::new(block));
490 self.scroll_to_bottom(&ScrollToBottom, cx);
491 cx.notify();
492 }
493
494 pub fn clear_block_below_cursor(&mut self, cx: &mut ViewContext<Self>) {
495 self.block_below_cursor = None;
496 self.scroll_top = Pixels::ZERO;
497 cx.notify();
498 }
499
500 fn next_blink_epoch(&mut self) -> usize {
501 self.blink_epoch += 1;
502 self.blink_epoch
503 }
504
505 fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
506 if epoch == self.blink_epoch {
507 self.blinking_paused = false;
508 self.blink_cursors(epoch, cx);
509 }
510 }
511
512 ///Attempt to paste the clipboard into the terminal
513 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
514 self.terminal.update(cx, |term, _| term.copy());
515 cx.notify();
516 }
517
518 ///Attempt to paste the clipboard into the terminal
519 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
520 if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
521 self.terminal
522 .update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
523 }
524 }
525
526 fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
527 self.clear_bell(cx);
528 self.terminal.update(cx, |term, _| {
529 term.input(text.0.to_string());
530 });
531 }
532
533 fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
534 if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
535 self.clear_bell(cx);
536 self.terminal.update(cx, |term, cx| {
537 term.try_keystroke(&keystroke, TerminalSettings::get_global(cx).option_as_meta);
538 });
539 }
540 }
541
542 fn dispatch_context(&self, cx: &AppContext) -> KeyContext {
543 let mut dispatch_context = KeyContext::new_with_defaults();
544 dispatch_context.add("Terminal");
545
546 let mode = self.terminal.read(cx).last_content.mode;
547 dispatch_context.set(
548 "screen",
549 if mode.contains(TermMode::ALT_SCREEN) {
550 "alt"
551 } else {
552 "normal"
553 },
554 );
555
556 if mode.contains(TermMode::APP_CURSOR) {
557 dispatch_context.add("DECCKM");
558 }
559 if mode.contains(TermMode::APP_KEYPAD) {
560 dispatch_context.add("DECPAM");
561 } else {
562 dispatch_context.add("DECPNM");
563 }
564 if mode.contains(TermMode::SHOW_CURSOR) {
565 dispatch_context.add("DECTCEM");
566 }
567 if mode.contains(TermMode::LINE_WRAP) {
568 dispatch_context.add("DECAWM");
569 }
570 if mode.contains(TermMode::ORIGIN) {
571 dispatch_context.add("DECOM");
572 }
573 if mode.contains(TermMode::INSERT) {
574 dispatch_context.add("IRM");
575 }
576 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
577 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
578 dispatch_context.add("LNM");
579 }
580 if mode.contains(TermMode::FOCUS_IN_OUT) {
581 dispatch_context.add("report_focus");
582 }
583 if mode.contains(TermMode::ALTERNATE_SCROLL) {
584 dispatch_context.add("alternate_scroll");
585 }
586 if mode.contains(TermMode::BRACKETED_PASTE) {
587 dispatch_context.add("bracketed_paste");
588 }
589 if mode.intersects(TermMode::MOUSE_MODE) {
590 dispatch_context.add("any_mouse_reporting");
591 }
592 {
593 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
594 "click"
595 } else if mode.contains(TermMode::MOUSE_DRAG) {
596 "drag"
597 } else if mode.contains(TermMode::MOUSE_MOTION) {
598 "motion"
599 } else {
600 "off"
601 };
602 dispatch_context.set("mouse_reporting", mouse_reporting);
603 }
604 {
605 let format = if mode.contains(TermMode::SGR_MOUSE) {
606 "sgr"
607 } else if mode.contains(TermMode::UTF8_MOUSE) {
608 "utf8"
609 } else {
610 "normal"
611 };
612 dispatch_context.set("mouse_format", format);
613 };
614 dispatch_context
615 }
616
617 fn set_terminal(&mut self, terminal: Model<Terminal>, cx: &mut ViewContext<TerminalView>) {
618 self._terminal_subscriptions =
619 subscribe_for_terminal_events(&terminal, self.workspace.clone(), cx);
620 self.terminal = terminal;
621 }
622}
623
624fn subscribe_for_terminal_events(
625 terminal: &Model<Terminal>,
626 workspace: WeakView<Workspace>,
627 cx: &mut ViewContext<TerminalView>,
628) -> Vec<Subscription> {
629 let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify());
630 let terminal_events_subscription =
631 cx.subscribe(terminal, move |this, _, event, cx| match event {
632 Event::Wakeup => {
633 cx.notify();
634 cx.emit(Event::Wakeup);
635 cx.emit(ItemEvent::UpdateTab);
636 cx.emit(SearchEvent::MatchesInvalidated);
637 }
638
639 Event::Bell => {
640 this.has_bell = true;
641 cx.emit(Event::Wakeup);
642 }
643
644 Event::BlinkChanged(blinking) => {
645 if matches!(
646 TerminalSettings::get_global(cx).blinking,
647 TerminalBlink::TerminalControlled
648 ) {
649 this.blinking_terminal_enabled = *blinking;
650 }
651 }
652
653 Event::TitleChanged => {
654 cx.emit(ItemEvent::UpdateTab);
655 }
656
657 Event::NewNavigationTarget(maybe_navigation_target) => {
658 this.can_navigate_to_selected_word = match maybe_navigation_target {
659 Some(MaybeNavigationTarget::Url(_)) => true,
660 Some(MaybeNavigationTarget::PathLike(path_like_target)) => {
661 if let Ok(fs) = workspace.update(cx, |workspace, cx| {
662 workspace.project().read(cx).fs().clone()
663 }) {
664 let valid_files_to_open_task = possible_open_targets(
665 fs,
666 &workspace,
667 &path_like_target.terminal_dir,
668 &path_like_target.maybe_path,
669 cx,
670 );
671 !smol::block_on(valid_files_to_open_task).is_empty()
672 } else {
673 false
674 }
675 }
676 None => false,
677 }
678 }
679
680 Event::Open(maybe_navigation_target) => match maybe_navigation_target {
681 MaybeNavigationTarget::Url(url) => cx.open_url(url),
682
683 MaybeNavigationTarget::PathLike(path_like_target) => {
684 if !this.can_navigate_to_selected_word {
685 return;
686 }
687 let task_workspace = workspace.clone();
688 let Some(fs) = workspace
689 .update(cx, |workspace, cx| {
690 workspace.project().read(cx).fs().clone()
691 })
692 .ok()
693 else {
694 return;
695 };
696
697 let path_like_target = path_like_target.clone();
698 cx.spawn(|terminal_view, mut cx| async move {
699 let valid_files_to_open = terminal_view
700 .update(&mut cx, |_, cx| {
701 possible_open_targets(
702 fs,
703 &task_workspace,
704 &path_like_target.terminal_dir,
705 &path_like_target.maybe_path,
706 cx,
707 )
708 })?
709 .await;
710 let paths_to_open = valid_files_to_open
711 .iter()
712 .map(|(p, _)| p.path.clone())
713 .collect();
714 let opened_items = task_workspace
715 .update(&mut cx, |workspace, cx| {
716 workspace.open_paths(
717 paths_to_open,
718 OpenVisible::OnlyDirectories,
719 None,
720 cx,
721 )
722 })
723 .context("workspace update")?
724 .await;
725
726 let mut has_dirs = false;
727 for ((path, metadata), opened_item) in valid_files_to_open
728 .into_iter()
729 .zip(opened_items.into_iter())
730 {
731 if metadata.is_dir {
732 has_dirs = true;
733 } else if let Some(Ok(opened_item)) = opened_item {
734 if let Some(row) = path.row {
735 let col = path.column.unwrap_or(0);
736 if let Some(active_editor) = opened_item.downcast::<Editor>() {
737 active_editor
738 .downgrade()
739 .update(&mut cx, |editor, cx| {
740 let snapshot = editor.snapshot(cx).display_snapshot;
741 let point = snapshot.buffer_snapshot.clip_point(
742 language::Point::new(
743 row.saturating_sub(1),
744 col.saturating_sub(1),
745 ),
746 Bias::Left,
747 );
748 editor.change_selections(
749 Some(Autoscroll::center()),
750 cx,
751 |s| s.select_ranges([point..point]),
752 );
753 })
754 .log_err();
755 }
756 }
757 }
758 }
759
760 if has_dirs {
761 task_workspace.update(&mut cx, |workspace, cx| {
762 workspace.project().update(cx, |_, cx| {
763 cx.emit(project::Event::ActivateProjectPanel);
764 })
765 })?;
766 }
767
768 anyhow::Ok(())
769 })
770 .detach_and_log_err(cx)
771 }
772 },
773 Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
774 Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
775 Event::SelectionsChanged => {
776 cx.invalidate_character_coordinates();
777 cx.emit(SearchEvent::ActiveMatchChanged)
778 }
779 });
780 vec![terminal_subscription, terminal_events_subscription]
781}
782
783fn possible_open_paths_metadata(
784 fs: Arc<dyn Fs>,
785 row: Option<u32>,
786 column: Option<u32>,
787 potential_paths: HashSet<PathBuf>,
788 cx: &mut ViewContext<TerminalView>,
789) -> Task<Vec<(PathWithPosition, Metadata)>> {
790 cx.background_executor().spawn(async move {
791 let mut canonical_paths = HashSet::default();
792 for path in potential_paths {
793 if let Ok(canonical) = fs.canonicalize(&path).await {
794 let sanitized = SanitizedPath::from(canonical);
795 canonical_paths.insert(sanitized.as_path().to_path_buf());
796 } else {
797 canonical_paths.insert(path);
798 }
799 }
800
801 let mut paths_with_metadata = Vec::with_capacity(canonical_paths.len());
802
803 let mut fetch_metadata_tasks = canonical_paths
804 .into_iter()
805 .map(|potential_path| async {
806 let metadata = fs.metadata(&potential_path).await.ok().flatten();
807 (
808 PathWithPosition {
809 path: potential_path,
810 row,
811 column,
812 },
813 metadata,
814 )
815 })
816 .collect::<FuturesUnordered<_>>();
817
818 while let Some((path, metadata)) = fetch_metadata_tasks.next().await {
819 if let Some(metadata) = metadata {
820 paths_with_metadata.push((path, metadata));
821 }
822 }
823
824 paths_with_metadata
825 })
826}
827
828fn possible_open_targets(
829 fs: Arc<dyn Fs>,
830 workspace: &WeakView<Workspace>,
831 cwd: &Option<PathBuf>,
832 maybe_path: &String,
833 cx: &mut ViewContext<TerminalView>,
834) -> Task<Vec<(PathWithPosition, Metadata)>> {
835 let path_position = PathWithPosition::parse_str(maybe_path.as_str());
836 let row = path_position.row;
837 let column = path_position.column;
838 let maybe_path = path_position.path;
839
840 let potential_paths = if maybe_path.is_absolute() {
841 HashSet::from_iter([maybe_path])
842 } else if maybe_path.starts_with("~") {
843 maybe_path
844 .strip_prefix("~")
845 .ok()
846 .and_then(|maybe_path| Some(dirs::home_dir()?.join(maybe_path)))
847 .map_or_else(HashSet::default, |p| HashSet::from_iter([p]))
848 } else {
849 let mut potential_cwd_and_workspace_paths = HashSet::default();
850 if let Some(cwd) = cwd {
851 let abs_path = Path::join(cwd, &maybe_path);
852 potential_cwd_and_workspace_paths.insert(abs_path);
853 }
854 if let Some(workspace) = workspace.upgrade() {
855 workspace.update(cx, |workspace, cx| {
856 for potential_worktree_path in workspace
857 .worktrees(cx)
858 .map(|worktree| worktree.read(cx).abs_path().join(&maybe_path))
859 {
860 potential_cwd_and_workspace_paths.insert(potential_worktree_path);
861 }
862
863 for prefix in GIT_DIFF_PATH_PREFIXES {
864 let prefix_str = &prefix.to_string();
865 if maybe_path.starts_with(prefix_str) {
866 let stripped = maybe_path.strip_prefix(prefix_str).unwrap_or(&maybe_path);
867 for potential_worktree_path in workspace
868 .worktrees(cx)
869 .map(|worktree| worktree.read(cx).abs_path().join(&stripped))
870 {
871 potential_cwd_and_workspace_paths.insert(potential_worktree_path);
872 }
873 }
874 }
875 });
876 }
877 potential_cwd_and_workspace_paths
878 };
879
880 possible_open_paths_metadata(fs, row, column, potential_paths, cx)
881}
882
883fn regex_to_literal(regex: &str) -> String {
884 regex
885 .chars()
886 .flat_map(|c| {
887 if REGEX_SPECIAL_CHARS.contains(&c) {
888 vec!['\\', c]
889 } else {
890 vec![c]
891 }
892 })
893 .collect()
894}
895
896pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {
897 let query = query.as_str();
898 if query == "." {
899 return None;
900 }
901 let searcher = RegexSearch::new(query);
902 searcher.ok()
903}
904
905impl TerminalView {
906 fn key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext<Self>) {
907 self.clear_bell(cx);
908 self.pause_cursor_blinking(cx);
909
910 self.terminal.update(cx, |term, cx| {
911 let handled = term.try_keystroke(
912 &event.keystroke,
913 TerminalSettings::get_global(cx).option_as_meta,
914 );
915 if handled {
916 cx.stop_propagation();
917 }
918 });
919 }
920
921 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
922 self.terminal.update(cx, |terminal, _| {
923 terminal.set_cursor_shape(self.cursor_shape);
924 terminal.focus_in();
925 });
926 self.blink_cursors(self.blink_epoch, cx);
927 cx.invalidate_character_coordinates();
928 cx.notify();
929 }
930
931 fn focus_out(&mut self, cx: &mut ViewContext<Self>) {
932 self.terminal.update(cx, |terminal, _| {
933 terminal.focus_out();
934 terminal.set_cursor_shape(CursorShape::Hollow);
935 });
936 cx.notify();
937 }
938}
939
940impl Render for TerminalView {
941 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
942 let terminal_handle = self.terminal.clone();
943 let terminal_view_handle = cx.view().clone();
944
945 let focused = self.focus_handle.is_focused(cx);
946
947 div()
948 .size_full()
949 .relative()
950 .track_focus(&self.focus_handle(cx))
951 .key_context(self.dispatch_context(cx))
952 .on_action(cx.listener(TerminalView::send_text))
953 .on_action(cx.listener(TerminalView::send_keystroke))
954 .on_action(cx.listener(TerminalView::copy))
955 .on_action(cx.listener(TerminalView::paste))
956 .on_action(cx.listener(TerminalView::clear))
957 .on_action(cx.listener(TerminalView::scroll_line_up))
958 .on_action(cx.listener(TerminalView::scroll_line_down))
959 .on_action(cx.listener(TerminalView::scroll_page_up))
960 .on_action(cx.listener(TerminalView::scroll_page_down))
961 .on_action(cx.listener(TerminalView::scroll_to_top))
962 .on_action(cx.listener(TerminalView::scroll_to_bottom))
963 .on_action(cx.listener(TerminalView::toggle_vi_mode))
964 .on_action(cx.listener(TerminalView::show_character_palette))
965 .on_action(cx.listener(TerminalView::select_all))
966 .on_key_down(cx.listener(Self::key_down))
967 .on_mouse_down(
968 MouseButton::Right,
969 cx.listener(|this, event: &MouseDownEvent, cx| {
970 if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
971 this.deploy_context_menu(event.position, cx);
972 cx.notify();
973 }
974 }),
975 )
976 .child(
977 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
978 div().size_full().child(TerminalElement::new(
979 terminal_handle,
980 terminal_view_handle,
981 self.workspace.clone(),
982 self.focus_handle.clone(),
983 focused,
984 self.should_show_cursor(focused, cx),
985 self.can_navigate_to_selected_word,
986 self.block_below_cursor.clone(),
987 )),
988 )
989 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
990 deferred(
991 anchored()
992 .position(*position)
993 .anchor(gpui::Corner::TopLeft)
994 .child(menu.clone()),
995 )
996 .with_priority(1)
997 }))
998 }
999}
1000
1001impl Item for TerminalView {
1002 type Event = ItemEvent;
1003
1004 fn tab_tooltip_content(&self, cx: &AppContext) -> Option<TabTooltipContent> {
1005 let terminal = self.terminal().read(cx);
1006 let title = terminal.title(false);
1007 let pid = terminal.pty_info.pid_getter().fallback_pid();
1008
1009 Some(TabTooltipContent::Custom(Box::new(
1010 move |cx: &mut WindowContext| {
1011 cx.new_view(|_| TerminalTooltip::new(title.clone(), pid))
1012 .into()
1013 },
1014 )))
1015 }
1016
1017 fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
1018 let terminal = self.terminal().read(cx);
1019 let title = terminal.title(true);
1020 let rerun_button = |task_id: task::TaskId| {
1021 IconButton::new("rerun-icon", IconName::Rerun)
1022 .icon_size(IconSize::Small)
1023 .size(ButtonSize::Compact)
1024 .icon_color(Color::Default)
1025 .shape(ui::IconButtonShape::Square)
1026 .tooltip(|cx| Tooltip::text("Rerun task", cx))
1027 .on_click(move |_, cx| {
1028 cx.dispatch_action(Box::new(zed_actions::Rerun {
1029 task_id: Some(task_id.0.clone()),
1030 allow_concurrent_runs: Some(true),
1031 use_new_terminal: Some(false),
1032 reevaluate_context: false,
1033 }));
1034 })
1035 };
1036
1037 let (icon, icon_color, rerun_button) = match terminal.task() {
1038 Some(terminal_task) => match &terminal_task.status {
1039 TaskStatus::Running => (
1040 IconName::Play,
1041 Color::Disabled,
1042 Some(rerun_button(terminal_task.id.clone())),
1043 ),
1044 TaskStatus::Unknown => (
1045 IconName::Warning,
1046 Color::Warning,
1047 Some(rerun_button(terminal_task.id.clone())),
1048 ),
1049 TaskStatus::Completed { success } => {
1050 let rerun_button = rerun_button(terminal_task.id.clone());
1051 if *success {
1052 (IconName::Check, Color::Success, Some(rerun_button))
1053 } else {
1054 (IconName::XCircle, Color::Error, Some(rerun_button))
1055 }
1056 }
1057 },
1058 None => (IconName::Terminal, Color::Muted, None),
1059 };
1060
1061 h_flex()
1062 .gap_1()
1063 .group("term-tab-icon")
1064 .child(
1065 h_flex()
1066 .group("term-tab-icon")
1067 .child(
1068 div()
1069 .when(rerun_button.is_some(), |this| {
1070 this.hover(|style| style.invisible().w_0())
1071 })
1072 .child(Icon::new(icon).color(icon_color)),
1073 )
1074 .when_some(rerun_button, |this, rerun_button| {
1075 this.child(
1076 div()
1077 .absolute()
1078 .visible_on_hover("term-tab-icon")
1079 .child(rerun_button),
1080 )
1081 }),
1082 )
1083 .child(Label::new(title).color(params.text_color()))
1084 .into_any()
1085 }
1086
1087 fn telemetry_event_text(&self) -> Option<&'static str> {
1088 None
1089 }
1090
1091 fn clone_on_split(
1092 &self,
1093 workspace_id: Option<WorkspaceId>,
1094 cx: &mut ViewContext<Self>,
1095 ) -> Option<View<Self>> {
1096 let window = cx.window_handle();
1097 let terminal = self
1098 .project
1099 .update(cx, |project, cx| {
1100 let terminal = self.terminal().read(cx);
1101 let working_directory = terminal
1102 .working_directory()
1103 .or_else(|| Some(project.active_project_directory(cx)?.to_path_buf()));
1104 let python_venv_directory = terminal.python_venv_directory.clone();
1105 project.create_terminal_with_venv(
1106 TerminalKind::Shell(working_directory),
1107 python_venv_directory,
1108 window,
1109 cx,
1110 )
1111 })
1112 .ok()?
1113 .log_err()?;
1114
1115 Some(cx.new_view(|cx| {
1116 TerminalView::new(
1117 terminal,
1118 self.workspace.clone(),
1119 workspace_id,
1120 self.project.clone(),
1121 cx,
1122 )
1123 }))
1124 }
1125
1126 fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
1127 match self.terminal.read(cx).task() {
1128 Some(task) => task.status == TaskStatus::Running,
1129 None => self.has_bell(),
1130 }
1131 }
1132
1133 fn has_conflict(&self, _cx: &AppContext) -> bool {
1134 false
1135 }
1136
1137 fn is_singleton(&self, _cx: &AppContext) -> bool {
1138 true
1139 }
1140
1141 fn as_searchable(&self, handle: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
1142 Some(Box::new(handle.clone()))
1143 }
1144
1145 fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation {
1146 if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1147 ToolbarItemLocation::PrimaryLeft
1148 } else {
1149 ToolbarItemLocation::Hidden
1150 }
1151 }
1152
1153 fn breadcrumbs(&self, _: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
1154 Some(vec![BreadcrumbText {
1155 text: self.terminal().read(cx).breadcrumb_text.clone(),
1156 highlights: None,
1157 font: None,
1158 }])
1159 }
1160
1161 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
1162 if self.terminal().read(cx).task().is_none() {
1163 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1164 cx.background_executor()
1165 .spawn(TERMINAL_DB.update_workspace_id(new_id, old_id, cx.entity_id().as_u64()))
1166 .detach();
1167 }
1168 self.workspace_id = workspace.database_id();
1169 }
1170 }
1171
1172 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
1173 f(*event)
1174 }
1175}
1176
1177impl SerializableItem for TerminalView {
1178 fn serialized_item_kind() -> &'static str {
1179 "Terminal"
1180 }
1181
1182 fn cleanup(
1183 workspace_id: WorkspaceId,
1184 alive_items: Vec<workspace::ItemId>,
1185 cx: &mut WindowContext,
1186 ) -> Task<gpui::Result<()>> {
1187 cx.spawn(|_| TERMINAL_DB.delete_unloaded_items(workspace_id, alive_items))
1188 }
1189
1190 fn serialize(
1191 &mut self,
1192 _workspace: &mut Workspace,
1193 item_id: workspace::ItemId,
1194 _closing: bool,
1195 cx: &mut ViewContext<Self>,
1196 ) -> Option<Task<gpui::Result<()>>> {
1197 let terminal = self.terminal().read(cx);
1198 if terminal.task().is_some() {
1199 return None;
1200 }
1201
1202 if let Some((cwd, workspace_id)) = terminal.working_directory().zip(self.workspace_id) {
1203 Some(cx.background_executor().spawn(async move {
1204 TERMINAL_DB
1205 .save_working_directory(item_id, workspace_id, cwd)
1206 .await
1207 }))
1208 } else {
1209 None
1210 }
1211 }
1212
1213 fn should_serialize(&self, event: &Self::Event) -> bool {
1214 matches!(event, ItemEvent::UpdateTab)
1215 }
1216
1217 fn deserialize(
1218 project: Model<Project>,
1219 workspace: WeakView<Workspace>,
1220 workspace_id: workspace::WorkspaceId,
1221 item_id: workspace::ItemId,
1222 cx: &mut WindowContext,
1223 ) -> Task<anyhow::Result<View<Self>>> {
1224 let window = cx.window_handle();
1225 cx.spawn(|mut cx| async move {
1226 let cwd = cx
1227 .update(|cx| {
1228 let from_db = TERMINAL_DB
1229 .get_working_directory(item_id, workspace_id)
1230 .log_err()
1231 .flatten();
1232 if from_db
1233 .as_ref()
1234 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1235 {
1236 from_db
1237 } else {
1238 workspace
1239 .upgrade()
1240 .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1241 }
1242 })
1243 .ok()
1244 .flatten();
1245
1246 let terminal = project
1247 .update(&mut cx, |project, cx| {
1248 project.create_terminal(TerminalKind::Shell(cwd), window, cx)
1249 })?
1250 .await?;
1251 cx.update(|cx| {
1252 cx.new_view(|cx| {
1253 TerminalView::new(
1254 terminal,
1255 workspace,
1256 Some(workspace_id),
1257 project.downgrade(),
1258 cx,
1259 )
1260 })
1261 })
1262 })
1263 }
1264}
1265
1266impl SearchableItem for TerminalView {
1267 type Match = RangeInclusive<Point>;
1268
1269 fn supported_options() -> SearchOptions {
1270 SearchOptions {
1271 case: false,
1272 word: false,
1273 regex: true,
1274 replacement: false,
1275 selection: false,
1276 }
1277 }
1278
1279 /// Clear stored matches
1280 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
1281 self.terminal().update(cx, |term, _| term.matches.clear())
1282 }
1283
1284 /// Store matches returned from find_matches somewhere for rendering
1285 fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>) {
1286 self.terminal()
1287 .update(cx, |term, _| term.matches = matches.to_vec())
1288 }
1289
1290 /// Returns the selection content to pre-load into this search
1291 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
1292 self.terminal()
1293 .read(cx)
1294 .last_content
1295 .selection_text
1296 .clone()
1297 .unwrap_or_default()
1298 }
1299
1300 /// Focus match at given index into the Vec of matches
1301 fn activate_match(&mut self, index: usize, _: &[Self::Match], cx: &mut ViewContext<Self>) {
1302 self.terminal()
1303 .update(cx, |term, _| term.activate_match(index));
1304 cx.notify();
1305 }
1306
1307 /// Add selections for all matches given.
1308 fn select_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>) {
1309 self.terminal()
1310 .update(cx, |term, _| term.select_matches(matches));
1311 cx.notify();
1312 }
1313
1314 /// Get all of the matches for this query, should be done on the background
1315 fn find_matches(
1316 &mut self,
1317 query: Arc<SearchQuery>,
1318 cx: &mut ViewContext<Self>,
1319 ) -> Task<Vec<Self::Match>> {
1320 let searcher = match &*query {
1321 SearchQuery::Text { .. } => regex_search_for_query(
1322 &(SearchQuery::text(
1323 regex_to_literal(query.as_str()),
1324 query.whole_word(),
1325 query.case_sensitive(),
1326 query.include_ignored(),
1327 query.files_to_include().clone(),
1328 query.files_to_exclude().clone(),
1329 None,
1330 )
1331 .unwrap()),
1332 ),
1333 SearchQuery::Regex { .. } => regex_search_for_query(&query),
1334 };
1335
1336 if let Some(s) = searcher {
1337 self.terminal()
1338 .update(cx, |term, cx| term.find_matches(s, cx))
1339 } else {
1340 Task::ready(vec![])
1341 }
1342 }
1343
1344 /// Reports back to the search toolbar what the active match should be (the selection)
1345 fn active_match_index(
1346 &mut self,
1347 matches: &[Self::Match],
1348 cx: &mut ViewContext<Self>,
1349 ) -> Option<usize> {
1350 // Selection head might have a value if there's a selection that isn't
1351 // associated with a match. Therefore, if there are no matches, we should
1352 // report None, no matter the state of the terminal
1353 let res = if !matches.is_empty() {
1354 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1355 // If selection head is contained in a match. Return that match
1356 if let Some(ix) = matches
1357 .iter()
1358 .enumerate()
1359 .find(|(_, search_match)| {
1360 search_match.contains(&selection_head)
1361 || search_match.start() > &selection_head
1362 })
1363 .map(|(ix, _)| ix)
1364 {
1365 Some(ix)
1366 } else {
1367 // If no selection after selection head, return the last match
1368 Some(matches.len().saturating_sub(1))
1369 }
1370 } else {
1371 // Matches found but no active selection, return the first last one (closest to cursor)
1372 Some(matches.len().saturating_sub(1))
1373 }
1374 } else {
1375 None
1376 };
1377
1378 res
1379 }
1380 fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext<Self>) {
1381 // Replacement is not supported in terminal view, so this is a no-op.
1382 }
1383}
1384
1385///Gets the working directory for the given workspace, respecting the user's settings.
1386/// None implies "~" on whichever machine we end up on.
1387pub(crate) fn default_working_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
1388 match &TerminalSettings::get_global(cx).working_directory {
1389 WorkingDirectory::CurrentProjectDirectory => workspace
1390 .project()
1391 .read(cx)
1392 .active_project_directory(cx)
1393 .as_deref()
1394 .map(Path::to_path_buf),
1395 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1396 WorkingDirectory::AlwaysHome => None,
1397 WorkingDirectory::Always { directory } => {
1398 shellexpand::full(&directory) //TODO handle this better
1399 .ok()
1400 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1401 .filter(|dir| dir.is_dir())
1402 }
1403 }
1404}
1405///Gets the first project's home directory, or the home directory
1406fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
1407 let worktree = workspace.worktrees(cx).next()?.read(cx);
1408 if !worktree.root_entry()?.is_dir() {
1409 return None;
1410 }
1411 Some(worktree.abs_path().to_path_buf())
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416 use super::*;
1417 use gpui::TestAppContext;
1418 use project::{Entry, Project, ProjectPath, Worktree};
1419 use std::path::Path;
1420 use workspace::AppState;
1421
1422 // Working directory calculation tests
1423
1424 // No Worktrees in project -> home_dir()
1425 #[gpui::test]
1426 async fn no_worktree(cx: &mut TestAppContext) {
1427 let (project, workspace) = init_test(cx).await;
1428 cx.read(|cx| {
1429 let workspace = workspace.read(cx);
1430 let active_entry = project.read(cx).active_entry();
1431
1432 //Make sure environment is as expected
1433 assert!(active_entry.is_none());
1434 assert!(workspace.worktrees(cx).next().is_none());
1435
1436 let res = default_working_directory(workspace, cx);
1437 assert_eq!(res, None);
1438 let res = first_project_directory(workspace, cx);
1439 assert_eq!(res, None);
1440 });
1441 }
1442
1443 // No active entry, but a worktree, worktree is a file -> home_dir()
1444 #[gpui::test]
1445 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
1446 let (project, workspace) = init_test(cx).await;
1447
1448 create_file_wt(project.clone(), "/root.txt", cx).await;
1449 cx.read(|cx| {
1450 let workspace = workspace.read(cx);
1451 let active_entry = project.read(cx).active_entry();
1452
1453 //Make sure environment is as expected
1454 assert!(active_entry.is_none());
1455 assert!(workspace.worktrees(cx).next().is_some());
1456
1457 let res = default_working_directory(workspace, cx);
1458 assert_eq!(res, None);
1459 let res = first_project_directory(workspace, cx);
1460 assert_eq!(res, None);
1461 });
1462 }
1463
1464 // No active entry, but a worktree, worktree is a folder -> worktree_folder
1465 #[gpui::test]
1466 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1467 let (project, workspace) = init_test(cx).await;
1468
1469 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1470 cx.update(|cx| {
1471 let workspace = workspace.read(cx);
1472 let active_entry = project.read(cx).active_entry();
1473
1474 assert!(active_entry.is_none());
1475 assert!(workspace.worktrees(cx).next().is_some());
1476
1477 let res = default_working_directory(workspace, cx);
1478 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
1479 let res = first_project_directory(workspace, cx);
1480 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
1481 });
1482 }
1483
1484 // Active entry with a work tree, worktree is a file -> worktree_folder()
1485 #[gpui::test]
1486 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
1487 let (project, workspace) = init_test(cx).await;
1488
1489 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1490 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
1491 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1492
1493 cx.update(|cx| {
1494 let workspace = workspace.read(cx);
1495 let active_entry = project.read(cx).active_entry();
1496
1497 assert!(active_entry.is_some());
1498
1499 let res = default_working_directory(workspace, cx);
1500 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1501 let res = first_project_directory(workspace, cx);
1502 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1503 });
1504 }
1505
1506 // Active entry, with a worktree, worktree is a folder -> worktree_folder
1507 #[gpui::test]
1508 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1509 let (project, workspace) = init_test(cx).await;
1510
1511 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1512 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
1513 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1514
1515 cx.update(|cx| {
1516 let workspace = workspace.read(cx);
1517 let active_entry = project.read(cx).active_entry();
1518
1519 assert!(active_entry.is_some());
1520
1521 let res = default_working_directory(workspace, cx);
1522 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
1523 let res = first_project_directory(workspace, cx);
1524 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1525 });
1526 }
1527
1528 /// Creates a worktree with 1 file: /root.txt
1529 pub async fn init_test(cx: &mut TestAppContext) -> (Model<Project>, View<Workspace>) {
1530 let params = cx.update(AppState::test);
1531 cx.update(|cx| {
1532 terminal::init(cx);
1533 theme::init(theme::LoadThemes::JustBase, cx);
1534 Project::init_settings(cx);
1535 language::init(cx);
1536 });
1537
1538 let project = Project::test(params.fs.clone(), [], cx).await;
1539 let workspace = cx
1540 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1541 .root_view(cx)
1542 .unwrap();
1543
1544 (project, workspace)
1545 }
1546
1547 /// Creates a worktree with 1 folder: /root{suffix}/
1548 async fn create_folder_wt(
1549 project: Model<Project>,
1550 path: impl AsRef<Path>,
1551 cx: &mut TestAppContext,
1552 ) -> (Model<Worktree>, Entry) {
1553 create_wt(project, true, path, cx).await
1554 }
1555
1556 /// Creates a worktree with 1 file: /root{suffix}.txt
1557 async fn create_file_wt(
1558 project: Model<Project>,
1559 path: impl AsRef<Path>,
1560 cx: &mut TestAppContext,
1561 ) -> (Model<Worktree>, Entry) {
1562 create_wt(project, false, path, cx).await
1563 }
1564
1565 async fn create_wt(
1566 project: Model<Project>,
1567 is_dir: bool,
1568 path: impl AsRef<Path>,
1569 cx: &mut TestAppContext,
1570 ) -> (Model<Worktree>, Entry) {
1571 let (wt, _) = project
1572 .update(cx, |project, cx| {
1573 project.find_or_create_worktree(path, true, cx)
1574 })
1575 .await
1576 .unwrap();
1577
1578 let entry = cx
1579 .update(|cx| wt.update(cx, |wt, cx| wt.create_entry(Path::new(""), is_dir, cx)))
1580 .await
1581 .unwrap()
1582 .to_included()
1583 .unwrap();
1584
1585 (wt, entry)
1586 }
1587
1588 pub fn insert_active_entry_for(
1589 wt: Model<Worktree>,
1590 entry: Entry,
1591 project: Model<Project>,
1592 cx: &mut TestAppContext,
1593 ) {
1594 cx.update(|cx| {
1595 let p = ProjectPath {
1596 worktree_id: wt.read(cx).id(),
1597 path: entry.path,
1598 };
1599 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
1600 });
1601 }
1602
1603 #[test]
1604 fn escapes_only_special_characters() {
1605 assert_eq!(regex_to_literal(r"test(\w)"), r"test\(\\w\)".to_string());
1606 }
1607
1608 #[test]
1609 fn empty_string_stays_empty() {
1610 assert_eq!(regex_to_literal(""), "".to_string());
1611 }
1612}