Skip to main content

qcode/ui/workspaces/
mod.rs

1//! The workspaces screen: the workspaces of the store, and the dialog that makes a new one.
2//!
3//! The screen is a state, an [`update`] and a [`view`], and it speaks its own [`Msg`], which
4//! the application maps to its own. Send it [`Msg::Refresh`] when it opens;
5//! everything else follows from what the person does. Opening a workspace is the one thing it
6//! cannot do itself, so [`update`] hands that workspace's identifier back to the application.
7//!
8//! Four things it never does quietly. A name that is already taken is told to the person and
9//! not turned into `name-2`. A folder chosen to start from is copied, never moved, so their own
10//! copy stays where it is — or, when the person chooses so in plain words, used where it stands,
11//! and then never deleted with the workspace. A copy or a clone that fails or is stopped takes the
12//! workspace folder it was filling with it, so a half-made workspace is never left on the list.
13
14mod 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
42/// Width of the dialogs, in cells. Wide enough for a path and a git address to read as one line
43/// on a normal terminal; a narrower screen shrinks them.
44const DIALOG_WIDTH: u16 = 68;
45
46/// Rows the folder browser gets inside the dialog.
47const PICKER_ROWS: u16 = 12;
48
49/// The fewest rows a terminal has for the folder browser to stand inside the form. The form's
50/// fields above it take most of a short terminal, and the browser's own path, filter and button
51/// take six of its rows, so on a shorter one the browser would show a folder or two at a time, or
52/// none; there it opens in a dialog of its own instead, which has the whole height for folders.
53const ROOMY_ROWS: u16 = 34;
54
55/// Rows the dialog of the folder browser keeps for its title, its buttons and its frame.
56const BROWSE_CHROME_ROWS: u16 = 8;
57
58/// Rows the live log gets while a workspace is being filled.
59const LOG_ROWS: u16 = 10;
60
61/// Rows the skeleton stands in for while the first listing is on its way.
62const SKELETON_ROWS: u16 = 6;
63
64/// How many lines of the log are kept. A clone of a large repository says a great deal, and only
65/// the end of it is ever read.
66const LOG_LINES: usize = 2000;
67
68/// What can happen on the workspaces screen.
69#[derive(Debug, Clone, PartialEq)]
70pub enum Msg {
71    /// Read the store again. This is also the message that opens the screen.
72    Refresh,
73    /// The store answered.
74    Listed {
75        /// Every workspace folder that was found, broken ones included.
76        entries: Vec<WorkspaceEntry>,
77        /// Why the store itself could not be read, when that is what happened.
78        trouble: Option<String>,
79    },
80    /// The selection moved to the workspace at this place in the list.
81    Select(usize),
82    /// The selection moved to the row that makes a new workspace.
83    SelectNew,
84    /// A row was opened with Enter or a click.
85    Activate(usize),
86    /// Open the new-workspace dialog.
87    Start,
88    /// Close whatever is open over the screen.
89    Dismiss,
90    /// The name is being typed.
91    Name(String),
92    /// Another source was chosen.
93    Source(usize),
94    /// The folder is to be copied, or used where it stands: the place in [`Place::ALL`].
95    Place(usize),
96    /// The git address is being written.
97    Url(String),
98    /// Something happened in the folder browser.
99    Picker(FilePickerMsg),
100    /// The folder browser was opened in a dialog of its own, or that dialog was closed.
101    Browse(bool),
102    /// Make the workspace.
103    Submit,
104    /// One line of the copy or the clone.
105    Line(String),
106    /// The filling task reported.
107    Progress(TaskEvent),
108    /// Stop the filling task.
109    Stop(TaskId),
110    /// The filling finished.
111    Filled,
112    /// Open the dialog that asks which workspace to delete.
113    Delete,
114    /// The selection of that dialog moved.
115    DeleteSelect(usize),
116    /// A workspace was chosen in that dialog: the engine is asked what it holds of it first.
117    DeletePick(usize),
118    /// The engine answered what it holds of the workspace to be deleted.
119    Surveyed(Box<Survey>),
120    /// The question was answered with yes.
121    DeleteConfirmed,
122    /// The question was answered with no.
123    DeleteKept,
124    /// The deletion is over.
125    Deleted(Box<Survey>, Outcome),
126}
127
128/// What is open over the list.
129#[derive(Debug)]
130enum Overlay {
131    /// The new-workspace dialog.
132    New(Box<Draft>),
133    /// The problems of the broken workspace at this place in the list.
134    Problems(usize),
135    /// The choice of the workspace to delete, with the one at this place in the list chosen.
136    Delete(usize),
137}
138
139/// The workspaces screen.
140#[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    /// The control the keyboard was on went away with a closed dialog or a deleted workspace, so
149    /// the next listing puts it back on the list, or on the empty page's button.
150    refocus: bool,
151    selected: usize,
152    /// Whether the selection is on the row that makes a new workspace rather than on `selected`.
153    on_new: bool,
154    overlay: Option<Overlay>,
155    tasks: Tasks,
156    log: LogBuffer,
157    job: Option<TaskId>,
158    /// The workspaces open in this QCode, which are not deleted from under their tabs.
159    open: Vec<WorkspaceId>,
160    /// A workspace is being looked at or deleted, so the button takes no second press.
161    deleting: bool,
162    /// What the question being asked is about.
163    doomed: Option<Box<Survey>>,
164    /// The workspace deleted last, for the application to forget, taken once.
165    deleted: Option<WorkspaceId>,
166}
167
168impl Workspaces {
169    /// A screen over `store`, cloning inside `engine` when there is one, with `recent`
170    /// holding the identifiers of the workspaces opened before, the most recent first.
171    #[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    /// Tells the screen which workspaces are open in this QCode right now: those are not deleted,
195    /// because their tabs are working in them.
196    pub fn set_open(&mut self, open: Vec<WorkspaceId>) {
197        self.open = open;
198    }
199
200    /// The workspace deleted last, taken once, so the application can forget it wherever it
201    /// remembers it.
202    pub fn just_deleted(&mut self) -> Option<WorkspaceId> {
203        self.deleted.take()
204    }
205
206    /// Whether a workspace is being filled right now, which is what makes leaving the screen
207    /// something to ask about.
208    #[must_use]
209    pub fn is_busy(&self) -> bool {
210        self.job.is_some()
211    }
212}
213
214/// Applies a message of the workspaces screen, answering with the work to do and the workspace the
215/// person opened, when they opened one.
216pub 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            // The list is not on screen until the store has answered, so the keyboard is put
227            // on it here rather than when the screen opened. Only the first answer does it, and
228            // the answer after a dialog or a workspace went away with the keyboard's control: a
229            // refresh must never take the keyboard off what the person is in the middle of.
230            let refocus = std::mem::take(&mut workspaces.refocus);
231            if (first || refocus) && workspaces.overlay.is_none() {
232                // An empty list has one button, which is what Enter should press.
233                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        // The task's own success message; the screen acts on the outcome that follows it, so
294        // there is only one place where a finished job is tidied away.
295        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
325/// The name of the name input, so the dialog can put the cursor in it as it opens.
326const NAME_INPUT: &str = "workspace-name";
327
328/// The name of the list, for the focus and the tests.
329const LIST: &str = "workspaces";
330
331/// The name of the empty list's page, whose one button makes the first workspace.
332const EMPTY: &str = "workspaces-empty";
333
334/// The name of the list the workspace to delete is chosen from.
335const DELETE_LIST: &str = "workspace-delete-list";
336
337/// Reads the store on a background thread.
338///
339/// The folders are made first: a store that has never been used has nothing to list, and a
340/// store that cannot be made is the same problem as one that cannot be read.
341fn 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            // A store that cannot be read at all answers with no workspaces and one diagnostic;
351            // anything else belongs to a workspace and is shown on its row.
352            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
361/// Opening a row: a broken workspace shows what is wrong with it, a whole one is handed to the
362/// application to open.
363fn 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            // A workspace whose file could not be read has no identifier to open it by, and it is
374            // already listed as broken, so there is nothing to hand on.
375            *opened = entry.file.as_ref().map(|file| file.id.clone());
376            Command::none()
377        }
378    }
379}
380
381/// Changes the draft and forgets what stopped the last attempt: the person has just answered it.
382fn 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
390/// Makes the workspace, and starts filling it when the source asks for it.
391///
392/// The folder is made here rather than on the task's thread, so a name that is taken is answered
393/// while the person is still looking at the field they typed it in.
394fn 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    // A folder used where it stands is named in the workspace file by the path the file system
400    // itself gives it: absolute, and with no link on the way that could later point elsewhere.
401    // Both sides are compared that way, so a store reached through a link is still found inside it.
402    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        // Nothing to fill: the workspace works in the person's folder, which is whole already.
441        (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        // An empty workspace is whole the moment its folders are there.
452        _ => {
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
479/// Tidies up after the filling task, whichever way it ended.
480///
481/// A run that failed or was stopped has already removed the workspace folder, so all three ways
482/// out end with the list being read again.
483fn 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        // A failure keeps the dialog open and is told inside it, above the form: it is about the
506        // dialog's own action, and a toast would only wait behind the dialog until it closed.
507        // The name is still there to try again with.
508        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
518/// Starts deleting the workspace at `index` by asking the engine what it holds of it, so that the
519/// question can name everything that goes. A workspace open in this QCode is refused at once.
520fn 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
540/// Asks the question, once, naming what goes and what stays; or says why there is nothing to
541/// ask, when a container of the workspace is running.
542fn 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    // The folder the agents worked in is the person's; the question says so in so many words,
557    // because "the workspace is deleted" is exactly where someone fears for it.
558    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
570/// What the question says about the engine's part of the workspace.
571fn 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
585/// The toast that says a running container is what keeps a workspace from being deleted.
586fn 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
591/// Deletes what the question was about, on a background thread.
592fn 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
603/// Says how the deletion went and reads the list again, which is the only honest account of what
604/// is left.
605fn 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            // The folder may be gone even so; whatever is left is named, and the list says the rest.
616            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
625/// Which input a problem asks the person to go back to.
626fn field_input(field: &str) -> &'static str {
627    match field {
628        URL_FIELD => URL_INPUT,
629        FOLDER_FIELD => LIST,
630        _ => NAME_INPUT,
631    }
632}
633
634/// The name of the address input.
635const URL_INPUT: &str = "workspace-url";
636
637/// The name the choice of where a new workspace starts from is focused by.
638const SOURCE_GROUP: &str = "workspace-source";
639
640/// The name of the choice between copying a folder and using it where it stands.
641const PLACE_GROUP: &str = "workspace-place";
642
643/// The name of the button that opens the folder browser in a dialog of its own.
644const BROWSE_BUTTON: &str = "workspace-browse";
645
646/// The name of the folder browser in its own dialog.
647const BROWSE_PICKER: &str = "workspace-browse-picker";
648
649/// Where the folder browser starts: the person's home folder, or the store when there is no
650/// home folder to be had.
651fn start_folder(store: &Store) -> PathBuf {
652    std::env::home_dir().unwrap_or_else(|| store.root().to_path_buf())
653}
654
655/// Draws the workspaces screen.
656pub 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
668/// The list, or whatever stands in its place.
669fn body(workspaces: &Workspaces, ui: &mut View<'_, Msg>) {
670    if !workspaces.listed {
671        // Only the first reading shows a skeleton. Every later one keeps the list that is
672        // already there until the new one arrives, so nothing blinks while it is refreshed.
673        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    // The way to a new workspace is the list's first row, so the keyboard reaches it the way it
698    // reaches every workspace, and the list and its one action start on the same edge.
699    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
719/// One workspace's row: what it is called, and either why it is broken or when it was last seen.
720fn 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
733/// What a row says on its right: that this is the workspace opened last, or the day it was made.
734fn 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
747/// The name a workspace is shown by: what its file calls it, or its folder when the file is gone.
748fn 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
755/// Whatever is open over the list.
756fn 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
769/// The choice of the workspace to delete.
770///
771/// A click on a row of the list opens that workspace, so the list itself cannot also be where a
772/// workspace is picked for deleting; this dialog is, and a click here only chooses. Nothing is
773/// deleted from it: the question that names everything that goes comes after it.
774fn 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
790/// The new-workspace dialog: the form, or the work it started.
791fn 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        // While the work runs, Esc and the close mark would leave a workspace half made; the way
797        // out is the button that stops the work and undoes it.
798        .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
817/// Whether the terminal is tall enough for the folder browser to stand inside the form.
818fn roomy(ui: &View<'_, Msg>) -> bool {
819    ui.size().height >= ROOMY_ROWS
820}
821
822/// The folder browser in a dialog of its own, over the form, with every row the terminal has.
823fn 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
839/// The label of the folder field, which says what becomes of the folder chosen in it.
840fn 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
847/// The form of the dialog.
848fn 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            // A radio group rather than segments: its chosen option carries a mark of its own, so
873            // the plain empty workspace reads as the one chosen, where a lit segment under a resting
874            // pointer looked like a choice of its own.
875            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                // Said where the folder is chosen: under "use it where it is", the agents change the
889                // person's real files, and that is the one thing they must not learn later.
890                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
944/// The dialog while the copy or the clone runs: what it is doing, and everything it has said.
945fn 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
957/// What is wrong with a broken workspace, in the words of whoever found it.
958fn 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/// The keys of the workspaces screen that are not in the keymap, for the key list.
980#[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}