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