Skip to main content

brep_app/panels/
plm_workspace.rs

1//! The workspace browser (plm-cad-integration-todo §3 S14): in PLM mode it replaces the
2//! file explorer. Decisions D9, D12 and D13; the server half is P6
3//! (`BREP_plm/src/workspace.rs`).
4//!
5//! * **Folders of links and files, per user.** A user arranges folders; a **link** points at
6//!   a part and follows its newest revision (drafts included), or is pinned to one revision.
7//!   A link is never a copy: opening it opens the part's document, and deleting it never
8//!   touches the part. A folder can also hold **files** (a customer's photo, notes), whose
9//!   every replace keeps the version before.
10//! * **Other users' workspaces**, read-only, when the administrator lets workspaces be
11//!   browsed (`workspaces_browsable`, on by default). The server refuses otherwise, and its
12//!   sentence is shown.
13//! * **Open by number (D13).** Part and Revision fields: a part id or number fills Revision
14//!   with the newest revision, drafts included; the user may pick another before opening.
15//! * **The verbs are the workspace's (D9).** New folder, Rename, Move and Delete act on the
16//!   workspace alone. Delete removes the link or the file, never a part or a revision.
17//! * **Files:** add one (the host hands the bytes in — [`WorkspaceBrowser::add_file`]; the
18//!   shell has no native file chooser, so a file arrives by drag and drop), open or download
19//!   a version, list versions, restore one (as a new version), and **Promote to attachment**
20//!   on a part or revision, which obeys every attachment refusal (S8).
21//!
22//! Every refusal is the server's own sentence, shown as sent. The browser talks through
23//! [`Workspaces`], which S1's client implements ([`PlmWorkspaces`]); the golden tests below
24//! run it against the REAL router. Nothing here is constructed without a server.
25
26use crate::automation::hit_keys::HitKeyDoc;
27use crate::plm::PlmFuture;
28use eframe::egui;
29use serde::Deserialize;
30use serde_json::{json, Value};
31use std::collections::HashMap;
32use std::task::Waker;
33
34use super::plm_attachments::{human_size, media_type_for, Attachment};
35use super::plm_parts::Pending;
36
37/// Hit keys the browser publishes, under the host's prefix (`<prefix>ws:…`).
38pub const HIT_KEYS: &[HitKeyDoc] = &[
39    HitKeyDoc { panel: "plm_workspace", prefix: "ws:entry:", meaning: "a workspace row, by entry name; click selects, double click opens a folder, a link's document or a file", command: None },
40    HitKeyDoc { panel: "plm_workspace", prefix: "ws:crumb:", meaning: "the breadcrumb: ws:crumb:0 is the top of the workspace", command: None },
41    HitKeyDoc { panel: "plm_workspace", prefix: "ws:owner", meaning: "whose workspace is shown (yours, or another user's, read-only)", command: None },
42    HitKeyDoc { panel: "plm_workspace", prefix: "ws:open-", meaning: "open by number: ws:open-part, ws:open-revision, ws:open-go", command: None },
43    HitKeyDoc { panel: "plm_workspace", prefix: "ws:new-folder", meaning: "New folder: ws:new-folder-name then ws:new-folder", command: None },
44    HitKeyDoc { panel: "plm_workspace", prefix: "ws:link-", meaning: "Link a part here: ws:link-part, ws:link-revision (empty follows the newest), ws:link-go", command: None },
45    HitKeyDoc { panel: "plm_workspace", prefix: "ws:sel:", meaning: "the selected entry's verbs: open, rename, rename-name, rename-go, cut, delete, delete-confirm, pin, follow, versions, download, promote, promote-part, promote-revision, promote-go", command: None },
46    HitKeyDoc { panel: "plm_workspace", prefix: "ws:paste", meaning: "move the cut entry into the folder shown", command: None },
47    HitKeyDoc { panel: "plm_workspace", prefix: "ws:version:", meaning: "a file's version row: ws:version:<n>:restore, ws:version:<n>:download", command: None },
48    HitKeyDoc { panel: "plm_workspace", prefix: "ws:reload", meaning: "reload the folder", command: None },
49    HitKeyDoc { panel: "plm_workspace", prefix: "ws:add-file", meaning: "Add file…: choose a file on this machine (the file chooser) to add to the folder shown", command: Some("file_pick") },
50];
51
52// --- the wire ------------------------------------------------------------------
53//
54// These mirror `BREP_plm/src/workspace.rs` and `src/api/workspace.rs`. The golden tests
55// below pin them against the REAL router. Every field defaults, so a server that adds a
56// field breaks nothing.
57
58/// A workspace the user may open (`GET /api/workspaces`).
59#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
60#[serde(default)]
61pub struct WorkspaceRow {
62    pub user_id: String,
63    pub username: String,
64    pub display_name: String,
65    pub mine: bool,
66    pub entries: usize,
67}
68
69#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
70#[serde(default)]
71pub struct WorkspaceList {
72    pub workspaces: Vec<WorkspaceRow>,
73    /// The administrator's setting: whether other users' workspaces may be browsed.
74    pub browsable: bool,
75}
76
77/// What a link resolves to now.
78#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
79#[serde(default)]
80pub struct LinkView {
81    pub part_id: String,
82    /// Pinned to one revision; `false` follows the newest, drafts included.
83    pub pinned: bool,
84    pub number: String,
85    pub name: String,
86    pub document_class: String,
87    pub revision_id: String,
88    pub revision_label: String,
89    pub lifecycle: String,
90    /// `part/<part>/rev/<revision>`: what opens.
91    pub document_key: String,
92    pub part_missing: bool,
93    /// Pinned to a draft that was since deleted.
94    pub revision_missing: bool,
95}
96
97/// A file's current version, in brief.
98#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
99#[serde(default)]
100pub struct FileView {
101    pub version: u32,
102    pub versions: usize,
103    pub size: u64,
104    pub media_type: String,
105    pub sha256: String,
106    pub uploaded_at: u64,
107    pub uploaded_by_name: String,
108}
109
110/// One entry: a folder, a link or a file.
111#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
112#[serde(default)]
113pub struct Entry {
114    pub id: String,
115    pub owner: String,
116    pub owner_name: String,
117    pub parent: String,
118    pub name: String,
119    /// `folder`, `link` or `file`.
120    pub kind: String,
121    pub created_at: u64,
122    pub modified_at: u64,
123    /// A folder's entry count.
124    pub children: Option<usize>,
125    pub link: Option<LinkView>,
126    pub file: Option<FileView>,
127}
128
129impl Entry {
130    pub fn is_folder(&self) -> bool {
131        self.kind == "folder"
132    }
133
134    /// What the row says after the name.
135    pub fn detail(&self) -> String {
136        match (&self.link, &self.file, self.children) {
137            (Some(link), _, _) if link.part_missing => "the part is gone".into(),
138            (Some(link), _, _) if link.revision_missing => format!("{} — the pinned revision was deleted", link.number),
139            (Some(link), _, _) => {
140                let how = if link.pinned { "pinned" } else { "newest" };
141                format!("{} rev {} ({}, {how})", link.number, link.revision_label, link.lifecycle)
142            }
143            (_, Some(file), _) => format!("v{} of {} · {}", file.version, file.versions, human_size(file.size)),
144            (_, _, Some(n)) => format!("{n} item(s)"),
145            _ => String::new(),
146        }
147    }
148}
149
150#[derive(Deserialize)]
151struct Folder {
152    #[serde(default)]
153    entries: Vec<Entry>,
154}
155
156#[derive(Deserialize)]
157struct Made {
158    entry: Entry,
159}
160
161#[derive(Deserialize)]
162struct Promoted {
163    attachment: Attachment,
164}
165
166/// One version of a file (`GET …/versions`).
167#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
168#[serde(default)]
169pub struct Version {
170    pub version: u32,
171    pub sha256: String,
172    pub size: u64,
173    pub media_type: String,
174    pub uploaded_at: u64,
175    pub uploaded_by_name: String,
176    /// The version this one restored.
177    pub restored_from: Option<u32>,
178    pub current: bool,
179}
180
181#[derive(Deserialize)]
182struct Versions {
183    #[serde(default)]
184    versions: Vec<Version>,
185}
186
187/// A revision as `GET /api/parts/:id` lists it.
188#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
189#[serde(default)]
190pub struct RevisionRef {
191    pub id: String,
192    pub label: String,
193    pub lifecycle: String,
194    pub document_key: String,
195}
196
197/// A part looked up by id or number (D13).
198#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
199#[serde(default)]
200pub struct PartLookup {
201    pub id: String,
202    pub number: String,
203    pub name: String,
204    /// The newest by creation, drafts included — what the Revision field is filled with.
205    pub newest_revision: Option<RevisionRef>,
206    pub revision_views: Vec<RevisionRef>,
207}
208
209/// Where a file is promoted to.
210#[derive(Clone, Debug, Default, PartialEq)]
211pub struct PromoteTo {
212    /// A part id or number.
213    pub part: String,
214    /// A revision id or label; empty attaches it to the part.
215    pub revision: String,
216    /// 0: the current version.
217    pub version: u32,
218    pub kind: String,
219}
220
221/// The PLM's workspace routes, as the browser needs them. An `Err` is the sentence to
222/// show: the server's refusal, word for word, or why no answer came.
223pub trait Workspaces {
224    fn workspaces(&self) -> PlmFuture<WorkspaceList>;
225    /// One folder of `owner`'s workspace (empty `owner`: mine; empty `parent`: the top).
226    fn folder(&self, owner: &str, parent: &str) -> PlmFuture<Vec<Entry>>;
227    fn create_folder(&self, parent: &str, name: &str) -> PlmFuture<Entry>;
228    /// Link a part (id or number) here; `revision` (id or label) pins it, empty follows.
229    fn link(&self, parent: &str, part: &str, revision: &str) -> PlmFuture<Entry>;
230    fn add_file(&self, parent: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry>;
231    fn replace_file(&self, id: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry>;
232    /// `{name}`, `{parent}`, `{revision}` — any of them.
233    fn update(&self, id: &str, fields: Value) -> PlmFuture<Entry>;
234    fn delete(&self, id: &str, recursive: bool) -> PlmFuture<()>;
235    fn versions(&self, id: &str) -> PlmFuture<Vec<Version>>;
236    fn restore(&self, id: &str, version: u32) -> PlmFuture<Entry>;
237    /// A version's bytes (0: the current one).
238    fn download(&self, id: &str, version: u32) -> PlmFuture<Vec<u8>>;
239    fn promote(&self, id: &str, to: &PromoteTo) -> PlmFuture<Attachment>;
240    /// `GET /api/parts/:id` by id or number.
241    fn lookup(&self, part: &str) -> PlmFuture<PartLookup>;
242}
243
244/// [`Workspaces`] over S1's [`PlmClient`](crate::plm::client::PlmClient).
245pub struct PlmWorkspaces {
246    pub client: std::rc::Rc<crate::plm::client::PlmClient>,
247}
248
249fn encode(text: &str) -> String {
250    let mut out = String::with_capacity(text.len());
251    for byte in text.bytes() {
252        if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
253            out.push(byte as char);
254        } else {
255            out.push_str(&format!("%{byte:02X}"));
256        }
257    }
258    out
259}
260
261impl PlmWorkspaces {
262    fn read<T: for<'de> Deserialize<'de> + 'static>(
263        &self,
264        method: &'static str,
265        path: String,
266        body: Option<Value>,
267    ) -> PlmFuture<T> {
268        let client = self.client.clone();
269        Box::pin(async move {
270            let body = body.map(|b| serde_json::to_vec(&b).unwrap_or_default());
271            let response = client.call(method, &path, body).await.map_err(|e| e.to_string())?;
272            serde_json::from_slice(&response.body)
273                .map_err(|e| format!("the PLM answered {path} with something this app cannot read: {e}"))
274        })
275    }
276
277    fn entry(&self, method: &'static str, path: String, body: Option<Value>) -> PlmFuture<Entry> {
278        let made: PlmFuture<Made> = self.read(method, path, body);
279        Box::pin(async move { Ok(made.await?.entry) })
280    }
281
282    fn bytes_entry(&self, method: &'static str, path: String, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry> {
283        let client = self.client.clone();
284        let media_type = media_type_for(name).to_string();
285        Box::pin(async move {
286            let response = client.call_typed(method, &path, bytes, &media_type).await.map_err(|e| e.to_string())?;
287            serde_json::from_slice::<Made>(&response.body)
288                .map(|m| m.entry)
289                .map_err(|e| format!("the PLM answered the upload with something this app cannot read: {e}"))
290        })
291    }
292}
293
294impl Workspaces for PlmWorkspaces {
295    fn workspaces(&self) -> PlmFuture<WorkspaceList> {
296        self.read("GET", "/api/workspaces".into(), None)
297    }
298
299    fn folder(&self, owner: &str, parent: &str) -> PlmFuture<Vec<Entry>> {
300        let folder: PlmFuture<Folder> =
301            self.read("GET", format!("/api/workspace/entries?owner={}&parent={}", encode(owner), encode(parent)), None);
302        Box::pin(async move { Ok(folder.await?.entries) })
303    }
304
305    fn create_folder(&self, parent: &str, name: &str) -> PlmFuture<Entry> {
306        self.entry("POST", "/api/workspace/folders".into(), Some(json!({ "parent": parent, "name": name })))
307    }
308
309    fn link(&self, parent: &str, part: &str, revision: &str) -> PlmFuture<Entry> {
310        self.entry("POST", "/api/workspace/links".into(), Some(json!({ "parent": parent, "part": part, "revision": revision })))
311    }
312
313    fn add_file(&self, parent: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry> {
314        let path = format!("/api/workspace/files?parent={}&name={}", encode(parent), encode(name));
315        self.bytes_entry("POST", path, name, bytes)
316    }
317
318    fn replace_file(&self, id: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry> {
319        self.bytes_entry("PUT", format!("/api/workspace/entries/{}/content", encode(id)), name, bytes)
320    }
321
322    fn update(&self, id: &str, fields: Value) -> PlmFuture<Entry> {
323        self.entry("PATCH", format!("/api/workspace/entries/{}", encode(id)), Some(fields))
324    }
325
326    fn delete(&self, id: &str, recursive: bool) -> PlmFuture<()> {
327        let client = self.client.clone();
328        let path = format!("/api/workspace/entries/{}?recursive={recursive}", encode(id));
329        Box::pin(async move {
330            client.call("DELETE", &path, None).await.map_err(|e| e.to_string())?;
331            Ok(())
332        })
333    }
334
335    fn versions(&self, id: &str) -> PlmFuture<Vec<Version>> {
336        let versions: PlmFuture<Versions> = self.read("GET", format!("/api/workspace/entries/{}/versions", encode(id)), None);
337        Box::pin(async move { Ok(versions.await?.versions) })
338    }
339
340    fn restore(&self, id: &str, version: u32) -> PlmFuture<Entry> {
341        self.entry("POST", format!("/api/workspace/entries/{}/versions/{version}/restore", encode(id)), None)
342    }
343
344    fn download(&self, id: &str, version: u32) -> PlmFuture<Vec<u8>> {
345        let client = self.client.clone();
346        let path = format!("/api/workspace/entries/{}/content?version={version}", encode(id));
347        Box::pin(async move { Ok(client.call("GET", &path, None).await.map_err(|e| e.to_string())?.body) })
348    }
349
350    fn promote(&self, id: &str, to: &PromoteTo) -> PlmFuture<Attachment> {
351        let body = json!({ "part": to.part, "revision": to.revision, "version": to.version, "kind": to.kind });
352        let promoted: PlmFuture<Promoted> = self.read("POST", format!("/api/workspace/entries/{}/promote", encode(id)), Some(body));
353        Box::pin(async move { Ok(promoted.await?.attachment) })
354    }
355
356    fn lookup(&self, part: &str) -> PlmFuture<PartLookup> {
357        self.read("GET", format!("/api/parts/{}", encode(part.trim())), None)
358    }
359}
360
361// --- the host's half ---------------------------------------------------------------
362
363/// `part/<part>/rev/<revision>` out of a store name, however the store spells it
364/// (`/models/part/<p>/rev/<r>.nbrep` natively, the bare key on the web).
365pub fn key_of(name: &str) -> Option<String> {
366    let tail = &name[name.find("part/")?..];
367    let segments: Vec<&str> = tail.split('/').collect();
368    match segments.as_slice() {
369        ["part", part, "rev", revision] if !part.is_empty() && !revision.is_empty() => {
370            let revision = revision.split('.').next().unwrap_or(revision);
371            (!revision.is_empty()).then(|| format!("part/{part}/rev/{revision}"))
372        }
373        _ => None,
374    }
375}
376
377/// The name the store opens the document `key` under: its one spelling, as the explorer
378/// spelled it before S14 (`/models/part/<p>/rev/<r>.nbrep` on the mirror store), so a
379/// document opened from the workspace and one opened any other way are the same tab.
380pub fn document_name(store: &dyn crate::store::ModelStore, key: &str) -> String {
381    store.canonical_identity(key)
382}
383
384/// Files dropped on the window this frame: (name, bytes). The web hands the bytes; a
385/// native drop hands a path, read here. This is how a file reaches the workspace — the
386/// shell has no file chooser of its own for arbitrary files.
387pub fn dropped(ctx: &egui::Context) -> Vec<(String, Vec<u8>)> {
388    let files = ctx.input(|i| i.raw.dropped_files.clone());
389    files
390        .into_iter()
391        .filter_map(|file| {
392            let name = if file.name.is_empty() {
393                file.path.as_ref()?.file_name()?.to_string_lossy().into_owned()
394            } else {
395                file.name.clone()
396            };
397            let bytes = match (&file.bytes, &file.path) {
398                (Some(bytes), _) => bytes.to_vec(),
399                (None, Some(path)) => std::fs::read(path).ok()?,
400                _ => return None,
401            };
402            Some((name, bytes))
403        })
404        .collect()
405}
406
407// --- the browser -----------------------------------------------------------------
408
409/// A pinned folder: its path from the top of the user's own workspace, (id, name) each.
410pub type Pin = Vec<(String, String)>;
411
412/// How a workspace folder is spelled in `@pinned` (the reserved preference S0 set aside
413/// for workspace folders in PLM mode): `workspace:` and the folder's path as JSON. The
414/// list stays an array of strings, the shape the file explorer keeps its pins in.
415pub fn pin_text(pin: &Pin) -> String {
416    format!("workspace:{}", serde_json::to_string(pin).unwrap_or_default())
417}
418
419/// The workspace folders in an `@pinned` value; anything else in it is left alone.
420pub fn pins_in(value: Option<&str>) -> Vec<Pin> {
421    let list: Vec<String> = value.and_then(|v| serde_json::from_str(v).ok()).unwrap_or_default();
422    list.iter()
423        .filter_map(|item| serde_json::from_str::<Pin>(item.strip_prefix("workspace:")?).ok())
424        .filter(|pin| !pin.is_empty())
425        .collect()
426}
427
428/// `value` (an `@pinned` value) with its workspace folders replaced by `pins`; the other
429/// entries are kept as they were.
430pub fn with_pins(value: Option<&str>, pins: &[Pin]) -> String {
431    let list: Vec<String> = value.and_then(|v| serde_json::from_str(v).ok()).unwrap_or_default();
432    let mut out: Vec<String> = list.into_iter().filter(|item| !item.starts_with("workspace:")).collect();
433    out.extend(pins.iter().map(pin_text));
434    serde_json::to_string(&out).unwrap_or_else(|_| "[]".into())
435}
436
437/// Read the pinned workspace folders from the store's `@pinned`.
438pub fn load_pins(store: &dyn crate::store::ModelStore) -> Vec<Pin> {
439    pins_in(store.read(crate::store::PINNED_KEY).as_deref())
440}
441
442/// Write `pins` into the store's `@pinned`, keeping whatever else it holds.
443pub fn save_pins(store: &dyn crate::store::ModelStore, pins: &[Pin]) -> Result<(), String> {
444    let current = store.read(crate::store::PINNED_KEY);
445    store.write(crate::store::PINNED_KEY, &with_pins(current.as_deref(), pins))
446}
447
448/// What the host does after a frame.
449#[derive(Debug, Default, PartialEq)]
450pub struct WorkspaceOutcome {
451    /// Open this document (`part/<part>/rev/<revision>`): a link was activated, or
452    /// Open by number was pressed.
453    pub open: Option<String>,
454    /// A file's bytes, to save where the user chooses: (file name, bytes).
455    pub downloaded: Option<(String, Vec<u8>)>,
456    /// The pinned folders changed: save this list to `@pinned` ([`save_pins`]).
457    pub pins: Option<Vec<Pin>>,
458    /// "Add file…" was pressed: open the file chooser, then
459    /// [`WorkspaceBrowser::add_file`] what it delivers.
460    pub pick_file: bool,
461}
462
463/// What a finished request changed.
464enum Done {
465    Entry(Entry),
466    Nothing,
467    Attachment(Attachment),
468}
469
470/// One user's workspace, one folder at a time.
471pub struct WorkspaceBrowser {
472    /// Whether the host has a file chooser: "Add file…" beside drag and drop.
473    pub can_pick_files: bool,
474    /// "Add file…" was pressed this frame.
475    pick_asked: bool,
476    /// Whose workspace: empty is the signed-in user's own.
477    pub owner: String,
478    pub owner_label: String,
479    /// The folders from the top down to the one shown: (id, name).
480    pub path: Vec<(String, String)>,
481    pub entries: Option<Vec<Entry>>,
482    pub workspaces: Option<WorkspaceList>,
483    /// The selected entry, by id.
484    pub selected: Option<String>,
485    /// An entry cut for a move, pasted into the folder shown.
486    pub cut: Option<Entry>,
487    /// A file's versions, once asked for: (entry id, versions).
488    pub versions: Option<(String, Vec<Version>)>,
489    /// The last refusal or failure, word for word.
490    pub problem: Option<String>,
491    /// What just happened, for the status line.
492    pub notice: Option<String>,
493    pub new_folder_name: String,
494    pub rename: Option<String>,
495    pub link_part: String,
496    pub link_revision: String,
497    pub open_part: String,
498    pub open_revision: String,
499    /// The part Open by number looked up, for its revision picker.
500    pub open_lookup: Option<PartLookup>,
501    pub promote: Option<PromoteTo>,
502    /// Delete was pressed once on the selected entry; the second press deletes.
503    pub delete_armed: bool,
504    /// The user's pinned folders (`@pinned`), once the host has read them.
505    pub pins: Option<Vec<Pin>>,
506    list_pending: Option<Pending<Vec<Entry>>>,
507    owners_pending: Option<Pending<WorkspaceList>>,
508    /// Every change still out: two started in one frame (a catalog link and a New part)
509    /// both land.
510    action_pending: Vec<(String, Pending<Done>)>,
511    /// A lookup, and whether to open what it finds.
512    lookup_pending: Option<(bool, Pending<PartLookup>)>,
513    download_pending: Option<(String, Pending<Vec<u8>>)>,
514    versions_pending: Option<(String, Pending<Vec<Version>>)>,
515    pub hits: HashMap<String, egui::Rect>,
516    /// A pin toggled while the browser drew, handed out in the frame's outcome.
517    pins_changed: Option<Vec<Pin>>,
518}
519
520impl Default for WorkspaceBrowser {
521    fn default() -> Self {
522        Self::new()
523    }
524}
525
526impl WorkspaceBrowser {
527    /// The signed-in user's own workspace, at the top.
528    pub fn new() -> Self {
529        WorkspaceBrowser {
530            can_pick_files: false,
531            pick_asked: false,
532            owner: String::new(),
533            owner_label: "My workspace".into(),
534            path: Vec::new(),
535            entries: None,
536            workspaces: None,
537            selected: None,
538            cut: None,
539            versions: None,
540            problem: None,
541            notice: None,
542            new_folder_name: String::new(),
543            rename: None,
544            link_part: String::new(),
545            link_revision: String::new(),
546            open_part: String::new(),
547            open_revision: String::new(),
548            open_lookup: None,
549            promote: None,
550            delete_armed: false,
551            pins: None,
552            list_pending: None,
553            owners_pending: None,
554            action_pending: Vec::new(),
555            lookup_pending: None,
556            download_pending: None,
557            versions_pending: None,
558            hits: HashMap::new(),
559            pins_changed: None,
560        }
561    }
562
563    /// The folder shown, by id; empty at the top. A new part saved from here is linked
564    /// into it (D9).
565    pub fn current_folder(&self) -> &str {
566        self.path.last().map(|(id, _)| id.as_str()).unwrap_or("")
567    }
568
569    /// Another user's workspace: browse only.
570    pub fn read_only(&self) -> bool {
571        !self.owner.is_empty()
572    }
573
574    pub fn busy(&self) -> bool {
575        self.list_pending.as_ref().is_some_and(Pending::is_open)
576            || self.owners_pending.as_ref().is_some_and(Pending::is_open)
577            || self.action_pending.iter().any(|(_, p)| p.is_open())
578            || self.lookup_pending.as_ref().is_some_and(|(_, p)| p.is_open())
579            || self.download_pending.as_ref().is_some_and(|(_, p)| p.is_open())
580            || self.versions_pending.as_ref().is_some_and(|(_, p)| p.is_open())
581    }
582
583    /// Ask again for the folder shown, and for whose workspaces may be opened.
584    pub fn reload(&mut self, plm: &dyn Workspaces) {
585        self.list_pending = Some(Pending::new(plm.folder(&self.owner, self.current_folder())));
586        self.owners_pending = Some(Pending::new(plm.workspaces()));
587    }
588
589    fn reload_folder(&mut self, plm: &dyn Workspaces) {
590        self.list_pending = Some(Pending::new(plm.folder(&self.owner, self.current_folder())));
591    }
592
593    /// Show another user's workspace (read-only), or the user's own (`owner` empty).
594    pub fn set_owner(&mut self, plm: &dyn Workspaces, owner: &str, label: &str) {
595        self.owner = owner.to_string();
596        self.owner_label = label.to_string();
597        self.path.clear();
598        self.selection_cleared();
599        self.entries = None;
600        self.reload_folder(plm);
601    }
602
603    fn selection_cleared(&mut self) {
604        self.selected = None;
605        self.rename = None;
606        self.promote = None;
607        self.versions = None;
608        self.delete_armed = false;
609    }
610
611    /// Walk into a folder of the one shown.
612    pub fn enter(&mut self, plm: &dyn Workspaces, folder: &Entry) {
613        self.path.push((folder.id.clone(), folder.name.clone()));
614        self.selection_cleared();
615        self.entries = None;
616        self.reload_folder(plm);
617    }
618
619    /// Go to a pinned folder of the user's own workspace.
620    pub fn go_to_pin(&mut self, plm: &dyn Workspaces, pin: &Pin) {
621        self.owner.clear();
622        self.owner_label = "My workspace".into();
623        self.path = pin.clone();
624        self.selection_cleared();
625        self.entries = None;
626        self.reload_folder(plm);
627    }
628
629    /// Pin the folder shown, or unpin it if it is pinned: the new list for `@pinned`.
630    pub fn toggle_pin(&mut self) -> Option<Vec<Pin>> {
631        if self.read_only() || self.path.is_empty() {
632            return None;
633        }
634        let mut pins = self.pins.clone().unwrap_or_default();
635        let here = self.current_folder().to_string();
636        let before = pins.len();
637        pins.retain(|pin| pin.last().map(|(id, _)| id.as_str()) != Some(here.as_str()));
638        if pins.len() == before {
639            pins.push(self.path.clone());
640        }
641        self.pins = Some(pins.clone());
642        Some(pins)
643    }
644
645    /// Walk back up to `depth` folders from the top (0: the top).
646    pub fn up_to(&mut self, plm: &dyn Workspaces, depth: usize) {
647        self.path.truncate(depth);
648        self.selection_cleared();
649        self.entries = None;
650        self.reload_folder(plm);
651    }
652
653    fn entry(&self, id: &str) -> Option<&Entry> {
654        self.entries.as_ref()?.iter().find(|e| e.id == id)
655    }
656
657    /// The selected entry.
658    pub fn selection(&self) -> Option<&Entry> {
659        self.entry(self.selected.as_deref()?)
660    }
661
662    /// Select the entry named `name` in the folder shown (what a click does).
663    pub fn select_named(&mut self, name: &str) -> bool {
664        let found = self.entries.as_ref().and_then(|list| list.iter().find(|e| e.name == name)).map(|e| e.id.clone());
665        let changed = found != self.selected;
666        if changed {
667            self.selection_cleared();
668        }
669        self.selected = found;
670        self.selected.is_some()
671    }
672
673    /// A double click: walk into a folder, open a link's document, download a file.
674    pub fn activate(&mut self, plm: &dyn Workspaces, entry: &Entry, outcome: &mut WorkspaceOutcome) {
675        match (entry.kind.as_str(), &entry.link) {
676            ("folder", _) => self.enter(plm, entry),
677            ("link", Some(link)) if link.part_missing => self.problem = Some(format!("{}: the part it links is gone", entry.name)),
678            ("link", Some(link)) if link.revision_missing || link.document_key.is_empty() => {
679                self.problem = Some(format!(
680                    "{}: it is pinned to a revision of {} that was deleted — pin it to another, or let it follow the newest",
681                    entry.name, link.number
682                ))
683            }
684            ("link", Some(link)) => outcome.open = Some(link.document_key.clone()),
685            ("file", _) => self.download(plm, entry, 0),
686            _ => {}
687        }
688    }
689
690    fn act(&mut self, what: impl Into<String>, future: PlmFuture<Done>) {
691        self.problem = None;
692        self.action_pending.push((what.into(), Pending::new(future)));
693    }
694
695    fn map<T: 'static>(future: PlmFuture<T>, done: impl FnOnce(T) -> Done + 'static) -> PlmFuture<Done> {
696        Box::pin(async move { future.await.map(done) })
697    }
698
699    /// New folder in the folder shown.
700    pub fn new_folder(&mut self, plm: &dyn Workspaces, name: &str) {
701        let future = Self::map(plm.create_folder(self.current_folder(), name.trim()), Done::Entry);
702        self.act(format!("made folder {}", name.trim()), future);
703    }
704
705    /// Link a part (id or number) into the folder shown; `revision` pins it, empty follows.
706    pub fn link(&mut self, plm: &dyn Workspaces, part: &str, revision: &str) {
707        let future = Self::map(plm.link(self.current_folder(), part.trim(), revision.trim()), Done::Entry);
708        self.act(format!("linked {}", part.trim()), future);
709    }
710
711    /// A file the host was handed (dropped on the pane): a new file in the folder shown,
712    /// or — when a file of that name is already there — its next version, the one before
713    /// kept.
714    pub fn add_file(&mut self, plm: &dyn Workspaces, name: &str, bytes: Vec<u8>) {
715        let existing = self
716            .entries
717            .as_ref()
718            .and_then(|list| list.iter().find(|e| e.kind == "file" && e.name.eq_ignore_ascii_case(name)))
719            .map(|e| e.id.clone());
720        match existing {
721            Some(id) => {
722                let future = Self::map(plm.replace_file(&id, name, bytes), Done::Entry);
723                self.act(format!("{name}: a new version, the one before kept"), future);
724            }
725            None => {
726                let future = Self::map(plm.add_file(self.current_folder(), name, bytes), Done::Entry);
727                self.act(format!("added {name}"), future);
728            }
729        }
730    }
731
732    pub fn rename(&mut self, plm: &dyn Workspaces, id: &str, name: &str) {
733        let future = Self::map(plm.update(id, json!({ "name": name.trim() })), Done::Entry);
734        self.act(format!("renamed to {}", name.trim()), future);
735    }
736
737    /// Cut an entry, to paste it into another folder of the same workspace.
738    pub fn cut(&mut self, id: &str) {
739        self.cut = self.entry(id).cloned();
740        if let Some(entry) = &self.cut {
741            self.notice = Some(format!("{} cut — open a folder and paste it there", entry.name));
742        }
743    }
744
745    /// Move the cut entry into the folder shown.
746    pub fn paste(&mut self, plm: &dyn Workspaces) {
747        let Some(entry) = self.cut.take() else { return };
748        let future = Self::map(plm.update(&entry.id, json!({ "parent": self.current_folder() })), Done::Entry);
749        self.act(format!("moved {}", entry.name), future);
750    }
751
752    /// Remove an entry: a link or a file, never a part (D9). A folder goes with everything in
753    /// it — the browser asks first ([`Self::delete_armed`]).
754    pub fn delete(&mut self, plm: &dyn Workspaces, id: &str) {
755        let Some(entry) = self.entry(id).cloned() else { return };
756        let future = Self::map(plm.delete(&entry.id, entry.is_folder()), |()| Done::Nothing);
757        let what = match entry.kind.as_str() {
758            "link" => format!("removed the link {} — the part is untouched", entry.name),
759            _ => format!("deleted {}", entry.name),
760        };
761        self.selection_cleared();
762        self.act(what, future);
763    }
764
765    /// Pin a link to `revision` (id or label), or let it follow the newest (empty).
766    pub fn pin(&mut self, plm: &dyn Workspaces, id: &str, revision: &str) {
767        let future = Self::map(plm.update(id, json!({ "revision": revision })), Done::Entry);
768        let what = if revision.is_empty() { "the link follows the newest revision".to_string() } else { format!("pinned to {revision}") };
769        self.act(what, future);
770    }
771
772    pub fn load_versions(&mut self, plm: &dyn Workspaces, id: &str) {
773        self.versions_pending = Some((id.to_string(), Pending::new(plm.versions(id))));
774    }
775
776    /// Make version `n` current again — as a new version, so none is lost.
777    pub fn restore(&mut self, plm: &dyn Workspaces, id: &str, version: u32) {
778        let future = Self::map(plm.restore(id, version), Done::Entry);
779        self.act(format!("restored version {version} as a new version"), future);
780    }
781
782    pub fn download(&mut self, plm: &dyn Workspaces, entry: &Entry, version: u32) {
783        self.download_pending = Some((entry.name.clone(), Pending::new(plm.download(&entry.id, version))));
784    }
785
786    /// Promote a file to a real attachment; the server's attachment refusals apply (S8).
787    pub fn promote(&mut self, plm: &dyn Workspaces, id: &str, to: &PromoteTo) {
788        let future = Self::map(plm.promote(id, to), Done::Attachment);
789        self.act("promoted", future);
790    }
791
792    /// Open by number: look the part up and fill Revision with its newest revision
793    /// (drafts included). With `open`, open it once found — or the revision the user
794    /// typed, if they typed one.
795    pub fn look_up(&mut self, plm: &dyn Workspaces, open: bool) {
796        self.problem = None;
797        self.lookup_pending = Some((open, Pending::new(plm.lookup(&self.open_part))));
798    }
799
800    /// Open the revision in the Revision field of the part looked up.
801    pub fn open_by_number(&mut self, plm: &dyn Workspaces, outcome: &mut WorkspaceOutcome) {
802        let looked_up = self.open_lookup.as_ref().filter(|p| {
803            let typed = self.open_part.trim();
804            p.id == typed || p.number.eq_ignore_ascii_case(typed)
805        });
806        match looked_up {
807            Some(part) => {
808                let wanted = self.open_revision.trim();
809                let revision = part
810                    .revision_views
811                    .iter()
812                    .find(|r| r.id == wanted || r.label.eq_ignore_ascii_case(wanted))
813                    .or(part.newest_revision.as_ref().filter(|_| wanted.is_empty()));
814                match revision {
815                    Some(r) if !r.document_key.is_empty() => {
816                        outcome.open = Some(r.document_key.clone());
817                        // Opened: the fields start empty next time, not as a picker of
818                        // this part's revisions.
819                        self.open_part.clear();
820                        self.open_revision.clear();
821                        self.open_lookup = None;
822                    }
823                    _ => self.problem = Some(format!("{} has no revision '{wanted}'", part.number)),
824                }
825            }
826            None => self.look_up(plm, true),
827        }
828    }
829
830    /// Drive every request: apply what the server answered.
831    pub fn poll(&mut self, waker: &Waker, plm: &dyn Workspaces, outcome: &mut WorkspaceOutcome) {
832        if let Some(answer) = self.list_pending.as_mut().and_then(|p| p.poll(waker)) {
833            self.list_pending = None;
834            match answer {
835                Ok(entries) => {
836                    if self.selected.as_deref().is_some_and(|id| !entries.iter().any(|e| e.id == id)) {
837                        self.selection_cleared();
838                    }
839                    self.entries = Some(entries);
840                }
841                Err(problem) => {
842                    self.entries = Some(Vec::new());
843                    self.problem = Some(problem);
844                }
845            }
846        }
847        if let Some(answer) = self.owners_pending.as_mut().and_then(|p| p.poll(waker)) {
848            self.owners_pending = None;
849            match answer {
850                Ok(list) => self.workspaces = Some(list),
851                Err(problem) => self.problem = Some(problem),
852            }
853        }
854        let mut answered = Vec::new();
855        self.action_pending.retain_mut(|(what, pending)| match pending.poll(waker) {
856            Some(answer) => {
857                answered.push((std::mem::take(what), answer));
858                false
859            }
860            None => true,
861        });
862        for (what, answer) in answered {
863            {
864                match answer {
865                    Ok(done) => {
866                        self.notice = Some(what);
867                        match done {
868                            Done::Entry(entry) => {
869                                if self.versions.as_ref().is_some_and(|(id, _)| *id == entry.id) {
870                                    self.load_versions(plm, &entry.id);
871                                }
872                                self.selected = Some(entry.id);
873                            }
874                            Done::Attachment(a) => {
875                                self.notice = Some(format!("promoted to an attachment: {} ({})", a.name, a.kind));
876                                self.promote = None;
877                            }
878                            Done::Nothing => {}
879                        }
880                        self.reload_folder(plm);
881                    }
882                    Err(problem) => self.problem = Some(problem),
883                }
884            }
885        }
886        if let Some((open, pending)) = self.lookup_pending.as_mut() {
887            let open = *open;
888            if let Some(answer) = pending.poll(waker) {
889                self.lookup_pending = None;
890                match answer {
891                    Ok(part) => {
892                        let typed = self.open_revision.trim().to_string();
893                        if typed.is_empty() || !part.revision_views.iter().any(|r| r.label.eq_ignore_ascii_case(&typed) || r.id == typed) {
894                            self.open_revision = part.newest_revision.as_ref().map(|r| r.label.clone()).unwrap_or_default();
895                        }
896                        self.open_part = part.number.clone();
897                        self.open_lookup = Some(part);
898                        if open {
899                            self.open_by_number(plm, outcome);
900                        }
901                    }
902                    Err(problem) => {
903                        self.open_lookup = None;
904                        self.problem = Some(problem);
905                    }
906                }
907            }
908        }
909        if let Some((name, pending)) = self.download_pending.as_mut() {
910            if let Some(answer) = pending.poll(waker) {
911                let name = std::mem::take(name);
912                self.download_pending = None;
913                match answer {
914                    Ok(bytes) => outcome.downloaded = Some((name, bytes)),
915                    Err(problem) => self.problem = Some(problem),
916                }
917            }
918        }
919        if let Some((id, pending)) = self.versions_pending.as_mut() {
920            if let Some(answer) = pending.poll(waker) {
921                let id = std::mem::take(id);
922                self.versions_pending = None;
923                match answer {
924                    Ok(list) => self.versions = Some((id, list)),
925                    Err(problem) => self.problem = Some(problem),
926                }
927            }
928        }
929    }
930}
931
932// --- drawing ----------------------------------------------------------------------
933
934/// A waker that repaints: an answer is drawn the frame it arrives.
935fn repaint_waker(ctx: &egui::Context) -> Waker {
936    struct Repaint(egui::Context);
937    impl std::task::Wake for Repaint {
938        fn wake(self: std::sync::Arc<Self>) {
939            self.0.request_repaint();
940        }
941    }
942    Waker::from(std::sync::Arc::new(Repaint(ctx.clone())))
943}
944
945/// A row's kind, in plain text: the app ships no symbol font, and an emoji folder draws
946/// as an empty box.
947fn icon(entry: &Entry) -> &'static str {
948    match entry.kind.as_str() {
949        "folder" => "[folder]",
950        "link" => "[link]",
951        _ => "[file]",
952    }
953}
954
955impl WorkspaceBrowser {
956    fn hit(&mut self, prefix: &str, key: &str, response: &egui::Response) {
957        self.hits.insert(format!("{prefix}ws:{key}"), response.rect);
958    }
959
960    /// Draw the browser, poll its requests, and say what the host must do. `prefix` is
961    /// the host's hit-key prefix (`plm/workspace:` in the PLM pane, `open:` in the Open
962    /// modal).
963    pub fn show(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) -> WorkspaceOutcome {
964        let mut outcome = WorkspaceOutcome::default();
965        let ctx = ui.ctx().clone();
966        let waker = repaint_waker(&ctx);
967        if self.entries.is_none() && !self.busy() {
968            self.reload(plm);
969        }
970        self.poll(&waker, plm, &mut outcome);
971        self.hits.clear();
972
973        self.owner_row(ui, plm, prefix);
974        self.open_row(ui, plm, prefix, &mut outcome);
975        ui.separator();
976        self.crumbs(ui, plm, prefix);
977        self.rows(ui, plm, prefix, &mut outcome);
978        self.selection_verbs(ui, plm, prefix);
979        if !self.read_only() {
980            ui.separator();
981            self.make_row(ui, plm, prefix);
982        }
983        outcome.pick_file = std::mem::take(&mut self.pick_asked);
984        if let Some(problem) = &self.problem {
985            ui.colored_label(ui.visuals().error_fg_color, problem);
986        } else if let Some(notice) = &self.notice {
987            ui.label(egui::RichText::new(notice).weak());
988        }
989        // Anything the frame started is answered on a later one: poll again now so a
990        // request that is already answered (the in-process router) lands this frame.
991        self.poll(&waker, plm, &mut outcome);
992        outcome.pins = self.pins_changed.take();
993        outcome
994    }
995
996    fn owner_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
997        let rows: Vec<WorkspaceRow> = self.workspaces.as_ref().map(|w| w.workspaces.clone()).unwrap_or_default();
998        let mut chosen: Option<(String, String)> = None;
999        ui.horizontal(|ui| {
1000            let combo = egui::ComboBox::from_id_salt((prefix, "ws-owner")).selected_text(&self.owner_label).show_ui(ui, |ui| {
1001                for row in &rows {
1002                    let label = if row.mine { "My workspace".to_string() } else { format!("{}'s workspace", row.display_name.trim().is_empty().then_some(&row.username).unwrap_or(&row.display_name)) };
1003                    let owner = if row.mine { String::new() } else { row.user_id.clone() };
1004                    if ui.selectable_label(self.owner == owner, &label).clicked() {
1005                        chosen = Some((owner, label));
1006                    }
1007                }
1008            });
1009            self.hit(prefix, "owner", &combo.response);
1010            if self.read_only() {
1011                ui.label(egui::RichText::new("read-only").weak());
1012            }
1013            if self.workspaces.as_ref().is_some_and(|w| !w.browsable) {
1014                ui.label(egui::RichText::new("other workspaces are private on this server").weak());
1015            }
1016            let reload = ui.small_button("\u{21BB}").on_hover_text("Reload");
1017            self.hit(prefix, "reload", &reload);
1018            if reload.clicked() {
1019                self.reload(plm);
1020            }
1021        });
1022        if let Some((owner, label)) = chosen {
1023            self.set_owner(plm, &owner, &label);
1024        }
1025    }
1026
1027    fn open_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str, outcome: &mut WorkspaceOutcome) {
1028        ui.horizontal(|ui| {
1029            ui.label("Part");
1030            let part = ui.add(egui::TextEdit::singleline(&mut self.open_part).desired_width(110.0).hint_text("number or id"));
1031            self.hit(prefix, "open-part", &part);
1032            if part.lost_focus() && !self.open_part.trim().is_empty() {
1033                // Typing a part fills Revision with its newest (D13).
1034                self.look_up(plm, false);
1035            }
1036            ui.label("Revision");
1037            let labels: Vec<(String, String)> = self
1038                .open_lookup
1039                .as_ref()
1040                .map(|p| p.revision_views.iter().map(|r| (r.label.clone(), format!("{} ({})", r.label, r.lifecycle))).collect())
1041                .unwrap_or_default();
1042            let revision = if labels.is_empty() {
1043                ui.add(egui::TextEdit::singleline(&mut self.open_revision).desired_width(60.0).hint_text("newest"))
1044            } else {
1045                let text = self.open_revision.clone();
1046                egui::ComboBox::from_id_salt((prefix, "ws-open-revision"))
1047                    .selected_text(text)
1048                    .show_ui(ui, |ui| {
1049                        for (label, shown) in labels.iter().rev() {
1050                            ui.selectable_value(&mut self.open_revision, label.clone(), shown);
1051                        }
1052                    })
1053                    .response
1054            };
1055            self.hit(prefix, "open-revision", &revision);
1056            let go = ui.add_enabled(!self.open_part.trim().is_empty(), egui::Button::new("Open"));
1057            self.hit(prefix, "open-go", &go);
1058            if go.clicked() {
1059                self.open_by_number(plm, outcome);
1060            }
1061        });
1062    }
1063
1064    fn crumbs(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1065        let mut up_to: Option<usize> = None;
1066        let mut paste = false;
1067        ui.horizontal_wrapped(|ui| {
1068            let top = ui.link(&self.owner_label);
1069            self.hit(prefix, "crumb:0", &top);
1070            if top.clicked() {
1071                up_to = Some(0);
1072            }
1073            for (depth, (_, name)) in self.path.clone().iter().enumerate() {
1074                ui.label("/");
1075                let crumb = ui.link(name);
1076                self.hit(prefix, &format!("crumb:{}", depth + 1), &crumb);
1077                if crumb.clicked() {
1078                    up_to = Some(depth + 1);
1079                }
1080            }
1081            if let Some(cut) = self.cut.clone().filter(|_| !self.read_only()) {
1082                let button = ui.small_button(format!("Paste {} here", cut.name));
1083                self.hit(prefix, "paste", &button);
1084                paste = button.clicked();
1085            }
1086            if !self.read_only() && !self.path.is_empty() {
1087                let here = self.current_folder().to_string();
1088                let pinned = self.pins.as_ref().is_some_and(|pins| pins.iter().any(|p| p.last().map(|(id, _)| id.as_str()) == Some(here.as_str())));
1089                let button = ui.small_button(if pinned { "Unpin folder" } else { "Pin folder" });
1090                self.hit(prefix, "pin-folder", &button);
1091                if button.clicked() {
1092                    self.pins_changed = self.toggle_pin();
1093                }
1094            }
1095        });
1096        let mut go_to: Option<Pin> = None;
1097        if let Some(pins) = self.pins.clone().filter(|pins| !pins.is_empty()) {
1098            ui.horizontal_wrapped(|ui| {
1099                ui.label(egui::RichText::new("Pinned:").weak());
1100                for (index, pin) in pins.iter().enumerate() {
1101                    let name = pin.last().map(|(_, name)| name.as_str()).unwrap_or("");
1102                    let button = ui.small_button(name).on_hover_text(pin.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>().join(" / "));
1103                    self.hit(prefix, &format!("pinned:{index}"), &button);
1104                    if button.clicked() {
1105                        go_to = Some(pin.clone());
1106                    }
1107                }
1108            });
1109        }
1110        if let Some(pin) = go_to {
1111            self.go_to_pin(plm, &pin);
1112        }
1113        if let Some(depth) = up_to {
1114            self.up_to(plm, depth);
1115        }
1116        if paste {
1117            self.paste(plm);
1118        }
1119    }
1120
1121    fn rows(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str, outcome: &mut WorkspaceOutcome) {
1122        let Some(entries) = self.entries.clone() else {
1123            ui.label(egui::RichText::new("Reading the workspace…").weak());
1124            return;
1125        };
1126        if entries.is_empty() {
1127            let empty = if self.read_only() { "Empty." } else { "Empty. Make a folder, link a part, or drop a file here." };
1128            ui.label(egui::RichText::new(empty).weak());
1129        }
1130        let mut activated: Option<Entry> = None;
1131        egui::ScrollArea::vertical().id_salt((prefix, "ws-rows")).max_height(260.0).show(ui, |ui| {
1132            for entry in &entries {
1133                let selected = self.selected.as_deref() == Some(entry.id.as_str());
1134                let text = format!("{} {}   {}", icon(entry), entry.name, entry.detail());
1135                let row = ui.add(egui::Button::selectable(selected, text).wrap_mode(egui::TextWrapMode::Truncate));
1136                self.hit(prefix, &format!("entry:{}", entry.name), &row);
1137                // Click selects, double click opens (every tree in this app).
1138                if row.clicked() {
1139                    self.select_named(&entry.name);
1140                }
1141                if row.double_clicked() {
1142                    activated = Some(entry.clone());
1143                }
1144            }
1145        });
1146        if let Some(entry) = activated {
1147            self.activate(plm, &entry, outcome);
1148        }
1149    }
1150
1151    fn selection_verbs(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1152        let Some(entry) = self.selection().cloned() else { return };
1153        let writable = !self.read_only();
1154        ui.separator();
1155        ui.label(egui::RichText::new(format!("{} {}", icon(&entry), entry.name)).strong());
1156        ui.horizontal_wrapped(|ui| {
1157            if writable {
1158                let rename = ui.small_button("Rename");
1159                self.hit(prefix, "sel:rename", &rename);
1160                if rename.clicked() {
1161                    self.rename = Some(entry.name.clone());
1162                }
1163                let cut = ui.small_button("Move…");
1164                self.hit(prefix, "sel:cut", &cut);
1165                if cut.clicked() {
1166                    self.cut(&entry.id);
1167                }
1168                let label = if self.delete_armed {
1169                    "Really delete?"
1170                } else if entry.kind == "link" {
1171                    "Remove link"
1172                } else {
1173                    "Delete"
1174                };
1175                let delete = ui.small_button(label).on_hover_text(match entry.kind.as_str() {
1176                    "link" => "Removes the link only: the part and its revisions are untouched.",
1177                    "folder" => "Deletes the folder and everything in it (links, files). No part is touched.",
1178                    _ => "Deletes the file and every version of it.",
1179                });
1180                self.hit(prefix, if self.delete_armed { "sel:delete-confirm" } else { "sel:delete" }, &delete);
1181                if delete.clicked() {
1182                    if self.delete_armed || entry.kind == "link" {
1183                        self.delete(plm, &entry.id);
1184                    } else {
1185                        self.delete_armed = true;
1186                    }
1187                }
1188            }
1189            if let Some(link) = entry.link.clone() {
1190                if writable && !link.part_missing {
1191                    if link.pinned {
1192                        let follow = ui.small_button("Follow newest");
1193                        self.hit(prefix, "sel:follow", &follow);
1194                        if follow.clicked() {
1195                            self.pin(plm, &entry.id, "");
1196                        }
1197                    } else if !link.revision_label.is_empty() {
1198                        let pin = ui.small_button(format!("Pin to {}", link.revision_label));
1199                        self.hit(prefix, "sel:pin", &pin);
1200                        if pin.clicked() {
1201                            self.pin(plm, &entry.id, &link.revision_id);
1202                        }
1203                    }
1204                }
1205            }
1206            if entry.kind == "file" {
1207                let download = ui.small_button("Download");
1208                self.hit(prefix, "sel:download", &download);
1209                if download.clicked() {
1210                    self.download(plm, &entry, 0);
1211                }
1212                let versions = ui.small_button("Versions");
1213                self.hit(prefix, "sel:versions", &versions);
1214                if versions.clicked() {
1215                    self.load_versions(plm, &entry.id);
1216                }
1217                if writable {
1218                    let promote = ui.small_button("Promote to attachment…");
1219                    self.hit(prefix, "sel:promote", &promote);
1220                    if promote.clicked() {
1221                        self.promote = Some(PromoteTo { kind: "other".into(), ..PromoteTo::default() });
1222                    }
1223                }
1224            }
1225        });
1226        if let Some(mut name) = self.rename.take() {
1227            let mut keep = true;
1228            ui.horizontal(|ui| {
1229                let field = ui.text_edit_singleline(&mut name);
1230                self.hit(prefix, "sel:rename-name", &field);
1231                let go = ui.small_button("Rename");
1232                self.hit(prefix, "sel:rename-go", &go);
1233                if go.clicked() {
1234                    self.rename(plm, &entry.id, &name);
1235                    keep = false;
1236                }
1237            });
1238            if keep {
1239                self.rename = Some(name);
1240            }
1241        }
1242        if let Some((id, versions)) = self.versions.clone().filter(|(id, _)| *id == entry.id) {
1243            for v in versions.iter().rev() {
1244                ui.horizontal(|ui| {
1245                    let restored = v.restored_from.map(|n| format!(", restored from v{n}")).unwrap_or_default();
1246                    ui.label(format!(
1247                        "v{}{} · {} · {}{restored}",
1248                        v.version,
1249                        if v.current { " (current)" } else { "" },
1250                        human_size(v.size),
1251                        v.uploaded_by_name
1252                    ));
1253                    let download = ui.small_button("Download");
1254                    self.hit(prefix, &format!("version:{}:download", v.version), &download);
1255                    if download.clicked() {
1256                        self.download(plm, &entry, v.version);
1257                    }
1258                    if writable && !v.current {
1259                        let restore = ui.small_button("Restore");
1260                        self.hit(prefix, &format!("version:{}:restore", v.version), &restore);
1261                        if restore.clicked() {
1262                            self.restore(plm, &id, v.version);
1263                        }
1264                    }
1265                });
1266            }
1267        }
1268        if let Some(mut to) = self.promote.take() {
1269            let mut keep = true;
1270            ui.horizontal_wrapped(|ui| {
1271                ui.label("Part");
1272                let part = ui.add(egui::TextEdit::singleline(&mut to.part).desired_width(100.0).hint_text("number"));
1273                self.hit(prefix, "sel:promote-part", &part);
1274                ui.label("Revision");
1275                let revision = ui.add(egui::TextEdit::singleline(&mut to.revision).desired_width(50.0).hint_text("part"));
1276                self.hit(prefix, "sel:promote-revision", &revision);
1277                egui::ComboBox::from_id_salt((prefix, "ws-promote-kind")).selected_text(&to.kind).show_ui(ui, |ui| {
1278                    for kind in ["drawing", "datasheet", "spec", "image", "other"] {
1279                        ui.selectable_value(&mut to.kind, kind.to_string(), kind);
1280                    }
1281                });
1282                let go = ui.add_enabled(!to.part.trim().is_empty(), egui::Button::new("Promote"));
1283                self.hit(prefix, "sel:promote-go", &go);
1284                if go.clicked() {
1285                    self.promote(plm, &entry.id, &to);
1286                }
1287                if ui.small_button("Cancel").clicked() {
1288                    keep = false;
1289                }
1290            });
1291            if keep {
1292                self.promote = Some(to);
1293            }
1294        }
1295    }
1296
1297    fn make_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1298        ui.horizontal(|ui| {
1299            let name = ui.add(egui::TextEdit::singleline(&mut self.new_folder_name).desired_width(120.0).hint_text("folder name"));
1300            self.hit(prefix, "new-folder-name", &name);
1301            let go = ui.add_enabled(!self.new_folder_name.trim().is_empty(), egui::Button::new("New folder"));
1302            self.hit(prefix, "new-folder", &go);
1303            if go.clicked() {
1304                let name = std::mem::take(&mut self.new_folder_name);
1305                self.new_folder(plm, &name);
1306            }
1307        });
1308        ui.horizontal(|ui| {
1309            let part = ui.add(egui::TextEdit::singleline(&mut self.link_part).desired_width(100.0).hint_text("part number"));
1310            self.hit(prefix, "link-part", &part);
1311            let revision = ui.add(egui::TextEdit::singleline(&mut self.link_revision).desired_width(50.0).hint_text("newest"));
1312            self.hit(prefix, "link-revision", &revision);
1313            let go = ui.add_enabled(!self.link_part.trim().is_empty(), egui::Button::new("Link here"));
1314            self.hit(prefix, "link-go", &go);
1315            if go.clicked() {
1316                let (part, revision) = (std::mem::take(&mut self.link_part), std::mem::take(&mut self.link_revision));
1317                self.link(plm, &part, &revision);
1318            }
1319        });
1320        ui.horizontal_wrapped(|ui| {
1321            if self.can_pick_files {
1322                let add = ui.button("Add file\u{2026}").on_hover_text("Choose a file on this machine to add to this folder");
1323                self.hit(prefix, "add-file", &add);
1324                self.pick_asked |= add.clicked();
1325            }
1326            ui.label(egui::RichText::new("Drop a file on this pane to add it here (a file of the same name gets a new version).").weak().small());
1327        });
1328    }
1329}
1330