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}