1use 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#[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 pub browsable: bool,
59}
60
61#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
63#[serde(default)]
64pub struct LinkView {
65 pub thumbnail_url: String,
66 pub part_id: String,
67 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 pub document_key: String,
77 pub part_missing: bool,
78 pub revision_missing: bool,
80}
81
82#[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#[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 pub kind: String,
106 pub created_at: u64,
107 pub modified_at: u64,
108 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 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#[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 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#[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#[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 pub newest_revision: Option<RevisionRef>,
191 pub revision_views: Vec<RevisionRef>,
192}
193
194#[derive(Clone, Debug, Default, PartialEq)]
196pub struct PromoteTo {
197 pub part: String,
199 pub revision: String,
201 pub version: u32,
203 pub kind: String,
204}
205
206pub 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 fn folder(&self, owner: &str, parent: &str) -> PlmFuture<Vec<Entry>>;
216 fn create_folder(&self, parent: &str, name: &str) -> PlmFuture<Entry>;
217 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 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 fn download(&self, id: &str, version: u32) -> PlmFuture<Vec<u8>>;
228 fn promote(&self, id: &str, to: &PromoteTo) -> PlmFuture<Attachment>;
229 fn lookup(&self, part: &str) -> PlmFuture<PartLookup>;
231}
232
233pub 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
354pub 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
370pub fn document_name(store: &dyn crate::store::ModelStore, key: &str) -> String {
374 store.canonical_identity(key)
375}
376
377pub 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
400pub type Pin = Vec<(String, String)>;
404
405pub fn pin_text(pin: &Pin) -> String {
409 format!("workspace:{}", serde_json::to_string(pin).unwrap_or_default())
410}
411
412pub 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
421pub 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
430pub fn load_pins(store: &dyn crate::store::ModelStore) -> Vec<Pin> {
432 pins_in(store.read(crate::store::PINNED_KEY).as_deref())
433}
434
435pub 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#[derive(Debug, Default, PartialEq)]
443pub struct WorkspaceOutcome {
444 pub open: Option<String>,
447 pub open_folder: Option<(String, String)>,
450 pub downloaded: Option<(String, Vec<u8>)>,
452 pub pins: Option<Vec<Pin>>,
454 pub pick_file: bool,
457}
458
459enum Done {
461 Entry(Entry),
462 Nothing,
463 Attachment(Attachment),
464}
465
466pub struct WorkspaceBrowser {
468 session: Option<std::rc::Rc<crate::plm::client::PlmClient>>,
469 thumbnails: crate::plm::thumbnail_view::ThumbnailCache,
470 pub can_pick_files: bool,
472 pub browse_only: bool,
473 pick_asked: bool,
475 pub owner: String,
477 pub owner_label: String,
478 pub path: Vec<(String, String)>,
480 pub entries: Option<Vec<Entry>>,
481 pub workspaces: Option<WorkspaceList>,
482 pub selected: Option<String>,
484 pub cut: Option<Entry>,
486 pub versions: Option<(String, Vec<Version>)>,
488 pub problem: Option<String>,
490 pub notice: Option<String>,
492 pub new_folder_name: String,
493 pub rename: Option<String>,
494 pub link_part: String,
495 pub link_revision: String,
496 pub open_part: String,
497 pub open_revision: String,
498 pub open_lookup: Option<PartLookup>,
500 pub promote: Option<PromoteTo>,
501 pub delete_armed: bool,
503 pub pins: Option<Vec<Pin>>,
505 list_pending: Option<Pending<Vec<Entry>>>,
506 owners_pending: Option<Pending<WorkspaceList>>,
507 action_pending: Vec<(String, Pending<Done>)>,
510 lookup_pending: Option<(bool, Pending<PartLookup>)>,
512 download_pending: Option<(String, Pending<Vec<u8>>)>,
513 versions_pending: Option<(String, Pending<Vec<Version>>)>,
514 pub hits: HashMap<String, egui::Rect>,
515 pins_changed: Option<Vec<Pin>>,
517}
518
519impl Default for WorkspaceBrowser {
520 fn default() -> Self {
521 Self::new()
522 }
523}
524
525impl WorkspaceBrowser {
526 pub fn new() -> Self {
528 WorkspaceBrowser {
529 session: None,
530 thumbnails: Default::default(),
531 can_pick_files: false,
532 browse_only: false,
533 pick_asked: false,
534 owner: String::new(),
535 owner_label: "My workspace".into(),
536 path: Vec::new(),
537 entries: None,
538 workspaces: None,
539 selected: None,
540 cut: None,
541 versions: None,
542 problem: None,
543 notice: None,
544 new_folder_name: String::new(),
545 rename: None,
546 link_part: String::new(),
547 link_revision: String::new(),
548 open_part: String::new(),
549 open_revision: String::new(),
550 open_lookup: None,
551 promote: None,
552 delete_armed: false,
553 pins: None,
554 list_pending: None,
555 owners_pending: None,
556 action_pending: Vec::new(),
557 lookup_pending: None,
558 download_pending: None,
559 versions_pending: None,
560 hits: HashMap::new(),
561 pins_changed: None,
562 }
563 }
564
565 pub fn current_folder(&self) -> &str {
568 self.path.last().map(|(id, _)| id.as_str()).unwrap_or("")
569 }
570
571 pub fn thumbnail_entries(&self) -> Vec<&str> {
572 self.entries.iter().flatten().filter(|entry| entry.link.as_ref()
573 .is_some_and(|link| self.thumbnails.ready(&link.thumbnail_url)))
574 .map(|entry| entry.id.as_str()).collect()
575 }
576
577 pub fn read_only(&self) -> bool {
579 self.browse_only || !self.owner.is_empty()
580 }
581
582 pub fn busy(&self) -> bool {
583 self.thumbnails.busy()
584 || self.list_pending.as_ref().is_some_and(Pending::is_open)
585 || self.owners_pending.as_ref().is_some_and(Pending::is_open)
586 || self.action_pending.iter().any(|(_, p)| p.is_open())
587 || self.lookup_pending.as_ref().is_some_and(|(_, p)| p.is_open())
588 || self.download_pending.as_ref().is_some_and(|(_, p)| p.is_open())
589 || self.versions_pending.as_ref().is_some_and(|(_, p)| p.is_open())
590 }
591
592 pub fn reload(&mut self, plm: &dyn Workspaces) {
594 self.list_pending = Some(Pending::new(plm.folder(&self.owner, self.current_folder())));
595 self.owners_pending = Some(Pending::new(plm.workspaces()));
596 }
597
598 fn reload_folder(&mut self, plm: &dyn Workspaces) {
599 self.list_pending = Some(Pending::new(plm.folder(&self.owner, self.current_folder())));
600 }
601
602 pub fn set_owner(&mut self, plm: &dyn Workspaces, owner: &str, label: &str) {
604 self.owner = owner.to_string();
605 self.owner_label = label.to_string();
606 self.path.clear();
607 self.selection_cleared();
608 self.entries = None;
609 self.reload_folder(plm);
610 }
611
612 fn selection_cleared(&mut self) {
613 self.selected = None;
614 self.rename = None;
615 self.promote = None;
616 self.versions = None;
617 self.delete_armed = false;
618 }
619
620 pub fn enter(&mut self, plm: &dyn Workspaces, folder: &Entry) {
622 self.path.push((folder.id.clone(), folder.name.clone()));
623 self.selection_cleared();
624 self.entries = None;
625 self.reload_folder(plm);
626 }
627
628 pub fn go_to_pin(&mut self, plm: &dyn Workspaces, pin: &Pin) {
630 self.owner.clear();
631 self.owner_label = "My workspace".into();
632 self.path = pin.clone();
633 self.selection_cleared();
634 self.entries = None;
635 self.reload_folder(plm);
636 }
637
638 pub fn toggle_pin(&mut self) -> Option<Vec<Pin>> {
640 if self.read_only() || self.path.is_empty() {
641 return None;
642 }
643 let mut pins = self.pins.clone().unwrap_or_default();
644 let here = self.current_folder().to_string();
645 let before = pins.len();
646 pins.retain(|pin| pin.last().map(|(id, _)| id.as_str()) != Some(here.as_str()));
647 if pins.len() == before {
648 pins.push(self.path.clone());
649 }
650 self.pins = Some(pins.clone());
651 Some(pins)
652 }
653
654 pub fn up_to(&mut self, plm: &dyn Workspaces, depth: usize) {
656 self.path.truncate(depth);
657 self.selection_cleared();
658 self.entries = None;
659 self.reload_folder(plm);
660 }
661
662 fn entry(&self, id: &str) -> Option<&Entry> {
663 self.entries.as_ref()?.iter().find(|e| e.id == id)
664 }
665
666 pub fn selection(&self) -> Option<&Entry> {
668 self.entry(self.selected.as_deref()?)
669 }
670
671 pub fn select_named(&mut self, name: &str) -> bool {
673 let found = self.entries.as_ref().and_then(|list| list.iter().find(|e| e.name == name)).map(|e| e.id.clone());
674 let changed = found != self.selected;
675 if changed {
676 self.selection_cleared();
677 }
678 self.selected = found;
679 self.selected.is_some()
680 }
681
682 pub fn activate(&mut self, plm: &dyn Workspaces, entry: &Entry, outcome: &mut WorkspaceOutcome) {
684 match (entry.kind.as_str(), &entry.link) {
685 ("folder", _) => self.enter(plm, entry),
686 ("link", Some(link)) if link.part_missing => self.problem = Some(format!("{}: the part it links is gone", entry.name)),
687 ("link", Some(link)) if link.revision_missing || link.document_key.is_empty() => {
688 self.problem = Some(format!(
689 "{}: it is pinned to a revision of {} that was deleted — pin it to another, or let it follow the newest",
690 entry.name, link.number
691 ))
692 }
693 ("link", Some(link)) => {
694 outcome.open = Some(link.document_key.clone());
695 outcome.open_folder = self.path.last().cloned();
696 }
697 ("file", _) => self.download(plm, entry, 0),
698 _ => {}
699 }
700 }
701
702 fn act(&mut self, what: impl Into<String>, future: PlmFuture<Done>) {
703 self.problem = None;
704 self.action_pending.push((what.into(), Pending::new(future)));
705 }
706
707 fn map<T: 'static>(future: PlmFuture<T>, done: impl FnOnce(T) -> Done + 'static) -> PlmFuture<Done> {
708 Box::pin(async move { future.await.map(done) })
709 }
710
711 pub fn new_folder(&mut self, plm: &dyn Workspaces, name: &str) {
713 let future = Self::map(plm.create_folder(self.current_folder(), name.trim()), Done::Entry);
714 self.act(format!("made folder {}", name.trim()), future);
715 }
716
717 pub fn link(&mut self, plm: &dyn Workspaces, part: &str, revision: &str) {
719 let future = Self::map(plm.link(self.current_folder(), part.trim(), revision.trim()), Done::Entry);
720 self.act(format!("linked {}", part.trim()), future);
721 }
722
723 pub fn add_file(&mut self, plm: &dyn Workspaces, name: &str, bytes: Vec<u8>) {
727 let existing = self
728 .entries
729 .as_ref()
730 .and_then(|list| list.iter().find(|e| e.kind == "file" && e.name.eq_ignore_ascii_case(name)))
731 .map(|e| e.id.clone());
732 match existing {
733 Some(id) => {
734 let future = Self::map(plm.replace_file(&id, name, bytes), Done::Entry);
735 self.act(format!("{name}: a new version, the one before kept"), future);
736 }
737 None => {
738 let future = Self::map(plm.add_file(self.current_folder(), name, bytes), Done::Entry);
739 self.act(format!("added {name}"), future);
740 }
741 }
742 }
743
744 pub fn rename(&mut self, plm: &dyn Workspaces, id: &str, name: &str) {
745 let future = Self::map(plm.update(id, json!({ "name": name.trim() })), Done::Entry);
746 self.act(format!("renamed to {}", name.trim()), future);
747 }
748
749 pub fn cut(&mut self, id: &str) {
751 self.cut = self.entry(id).cloned();
752 if let Some(entry) = &self.cut {
753 self.notice = Some(format!("{} cut — open a folder and paste it there", entry.name));
754 }
755 }
756
757 pub fn paste(&mut self, plm: &dyn Workspaces) {
759 let Some(entry) = self.cut.take() else { return };
760 let future = Self::map(plm.update(&entry.id, json!({ "parent": self.current_folder() })), Done::Entry);
761 self.act(format!("moved {}", entry.name), future);
762 }
763
764 pub fn delete(&mut self, plm: &dyn Workspaces, id: &str) {
767 let Some(entry) = self.entry(id).cloned() else { return };
768 let future = Self::map(plm.delete(&entry.id, entry.is_folder()), |()| Done::Nothing);
769 let what = match entry.kind.as_str() {
770 "link" => format!("removed the link {} — the part is untouched", entry.name),
771 _ => format!("deleted {}", entry.name),
772 };
773 self.selection_cleared();
774 self.act(what, future);
775 }
776
777 pub fn pin(&mut self, plm: &dyn Workspaces, id: &str, revision: &str) {
779 let future = Self::map(plm.update(id, json!({ "revision": revision })), Done::Entry);
780 let what = if revision.is_empty() { "the link follows the newest revision".to_string() } else { format!("pinned to {revision}") };
781 self.act(what, future);
782 }
783
784 pub fn load_versions(&mut self, plm: &dyn Workspaces, id: &str) {
785 self.versions_pending = Some((id.to_string(), Pending::new(plm.versions(id))));
786 }
787
788 pub fn restore(&mut self, plm: &dyn Workspaces, id: &str, version: u32) {
790 let future = Self::map(plm.restore(id, version), Done::Entry);
791 self.act(format!("restored version {version} as a new version"), future);
792 }
793
794 pub fn download(&mut self, plm: &dyn Workspaces, entry: &Entry, version: u32) {
795 self.download_pending = Some((entry.name.clone(), Pending::new(plm.download(&entry.id, version))));
796 }
797
798 pub fn promote(&mut self, plm: &dyn Workspaces, id: &str, to: &PromoteTo) {
800 let future = Self::map(plm.promote(id, to), Done::Attachment);
801 self.act("promoted", future);
802 }
803
804 pub fn look_up(&mut self, plm: &dyn Workspaces, open: bool) {
808 let open = open || self.lookup_pending.as_ref().is_some_and(|(opening, _)| *opening);
811 self.problem = None;
812 self.lookup_pending = Some((open, Pending::new(plm.lookup(&self.open_part))));
813 }
814
815 pub fn problem_text(&self) -> Option<String> {
819 self.problem.as_deref().map(|problem| {
820 if problem.starts_with("the PLM server did not answer") {
821 "The PLM server is not reachable. Your open tabs are kept; try again (Reload) when it is back.".to_string()
822 } else {
823 problem.to_string()
824 }
825 })
826 }
827
828 pub fn open_by_number(&mut self, plm: &dyn Workspaces, outcome: &mut WorkspaceOutcome) {
829 let looked_up = self.open_lookup.as_ref().filter(|p| {
830 let typed = self.open_part.trim();
831 p.id == typed || p.number.eq_ignore_ascii_case(typed)
832 });
833 match looked_up {
834 Some(part) => {
835 let wanted = self.open_revision.trim();
836 let revision = part
837 .revision_views
838 .iter()
839 .find(|r| r.id == wanted || r.label.eq_ignore_ascii_case(wanted))
840 .or(part.newest_revision.as_ref().filter(|_| wanted.is_empty()));
841 match revision {
842 Some(r) if !r.document_key.is_empty() => {
843 outcome.open = Some(r.document_key.clone());
844 self.open_part.clear();
847 self.open_revision.clear();
848 self.open_lookup = None;
849 }
850 _ => self.problem = Some(format!("{} has no revision '{wanted}'", part.number)),
851 }
852 }
853 None => self.look_up(plm, true),
854 }
855 }
856
857 pub fn poll(&mut self, waker: &Waker, plm: &dyn Workspaces, outcome: &mut WorkspaceOutcome) {
859 self.thumbnails.poll(waker);
860 if let Some(answer) = self.list_pending.as_mut().and_then(|p| p.poll(waker)) {
861 self.list_pending = None;
862 match answer {
863 Ok(entries) => {
864 if self.selected.as_deref().is_some_and(|id| !entries.iter().any(|e| e.id == id)) {
865 self.selection_cleared();
866 }
867 self.entries = Some(entries);
868 }
869 Err(problem) => {
870 if self.selected.is_some() { self.selection_cleared(); }
875 self.entries = Some(Vec::new());
876 self.problem = Some(problem);
877 }
878 }
879 }
880 if let Some(answer) = self.owners_pending.as_mut().and_then(|p| p.poll(waker)) {
881 self.owners_pending = None;
882 match answer {
883 Ok(list) => self.workspaces = Some(list),
884 Err(problem) => self.problem = Some(problem),
885 }
886 }
887 let mut answered = Vec::new();
888 self.action_pending.retain_mut(|(what, pending)| match pending.poll(waker) {
889 Some(answer) => {
890 answered.push((std::mem::take(what), answer));
891 false
892 }
893 None => true,
894 });
895 for (what, answer) in answered {
896 {
897 match answer {
898 Ok(done) => {
899 self.notice = Some(what);
900 match done {
901 Done::Entry(entry) => {
902 if self.versions.as_ref().is_some_and(|(id, _)| *id == entry.id) {
903 self.load_versions(plm, &entry.id);
904 }
905 self.selected = Some(entry.id);
906 }
907 Done::Attachment(a) => {
908 self.notice = Some(format!("promoted to an attachment: {} ({})", a.name, a.kind));
909 self.promote = None;
910 }
911 Done::Nothing => {}
912 }
913 self.reload_folder(plm);
914 }
915 Err(problem) => self.problem = Some(problem),
916 }
917 }
918 }
919 if let Some((open, pending)) = self.lookup_pending.as_mut() {
920 let open = *open;
921 if let Some(answer) = pending.poll(waker) {
922 self.lookup_pending = None;
923 match answer {
924 Ok(part) => {
925 let typed = self.open_revision.trim().to_string();
926 if typed.is_empty() || !part.revision_views.iter().any(|r| r.label.eq_ignore_ascii_case(&typed) || r.id == typed) {
927 self.open_revision = part.newest_revision.as_ref().map(|r| r.label.clone()).unwrap_or_default();
928 }
929 self.open_part = part.number.clone();
930 self.open_lookup = Some(part);
931 if open {
932 self.open_by_number(plm, outcome);
933 }
934 }
935 Err(problem) => {
936 self.open_lookup = None;
937 self.problem = Some(problem);
938 }
939 }
940 }
941 }
942 if let Some((name, pending)) = self.download_pending.as_mut() {
943 if let Some(answer) = pending.poll(waker) {
944 let name = std::mem::take(name);
945 self.download_pending = None;
946 match answer {
947 Ok(bytes) => outcome.downloaded = Some((name, bytes)),
948 Err(problem) => self.problem = Some(problem),
949 }
950 }
951 }
952 if let Some((id, pending)) = self.versions_pending.as_mut() {
953 if let Some(answer) = pending.poll(waker) {
954 let id = std::mem::take(id);
955 self.versions_pending = None;
956 match answer {
957 Ok(list) => self.versions = Some((id, list)),
958 Err(problem) => self.problem = Some(problem),
959 }
960 }
961 }
962 }
963}
964
965fn repaint_waker(ctx: &egui::Context) -> Waker {
969 struct Repaint(egui::Context);
970 impl std::task::Wake for Repaint {
971 fn wake(self: std::sync::Arc<Self>) {
972 self.0.request_repaint();
973 }
974 }
975 Waker::from(std::sync::Arc::new(Repaint(ctx.clone())))
976}
977
978fn icon(entry: &Entry) -> &'static str {
981 match entry.kind.as_str() {
982 "folder" => "[folder]",
983 "link" => "[link]",
984 _ => "[file]",
985 }
986}
987
988impl WorkspaceBrowser {
989 fn hit(&mut self, prefix: &str, key: &str, response: &egui::Response) {
990 self.hits.insert(format!("{prefix}ws:{key}"), response.rect);
991 }
992
993 pub fn show(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) -> WorkspaceOutcome {
997 if let Some(client) = plm.session() {
998 if self.session.as_ref().is_some_and(|old| !std::rc::Rc::ptr_eq(old, &client)) {
999 let can_pick_files = self.can_pick_files;
1001 let browse_only = self.browse_only;
1002 *self = Self::new();
1003 self.can_pick_files = can_pick_files;
1004 self.browse_only = browse_only;
1005 }
1006 self.session = Some(client);
1007 }
1008 let mut outcome = WorkspaceOutcome::default();
1009 let ctx = ui.ctx().clone();
1010 let waker = repaint_waker(&ctx);
1011 if self.entries.is_none() && !self.busy() {
1012 self.reload(plm);
1013 }
1014 self.poll(&waker, plm, &mut outcome);
1015 self.hits.clear();
1016
1017 self.owner_row(ui, plm, prefix);
1018 self.open_row(ui, plm, prefix, &mut outcome);
1019 ui.separator();
1020 self.crumbs(ui, plm, prefix);
1021 self.rows(ui, plm, prefix, &mut outcome);
1022 self.selection_verbs(ui, plm, prefix);
1023 if !self.read_only() {
1024 ui.separator();
1025 self.make_row(ui, plm, prefix);
1026 }
1027 outcome.pick_file = std::mem::take(&mut self.pick_asked);
1028 if let Some(problem) = self.problem_text() {
1029 ui.colored_label(ui.visuals().error_fg_color, problem);
1030 } else if let Some(notice) = &self.notice {
1031 ui.label(egui::RichText::new(notice).weak());
1032 }
1033 self.poll(&waker, plm, &mut outcome);
1036 outcome.pins = self.pins_changed.take();
1037 outcome
1038 }
1039
1040 fn owner_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1041 let rows: Vec<WorkspaceRow> = self.workspaces.as_ref().map(|w| w.workspaces.clone()).unwrap_or_default();
1042 let mut chosen: Option<(String, String)> = None;
1043 ui.horizontal(|ui| {
1044 let combo = egui::ComboBox::from_id_salt((prefix, "ws-owner")).selected_text(&self.owner_label).show_ui(ui, |ui| {
1045 for row in &rows {
1046 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)) };
1047 let owner = if row.mine { String::new() } else { row.user_id.clone() };
1048 if ui.selectable_label(self.owner == owner, &label).clicked() {
1049 chosen = Some((owner, label));
1050 }
1051 }
1052 });
1053 self.hit(prefix, "owner", &combo.response);
1054 if self.read_only() {
1055 ui.label(egui::RichText::new("read-only").weak());
1056 }
1057 if self.workspaces.as_ref().is_some_and(|w| !w.browsable) {
1058 ui.label(egui::RichText::new("other workspaces are private on this server").weak());
1059 }
1060 let reload = ui.small_button("\u{21BB}").on_hover_text("Reload");
1061 self.hit(prefix, "reload", &reload);
1062 if reload.clicked() {
1063 self.reload(plm);
1064 }
1065 });
1066 if let Some((owner, label)) = chosen {
1067 self.set_owner(plm, &owner, &label);
1068 }
1069 }
1070
1071 const REVISION_FIELD_WIDTH: f32 = 80.0;
1081
1082 fn open_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str, outcome: &mut WorkspaceOutcome) {
1083 ui.horizontal(|ui| {
1084 ui.label("Part");
1085 let part = ui.add(egui::TextEdit::singleline(&mut self.open_part).desired_width(110.0).hint_text("number or id"));
1086 self.hit(prefix, "open-part", &part);
1087 if part.lost_focus() && !self.open_part.trim().is_empty() {
1088 self.look_up(plm, false);
1090 }
1091 ui.label("Revision");
1092 let labels: Vec<(String, String)> = self
1093 .open_lookup
1094 .as_ref()
1095 .map(|p| p.revision_views.iter().map(|r| (r.label.clone(), format!("{} ({})", r.label, r.lifecycle))).collect())
1096 .unwrap_or_default();
1097 let revision = if labels.is_empty() {
1098 ui.add_sized(
1099 [Self::REVISION_FIELD_WIDTH, ui.spacing().interact_size.y],
1100 egui::TextEdit::singleline(&mut self.open_revision).hint_text("newest"),
1101 )
1102 } else {
1103 let text = self.open_revision.clone();
1104 egui::ComboBox::from_id_salt((prefix, "ws-open-revision"))
1105 .width(Self::REVISION_FIELD_WIDTH)
1106 .selected_text(text)
1107 .show_ui(ui, |ui| {
1108 for (label, shown) in labels.iter().rev() {
1109 ui.selectable_value(&mut self.open_revision, label.clone(), shown);
1110 }
1111 })
1112 .response
1113 };
1114 self.hit(prefix, "open-revision", &revision);
1115 let go = ui.add_enabled(!self.open_part.trim().is_empty(), egui::Button::new("Open"));
1116 self.hit(prefix, "open-go", &go);
1117 if go.clicked() {
1118 self.open_by_number(plm, outcome);
1119 }
1120 });
1121 }
1122
1123 fn crumbs(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1124 let mut up_to: Option<usize> = None;
1125 let mut paste = false;
1126 ui.horizontal_wrapped(|ui| {
1127 let top = ui.link(&self.owner_label);
1128 self.hit(prefix, "crumb:0", &top);
1129 if top.clicked() {
1130 up_to = Some(0);
1131 }
1132 for (depth, (_, name)) in self.path.clone().iter().enumerate() {
1133 ui.label("/");
1134 let crumb = ui.link(name);
1135 self.hit(prefix, &format!("crumb:{}", depth + 1), &crumb);
1136 if crumb.clicked() {
1137 up_to = Some(depth + 1);
1138 }
1139 }
1140 if let Some(cut) = self.cut.clone().filter(|_| !self.read_only()) {
1141 let button = ui.small_button(format!("Paste {} here", cut.name));
1142 self.hit(prefix, "paste", &button);
1143 paste = button.clicked();
1144 }
1145 if !self.read_only() && !self.path.is_empty() {
1146 let here = self.current_folder().to_string();
1147 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())));
1148 let button = ui.small_button(if pinned { "Unpin folder" } else { "Pin folder" });
1149 self.hit(prefix, "pin-folder", &button);
1150 if button.clicked() {
1151 self.pins_changed = self.toggle_pin();
1152 }
1153 }
1154 });
1155 let mut go_to: Option<Pin> = None;
1156 if let Some(pins) = self.pins.clone().filter(|pins| !pins.is_empty()) {
1157 ui.horizontal_wrapped(|ui| {
1158 ui.label(egui::RichText::new("Pinned:").weak());
1159 for (index, pin) in pins.iter().enumerate() {
1160 let name = pin.last().map(|(_, name)| name.as_str()).unwrap_or("");
1161 let button = ui.small_button(name).on_hover_text(pin.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>().join(" / "));
1162 self.hit(prefix, &format!("pinned:{index}"), &button);
1163 if button.clicked() {
1164 go_to = Some(pin.clone());
1165 }
1166 }
1167 });
1168 }
1169 if let Some(pin) = go_to {
1170 self.go_to_pin(plm, &pin);
1171 }
1172 if let Some(depth) = up_to {
1173 self.up_to(plm, depth);
1174 }
1175 if paste {
1176 self.paste(plm);
1177 }
1178 }
1179
1180 fn rows(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str, outcome: &mut WorkspaceOutcome) {
1184 let Some(entries) = self.entries.take() else {
1185 ui.label(egui::RichText::new("Reading the workspace…").weak());
1186 return;
1187 };
1188 if entries.is_empty() {
1189 let empty = if self.read_only() { "Empty." } else { "Empty. Make a folder, link a part, or drop a file here." };
1190 ui.label(egui::RichText::new(empty).weak());
1191 }
1192 let mut activated: Option<Entry> = None;
1193 let mut clicked: Option<String> = None;
1194 let row_height = crate::plm::thumbnail_view::SIDE;
1195 let pitch = row_height + ui.spacing().item_spacing.y;
1196 egui::ScrollArea::vertical().id_salt((prefix, "ws-rows")).max_height(260.0).show_rows(ui, row_height, entries.len(), |ui, shown| {
1197 let top = ui.cursor().top() - shown.start as f32 * pitch;
1198 let (left, width) = (ui.cursor().left(), ui.available_width());
1199 for (index, entry) in entries.iter().enumerate() {
1200 if shown.contains(&index) {
1201 continue;
1202 }
1203 let rect = egui::Rect::from_min_size(egui::pos2(left, top + index as f32 * pitch), egui::vec2(width, row_height));
1204 self.hits.insert(format!("{prefix}ws:entry:{}", entry.name), rect);
1205 }
1206 for entry in &entries[shown] {
1207 ui.push_id(&entry.id, |ui| {
1208 let selected = self.selected.as_deref() == Some(entry.id.as_str());
1209 let text = format!("{} {} {}", icon(entry), entry.name, entry.detail());
1210 let row = ui.horizontal(|ui| {
1211 let url = entry.link.as_ref().map_or("", |link| link.thumbnail_url.as_str());
1212 let preview = self.thumbnails.show(ui, url, |url| plm.thumbnail(url));
1213 ui.add_sized(
1214 [ui.available_width(), crate::plm::thumbnail_view::SIDE],
1215 egui::Button::selectable(selected, text).wrap_mode(egui::TextWrapMode::Truncate),
1216 ).union(preview)
1217 }).inner;
1218 self.hit(prefix, &format!("entry:{}", entry.name), &row);
1219 if row.clicked() {
1221 clicked = Some(entry.name.clone());
1222 }
1223 if row.double_clicked() {
1224 activated = Some(entry.clone());
1225 }
1226 });
1227 }
1228 });
1229 if self.entries.is_none() {
1230 self.entries = Some(entries);
1231 }
1232 if let Some(name) = clicked {
1233 self.select_named(&name);
1234 }
1235 if let Some(entry) = activated {
1236 self.activate(plm, &entry, outcome);
1237 }
1238 }
1239
1240 fn selection_verbs(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1241 let Some(entry) = self.selection().cloned() else { return };
1242 let writable = !self.read_only();
1243 ui.separator();
1244 ui.label(egui::RichText::new(format!("{} {}", icon(&entry), entry.name)).strong());
1245 ui.horizontal_wrapped(|ui| {
1246 if writable {
1247 let rename = ui.small_button("Rename");
1248 self.hit(prefix, "sel:rename", &rename);
1249 if rename.clicked() {
1250 self.rename = Some(entry.name.clone());
1251 }
1252 let cut = ui.small_button("Move…");
1253 self.hit(prefix, "sel:cut", &cut);
1254 if cut.clicked() {
1255 self.cut(&entry.id);
1256 }
1257 let label = if self.delete_armed {
1258 "Really delete?"
1259 } else if entry.kind == "link" {
1260 "Remove link"
1261 } else {
1262 "Delete"
1263 };
1264 let delete = ui.small_button(label).on_hover_text(match entry.kind.as_str() {
1265 "link" => "Removes the link only: the part and its revisions are untouched.",
1266 "folder" => "Deletes the folder and everything in it (links, files). No part is touched.",
1267 _ => "Deletes the file and every version of it.",
1268 });
1269 self.hit(prefix, if self.delete_armed { "sel:delete-confirm" } else { "sel:delete" }, &delete);
1270 if delete.clicked() {
1271 if self.delete_armed || entry.kind == "link" {
1272 self.delete(plm, &entry.id);
1273 } else {
1274 self.delete_armed = true;
1275 }
1276 }
1277 }
1278 if let Some(link) = entry.link.clone() {
1279 if writable && !link.part_missing {
1280 if link.pinned {
1281 let follow = ui.small_button("Follow newest");
1282 self.hit(prefix, "sel:follow", &follow);
1283 if follow.clicked() {
1284 self.pin(plm, &entry.id, "");
1285 }
1286 } else if !link.revision_label.is_empty() {
1287 let pin = ui.small_button(format!("Pin to {}", link.revision_label));
1288 self.hit(prefix, "sel:pin", &pin);
1289 if pin.clicked() {
1290 self.pin(plm, &entry.id, &link.revision_id);
1291 }
1292 }
1293 }
1294 }
1295 if entry.kind == "file" {
1296 let download = ui.small_button("Download");
1297 self.hit(prefix, "sel:download", &download);
1298 if download.clicked() {
1299 self.download(plm, &entry, 0);
1300 }
1301 let versions = ui.small_button("Versions");
1302 self.hit(prefix, "sel:versions", &versions);
1303 if versions.clicked() {
1304 self.load_versions(plm, &entry.id);
1305 }
1306 if writable {
1307 let promote = ui.small_button("Promote to attachment…");
1308 self.hit(prefix, "sel:promote", &promote);
1309 if promote.clicked() {
1310 self.promote = Some(PromoteTo { kind: "other".into(), ..PromoteTo::default() });
1311 }
1312 }
1313 }
1314 });
1315 if let Some(mut name) = self.rename.take() {
1316 let mut keep = true;
1317 ui.horizontal(|ui| {
1318 let field = ui.text_edit_singleline(&mut name);
1319 self.hit(prefix, "sel:rename-name", &field);
1320 let go = ui.small_button("Rename");
1321 self.hit(prefix, "sel:rename-go", &go);
1322 if go.clicked() {
1323 self.rename(plm, &entry.id, &name);
1324 keep = false;
1325 }
1326 });
1327 if keep {
1328 self.rename = Some(name);
1329 }
1330 }
1331 if let Some((id, versions)) = self.versions.clone().filter(|(id, _)| *id == entry.id) {
1332 for v in versions.iter().rev() {
1333 ui.horizontal(|ui| {
1334 let restored = v.restored_from.map(|n| format!(", restored from v{n}")).unwrap_or_default();
1335 ui.label(format!(
1336 "v{}{} · {} · {}{restored}",
1337 v.version,
1338 if v.current { " (current)" } else { "" },
1339 human_size(v.size),
1340 v.uploaded_by_name
1341 ));
1342 let download = ui.small_button("Download");
1343 self.hit(prefix, &format!("version:{}:download", v.version), &download);
1344 if download.clicked() {
1345 self.download(plm, &entry, v.version);
1346 }
1347 if writable && !v.current {
1348 let restore = ui.small_button("Restore");
1349 self.hit(prefix, &format!("version:{}:restore", v.version), &restore);
1350 if restore.clicked() {
1351 self.restore(plm, &id, v.version);
1352 }
1353 }
1354 });
1355 }
1356 }
1357 if let Some(mut to) = self.promote.take() {
1358 let mut keep = true;
1359 ui.horizontal_wrapped(|ui| {
1360 ui.label("Part");
1361 let part = ui.add(egui::TextEdit::singleline(&mut to.part).desired_width(100.0).hint_text("number"));
1362 self.hit(prefix, "sel:promote-part", &part);
1363 ui.label("Revision");
1364 let revision = ui.add(egui::TextEdit::singleline(&mut to.revision).desired_width(50.0).hint_text("part"));
1365 self.hit(prefix, "sel:promote-revision", &revision);
1366 egui::ComboBox::from_id_salt((prefix, "ws-promote-kind")).selected_text(&to.kind).show_ui(ui, |ui| {
1367 for kind in ["drawing", "datasheet", "spec", "image", "other"] {
1368 ui.selectable_value(&mut to.kind, kind.to_string(), kind);
1369 }
1370 });
1371 let go = ui.add_enabled(!to.part.trim().is_empty(), egui::Button::new("Promote"));
1372 self.hit(prefix, "sel:promote-go", &go);
1373 if go.clicked() {
1374 self.promote(plm, &entry.id, &to);
1375 }
1376 if ui.small_button("Cancel").clicked() {
1377 keep = false;
1378 }
1379 });
1380 if keep {
1381 self.promote = Some(to);
1382 }
1383 }
1384 }
1385
1386 fn make_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1387 ui.horizontal(|ui| {
1388 let name = ui.add(egui::TextEdit::singleline(&mut self.new_folder_name).desired_width(120.0).hint_text("folder name"));
1389 self.hit(prefix, "new-folder-name", &name);
1390 let go = ui.add_enabled(!self.new_folder_name.trim().is_empty(), egui::Button::new("New folder"));
1391 self.hit(prefix, "new-folder", &go);
1392 if go.clicked() {
1393 let name = std::mem::take(&mut self.new_folder_name);
1394 self.new_folder(plm, &name);
1395 }
1396 });
1397 ui.horizontal(|ui| {
1398 let part = ui.add(egui::TextEdit::singleline(&mut self.link_part).desired_width(100.0).hint_text("part number"));
1399 self.hit(prefix, "link-part", &part);
1400 let revision = ui.add(egui::TextEdit::singleline(&mut self.link_revision).desired_width(50.0).hint_text("newest"));
1401 self.hit(prefix, "link-revision", &revision);
1402 let go = ui.add_enabled(!self.link_part.trim().is_empty(), egui::Button::new("Link here"));
1403 self.hit(prefix, "link-go", &go);
1404 if go.clicked() {
1405 let (part, revision) = (std::mem::take(&mut self.link_part), std::mem::take(&mut self.link_revision));
1406 self.link(plm, &part, &revision);
1407 }
1408 });
1409 ui.horizontal_wrapped(|ui| {
1410 if self.can_pick_files {
1411 let add = ui.button("Add file\u{2026}").on_hover_text("Choose a file on this machine to add to this folder");
1412 self.hit(prefix, "add-file", &add);
1413 self.pick_asked |= add.clicked();
1414 }
1415 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());
1416 });
1417 }
1418}
1419
1420