1mod draft;
15mod fill;
16mod remove;
17
18pub(crate) use remove::Names;
19#[cfg(test)]
20mod tests;
21
22use std::path::PathBuf;
23
24use qframe::date::Date;
25use qframe::prelude::*;
26use qframe::runtime::{Confirm, Task, TaskEvent, TaskId, TaskOutcome, Tasks};
27use qframe::widgets::{
28 EmptyState, Field, FilePicker, FilePickerMsg, Form, FormErrors, LogBuffer, LogLevel, LogLine, LogView, Modal,
29 RadioGroup, ScrollView, Skeleton, TaskList, TextInput, Toast,
30};
31
32use crate::engine::Engine;
33use crate::store::{Store, WorkspaceEntry, WorkspaceId};
34use crate::ui::page;
35
36pub use draft::{Place, Source};
37
38use draft::{Draft, FOLDER_FIELD, NAME_FIELD, Problem, URL_FIELD};
39use fill::{Fill, Job};
40use remove::{Outcome, Reach, Survey};
41
42const DIALOG_WIDTH: u16 = 68;
45
46const PICKER_ROWS: u16 = 12;
48
49const ROOMY_ROWS: u16 = 34;
54
55const BROWSE_CHROME_ROWS: u16 = 8;
57
58const LOG_ROWS: u16 = 10;
60
61const SKELETON_ROWS: u16 = 6;
63
64const LOG_LINES: usize = 2000;
67
68#[derive(Debug, Clone, PartialEq)]
70pub enum Msg {
71 Refresh,
73 Listed {
75 entries: Vec<WorkspaceEntry>,
77 trouble: Option<String>,
79 },
80 Select(usize),
82 SelectNew,
84 Activate(usize),
86 Start,
88 Dismiss,
90 Name(String),
92 Source(usize),
94 Place(usize),
96 Url(String),
98 Picker(FilePickerMsg),
100 Browse(bool),
102 Submit,
104 Line(String),
106 Progress(TaskEvent),
108 Stop(TaskId),
110 Filled,
112 Delete,
114 DeleteSelect(usize),
116 DeletePick(usize),
118 Surveyed(Box<Survey>),
120 DeleteConfirmed,
122 DeleteKept,
124 Deleted(Box<Survey>, Outcome),
126}
127
128#[derive(Debug)]
130enum Overlay {
131 New(Box<Draft>),
133 Problems(usize),
135 Delete(usize),
137}
138
139#[derive(Debug)]
141pub struct Workspaces {
142 store: Store,
143 engine: Option<Engine>,
144 recent: Vec<String>,
145 entries: Vec<WorkspaceEntry>,
146 trouble: Option<String>,
147 listed: bool,
148 refocus: bool,
151 selected: usize,
152 on_new: bool,
154 overlay: Option<Overlay>,
155 tasks: Tasks,
156 log: LogBuffer,
157 job: Option<TaskId>,
158 open: Vec<WorkspaceId>,
160 deleting: bool,
162 doomed: Option<Box<Survey>>,
164 deleted: Option<WorkspaceId>,
166}
167
168impl Workspaces {
169 #[must_use]
172 pub fn new(store: Store, engine: Option<Engine>, recent: Vec<String>) -> Self {
173 Self {
174 store,
175 engine,
176 recent,
177 entries: Vec::new(),
178 trouble: None,
179 listed: false,
180 refocus: false,
181 selected: 0,
182 on_new: false,
183 overlay: None,
184 tasks: Tasks::new(),
185 log: LogBuffer::new(LOG_LINES),
186 job: None,
187 open: Vec::new(),
188 deleting: false,
189 doomed: None,
190 deleted: None,
191 }
192 }
193
194 pub fn set_open(&mut self, open: Vec<WorkspaceId>) {
197 self.open = open;
198 }
199
200 pub fn just_deleted(&mut self) -> Option<WorkspaceId> {
203 self.deleted.take()
204 }
205
206 #[must_use]
209 pub fn is_busy(&self) -> bool {
210 self.job.is_some()
211 }
212}
213
214pub fn update(workspaces: &mut Workspaces, message: Msg) -> (Command<Msg>, Option<WorkspaceId>) {
217 let mut opened = None;
218 let command = match message {
219 Msg::Refresh => list(&workspaces.store),
220 Msg::Listed { entries, trouble } => {
221 let first = !workspaces.listed;
222 workspaces.entries = entries;
223 workspaces.trouble = trouble;
224 workspaces.listed = true;
225 workspaces.selected = workspaces.selected.min(workspaces.entries.len().saturating_sub(1));
226 let refocus = std::mem::take(&mut workspaces.refocus);
231 if (first || refocus) && workspaces.overlay.is_none() {
232 Command::focus(if workspaces.entries.is_empty() { EMPTY } else { LIST })
234 } else {
235 Command::none()
236 }
237 }
238 Msg::Select(index) => {
239 if index < workspaces.entries.len() {
240 workspaces.selected = index;
241 workspaces.on_new = false;
242 }
243 Command::none()
244 }
245 Msg::SelectNew => {
246 workspaces.on_new = true;
247 Command::none()
248 }
249 Msg::Activate(index) => activate(workspaces, index, &mut opened),
250 Msg::Start => {
251 let mut draft = Draft::new(start_folder(&workspaces.store));
252 let reading = draft.browser.open(draft.browser.folder().to_path_buf(), Msg::Picker);
253 workspaces.overlay = Some(Overlay::New(Box::new(draft)));
254 Command::batch([reading, Command::focus(NAME_INPUT)])
255 }
256 Msg::Dismiss => {
257 workspaces.overlay = None;
258 Command::none()
259 }
260 Msg::Name(text) => edit(workspaces, |draft| draft.name = text),
261 Msg::Source(index) => edit(workspaces, |draft| draft.source = Source::from_index(index)),
262 Msg::Place(index) => edit(workspaces, |draft| draft.place = Place::from_index(index)),
263 Msg::Url(text) => edit(workspaces, |draft| draft.url = text),
264 Msg::Picker(FilePickerMsg::Chosen(path)) => {
265 let browsing = matches!(&workspaces.overlay, Some(Overlay::New(draft)) if draft.browsing);
266 let command = edit(workspaces, |draft| {
267 draft.folder = Some(path);
268 draft.browsing = false;
269 });
270 if browsing { Command::batch([command, Command::focus(BROWSE_BUTTON)]) } else { command }
271 }
272 Msg::Browse(open) => {
273 let command = edit(workspaces, |draft| draft.browsing = open);
274 Command::batch([command, Command::focus(if open { BROWSE_PICKER } else { BROWSE_BUTTON })])
275 }
276 Msg::Picker(message) => match workspaces.overlay {
277 Some(Overlay::New(ref mut draft)) => draft.browser.update(message, Msg::Picker),
278 _ => Command::none(),
279 },
280 Msg::Submit => submit(workspaces),
281 Msg::Line(text) => {
282 workspaces.log.push(LogLine::new(LogLevel::Info, text));
283 Command::none()
284 }
285 Msg::Progress(event) => {
286 workspaces.tasks.apply(&event);
287 match event {
288 TaskEvent::Finished { id, outcome } if workspaces.job == Some(id) => finished(workspaces, &outcome),
289 _ => Command::none(),
290 }
291 }
292 Msg::Stop(id) => Command::cancel_task(id),
293 Msg::Filled => Command::none(),
296 Msg::Delete => {
297 if workspaces.deleting || workspaces.entries.is_empty() {
298 Command::none()
299 } else {
300 workspaces.overlay = Some(Overlay::Delete(workspaces.selected));
301 Command::focus(DELETE_LIST)
302 }
303 }
304 Msg::DeleteSelect(index) => {
305 if let Some(Overlay::Delete(chosen)) = workspaces.overlay.as_mut()
306 && index < workspaces.entries.len()
307 {
308 *chosen = index;
309 }
310 Command::none()
311 }
312 Msg::DeletePick(index) => ask_delete(workspaces, index),
313 Msg::Surveyed(survey) => surveyed(workspaces, survey),
314 Msg::DeleteConfirmed => delete(workspaces),
315 Msg::DeleteKept => {
316 workspaces.deleting = false;
317 workspaces.doomed = None;
318 Command::none()
319 }
320 Msg::Deleted(survey, outcome) => deleted(workspaces, &survey, outcome),
321 };
322 (command, opened)
323}
324
325const NAME_INPUT: &str = "workspace-name";
327
328const LIST: &str = "workspaces";
330
331const EMPTY: &str = "workspaces-empty";
333
334const DELETE_LIST: &str = "workspace-delete-list";
336
337fn list(store: &Store) -> Command<Msg> {
342 let store = store.clone();
343 let code = qframe::i18n::active_code();
344 Command::perform(move || {
345 crate::ui::in_language(&code, || {
346 if let Err(problem) = store.prepare() {
347 return Msg::Listed { entries: Vec::new(), trouble: Some(problem.to_string()) };
348 }
349 let loaded = store.workspaces();
350 let trouble = match (loaded.value.is_empty(), loaded.diagnostics.first()) {
353 (true, Some(problem)) => Some(problem.to_string()),
354 _ => None,
355 };
356 Msg::Listed { entries: loaded.value, trouble }
357 })
358 })
359}
360
361fn activate(workspaces: &mut Workspaces, index: usize, opened: &mut Option<WorkspaceId>) -> Command<Msg> {
364 match workspaces.entries.get(index) {
365 None => Command::none(),
366 Some(entry) if entry.is_broken() => {
367 workspaces.selected = index;
368 workspaces.overlay = Some(Overlay::Problems(index));
369 Command::none()
370 }
371 Some(entry) => {
372 workspaces.selected = index;
373 *opened = entry.file.as_ref().map(|file| file.id.clone());
376 Command::none()
377 }
378 }
379}
380
381fn edit(workspaces: &mut Workspaces, change: impl FnOnce(&mut Draft)) -> Command<Msg> {
383 if let Some(Overlay::New(draft)) = workspaces.overlay.as_mut() {
384 change(draft);
385 draft.problem = None;
386 }
387 Command::none()
388}
389
390fn submit(workspaces: &mut Workspaces) -> Command<Msg> {
395 let Some(Overlay::New(draft)) = workspaces.overlay.as_mut() else { return Command::none() };
396 if draft.busy {
397 return Command::none();
398 }
399 let in_place = draft.source == Source::Folder && draft.place == Place::InPlace;
403 let store = if in_place {
404 if let Some(folder) = draft.folder.as_mut() {
405 match std::fs::canonicalize(&*folder) {
406 Ok(real) if real.is_dir() => *folder = real,
407 Ok(real) => {
408 draft.problem = Some(Problem::PlaceGone(real.display().to_string()));
409 return Command::focus(field_input(FOLDER_FIELD));
410 }
411 Err(error) => {
412 draft.problem = Some(Problem::PlaceGone(format!("{}: {error}", folder.display())));
413 return Command::focus(field_input(FOLDER_FIELD));
414 }
415 }
416 }
417 std::fs::canonicalize(workspaces.store.root()).unwrap_or_else(|_| workspaces.store.root().to_path_buf())
418 } else {
419 workspaces.store.root().to_path_buf()
420 };
421 if let Some(problem) = draft.check(&store, workspaces.engine.is_some()) {
422 let field = problem.field();
423 draft.problem = Some(problem);
424 return Command::focus(field_input(field));
425 }
426
427 let folder = if in_place { draft.folder.clone() } else { None };
428 let file = match workspaces.store.create_workspace_in(&draft.name, Date::today_utc(), folder) {
429 Ok(file) => file,
430 Err(error) => {
431 let problem = Problem::from(error);
432 let field = problem.field();
433 draft.problem = Some(problem);
434 return Command::focus(field_input(field));
435 }
436 };
437
438 let paths = workspaces.store.workspace_paths(&file.id);
439 let fill = match (draft.source, draft.folder.clone(), workspaces.engine.clone()) {
440 (Source::Folder, Some(_), _) if in_place => {
442 workspaces.overlay = None;
443 workspaces.refocus = true;
444 return Command::batch([
445 Command::toast(Toast::success(t!("workspaces.created-toast", name = file.name))),
446 list(&workspaces.store),
447 ]);
448 }
449 (Source::Folder, Some(from), _) => Fill::Copy { from },
450 (Source::Git, _, Some(engine)) => Fill::Clone { engine, url: draft.url.trim().to_owned() },
451 _ => {
453 workspaces.overlay = None;
454 workspaces.refocus = true;
455 return Command::batch([
456 Command::toast(Toast::success(t!("workspaces.created-toast", name = file.name))),
457 list(&workspaces.store),
458 ]);
459 }
460 };
461
462 let label = match draft.source {
463 Source::Git => t!("workspaces.cloning", name = file.name.clone()),
464 _ => t!("workspaces.copying", name = file.name.clone()),
465 };
466 let job = Job { fill, root: paths.root, into: paths.code, id: file.id.as_str().to_owned() };
467 draft.busy = true;
468 draft.name = file.name;
469 draft.problem = None;
470 workspaces.log.clear();
471 workspaces.tasks.clear_finished();
472 let code = qframe::i18n::active_code();
473 let task =
474 Task::new(label, move |cx| crate::ui::in_language(&code, || fill::run(&job, cx))).on_event(Msg::Progress);
475 workspaces.job = Some(task.id());
476 Command::task(task)
477}
478
479fn finished(workspaces: &mut Workspaces, outcome: &TaskOutcome) -> Command<Msg> {
484 workspaces.job = None;
485 let name = match workspaces.overlay.as_mut() {
486 Some(Overlay::New(draft)) => {
487 draft.busy = false;
488 draft.name.clone()
489 }
490 _ => String::new(),
491 };
492 let told = match outcome {
493 TaskOutcome::Done => {
494 workspaces.overlay = None;
495 workspaces.refocus = true;
496 Command::toast(Toast::success(t!("workspaces.created-toast", name = name)))
497 }
498 TaskOutcome::Cancelled => {
499 workspaces.overlay = None;
500 workspaces.refocus = true;
501 Command::toast(
502 Toast::warning(t!("workspaces.cancelled-toast", name = name)).body(t!("workspaces.cancelled-body")),
503 )
504 }
505 TaskOutcome::Failed(detail) => {
509 if let Some(Overlay::New(draft)) = workspaces.overlay.as_mut() {
510 draft.problem = Some(Problem::Failed(detail.clone()));
511 }
512 Command::none()
513 }
514 };
515 Command::batch([told, list(&workspaces.store)])
516}
517
518fn ask_delete(workspaces: &mut Workspaces, index: usize) -> Command<Msg> {
521 if workspaces.deleting {
522 return Command::none();
523 }
524 let Some(entry) = workspaces.entries.get(index).cloned() else { return Command::none() };
525 workspaces.overlay = None;
526 let name = display_name(&entry);
527 let open = entry.file.as_ref().is_some_and(|file| workspaces.open.contains(&file.id));
528 if open {
529 return Command::toast(
530 Toast::warning(t!("workspaces.removal.open", name = name.as_str()))
531 .body(t!("workspaces.removal.open-body")),
532 );
533 }
534 workspaces.deleting = true;
535 let store = workspaces.store.clone();
536 let engine = workspaces.engine.clone();
537 Command::perform(move || Msg::Surveyed(Box::new(remove::survey(&store, engine.as_ref(), &entry, name))))
538}
539
540fn surveyed(workspaces: &mut Workspaces, survey: Box<Survey>) -> Command<Msg> {
543 let running = survey.running();
544 if !running.is_empty() {
545 workspaces.deleting = false;
546 return Command::toast(running_toast(&survey.name, running));
547 }
548 let folder =
549 if survey.in_place.is_some() { "workspaces.removal.folder-in-place" } else { "workspaces.removal.folder" };
550 let mut message = format!(
551 "{} {} {}",
552 t!(folder, path = survey.dir.display().to_string()),
553 engine_words(&survey.engine),
554 t!("workspaces.removal.kept")
555 );
556 if let Some(folder) = &survey.in_place {
559 message = format!("{message} {}", t!("workspaces.removal.in-place", path = folder.display().to_string()));
560 }
561 let question = Confirm::new(t!("workspaces.removal.title", name = survey.name.as_str()), Msg::DeleteConfirmed)
562 .message(message)
563 .confirm_label(t!("workspaces.removal.confirm"))
564 .on_cancel(Msg::DeleteKept)
565 .danger();
566 workspaces.doomed = Some(survey);
567 Command::confirm(question)
568}
569
570fn engine_words(reach: &Reach) -> String {
572 match reach {
573 Reach::Absent => t!("workspaces.removal.no-engine"),
574 Reach::Unreachable(said) => t!("workspaces.removal.unreachable", said = said.as_str()),
575 Reach::Listed { containers, volumes, .. } if containers.is_empty() && volumes.is_empty() => {
576 t!("workspaces.removal.nothing-in-engine")
577 }
578 Reach::Listed { containers, volumes, .. } => {
579 let all: Vec<&str> = containers.iter().chain(volumes).map(String::as_str).collect();
580 t!("workspaces.removal.engine", names = all.join(", "))
581 }
582 }
583}
584
585fn running_toast(name: &str, running: &[String]) -> Toast<Msg> {
587 Toast::warning(t!("workspaces.removal.running", name = name))
588 .body(t!("workspaces.removal.running-body", containers = running.join(", ")))
589}
590
591fn delete(workspaces: &mut Workspaces) -> Command<Msg> {
593 let Some(survey) = workspaces.doomed.take() else { return Command::none() };
594 let store = workspaces.store.clone();
595 let engine = workspaces.engine.clone();
596 let code = qframe::i18n::active_code();
597 Command::perform(move || {
598 let outcome = crate::ui::in_language(&code, || remove::remove(&store, engine.as_ref(), &survey));
599 Msg::Deleted(survey, outcome)
600 })
601}
602
603fn deleted(workspaces: &mut Workspaces, survey: &Survey, outcome: Outcome) -> Command<Msg> {
606 workspaces.deleting = false;
607 workspaces.refocus = true;
608 let told = match outcome {
609 Outcome::Deleted => {
610 workspaces.deleted.clone_from(&survey.id);
611 Toast::success(t!("workspaces.removal.done", name = survey.name.as_str()))
612 }
613 Outcome::Running(running) => running_toast(&survey.name, &running),
614 Outcome::Partly(left) => {
615 if !survey.dir.exists() {
617 workspaces.deleted.clone_from(&survey.id);
618 }
619 Toast::danger(t!("workspaces.removal.partly", name = survey.name.as_str())).body(left.join("\n"))
620 }
621 };
622 Command::batch([Command::toast(told), list(&workspaces.store)])
623}
624
625fn field_input(field: &str) -> &'static str {
627 match field {
628 URL_FIELD => URL_INPUT,
629 FOLDER_FIELD => LIST,
630 _ => NAME_INPUT,
631 }
632}
633
634const URL_INPUT: &str = "workspace-url";
636
637const SOURCE_GROUP: &str = "workspace-source";
639
640const PLACE_GROUP: &str = "workspace-place";
642
643const BROWSE_BUTTON: &str = "workspace-browse";
645
646const BROWSE_PICKER: &str = "workspace-browse-picker";
648
649fn start_folder(store: &Store) -> PathBuf {
652 std::env::home_dir().unwrap_or_else(|| store.root().to_path_buf())
653}
654
655pub fn view(workspaces: &Workspaces, ui: &mut View<'_, Msg>) {
657 page::column(ui, page::WIDTH, |ui| {
658 ui.column(|ui| {
659 ui.add(Text::new(t!("workspaces.title")).bold().no_wrap());
660 body(workspaces, ui);
661 })
662 .fill()
663 .gap(1);
664 });
665 overlay(workspaces, ui);
666}
667
668fn body(workspaces: &Workspaces, ui: &mut View<'_, Msg>) {
670 if !workspaces.listed {
671 ui.add(Skeleton::lines(SKELETON_ROWS)).fill_width();
674 return;
675 }
676 if let Some(trouble) = &workspaces.trouble {
677 ui.add(
678 EmptyState::new(t!("workspaces.unreadable-title"))
679 .icon("error")
680 .message(trouble.clone())
681 .action(Button::new(t!("workspaces.refresh")).on_press(Msg::Refresh)),
682 )
683 .fill();
684 return;
685 }
686 if workspaces.entries.is_empty() {
687 ui.add(
688 EmptyState::new(t!("workspaces.empty-title"))
689 .icon("inbox")
690 .message(t!("workspaces.empty-message"))
691 .action(Button::new(t!("workspaces.new")).variant("primary").on_press(Msg::Start)),
692 )
693 .fill()
694 .id(EMPTY);
695 return;
696 }
697 let new = ListItem::new(t!("workspaces.new")).icon("add", Some("accent"));
700 let items = std::iter::once(new).chain(workspaces.entries.iter().map(|entry| row(workspaces, entry)));
701 let selected = if workspaces.on_new { 0 } else { workspaces.selected + 1 };
702 let list = List::new(items)
703 .label_first(true)
704 .selected(Some(selected))
705 .on_select(|row| row.checked_sub(1).map_or(Msg::SelectNew, Msg::Select))
706 .on_activate(|row| row.checked_sub(1).map_or(Msg::Start, Msg::Activate));
707 ui.add(list.wrap(true)).id(LIST).fill();
708 ui.row(|ui| {
709 ui.spacer();
710 let mut delete = Button::new(t!("workspaces.removal.button")).variant("danger").loading(workspaces.deleting);
711 if !workspaces.deleting {
712 delete = delete.on_press(Msg::Delete);
713 }
714 ui.add(delete).id("workspace-delete");
715 })
716 .fill_width();
717}
718
719fn row(workspaces: &Workspaces, entry: &WorkspaceEntry) -> ListItem {
721 let item = ListItem::new(display_name(entry));
722 if entry.is_broken() {
723 let count = i64::try_from(entry.problems.len()).unwrap_or(i64::MAX);
724 return item.icon("error", Some("danger")).detail(format!(
725 "{} · {}",
726 t!("workspaces.broken"),
727 t!("workspaces.broken-detail", n = count)
728 ));
729 }
730 item.icon("folder", None).detail(when(workspaces, entry))
731}
732
733fn when(workspaces: &Workspaces, entry: &WorkspaceEntry) -> String {
735 let id = entry.file.as_ref().map(|file| file.id.as_str());
736 if let (Some(id), Some(recent)) = (id, workspaces.recent.first())
737 && id == recent
738 {
739 return t!("workspaces.last-opened");
740 }
741 match entry.file.as_ref().and_then(|file| file.created) {
742 Some(date) => t!("workspaces.created", date = date.to_string()),
743 None => t!("workspaces.undated"),
744 }
745}
746
747fn display_name(entry: &WorkspaceEntry) -> String {
749 match &entry.file {
750 Some(file) => file.name.clone(),
751 None => entry.dir.file_name().unwrap_or(entry.dir.as_os_str()).to_string_lossy().into_owned(),
752 }
753}
754
755fn overlay(workspaces: &Workspaces, ui: &mut View<'_, Msg>) {
757 match &workspaces.overlay {
758 None => {}
759 Some(Overlay::New(draft)) => new_workspace(workspaces, draft, ui),
760 Some(Overlay::Problems(index)) => {
761 if let Some(entry) = workspaces.entries.get(*index) {
762 problems(entry, ui);
763 }
764 }
765 Some(Overlay::Delete(chosen)) => choose_doomed(workspaces, *chosen, ui),
766 }
767}
768
769fn choose_doomed(workspaces: &Workspaces, chosen: usize, ui: &mut View<'_, Msg>) {
775 let dialog = Modal::new()
776 .title(t!("workspaces.removal.choose-title"))
777 .width(DIALOG_WIDTH)
778 .on_close(Msg::Dismiss)
779 .close_on_click_outside(true)
780 .action(Button::new(t!("workspaces.cancel")).on_press(Msg::Dismiss));
781 let items: Vec<ListItem> = workspaces.entries.iter().map(|entry| row(workspaces, entry)).collect();
782 let rows = u16::try_from(items.len()).unwrap_or(u16::MAX).min(LOG_ROWS);
783 ui.add_with(dialog, |ui| {
784 ui.add(Text::new(t!("workspaces.removal.choose-message")).role("secondary")).fill_width();
785 let list = List::new(items).selected(Some(chosen)).on_select(Msg::DeleteSelect).on_activate(Msg::DeletePick);
786 ui.add(list.wrap(true)).id(DELETE_LIST).height(Length::Cells(rows)).fill_width();
787 });
788}
789
790fn new_workspace(workspaces: &Workspaces, draft: &Draft, ui: &mut View<'_, Msg>) {
792 let mut dialog = Modal::new()
793 .title(t!("workspaces.new-title"))
794 .width(DIALOG_WIDTH)
795 .on_close(Msg::Dismiss)
796 .dismissable(!draft.busy);
799 dialog = match (draft.busy, workspaces.job) {
800 (true, Some(id)) => dialog.action(Button::new(t!("workspaces.stop")).variant("danger").on_press(Msg::Stop(id))),
801 _ => dialog
802 .action(Button::new(t!("workspaces.cancel")).on_press(Msg::Dismiss))
803 .action(Button::new(t!("workspaces.create")).variant("primary").on_press(Msg::Submit)),
804 };
805 ui.add_with(dialog, |ui| {
806 if draft.busy {
807 working(workspaces, ui);
808 } else {
809 form(workspaces, draft, ui);
810 }
811 });
812 if draft.browsing && !draft.busy && draft.source == Source::Folder && !roomy(ui) {
813 browse(draft, ui);
814 }
815}
816
817fn roomy(ui: &View<'_, Msg>) -> bool {
819 ui.size().height >= ROOMY_ROWS
820}
821
822fn browse(draft: &Draft, ui: &mut View<'_, Msg>) {
824 let rows = ui.size().height.saturating_sub(BROWSE_CHROME_ROWS);
825 let dialog = Modal::new()
826 .title(folder_label(draft))
827 .width(DIALOG_WIDTH)
828 .on_close(Msg::Browse(false))
829 .action(Button::new(t!("workspaces.cancel")).on_press(Msg::Browse(false)));
830 ui.add_with(dialog, |ui| {
831 FilePicker::new(&draft.browser, Msg::Picker)
832 .show(ui)
833 .id(BROWSE_PICKER)
834 .height(Length::Cells(rows))
835 .fill_width();
836 });
837}
838
839fn folder_label(draft: &Draft) -> String {
841 match draft.place {
842 Place::Copy => t!("workspaces.folder-label"),
843 Place::InPlace => t!("workspaces.place-folder-label"),
844 }
845}
846
847fn form(workspaces: &Workspaces, draft: &Draft, ui: &mut View<'_, Msg>) {
849 let mut errors = FormErrors::new();
850 if let Some(problem) = draft.problem.as_ref().or(draft.typing_problem().as_ref()) {
851 errors.set(problem.field(), problem.message());
852 }
853 let engine = workspaces.engine.is_some();
854 let preview = draft.preview().map(|id| t!("workspaces.name-preview", id = id));
855 Form::new().summary(&errors).show(ui, |fields| {
856 let mut name = Field::new(t!("workspaces.name-label")).required(true).error(errors.get(NAME_FIELD));
857 if let Some(preview) = preview {
858 name = name.hint(preview);
859 }
860 fields.field(name, |ui| {
861 ui.add(
862 TextInput::new(&draft.name)
863 .placeholder(t!("workspaces.name-placeholder"))
864 .invalid(errors.has(NAME_FIELD))
865 .on_change(Msg::Name)
866 .on_submit(|_| Msg::Submit),
867 )
868 .id(NAME_INPUT)
869 .fill_width();
870 });
871 fields.field(Field::new(t!("workspaces.source-label")), |ui| {
872 let group = RadioGroup::new(Source::ALL.map(Source::label))
876 .horizontal(true)
877 .selected(Some(draft.source.index()))
878 .on_select(Msg::Source);
879 ui.add(group.wrap(true)).id(SOURCE_GROUP).fill_width();
880 });
881 match draft.source {
882 Source::Empty => {}
883 Source::Folder => {
884 let chosen = match &draft.folder {
885 Some(folder) => folder.display().to_string(),
886 None => t!("workspaces.folder-none"),
887 };
888 let hint = match draft.place {
891 Place::Copy => t!("workspaces.folder-hint"),
892 Place::InPlace => t!("workspaces.place-in-place-hint"),
893 };
894 let roomy = roomy(fields.ui());
895 let field = Field::new(folder_label(draft)).hint(hint).error(errors.get(FOLDER_FIELD));
896 fields.field(field, |ui| {
897 ui.row(|ui| {
898 ui.add(Text::new(chosen).role(if draft.folder.is_some() { "body" } else { "secondary" }))
899 .fill_width();
900 if !roomy {
901 ui.add(Button::new(t!("workspaces.folder-browse")).on_press(Msg::Browse(true)))
902 .id(BROWSE_BUTTON);
903 }
904 })
905 .gap(2)
906 .fill_width();
907 });
908 fields.field(Field::new(t!("workspaces.place-label")), |ui| {
909 let group = RadioGroup::new(Place::ALL.map(Place::label))
910 .horizontal(true)
911 .selected(Some(draft.place.index()))
912 .on_select(Msg::Place);
913 ui.add(group.wrap(true)).id(PLACE_GROUP).fill_width();
914 });
915 if roomy {
916 FilePicker::new(&draft.browser, Msg::Picker)
917 .show(fields.ui())
918 .height(Length::Cells(PICKER_ROWS))
919 .fill_width();
920 }
921 }
922 Source::Git => {
923 let mut field = Field::new(t!("workspaces.url-label")).disabled(!engine).error(errors.get(URL_FIELD));
924 if !engine {
925 field = field.hint(t!("workspaces.engine-missing"));
926 }
927 fields.field(field, |ui| {
928 ui.add(
929 TextInput::new(&draft.url)
930 .placeholder(t!("workspaces.url-placeholder"))
931 .disabled(!engine)
932 .invalid(errors.has(URL_FIELD))
933 .on_change(Msg::Url)
934 .on_submit(|_| Msg::Submit),
935 )
936 .id(URL_INPUT)
937 .fill_width();
938 });
939 }
940 }
941 });
942}
943
944fn working(workspaces: &Workspaces, ui: &mut View<'_, Msg>) {
946 ui.column(|ui| {
947 TaskList::new(&workspaces.tasks).show(ui).fill_width();
948 ui.add(LogView::new(&workspaces.log).empty_text(t!("workspaces.log-empty")))
949 .id("workspace-log")
950 .height(Length::Cells(LOG_ROWS))
951 .fill_width();
952 })
953 .fill_width()
954 .gap(1);
955}
956
957fn problems(entry: &WorkspaceEntry, ui: &mut View<'_, Msg>) {
959 let dialog = Modal::new()
960 .title(t!("workspaces.broken-title", name = display_name(entry)))
961 .width(DIALOG_WIDTH)
962 .variant("danger")
963 .on_close(Msg::Dismiss)
964 .close_on_click_outside(true)
965 .action(Button::new(t!("workspaces.close")).on_press(Msg::Dismiss));
966 let lines: Vec<String> = entry.problems.iter().map(ToString::to_string).collect();
967 ui.add_with(dialog, |ui| {
968 ui.add(Text::new(t!("workspaces.broken-message")).role("secondary")).fill_width();
969 ui.add_with(ScrollView::new(), |ui| {
970 for line in lines {
971 ui.add(Text::new(line)).fill_width();
972 }
973 })
974 .height(Length::Cells(LOG_ROWS))
975 .fill_width();
976 });
977}
978
979#[must_use]
981pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
982 let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
983 vec![(move_keys, t!("hints.move")), (icons.glyph("enter").into_owned(), t!("hints.open"))]
984}