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