Skip to main content

qcode/ui/workspaces/
draft.rs

1//! The new-workspace dialog's own state: the name, where the content comes from, and everything
2//! that can stop a workspace being made.
3//!
4//! Nothing here touches the disk. Every problem is a value the view turns into a sentence from
5//! the language files, so the same check reads the same way in every language and can be tested
6//! without a screen.
7
8use std::path::{Path, PathBuf};
9
10use qframe::t;
11use qframe::widgets::{FileBrowser, PickMode};
12
13use crate::store::{NewWorkspaceError, WorkspaceId, WorkspaceIdError};
14
15/// Where a new workspace's content comes from.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Source {
18    /// Nothing but the folder tree of a workspace.
19    Empty,
20    /// A folder of the person's own, copied in. Their folder is never moved.
21    Folder,
22    /// A git address, cloned inside a container.
23    Git,
24}
25
26impl Source {
27    /// The sources in the order the dialog offers them.
28    pub const ALL: [Self; 3] = [Self::Empty, Self::Folder, Self::Git];
29
30    /// The source at `index` of [`Source::ALL`]; anything else is the plain empty workspace, which
31    /// is the one source that can never fail.
32    #[must_use]
33    pub fn from_index(index: usize) -> Self {
34        Self::ALL.get(index).copied().unwrap_or(Self::Empty)
35    }
36
37    /// Its place in [`Source::ALL`].
38    #[must_use]
39    pub fn index(self) -> usize {
40        Self::ALL.iter().position(|source| *source == self).unwrap_or(0)
41    }
42
43    /// The translated label of its segment.
44    #[must_use]
45    pub fn label(self) -> String {
46        match self {
47            Self::Empty => t!("workspaces.source-empty"),
48            Self::Folder => t!("workspaces.source-folder"),
49            Self::Git => t!("workspaces.source-git"),
50        }
51    }
52}
53
54/// What becomes of a folder a new workspace starts from.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Place {
57    /// Copied into the workspace's own `Work/`; the person's folder is only read. The default,
58    /// because it is the one that cannot change anything of theirs.
59    Copy,
60    /// Used where it stands: nothing is copied, and the agents work in the folder itself.
61    InPlace,
62}
63
64impl Place {
65    /// The two, in the order the dialog offers them.
66    pub const ALL: [Self; 2] = [Self::Copy, Self::InPlace];
67
68    /// The one at `index` of [`Place::ALL`]; anything else is the copy, which changes nothing of
69    /// the person's.
70    #[must_use]
71    pub fn from_index(index: usize) -> Self {
72        Self::ALL.get(index).copied().unwrap_or(Self::Copy)
73    }
74
75    /// Its place in [`Place::ALL`].
76    #[must_use]
77    pub fn index(self) -> usize {
78        Self::ALL.iter().position(|place| *place == self).unwrap_or(0)
79    }
80
81    /// The translated label of its option.
82    #[must_use]
83    pub fn label(self) -> String {
84        match self {
85            Self::Copy => t!("workspaces.place-copy"),
86            Self::InPlace => t!("workspaces.place-in-place"),
87        }
88    }
89}
90
91/// What stopped a workspace being made, kept apart from its wording.
92///
93/// A name that is already taken is never quietly turned into `name-2`: it becomes
94/// [`Problem::Taken`], which the dialog shows beside the name field and asks the person to
95/// change.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum Problem {
98    /// The name yields no identifier, and why.
99    Name(WorkspaceIdError),
100    /// A workspace of this identifier is already in the store.
101    Taken(String),
102    /// The store could not be written; the diagnostic says which path and why.
103    Blocked(String),
104    /// No folder was chosen to copy.
105    NoFolder,
106    /// The chosen folder holds the store, so copying it would copy the copy.
107    FolderHoldsStore,
108    /// The folder to use where it stands and the store are one inside the other: the agents
109    /// would reach every workspace and profile, or work inside QCode's own folder.
110    PlaceTouchesStore,
111    /// The folder to use where it stands is not there, or is no folder; the text is what the file
112    /// system said.
113    PlaceGone(String),
114    /// No address was written to clone.
115    NoUrl,
116    /// An address that would reach git as an option rather than a repository.
117    UrlIsOption,
118    /// Cloning was asked for while no container engine is there to clone in.
119    NoEngine,
120    /// The copy or the clone ran and did not finish; the text is what it said.
121    Failed(String),
122}
123
124impl Problem {
125    /// The field the problem belongs beside.
126    #[must_use]
127    pub fn field(&self) -> &'static str {
128        match self {
129            Self::Name(_) | Self::Taken(_) => NAME_FIELD,
130            Self::NoFolder | Self::FolderHoldsStore | Self::PlaceTouchesStore | Self::PlaceGone(_) => FOLDER_FIELD,
131            Self::NoUrl | Self::UrlIsOption | Self::NoEngine => URL_FIELD,
132            // The store refusing to be written, and work that ran and stopped, are about
133            // nothing the person typed; marking a field would blame the wrong thing, so they
134            // are only told above the form.
135            Self::Blocked(_) | Self::Failed(_) => WORK_FIELD,
136        }
137    }
138
139    /// The sentence the person reads.
140    #[must_use]
141    pub fn message(&self) -> String {
142        match self {
143            Self::Name(problem) => problem.said(),
144            Self::Taken(id) => t!("workspaces.name-taken", id = id.as_str()),
145            Self::Blocked(detail) => format!("{} {detail}", t!("workspaces.blocked")),
146            Self::NoFolder => t!("workspaces.folder-missing"),
147            Self::FolderHoldsStore => t!("workspaces.folder-inside"),
148            Self::PlaceTouchesStore => t!("workspaces.place-store"),
149            Self::PlaceGone(detail) => format!("{} {detail}", t!("workspaces.place-gone")),
150            Self::NoUrl => t!("workspaces.url-missing"),
151            Self::UrlIsOption => t!("workspaces.url-flag"),
152            Self::NoEngine => t!("workspaces.engine-missing"),
153            Self::Failed(detail) => format!("{} {detail}", t!("workspaces.failed")),
154        }
155    }
156}
157
158impl From<NewWorkspaceError> for Problem {
159    fn from(error: NewWorkspaceError) -> Self {
160        match error {
161            NewWorkspaceError::Name(problem) => Self::Name(problem),
162            NewWorkspaceError::Taken(id) => Self::Taken(id.as_str().to_owned()),
163            NewWorkspaceError::Blocked(diagnostic) => Self::Blocked(diagnostic.to_string()),
164        }
165    }
166}
167
168/// The name of the name field, which the error summary and the focus use.
169pub const NAME_FIELD: &str = "name";
170
171/// The name of the folder field.
172pub const FOLDER_FIELD: &str = "folder";
173
174/// The name of the address field.
175pub const URL_FIELD: &str = "url";
176
177/// The name of the problem that belongs to no field: it is shown above the form and nowhere else.
178pub const WORK_FIELD: &str = "work";
179
180/// Everything the new-workspace dialog holds while it is open.
181#[derive(Debug)]
182pub struct Draft {
183    /// The name as it is being typed.
184    pub name: String,
185    /// Which of the three sources is chosen.
186    pub source: Source,
187    /// The git address, while one is being written.
188    pub url: String,
189    /// The folder browser of the folder source.
190    pub browser: FileBrowser,
191    /// The folder chosen to start from.
192    pub folder: Option<PathBuf>,
193    /// Whether that folder is copied or used where it stands.
194    pub place: Place,
195    /// What stopped the last attempt, until something is changed.
196    pub problem: Option<Problem>,
197    /// Whether the long work is running, which turns the dialog into its own progress.
198    pub busy: bool,
199    /// Whether the folder browser is open in a dialog of its own, which is where it opens on a
200    /// terminal too short to hold it inside the form.
201    pub browsing: bool,
202}
203
204impl Draft {
205    /// An empty draft whose folder browser starts at `start`.
206    #[must_use]
207    pub fn new(start: PathBuf) -> Self {
208        Self {
209            name: String::new(),
210            source: Source::Empty,
211            url: String::new(),
212            browser: FileBrowser::new(start, PickMode::Folders),
213            folder: None,
214            place: Place::Copy,
215            problem: None,
216            busy: false,
217            browsing: false,
218        }
219    }
220
221    /// The identifier the name would get on disk, while it yields one.
222    #[must_use]
223    pub fn preview(&self) -> Option<String> {
224        WorkspaceId::from_display_name(&self.name).ok().map(|id| id.as_str().to_owned())
225    }
226
227    /// The problem of the name as it is being typed, or `None` while it is still empty: an
228    /// untouched field is not a mistake, it is a field nobody has reached yet.
229    #[must_use]
230    pub fn typing_problem(&self) -> Option<Problem> {
231        if self.name.trim().is_empty() {
232            return None;
233        }
234        WorkspaceId::from_display_name(&self.name).err().map(Problem::Name)
235    }
236
237    /// Everything that can be answered without touching the disk, checked in the order the
238    /// dialog reads: first the name, then whatever the chosen source needs.
239    ///
240    /// `store` is the store folder, which a folder to copy may not contain, and `engine`
241    /// says whether there is a container engine to clone in.
242    #[must_use]
243    pub fn check(&self, store: &Path, engine: bool) -> Option<Problem> {
244        if let Err(problem) = WorkspaceId::from_display_name(&self.name) {
245            return Some(Problem::Name(problem));
246        }
247        match self.source {
248            Source::Empty => None,
249            Source::Folder => match (&self.folder, self.place) {
250                (None, _) => Some(Problem::NoFolder),
251                // A folder holding the store would be copied into its own copy, which never
252                // ends; the store is inside it exactly when its path starts with the folder.
253                (Some(folder), Place::Copy) if store.starts_with(folder) => Some(Problem::FolderHoldsStore),
254                (Some(_), Place::Copy) => None,
255                // Mounted as the work folder, a folder holding the store hands the agents every
256                // workspace and profile; one inside the store works in QCode's own folder, where
257                // deleting another workspace could take it along.
258                (Some(folder), Place::InPlace) if store.starts_with(folder) || folder.starts_with(store) => {
259                    Some(Problem::PlaceTouchesStore)
260                }
261                (Some(_), Place::InPlace) => None,
262            },
263            Source::Git => match self.url.trim() {
264                "" => Some(Problem::NoUrl),
265                url if url.starts_with('-') => Some(Problem::UrlIsOption),
266                _ if !engine => Some(Problem::NoEngine),
267                _ => None,
268            },
269        }
270    }
271}