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