1mod color_translation;
2pub mod connection;
3mod modal;
4pub mod terminal_element;
5
6use alacritty_terminal::{
7 event::{Event as AlacTermEvent, EventListener},
8 grid::Scroll,
9 term::SizeInfo,
10};
11
12use connection::{Event, TerminalConnection};
13use dirs::home_dir;
14use editor::Input;
15use futures::channel::mpsc::UnboundedSender;
16use gpui::{
17 actions, elements::*, impl_internal_actions, AppContext, ClipboardItem, Entity, ModelHandle,
18 MutableAppContext, View, ViewContext,
19};
20use modal::deploy_modal;
21
22use project::{Project, ProjectPath};
23use settings::Settings;
24use smallvec::SmallVec;
25use std::path::PathBuf;
26use workspace::{Item, Workspace};
27
28use crate::terminal_element::TerminalEl;
29
30//ASCII Control characters on a keyboard
31const ETX_CHAR: char = 3_u8 as char; //'End of text', the control code for 'ctrl-c'
32const TAB_CHAR: char = 9_u8 as char;
33const CARRIAGE_RETURN_CHAR: char = 13_u8 as char;
34const ESC_CHAR: char = 27_u8 as char; // == \x1b
35const DEL_CHAR: char = 127_u8 as char;
36const LEFT_SEQ: &str = "\x1b[D";
37const RIGHT_SEQ: &str = "\x1b[C";
38const UP_SEQ: &str = "\x1b[A";
39const DOWN_SEQ: &str = "\x1b[B";
40const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
41const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
42const DEBUG_CELL_WIDTH: f32 = 5.;
43const DEBUG_LINE_HEIGHT: f32 = 5.;
44
45//For bel, use a yellow dot. (equivalent to dirty file with conflict)
46//For title, introduce max title length and
47
48///Event to transmit the scroll from the element to the view
49#[derive(Clone, Debug, PartialEq)]
50pub struct ScrollTerminal(pub i32);
51
52actions!(
53 terminal,
54 [
55 Sigint,
56 Escape,
57 Del,
58 Return,
59 Left,
60 Right,
61 Up,
62 Down,
63 Tab,
64 Clear,
65 Copy,
66 Paste,
67 Deploy,
68 Quit,
69 DeployModal,
70 ]
71);
72impl_internal_actions!(terminal, [ScrollTerminal]);
73
74///Initialize and register all of our action handlers
75pub fn init(cx: &mut MutableAppContext) {
76 cx.add_action(Terminal::deploy);
77 cx.add_action(Terminal::send_sigint);
78 cx.add_action(Terminal::escape);
79 cx.add_action(Terminal::quit);
80 cx.add_action(Terminal::del);
81 cx.add_action(Terminal::carriage_return);
82 cx.add_action(Terminal::left);
83 cx.add_action(Terminal::right);
84 cx.add_action(Terminal::up);
85 cx.add_action(Terminal::down);
86 cx.add_action(Terminal::tab);
87 cx.add_action(Terminal::copy);
88 cx.add_action(Terminal::paste);
89 cx.add_action(Terminal::scroll_terminal);
90 cx.add_action(Terminal::input);
91 cx.add_action(Terminal::clear);
92 cx.add_action(deploy_modal);
93}
94
95///A translation struct for Alacritty to communicate with us from their event loop
96#[derive(Clone)]
97pub struct ZedListener(UnboundedSender<AlacTermEvent>);
98
99impl EventListener for ZedListener {
100 fn send_event(&self, event: AlacTermEvent) {
101 self.0.unbounded_send(event).ok();
102 }
103}
104
105///A terminal view, maintains the PTY's file handles and communicates with the terminal
106pub struct Terminal {
107 connection: ModelHandle<TerminalConnection>,
108 has_new_content: bool,
109 //Currently using iTerm bell, show bell emoji in tab until input is received
110 has_bell: bool,
111 // Only for styling purposes. Doesn't effect behavior
112 modal: bool,
113}
114
115impl Entity for Terminal {
116 type Event = Event;
117}
118
119impl Terminal {
120 ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
121 ///To get the right working directory from a workspace, use: `get_wd_for_workspace()`
122 fn new(working_directory: Option<PathBuf>, modal: bool, cx: &mut ViewContext<Self>) -> Self {
123 //The details here don't matter, the terminal will be resized on the first layout
124 let size_info = SizeInfo::new(
125 DEBUG_TERMINAL_WIDTH,
126 DEBUG_TERMINAL_HEIGHT,
127 DEBUG_CELL_WIDTH,
128 DEBUG_LINE_HEIGHT,
129 0.,
130 0.,
131 false,
132 );
133
134 let connection =
135 cx.add_model(|cx| TerminalConnection::new(working_directory, size_info, cx));
136
137 Terminal::from_connection(connection, modal, cx)
138 }
139
140 fn from_connection(
141 connection: ModelHandle<TerminalConnection>,
142 modal: bool,
143 cx: &mut ViewContext<Self>,
144 ) -> Terminal {
145 cx.observe(&connection, |_, _, cx| cx.notify()).detach();
146 cx.subscribe(&connection, |this, _, event, cx| match event {
147 Event::Wakeup => {
148 if cx.is_self_focused() {
149 cx.notify()
150 } else {
151 this.has_new_content = true;
152 cx.emit(Event::TitleChanged);
153 }
154 }
155 Event::Bell => {
156 this.has_bell = true;
157 cx.emit(Event::TitleChanged);
158 }
159 _ => cx.emit(*event),
160 })
161 .detach();
162
163 Terminal {
164 connection,
165 has_new_content: true,
166 has_bell: false,
167 modal,
168 }
169 }
170
171 ///Scroll the terminal. This locks the terminal
172 fn scroll_terminal(&mut self, scroll: &ScrollTerminal, cx: &mut ViewContext<Self>) {
173 self.connection
174 .read(cx)
175 .term
176 .lock()
177 .scroll_display(Scroll::Delta(scroll.0));
178 }
179
180 fn input(&mut self, Input(text): &Input, cx: &mut ViewContext<Self>) {
181 self.connection.update(cx, |connection, _| {
182 //TODO: This is probably not encoding UTF8 correctly (see alacritty/src/input.rs:L825-837)
183 connection.write_to_pty(text.clone());
184 });
185
186 if self.has_bell {
187 self.has_bell = false;
188 cx.emit(Event::TitleChanged);
189 }
190 }
191
192 fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
193 self.connection
194 .update(cx, |connection, _| connection.clear());
195 }
196
197 ///Create a new Terminal in the current working directory or the user's home directory
198 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
199 let wd = get_wd_for_workspace(workspace, cx);
200 workspace.add_item(Box::new(cx.add_view(|cx| Terminal::new(wd, false, cx))), cx);
201 }
202
203 ///Tell Zed to close us
204 fn quit(&mut self, _: &Quit, cx: &mut ViewContext<Self>) {
205 cx.emit(Event::CloseTerminal);
206 }
207
208 ///Attempt to paste the clipboard into the terminal
209 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
210 let term = self.connection.read(cx).term.lock();
211 let copy_text = term.selection_to_string();
212 match copy_text {
213 Some(s) => cx.write_to_clipboard(ClipboardItem::new(s)),
214 None => (),
215 }
216 }
217
218 ///Attempt to paste the clipboard into the terminal
219 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
220 if let Some(item) = cx.read_from_clipboard() {
221 self.connection.update(cx, |connection, _| {
222 connection.write_to_pty(item.text().to_owned());
223 })
224 }
225 }
226
227 ///Send the `up` key
228 fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
229 self.connection.update(cx, |connection, _| {
230 connection.write_to_pty(UP_SEQ.to_string());
231 });
232 }
233
234 ///Send the `down` key
235 fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
236 self.connection.update(cx, |connection, _| {
237 connection.write_to_pty(DOWN_SEQ.to_string());
238 });
239 }
240
241 ///Send the `tab` key
242 fn tab(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
243 self.connection.update(cx, |connection, _| {
244 connection.write_to_pty(TAB_CHAR.to_string());
245 });
246 }
247
248 ///Send `SIGINT` (`ctrl-c`)
249 fn send_sigint(&mut self, _: &Sigint, cx: &mut ViewContext<Self>) {
250 self.connection.update(cx, |connection, _| {
251 connection.write_to_pty(ETX_CHAR.to_string());
252 });
253 }
254
255 ///Send the `escape` key
256 fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
257 self.connection.update(cx, |connection, _| {
258 connection.write_to_pty(ESC_CHAR.to_string());
259 });
260 }
261
262 ///Send the `delete` key. TODO: Difference between this and backspace?
263 fn del(&mut self, _: &Del, cx: &mut ViewContext<Self>) {
264 self.connection.update(cx, |connection, _| {
265 connection.write_to_pty(DEL_CHAR.to_string());
266 });
267 }
268
269 ///Send a carriage return. TODO: May need to check the terminal mode.
270 fn carriage_return(&mut self, _: &Return, cx: &mut ViewContext<Self>) {
271 self.connection.update(cx, |connection, _| {
272 connection.write_to_pty(CARRIAGE_RETURN_CHAR.to_string());
273 });
274 }
275
276 //Send the `left` key
277 fn left(&mut self, _: &Left, cx: &mut ViewContext<Self>) {
278 self.connection.update(cx, |connection, _| {
279 connection.write_to_pty(LEFT_SEQ.to_string());
280 });
281 }
282
283 //Send the `right` key
284 fn right(&mut self, _: &Right, cx: &mut ViewContext<Self>) {
285 self.connection.update(cx, |connection, _| {
286 connection.write_to_pty(RIGHT_SEQ.to_string());
287 });
288 }
289}
290
291impl View for Terminal {
292 fn ui_name() -> &'static str {
293 "Terminal"
294 }
295
296 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
297 let element = {
298 let connection_handle = self.connection.clone().downgrade();
299 let view_id = cx.view_id();
300 TerminalEl::new(view_id, connection_handle, self.modal).contained()
301 };
302
303 if self.modal {
304 let settings = cx.global::<Settings>();
305 let container_style = settings.theme.terminal.modal_container;
306 element.with_style(container_style).boxed()
307 } else {
308 element.boxed()
309 }
310 }
311
312 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
313 cx.emit(Event::Activate);
314 self.has_new_content = false;
315 }
316
317 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
318 let mut context = Self::default_keymap_context();
319 if self.modal {
320 context.set.insert("ModalTerminal".into());
321 }
322 context
323 }
324}
325
326impl Item for Terminal {
327 fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
328 let settings = cx.global::<Settings>();
329 let search_theme = &settings.theme.search; //TODO properly integrate themes
330
331 let mut flex = Flex::row();
332
333 if self.has_bell {
334 flex.add_child(
335 Svg::new("icons/zap.svg") //TODO: Swap out for a better icon, or at least resize this
336 .with_color(tab_theme.label.text.color)
337 .constrained()
338 .with_width(search_theme.tab_icon_width)
339 .aligned()
340 .boxed(),
341 );
342 };
343
344 flex.with_child(
345 Label::new(
346 self.connection.read(cx).title.clone(),
347 tab_theme.label.clone(),
348 )
349 .aligned()
350 .contained()
351 .with_margin_left(if self.has_bell {
352 search_theme.tab_icon_spacing
353 } else {
354 0.
355 })
356 .boxed(),
357 )
358 .boxed()
359 }
360
361 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
362 //From what I can tell, there's no way to tell the current working
363 //Directory of the terminal from outside the terminal. There might be
364 //solutions to this, but they are non-trivial and require more IPC
365 Some(Terminal::new(
366 self.connection.read(cx).associated_directory.clone(),
367 false,
368 cx,
369 ))
370 }
371
372 fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
373 None
374 }
375
376 fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
377 SmallVec::new()
378 }
379
380 fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
381 false
382 }
383
384 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
385
386 fn can_save(&self, _cx: &gpui::AppContext) -> bool {
387 false
388 }
389
390 fn save(
391 &mut self,
392 _project: gpui::ModelHandle<Project>,
393 _cx: &mut ViewContext<Self>,
394 ) -> gpui::Task<gpui::anyhow::Result<()>> {
395 unreachable!("save should not have been called");
396 }
397
398 fn save_as(
399 &mut self,
400 _project: gpui::ModelHandle<Project>,
401 _abs_path: std::path::PathBuf,
402 _cx: &mut ViewContext<Self>,
403 ) -> gpui::Task<gpui::anyhow::Result<()>> {
404 unreachable!("save_as should not have been called");
405 }
406
407 fn reload(
408 &mut self,
409 _project: gpui::ModelHandle<Project>,
410 _cx: &mut ViewContext<Self>,
411 ) -> gpui::Task<gpui::anyhow::Result<()>> {
412 gpui::Task::ready(Ok(()))
413 }
414
415 fn is_dirty(&self, _: &gpui::AppContext) -> bool {
416 self.has_new_content
417 }
418
419 fn should_update_tab_on_event(event: &Self::Event) -> bool {
420 matches!(event, &Event::TitleChanged)
421 }
422
423 fn should_close_item_on_event(event: &Self::Event) -> bool {
424 matches!(event, &Event::CloseTerminal)
425 }
426
427 fn should_activate_item_on_event(event: &Self::Event) -> bool {
428 matches!(event, &Event::Activate)
429 }
430}
431
432///Gets the intuitively correct working directory from the given workspace
433///If there is an active entry for this project, returns that entry's worktree root.
434///If there's no active entry but there is a worktree, returns that worktrees root.
435///If either of these roots are files, or if there are any other query failures,
436/// returns the user's home directory
437fn get_wd_for_workspace(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
438 let project = workspace.project().read(cx);
439
440 project
441 .active_entry()
442 .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
443 .or_else(|| workspace.worktrees(cx).next())
444 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
445 .and_then(|wt| {
446 wt.root_entry()
447 .filter(|re| re.is_dir())
448 .map(|_| wt.abs_path().to_path_buf())
449 })
450 .or_else(|| home_dir())
451}
452
453#[cfg(test)]
454mod tests {
455
456 use super::*;
457 use alacritty_terminal::{
458 grid::GridIterator,
459 index::{Column, Line, Point, Side},
460 selection::{Selection, SelectionType},
461 term::cell::Cell,
462 };
463 use gpui::TestAppContext;
464 use itertools::Itertools;
465
466 use std::{path::Path, time::Duration};
467 use workspace::AppState;
468
469 ///Basic integration test, can we get the terminal to show up, execute a command,
470 //and produce noticable output?
471 #[gpui::test]
472 async fn test_terminal(cx: &mut TestAppContext) {
473 let terminal = cx.add_view(Default::default(), |cx| Terminal::new(None, false, cx));
474
475 terminal.update(cx, |terminal, cx| {
476 terminal.connection.update(cx, |connection, _| {
477 connection.write_to_pty("expr 3 + 4".to_string());
478 });
479 terminal.carriage_return(&Return, cx);
480 });
481
482 cx.set_condition_duration(Some(Duration::from_secs(2)));
483 terminal
484 .condition(cx, |terminal, cx| {
485 let term = terminal.connection.read(cx).term.clone();
486 let content = grid_as_str(term.lock().renderable_content().display_iter);
487 content.contains("7")
488 })
489 .await;
490 cx.set_condition_duration(None);
491 }
492
493 /// Integration test for selections, clipboard, and terminal execution
494 #[gpui::test]
495 async fn test_copy(cx: &mut TestAppContext) {
496 let mut result_line: i32 = 0;
497 let terminal = cx.add_view(Default::default(), |cx| Terminal::new(None, false, cx));
498 cx.set_condition_duration(Some(Duration::from_secs(2)));
499
500 terminal.update(cx, |terminal, cx| {
501 terminal.connection.update(cx, |connection, _| {
502 connection.write_to_pty("expr 3 + 4".to_string());
503 });
504 terminal.carriage_return(&Return, cx);
505 });
506
507 terminal
508 .condition(cx, |terminal, cx| {
509 let term = terminal.connection.read(cx).term.clone();
510 let content = grid_as_str(term.lock().renderable_content().display_iter);
511
512 if content.contains("7") {
513 let idx = content.chars().position(|c| c == '7').unwrap();
514 result_line = content.chars().take(idx).filter(|c| *c == '\n').count() as i32;
515 true
516 } else {
517 false
518 }
519 })
520 .await;
521
522 terminal.update(cx, |terminal, cx| {
523 let mut term = terminal.connection.read(cx).term.lock();
524 term.selection = Some(Selection::new(
525 SelectionType::Semantic,
526 Point::new(Line(2), Column(0)),
527 Side::Right,
528 ));
529 drop(term);
530 terminal.copy(&Copy, cx)
531 });
532
533 cx.assert_clipboard_content(Some(&"7"));
534 cx.set_condition_duration(None);
535 }
536
537 ///Working directory calculation tests
538
539 ///No Worktrees in project -> home_dir()
540 #[gpui::test]
541 async fn no_worktree(cx: &mut TestAppContext) {
542 //Setup variables
543 let params = cx.update(AppState::test);
544 let project = Project::test(params.fs.clone(), [], cx).await;
545 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
546
547 //Test
548 cx.read(|cx| {
549 let workspace = workspace.read(cx);
550 let active_entry = project.read(cx).active_entry();
551
552 //Make sure enviroment is as expeted
553 assert!(active_entry.is_none());
554 assert!(workspace.worktrees(cx).next().is_none());
555
556 let res = get_wd_for_workspace(workspace, cx);
557 assert_eq!(res, home_dir())
558 });
559 }
560
561 ///No active entry, but a worktree, worktree is a file -> home_dir()
562 #[gpui::test]
563 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
564 //Setup variables
565 let params = cx.update(AppState::test);
566 let project = Project::test(params.fs.clone(), [], cx).await;
567 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
568 let (wt, _) = project
569 .update(cx, |project, cx| {
570 project.find_or_create_local_worktree("/root.txt", true, cx)
571 })
572 .await
573 .unwrap();
574
575 cx.update(|cx| {
576 wt.update(cx, |wt, cx| {
577 wt.as_local()
578 .unwrap()
579 .create_entry(Path::new(""), false, cx)
580 })
581 })
582 .await
583 .unwrap();
584
585 //Test
586 cx.read(|cx| {
587 let workspace = workspace.read(cx);
588 let active_entry = project.read(cx).active_entry();
589
590 //Make sure enviroment is as expeted
591 assert!(active_entry.is_none());
592 assert!(workspace.worktrees(cx).next().is_some());
593
594 let res = get_wd_for_workspace(workspace, cx);
595 assert_eq!(res, home_dir())
596 });
597 }
598
599 //No active entry, but a worktree, worktree is a folder -> worktree_folder
600 #[gpui::test]
601 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
602 //Setup variables
603 let params = cx.update(AppState::test);
604 let project = Project::test(params.fs.clone(), [], cx).await;
605 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
606 let (wt, _) = project
607 .update(cx, |project, cx| {
608 project.find_or_create_local_worktree("/root/", true, cx)
609 })
610 .await
611 .unwrap();
612
613 //Setup root folder
614 cx.update(|cx| {
615 wt.update(cx, |wt, cx| {
616 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
617 })
618 })
619 .await
620 .unwrap();
621
622 //Test
623 cx.update(|cx| {
624 let workspace = workspace.read(cx);
625 let active_entry = project.read(cx).active_entry();
626
627 assert!(active_entry.is_none());
628 assert!(workspace.worktrees(cx).next().is_some());
629
630 let res = get_wd_for_workspace(workspace, cx);
631 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
632 });
633 }
634
635 //Active entry with a work tree, worktree is a file -> home_dir()
636 #[gpui::test]
637 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
638 //Setup variables
639 let params = cx.update(AppState::test);
640 let project = Project::test(params.fs.clone(), [], cx).await;
641 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
642 let (wt, _) = project
643 .update(cx, |project, cx| {
644 project.find_or_create_local_worktree("/root.txt", true, cx)
645 })
646 .await
647 .unwrap();
648
649 //Setup root
650 let entry = cx
651 .update(|cx| {
652 wt.update(cx, |wt, cx| {
653 wt.as_local()
654 .unwrap()
655 .create_entry(Path::new(""), false, cx)
656 })
657 })
658 .await
659 .unwrap();
660
661 cx.update(|cx| {
662 let p = ProjectPath {
663 worktree_id: wt.read(cx).id(),
664 path: entry.path,
665 };
666 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
667 });
668
669 //Test
670 cx.update(|cx| {
671 let workspace = workspace.read(cx);
672 let active_entry = project.read(cx).active_entry();
673
674 assert!(active_entry.is_some());
675
676 let res = get_wd_for_workspace(workspace, cx);
677 assert_eq!(res, home_dir());
678 });
679 }
680
681 //Active entry, with a worktree, worktree is a folder -> worktree_folder
682 #[gpui::test]
683 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
684 //Setup variables
685 let params = cx.update(AppState::test);
686 let project = Project::test(params.fs.clone(), [], cx).await;
687 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
688 let (wt, _) = project
689 .update(cx, |project, cx| {
690 project.find_or_create_local_worktree("/root/", true, cx)
691 })
692 .await
693 .unwrap();
694
695 //Setup root
696 let entry = cx
697 .update(|cx| {
698 wt.update(cx, |wt, cx| {
699 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
700 })
701 })
702 .await
703 .unwrap();
704
705 cx.update(|cx| {
706 let p = ProjectPath {
707 worktree_id: wt.read(cx).id(),
708 path: entry.path,
709 };
710 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
711 });
712
713 //Test
714 cx.update(|cx| {
715 let workspace = workspace.read(cx);
716 let active_entry = project.read(cx).active_entry();
717
718 assert!(active_entry.is_some());
719
720 let res = get_wd_for_workspace(workspace, cx);
721 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
722 });
723 }
724
725 pub(crate) fn grid_as_str(grid_iterator: GridIterator<Cell>) -> String {
726 let lines = grid_iterator.group_by(|i| i.point.line.0);
727 lines
728 .into_iter()
729 .map(|(_, line)| line.map(|i| i.c).collect::<String>())
730 .collect::<Vec<String>>()
731 .join("\n")
732 }
733}