1use 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#[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 pub browsable: bool,
60}
61
62#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
64#[serde(default)]
65pub struct LinkView {
66 pub thumbnail_url: String,
67 pub part_id: String,
68 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 pub document_key: String,
78 pub part_missing: bool,
79 pub revision_missing: bool,
81}
82
83#[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#[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 pub kind: String,
107 pub created_at: u64,
108 pub modified_at: u64,
109 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 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#[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 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#[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#[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 pub newest_revision: Option<RevisionRef>,
192 pub revision_views: Vec<RevisionRef>,
193}
194
195#[derive(Clone, Debug, Default, PartialEq)]
197pub struct PromoteTo {
198 pub part: String,
200 pub revision: String,
202 pub version: u32,
204 pub kind: String,
205}
206
207pub 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 fn folder(&self, owner: &str, parent: &str) -> PlmFuture<Vec<Entry>>;
217 fn create_folder(&self, parent: &str, name: &str) -> PlmFuture<Entry>;
218 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 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 fn download(&self, id: &str, version: u32) -> PlmFuture<Vec<u8>>;
229 fn promote(&self, id: &str, to: &PromoteTo) -> PlmFuture<Attachment>;
230 fn lookup(&self, part: &str) -> PlmFuture<PartLookup>;
232}
233
234pub 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
343pub fn document_name(store: &dyn crate::store::ModelStore, key: &str) -> String {
349 store.canonical_identity(key)
350}
351
352pub 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
375pub type Pin = Vec<(String, String)>;
379
380pub fn pin_text(pin: &Pin) -> String {
384 format!("workspace:{}", serde_json::to_string(pin).unwrap_or_default())
385}
386
387pub 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
396pub 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
405pub fn load_pins(store: &dyn crate::store::ModelStore) -> Vec<Pin> {
407 pins_in(store.read(crate::store::PINNED_KEY).as_deref())
408}
409
410pub 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#[derive(Debug, Default, PartialEq)]
418pub struct WorkspaceOutcome {
419 pub open: Option<String>,
422 pub open_folder: Option<(String, String)>,
425 pub downloaded: Option<(String, Vec<u8>)>,
427 pub pins: Option<Vec<Pin>>,
429 pub pick_file: bool,
432}
433
434enum Done {
436 Entry(Entry),
437 Nothing,
438 Attachment(Attachment),
439}
440
441pub struct WorkspaceBrowser {
443 session: Option<std::rc::Rc<crate::plm::client::PlmClient>>,
444 thumbnails: crate::plm::thumbnail_view::ThumbnailCache,
445 pub can_pick_files: bool,
447 pub browse_only: bool,
448 pick_asked: bool,
450 pub owner: String,
452 pub owner_label: String,
453 pub path: Vec<(String, String)>,
455 pub entries: Option<Vec<Entry>>,
456 pub workspaces: Option<WorkspaceList>,
457 pub selected: Option<String>,
459 pub cut: Option<Entry>,
461 pub versions: Option<(String, Vec<Version>)>,
463 pub problem: Option<String>,
465 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 pub open_lookup: Option<PartLookup>,
475 pub promote: Option<PromoteTo>,
476 pub delete_armed: bool,
478 pub pins: Option<Vec<Pin>>,
480 list_pending: Option<Pending<Vec<Entry>>>,
481 owners_pending: Option<Pending<WorkspaceList>>,
482 action_pending: Vec<(String, Pending<Done>)>,
485 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 pins_changed: Option<Vec<Pin>>,
492}
493
494impl Default for WorkspaceBrowser {
495 fn default() -> Self {
496 Self::new()
497 }
498}
499
500impl WorkspaceBrowser {
501 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 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 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 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 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 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 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 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 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 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 pub fn selection(&self) -> Option<&Entry> {
656 self.entry(self.selected.as_deref()?)
657 }
658
659 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 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 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 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 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 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 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 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 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 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 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 pub fn look_up(&mut self, plm: &dyn Workspaces, open: bool) {
796 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 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 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 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 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
953fn 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
966fn 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 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 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 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 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 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 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 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