1use crate::automation::hit_keys::HitKeyDoc;
27use crate::plm::PlmFuture;
28use eframe::egui;
29use serde::Deserialize;
30use serde_json::{json, Value};
31use std::collections::HashMap;
32use std::task::Waker;
33
34use super::plm_attachments::{human_size, media_type_for, Attachment};
35use super::plm_parts::Pending;
36
37pub const HIT_KEYS: &[HitKeyDoc] = &[
39 HitKeyDoc { panel: "plm_workspace", prefix: "ws:entry:", meaning: "a workspace row, by entry name; click selects, double click opens a folder, a link's document or a file", command: None },
40 HitKeyDoc { panel: "plm_workspace", prefix: "ws:crumb:", meaning: "the breadcrumb: ws:crumb:0 is the top of the workspace", command: None },
41 HitKeyDoc { panel: "plm_workspace", prefix: "ws:owner", meaning: "whose workspace is shown (yours, or another user's, read-only)", command: None },
42 HitKeyDoc { panel: "plm_workspace", prefix: "ws:open-", meaning: "open by number: ws:open-part, ws:open-revision, ws:open-go", command: None },
43 HitKeyDoc { panel: "plm_workspace", prefix: "ws:new-folder", meaning: "New folder: ws:new-folder-name then ws:new-folder", command: None },
44 HitKeyDoc { panel: "plm_workspace", prefix: "ws:link-", meaning: "Link a part here: ws:link-part, ws:link-revision (empty follows the newest), ws:link-go", command: None },
45 HitKeyDoc { panel: "plm_workspace", prefix: "ws:sel:", meaning: "the selected entry's verbs: open, rename, rename-name, rename-go, cut, delete, delete-confirm, pin, follow, versions, download, promote, promote-part, promote-revision, promote-go", command: None },
46 HitKeyDoc { panel: "plm_workspace", prefix: "ws:paste", meaning: "move the cut entry into the folder shown", command: None },
47 HitKeyDoc { panel: "plm_workspace", prefix: "ws:version:", meaning: "a file's version row: ws:version:<n>:restore, ws:version:<n>:download", command: None },
48 HitKeyDoc { panel: "plm_workspace", prefix: "ws:reload", meaning: "reload the folder", command: None },
49 HitKeyDoc { panel: "plm_workspace", prefix: "ws:add-file", meaning: "Add file…: choose a file on this machine (the file chooser) to add to the folder shown", command: Some("file_pick") },
50];
51
52#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
60#[serde(default)]
61pub struct WorkspaceRow {
62 pub user_id: String,
63 pub username: String,
64 pub display_name: String,
65 pub mine: bool,
66 pub entries: usize,
67}
68
69#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
70#[serde(default)]
71pub struct WorkspaceList {
72 pub workspaces: Vec<WorkspaceRow>,
73 pub browsable: bool,
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
79#[serde(default)]
80pub struct LinkView {
81 pub thumbnail_url: String,
82 pub part_id: String,
83 pub pinned: bool,
85 pub number: String,
86 pub name: String,
87 pub document_class: String,
88 pub revision_id: String,
89 pub revision_label: String,
90 pub lifecycle: String,
91 pub document_key: String,
93 pub part_missing: bool,
94 pub revision_missing: bool,
96}
97
98#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
100#[serde(default)]
101pub struct FileView {
102 pub version: u32,
103 pub versions: usize,
104 pub size: u64,
105 pub media_type: String,
106 pub sha256: String,
107 pub uploaded_at: u64,
108 pub uploaded_by_name: String,
109}
110
111#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
113#[serde(default)]
114pub struct Entry {
115 pub id: String,
116 pub owner: String,
117 pub owner_name: String,
118 pub parent: String,
119 pub name: String,
120 pub kind: String,
122 pub created_at: u64,
123 pub modified_at: u64,
124 pub children: Option<usize>,
126 pub link: Option<LinkView>,
127 pub file: Option<FileView>,
128}
129
130impl Entry {
131 pub fn is_folder(&self) -> bool {
132 self.kind == "folder"
133 }
134
135 pub fn detail(&self) -> String {
137 match (&self.link, &self.file, self.children) {
138 (Some(link), _, _) if link.part_missing => "the part is gone".into(),
139 (Some(link), _, _) if link.revision_missing => format!("{} — the pinned revision was deleted", link.number),
140 (Some(link), _, _) => {
141 let how = if link.pinned { "pinned" } else { "newest" };
142 format!("{} rev {} ({}, {how})", link.number, link.revision_label, link.lifecycle)
143 }
144 (_, Some(file), _) => format!("v{} of {} · {}", file.version, file.versions, human_size(file.size)),
145 (_, _, Some(n)) => format!("{n} item(s)"),
146 _ => String::new(),
147 }
148 }
149}
150
151#[derive(Deserialize)]
152struct Folder {
153 #[serde(default)]
154 entries: Vec<Entry>,
155}
156
157#[derive(Deserialize)]
158struct Made {
159 entry: Entry,
160}
161
162#[derive(Deserialize)]
163struct Promoted {
164 attachment: Attachment,
165}
166
167#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
169#[serde(default)]
170pub struct Version {
171 pub version: u32,
172 pub sha256: String,
173 pub size: u64,
174 pub media_type: String,
175 pub uploaded_at: u64,
176 pub uploaded_by_name: String,
177 pub restored_from: Option<u32>,
179 pub current: bool,
180}
181
182#[derive(Deserialize)]
183struct Versions {
184 #[serde(default)]
185 versions: Vec<Version>,
186}
187
188#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
190#[serde(default)]
191pub struct RevisionRef {
192 pub id: String,
193 pub label: String,
194 pub lifecycle: String,
195 pub document_key: String,
196}
197
198#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
200#[serde(default)]
201pub struct PartLookup {
202 pub id: String,
203 pub number: String,
204 pub name: String,
205 pub newest_revision: Option<RevisionRef>,
207 pub revision_views: Vec<RevisionRef>,
208}
209
210#[derive(Clone, Debug, Default, PartialEq)]
212pub struct PromoteTo {
213 pub part: String,
215 pub revision: String,
217 pub version: u32,
219 pub kind: String,
220}
221
222pub trait Workspaces {
225 fn session(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> { None }
226 fn thumbnail(&self, _url: &str) -> PlmFuture<Option<Vec<u8>>> {
227 Box::pin(async { Ok(None) })
228 }
229 fn workspaces(&self) -> PlmFuture<WorkspaceList>;
230 fn folder(&self, owner: &str, parent: &str) -> PlmFuture<Vec<Entry>>;
232 fn create_folder(&self, parent: &str, name: &str) -> PlmFuture<Entry>;
233 fn link(&self, parent: &str, part: &str, revision: &str) -> PlmFuture<Entry>;
235 fn add_file(&self, parent: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry>;
236 fn replace_file(&self, id: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry>;
237 fn update(&self, id: &str, fields: Value) -> PlmFuture<Entry>;
239 fn delete(&self, id: &str, recursive: bool) -> PlmFuture<()>;
240 fn versions(&self, id: &str) -> PlmFuture<Vec<Version>>;
241 fn restore(&self, id: &str, version: u32) -> PlmFuture<Entry>;
242 fn download(&self, id: &str, version: u32) -> PlmFuture<Vec<u8>>;
244 fn promote(&self, id: &str, to: &PromoteTo) -> PlmFuture<Attachment>;
245 fn lookup(&self, part: &str) -> PlmFuture<PartLookup>;
247}
248
249pub struct PlmWorkspaces {
251 pub client: std::rc::Rc<crate::plm::client::PlmClient>,
252}
253
254fn encode(text: &str) -> String {
255 let mut out = String::with_capacity(text.len());
256 for byte in text.bytes() {
257 if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
258 out.push(byte as char);
259 } else {
260 out.push_str(&format!("%{byte:02X}"));
261 }
262 }
263 out
264}
265
266impl PlmWorkspaces {
267 fn read<T: for<'de> Deserialize<'de> + 'static>(
268 &self,
269 method: &'static str,
270 path: String,
271 body: Option<Value>,
272 ) -> PlmFuture<T> {
273 let client = self.client.clone();
274 Box::pin(async move {
275 let body = body.map(|b| serde_json::to_vec(&b).unwrap_or_default());
276 let response = client.call(method, &path, body).await.map_err(|e| e.to_string())?;
277 serde_json::from_slice(&response.body)
278 .map_err(|e| format!("the PLM answered {path} with something this app cannot read: {e}"))
279 })
280 }
281
282 fn entry(&self, method: &'static str, path: String, body: Option<Value>) -> PlmFuture<Entry> {
283 let made: PlmFuture<Made> = self.read(method, path, body);
284 Box::pin(async move { Ok(made.await?.entry) })
285 }
286
287 fn bytes_entry(&self, method: &'static str, path: String, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry> {
288 let client = self.client.clone();
289 let media_type = media_type_for(name).to_string();
290 Box::pin(async move {
291 let response = client.call_typed(method, &path, bytes, &media_type).await.map_err(|e| e.to_string())?;
292 serde_json::from_slice::<Made>(&response.body)
293 .map(|m| m.entry)
294 .map_err(|e| format!("the PLM answered the upload with something this app cannot read: {e}"))
295 })
296 }
297}
298
299impl Workspaces for PlmWorkspaces {
300 fn session(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> { Some(self.client.clone()) }
301 fn thumbnail(&self, url: &str) -> PlmFuture<Option<Vec<u8>>> {
302 crate::plm::thumbnail_view::fetch(self.client.clone(), url.to_string())
303 }
304 fn workspaces(&self) -> PlmFuture<WorkspaceList> {
305 self.read("GET", "/api/workspaces".into(), None)
306 }
307
308 fn folder(&self, owner: &str, parent: &str) -> PlmFuture<Vec<Entry>> {
309 let folder: PlmFuture<Folder> =
310 self.read("GET", format!("/api/workspace/entries?owner={}&parent={}", encode(owner), encode(parent)), None);
311 Box::pin(async move { Ok(folder.await?.entries) })
312 }
313
314 fn create_folder(&self, parent: &str, name: &str) -> PlmFuture<Entry> {
315 self.entry("POST", "/api/workspace/folders".into(), Some(json!({ "parent": parent, "name": name })))
316 }
317
318 fn link(&self, parent: &str, part: &str, revision: &str) -> PlmFuture<Entry> {
319 self.entry("POST", "/api/workspace/links".into(), Some(json!({ "parent": parent, "part": part, "revision": revision })))
320 }
321
322 fn add_file(&self, parent: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry> {
323 let path = format!("/api/workspace/files?parent={}&name={}", encode(parent), encode(name));
324 self.bytes_entry("POST", path, name, bytes)
325 }
326
327 fn replace_file(&self, id: &str, name: &str, bytes: Vec<u8>) -> PlmFuture<Entry> {
328 self.bytes_entry("PUT", format!("/api/workspace/entries/{}/content", encode(id)), name, bytes)
329 }
330
331 fn update(&self, id: &str, fields: Value) -> PlmFuture<Entry> {
332 self.entry("PATCH", format!("/api/workspace/entries/{}", encode(id)), Some(fields))
333 }
334
335 fn delete(&self, id: &str, recursive: bool) -> PlmFuture<()> {
336 let client = self.client.clone();
337 let path = format!("/api/workspace/entries/{}?recursive={recursive}", encode(id));
338 Box::pin(async move {
339 client.call("DELETE", &path, None).await.map_err(|e| e.to_string())?;
340 Ok(())
341 })
342 }
343
344 fn versions(&self, id: &str) -> PlmFuture<Vec<Version>> {
345 let versions: PlmFuture<Versions> = self.read("GET", format!("/api/workspace/entries/{}/versions", encode(id)), None);
346 Box::pin(async move { Ok(versions.await?.versions) })
347 }
348
349 fn restore(&self, id: &str, version: u32) -> PlmFuture<Entry> {
350 self.entry("POST", format!("/api/workspace/entries/{}/versions/{version}/restore", encode(id)), None)
351 }
352
353 fn download(&self, id: &str, version: u32) -> PlmFuture<Vec<u8>> {
354 let client = self.client.clone();
355 let path = format!("/api/workspace/entries/{}/content?version={version}", encode(id));
356 Box::pin(async move { Ok(client.call("GET", &path, None).await.map_err(|e| e.to_string())?.body) })
357 }
358
359 fn promote(&self, id: &str, to: &PromoteTo) -> PlmFuture<Attachment> {
360 let body = json!({ "part": to.part, "revision": to.revision, "version": to.version, "kind": to.kind });
361 let promoted: PlmFuture<Promoted> = self.read("POST", format!("/api/workspace/entries/{}/promote", encode(id)), Some(body));
362 Box::pin(async move { Ok(promoted.await?.attachment) })
363 }
364
365 fn lookup(&self, part: &str) -> PlmFuture<PartLookup> {
366 self.read("GET", format!("/api/parts/{}", encode(part.trim())), None)
367 }
368}
369
370pub fn key_of(name: &str) -> Option<String> {
375 let tail = &name[name.find("part/")?..];
376 let segments: Vec<&str> = tail.split('/').collect();
377 match segments.as_slice() {
378 ["part", part, "rev", revision] if !part.is_empty() && !revision.is_empty() => {
379 let revision = revision.split('.').next().unwrap_or(revision);
380 (!revision.is_empty()).then(|| format!("part/{part}/rev/{revision}"))
381 }
382 _ => None,
383 }
384}
385
386pub fn document_name(store: &dyn crate::store::ModelStore, key: &str) -> String {
390 store.canonical_identity(key)
391}
392
393pub fn dropped(ctx: &egui::Context) -> Vec<(String, Vec<u8>)> {
397 let files = ctx.input(|i| i.raw.dropped_files.clone());
398 files
399 .into_iter()
400 .filter_map(|file| {
401 let name = if file.name.is_empty() {
402 file.path.as_ref()?.file_name()?.to_string_lossy().into_owned()
403 } else {
404 file.name.clone()
405 };
406 let bytes = match (&file.bytes, &file.path) {
407 (Some(bytes), _) => bytes.to_vec(),
408 (None, Some(path)) => std::fs::read(path).ok()?,
409 _ => return None,
410 };
411 Some((name, bytes))
412 })
413 .collect()
414}
415
416pub type Pin = Vec<(String, String)>;
420
421pub fn pin_text(pin: &Pin) -> String {
425 format!("workspace:{}", serde_json::to_string(pin).unwrap_or_default())
426}
427
428pub fn pins_in(value: Option<&str>) -> Vec<Pin> {
430 let list: Vec<String> = value.and_then(|v| serde_json::from_str(v).ok()).unwrap_or_default();
431 list.iter()
432 .filter_map(|item| serde_json::from_str::<Pin>(item.strip_prefix("workspace:")?).ok())
433 .filter(|pin| !pin.is_empty())
434 .collect()
435}
436
437pub fn with_pins(value: Option<&str>, pins: &[Pin]) -> String {
440 let list: Vec<String> = value.and_then(|v| serde_json::from_str(v).ok()).unwrap_or_default();
441 let mut out: Vec<String> = list.into_iter().filter(|item| !item.starts_with("workspace:")).collect();
442 out.extend(pins.iter().map(pin_text));
443 serde_json::to_string(&out).unwrap_or_else(|_| "[]".into())
444}
445
446pub fn load_pins(store: &dyn crate::store::ModelStore) -> Vec<Pin> {
448 pins_in(store.read(crate::store::PINNED_KEY).as_deref())
449}
450
451pub fn save_pins(store: &dyn crate::store::ModelStore, pins: &[Pin]) -> Result<(), String> {
453 let current = store.read(crate::store::PINNED_KEY);
454 store.write(crate::store::PINNED_KEY, &with_pins(current.as_deref(), pins))
455}
456
457#[derive(Debug, Default, PartialEq)]
459pub struct WorkspaceOutcome {
460 pub open: Option<String>,
463 pub downloaded: Option<(String, Vec<u8>)>,
465 pub pins: Option<Vec<Pin>>,
467 pub pick_file: bool,
470}
471
472enum Done {
474 Entry(Entry),
475 Nothing,
476 Attachment(Attachment),
477}
478
479pub struct WorkspaceBrowser {
481 session: Option<std::rc::Rc<crate::plm::client::PlmClient>>,
482 thumbnails: crate::plm::thumbnail_view::ThumbnailCache,
483 pub can_pick_files: bool,
485 pick_asked: bool,
487 pub owner: String,
489 pub owner_label: String,
490 pub path: Vec<(String, String)>,
492 pub entries: Option<Vec<Entry>>,
493 pub workspaces: Option<WorkspaceList>,
494 pub selected: Option<String>,
496 pub cut: Option<Entry>,
498 pub versions: Option<(String, Vec<Version>)>,
500 pub problem: Option<String>,
502 pub notice: Option<String>,
504 pub new_folder_name: String,
505 pub rename: Option<String>,
506 pub link_part: String,
507 pub link_revision: String,
508 pub open_part: String,
509 pub open_revision: String,
510 pub open_lookup: Option<PartLookup>,
512 pub promote: Option<PromoteTo>,
513 pub delete_armed: bool,
515 pub pins: Option<Vec<Pin>>,
517 list_pending: Option<Pending<Vec<Entry>>>,
518 owners_pending: Option<Pending<WorkspaceList>>,
519 action_pending: Vec<(String, Pending<Done>)>,
522 lookup_pending: Option<(bool, Pending<PartLookup>)>,
524 download_pending: Option<(String, Pending<Vec<u8>>)>,
525 versions_pending: Option<(String, Pending<Vec<Version>>)>,
526 pub hits: HashMap<String, egui::Rect>,
527 pins_changed: Option<Vec<Pin>>,
529}
530
531impl Default for WorkspaceBrowser {
532 fn default() -> Self {
533 Self::new()
534 }
535}
536
537impl WorkspaceBrowser {
538 pub fn new() -> Self {
540 WorkspaceBrowser {
541 session: None,
542 thumbnails: Default::default(),
543 can_pick_files: false,
544 pick_asked: false,
545 owner: String::new(),
546 owner_label: "My workspace".into(),
547 path: Vec::new(),
548 entries: None,
549 workspaces: None,
550 selected: None,
551 cut: None,
552 versions: None,
553 problem: None,
554 notice: None,
555 new_folder_name: String::new(),
556 rename: None,
557 link_part: String::new(),
558 link_revision: String::new(),
559 open_part: String::new(),
560 open_revision: String::new(),
561 open_lookup: None,
562 promote: None,
563 delete_armed: false,
564 pins: None,
565 list_pending: None,
566 owners_pending: None,
567 action_pending: Vec::new(),
568 lookup_pending: None,
569 download_pending: None,
570 versions_pending: None,
571 hits: HashMap::new(),
572 pins_changed: None,
573 }
574 }
575
576 pub fn current_folder(&self) -> &str {
579 self.path.last().map(|(id, _)| id.as_str()).unwrap_or("")
580 }
581
582 pub fn thumbnail_entries(&self) -> Vec<&str> {
583 self.entries.iter().flatten().filter(|entry| entry.link.as_ref()
584 .is_some_and(|link| self.thumbnails.ready(&link.thumbnail_url)))
585 .map(|entry| entry.id.as_str()).collect()
586 }
587
588 pub fn read_only(&self) -> bool {
590 !self.owner.is_empty()
591 }
592
593 pub fn busy(&self) -> bool {
594 self.thumbnails.busy()
595 || self.list_pending.as_ref().is_some_and(Pending::is_open)
596 || self.owners_pending.as_ref().is_some_and(Pending::is_open)
597 || self.action_pending.iter().any(|(_, p)| p.is_open())
598 || self.lookup_pending.as_ref().is_some_and(|(_, p)| p.is_open())
599 || self.download_pending.as_ref().is_some_and(|(_, p)| p.is_open())
600 || self.versions_pending.as_ref().is_some_and(|(_, p)| p.is_open())
601 }
602
603 pub fn reload(&mut self, plm: &dyn Workspaces) {
605 self.thumbnails.clear();
606 self.list_pending = Some(Pending::new(plm.folder(&self.owner, self.current_folder())));
607 self.owners_pending = Some(Pending::new(plm.workspaces()));
608 }
609
610 fn reload_folder(&mut self, plm: &dyn Workspaces) {
611 self.thumbnails.clear();
612 self.list_pending = Some(Pending::new(plm.folder(&self.owner, self.current_folder())));
613 }
614
615 pub fn set_owner(&mut self, plm: &dyn Workspaces, owner: &str, label: &str) {
617 self.owner = owner.to_string();
618 self.owner_label = label.to_string();
619 self.path.clear();
620 self.selection_cleared();
621 self.entries = None;
622 self.reload_folder(plm);
623 }
624
625 fn selection_cleared(&mut self) {
626 self.selected = None;
627 self.rename = None;
628 self.promote = None;
629 self.versions = None;
630 self.delete_armed = false;
631 }
632
633 pub fn enter(&mut self, plm: &dyn Workspaces, folder: &Entry) {
635 self.path.push((folder.id.clone(), folder.name.clone()));
636 self.selection_cleared();
637 self.entries = None;
638 self.reload_folder(plm);
639 }
640
641 pub fn go_to_pin(&mut self, plm: &dyn Workspaces, pin: &Pin) {
643 self.owner.clear();
644 self.owner_label = "My workspace".into();
645 self.path = pin.clone();
646 self.selection_cleared();
647 self.entries = None;
648 self.reload_folder(plm);
649 }
650
651 pub fn toggle_pin(&mut self) -> Option<Vec<Pin>> {
653 if self.read_only() || self.path.is_empty() {
654 return None;
655 }
656 let mut pins = self.pins.clone().unwrap_or_default();
657 let here = self.current_folder().to_string();
658 let before = pins.len();
659 pins.retain(|pin| pin.last().map(|(id, _)| id.as_str()) != Some(here.as_str()));
660 if pins.len() == before {
661 pins.push(self.path.clone());
662 }
663 self.pins = Some(pins.clone());
664 Some(pins)
665 }
666
667 pub fn up_to(&mut self, plm: &dyn Workspaces, depth: usize) {
669 self.path.truncate(depth);
670 self.selection_cleared();
671 self.entries = None;
672 self.reload_folder(plm);
673 }
674
675 fn entry(&self, id: &str) -> Option<&Entry> {
676 self.entries.as_ref()?.iter().find(|e| e.id == id)
677 }
678
679 pub fn selection(&self) -> Option<&Entry> {
681 self.entry(self.selected.as_deref()?)
682 }
683
684 pub fn select_named(&mut self, name: &str) -> bool {
686 let found = self.entries.as_ref().and_then(|list| list.iter().find(|e| e.name == name)).map(|e| e.id.clone());
687 let changed = found != self.selected;
688 if changed {
689 self.selection_cleared();
690 }
691 self.selected = found;
692 self.selected.is_some()
693 }
694
695 pub fn activate(&mut self, plm: &dyn Workspaces, entry: &Entry, outcome: &mut WorkspaceOutcome) {
697 match (entry.kind.as_str(), &entry.link) {
698 ("folder", _) => self.enter(plm, entry),
699 ("link", Some(link)) if link.part_missing => self.problem = Some(format!("{}: the part it links is gone", entry.name)),
700 ("link", Some(link)) if link.revision_missing || link.document_key.is_empty() => {
701 self.problem = Some(format!(
702 "{}: it is pinned to a revision of {} that was deleted — pin it to another, or let it follow the newest",
703 entry.name, link.number
704 ))
705 }
706 ("link", Some(link)) => outcome.open = Some(link.document_key.clone()),
707 ("file", _) => self.download(plm, entry, 0),
708 _ => {}
709 }
710 }
711
712 fn act(&mut self, what: impl Into<String>, future: PlmFuture<Done>) {
713 self.problem = None;
714 self.action_pending.push((what.into(), Pending::new(future)));
715 }
716
717 fn map<T: 'static>(future: PlmFuture<T>, done: impl FnOnce(T) -> Done + 'static) -> PlmFuture<Done> {
718 Box::pin(async move { future.await.map(done) })
719 }
720
721 pub fn new_folder(&mut self, plm: &dyn Workspaces, name: &str) {
723 let future = Self::map(plm.create_folder(self.current_folder(), name.trim()), Done::Entry);
724 self.act(format!("made folder {}", name.trim()), future);
725 }
726
727 pub fn link(&mut self, plm: &dyn Workspaces, part: &str, revision: &str) {
729 let future = Self::map(plm.link(self.current_folder(), part.trim(), revision.trim()), Done::Entry);
730 self.act(format!("linked {}", part.trim()), future);
731 }
732
733 pub fn add_file(&mut self, plm: &dyn Workspaces, name: &str, bytes: Vec<u8>) {
737 let existing = self
738 .entries
739 .as_ref()
740 .and_then(|list| list.iter().find(|e| e.kind == "file" && e.name.eq_ignore_ascii_case(name)))
741 .map(|e| e.id.clone());
742 match existing {
743 Some(id) => {
744 let future = Self::map(plm.replace_file(&id, name, bytes), Done::Entry);
745 self.act(format!("{name}: a new version, the one before kept"), future);
746 }
747 None => {
748 let future = Self::map(plm.add_file(self.current_folder(), name, bytes), Done::Entry);
749 self.act(format!("added {name}"), future);
750 }
751 }
752 }
753
754 pub fn rename(&mut self, plm: &dyn Workspaces, id: &str, name: &str) {
755 let future = Self::map(plm.update(id, json!({ "name": name.trim() })), Done::Entry);
756 self.act(format!("renamed to {}", name.trim()), future);
757 }
758
759 pub fn cut(&mut self, id: &str) {
761 self.cut = self.entry(id).cloned();
762 if let Some(entry) = &self.cut {
763 self.notice = Some(format!("{} cut — open a folder and paste it there", entry.name));
764 }
765 }
766
767 pub fn paste(&mut self, plm: &dyn Workspaces) {
769 let Some(entry) = self.cut.take() else { return };
770 let future = Self::map(plm.update(&entry.id, json!({ "parent": self.current_folder() })), Done::Entry);
771 self.act(format!("moved {}", entry.name), future);
772 }
773
774 pub fn delete(&mut self, plm: &dyn Workspaces, id: &str) {
777 let Some(entry) = self.entry(id).cloned() else { return };
778 let future = Self::map(plm.delete(&entry.id, entry.is_folder()), |()| Done::Nothing);
779 let what = match entry.kind.as_str() {
780 "link" => format!("removed the link {} — the part is untouched", entry.name),
781 _ => format!("deleted {}", entry.name),
782 };
783 self.selection_cleared();
784 self.act(what, future);
785 }
786
787 pub fn pin(&mut self, plm: &dyn Workspaces, id: &str, revision: &str) {
789 let future = Self::map(plm.update(id, json!({ "revision": revision })), Done::Entry);
790 let what = if revision.is_empty() { "the link follows the newest revision".to_string() } else { format!("pinned to {revision}") };
791 self.act(what, future);
792 }
793
794 pub fn load_versions(&mut self, plm: &dyn Workspaces, id: &str) {
795 self.versions_pending = Some((id.to_string(), Pending::new(plm.versions(id))));
796 }
797
798 pub fn restore(&mut self, plm: &dyn Workspaces, id: &str, version: u32) {
800 let future = Self::map(plm.restore(id, version), Done::Entry);
801 self.act(format!("restored version {version} as a new version"), future);
802 }
803
804 pub fn download(&mut self, plm: &dyn Workspaces, entry: &Entry, version: u32) {
805 self.download_pending = Some((entry.name.clone(), Pending::new(plm.download(&entry.id, version))));
806 }
807
808 pub fn promote(&mut self, plm: &dyn Workspaces, id: &str, to: &PromoteTo) {
810 let future = Self::map(plm.promote(id, to), Done::Attachment);
811 self.act("promoted", future);
812 }
813
814 pub fn look_up(&mut self, plm: &dyn Workspaces, open: bool) {
818 self.problem = None;
819 self.lookup_pending = Some((open, Pending::new(plm.lookup(&self.open_part))));
820 }
821
822 pub fn open_by_number(&mut self, plm: &dyn Workspaces, outcome: &mut WorkspaceOutcome) {
824 let looked_up = self.open_lookup.as_ref().filter(|p| {
825 let typed = self.open_part.trim();
826 p.id == typed || p.number.eq_ignore_ascii_case(typed)
827 });
828 match looked_up {
829 Some(part) => {
830 let wanted = self.open_revision.trim();
831 let revision = part
832 .revision_views
833 .iter()
834 .find(|r| r.id == wanted || r.label.eq_ignore_ascii_case(wanted))
835 .or(part.newest_revision.as_ref().filter(|_| wanted.is_empty()));
836 match revision {
837 Some(r) if !r.document_key.is_empty() => {
838 outcome.open = Some(r.document_key.clone());
839 self.open_part.clear();
842 self.open_revision.clear();
843 self.open_lookup = None;
844 }
845 _ => self.problem = Some(format!("{} has no revision '{wanted}'", part.number)),
846 }
847 }
848 None => self.look_up(plm, true),
849 }
850 }
851
852 pub fn poll(&mut self, waker: &Waker, plm: &dyn Workspaces, outcome: &mut WorkspaceOutcome) {
854 self.thumbnails.poll(waker);
855 if let Some(answer) = self.list_pending.as_mut().and_then(|p| p.poll(waker)) {
856 self.list_pending = None;
857 match answer {
858 Ok(entries) => {
859 if self.selected.as_deref().is_some_and(|id| !entries.iter().any(|e| e.id == id)) {
860 self.selection_cleared();
861 }
862 self.entries = Some(entries);
863 }
864 Err(problem) => {
865 self.entries = Some(Vec::new());
866 self.problem = Some(problem);
867 }
868 }
869 }
870 if let Some(answer) = self.owners_pending.as_mut().and_then(|p| p.poll(waker)) {
871 self.owners_pending = None;
872 match answer {
873 Ok(list) => self.workspaces = Some(list),
874 Err(problem) => self.problem = Some(problem),
875 }
876 }
877 let mut answered = Vec::new();
878 self.action_pending.retain_mut(|(what, pending)| match pending.poll(waker) {
879 Some(answer) => {
880 answered.push((std::mem::take(what), answer));
881 false
882 }
883 None => true,
884 });
885 for (what, answer) in answered {
886 {
887 match answer {
888 Ok(done) => {
889 self.notice = Some(what);
890 match done {
891 Done::Entry(entry) => {
892 if self.versions.as_ref().is_some_and(|(id, _)| *id == entry.id) {
893 self.load_versions(plm, &entry.id);
894 }
895 self.selected = Some(entry.id);
896 }
897 Done::Attachment(a) => {
898 self.notice = Some(format!("promoted to an attachment: {} ({})", a.name, a.kind));
899 self.promote = None;
900 }
901 Done::Nothing => {}
902 }
903 self.reload_folder(plm);
904 }
905 Err(problem) => self.problem = Some(problem),
906 }
907 }
908 }
909 if let Some((open, pending)) = self.lookup_pending.as_mut() {
910 let open = *open;
911 if let Some(answer) = pending.poll(waker) {
912 self.lookup_pending = None;
913 match answer {
914 Ok(part) => {
915 let typed = self.open_revision.trim().to_string();
916 if typed.is_empty() || !part.revision_views.iter().any(|r| r.label.eq_ignore_ascii_case(&typed) || r.id == typed) {
917 self.open_revision = part.newest_revision.as_ref().map(|r| r.label.clone()).unwrap_or_default();
918 }
919 self.open_part = part.number.clone();
920 self.open_lookup = Some(part);
921 if open {
922 self.open_by_number(plm, outcome);
923 }
924 }
925 Err(problem) => {
926 self.open_lookup = None;
927 self.problem = Some(problem);
928 }
929 }
930 }
931 }
932 if let Some((name, pending)) = self.download_pending.as_mut() {
933 if let Some(answer) = pending.poll(waker) {
934 let name = std::mem::take(name);
935 self.download_pending = None;
936 match answer {
937 Ok(bytes) => outcome.downloaded = Some((name, bytes)),
938 Err(problem) => self.problem = Some(problem),
939 }
940 }
941 }
942 if let Some((id, pending)) = self.versions_pending.as_mut() {
943 if let Some(answer) = pending.poll(waker) {
944 let id = std::mem::take(id);
945 self.versions_pending = None;
946 match answer {
947 Ok(list) => self.versions = Some((id, list)),
948 Err(problem) => self.problem = Some(problem),
949 }
950 }
951 }
952 }
953}
954
955fn repaint_waker(ctx: &egui::Context) -> Waker {
959 struct Repaint(egui::Context);
960 impl std::task::Wake for Repaint {
961 fn wake(self: std::sync::Arc<Self>) {
962 self.0.request_repaint();
963 }
964 }
965 Waker::from(std::sync::Arc::new(Repaint(ctx.clone())))
966}
967
968fn icon(entry: &Entry) -> &'static str {
971 match entry.kind.as_str() {
972 "folder" => "[folder]",
973 "link" => "[link]",
974 _ => "[file]",
975 }
976}
977
978impl WorkspaceBrowser {
979 fn hit(&mut self, prefix: &str, key: &str, response: &egui::Response) {
980 self.hits.insert(format!("{prefix}ws:{key}"), response.rect);
981 }
982
983 pub fn show(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) -> WorkspaceOutcome {
987 if let Some(client) = plm.session() {
988 if self.session.as_ref().is_some_and(|old| !std::rc::Rc::ptr_eq(old, &client)) {
989 let can_pick_files = self.can_pick_files;
991 *self = Self::new();
992 self.can_pick_files = can_pick_files;
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 {
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 fn open_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str, outcome: &mut WorkspaceOutcome) {
1060 ui.horizontal(|ui| {
1061 ui.label("Part");
1062 let part = ui.add(egui::TextEdit::singleline(&mut self.open_part).desired_width(110.0).hint_text("number or id"));
1063 self.hit(prefix, "open-part", &part);
1064 if part.lost_focus() && !self.open_part.trim().is_empty() {
1065 self.look_up(plm, false);
1067 }
1068 ui.label("Revision");
1069 let labels: Vec<(String, String)> = self
1070 .open_lookup
1071 .as_ref()
1072 .map(|p| p.revision_views.iter().map(|r| (r.label.clone(), format!("{} ({})", r.label, r.lifecycle))).collect())
1073 .unwrap_or_default();
1074 let revision = if labels.is_empty() {
1075 ui.add(egui::TextEdit::singleline(&mut self.open_revision).desired_width(60.0).hint_text("newest"))
1076 } else {
1077 let text = self.open_revision.clone();
1078 egui::ComboBox::from_id_salt((prefix, "ws-open-revision"))
1079 .selected_text(text)
1080 .show_ui(ui, |ui| {
1081 for (label, shown) in labels.iter().rev() {
1082 ui.selectable_value(&mut self.open_revision, label.clone(), shown);
1083 }
1084 })
1085 .response
1086 };
1087 self.hit(prefix, "open-revision", &revision);
1088 let go = ui.add_enabled(!self.open_part.trim().is_empty(), egui::Button::new("Open"));
1089 self.hit(prefix, "open-go", &go);
1090 if go.clicked() {
1091 self.open_by_number(plm, outcome);
1092 }
1093 });
1094 }
1095
1096 fn crumbs(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1097 let mut up_to: Option<usize> = None;
1098 let mut paste = false;
1099 ui.horizontal_wrapped(|ui| {
1100 let top = ui.link(&self.owner_label);
1101 self.hit(prefix, "crumb:0", &top);
1102 if top.clicked() {
1103 up_to = Some(0);
1104 }
1105 for (depth, (_, name)) in self.path.clone().iter().enumerate() {
1106 ui.label("/");
1107 let crumb = ui.link(name);
1108 self.hit(prefix, &format!("crumb:{}", depth + 1), &crumb);
1109 if crumb.clicked() {
1110 up_to = Some(depth + 1);
1111 }
1112 }
1113 if let Some(cut) = self.cut.clone().filter(|_| !self.read_only()) {
1114 let button = ui.small_button(format!("Paste {} here", cut.name));
1115 self.hit(prefix, "paste", &button);
1116 paste = button.clicked();
1117 }
1118 if !self.read_only() && !self.path.is_empty() {
1119 let here = self.current_folder().to_string();
1120 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())));
1121 let button = ui.small_button(if pinned { "Unpin folder" } else { "Pin folder" });
1122 self.hit(prefix, "pin-folder", &button);
1123 if button.clicked() {
1124 self.pins_changed = self.toggle_pin();
1125 }
1126 }
1127 });
1128 let mut go_to: Option<Pin> = None;
1129 if let Some(pins) = self.pins.clone().filter(|pins| !pins.is_empty()) {
1130 ui.horizontal_wrapped(|ui| {
1131 ui.label(egui::RichText::new("Pinned:").weak());
1132 for (index, pin) in pins.iter().enumerate() {
1133 let name = pin.last().map(|(_, name)| name.as_str()).unwrap_or("");
1134 let button = ui.small_button(name).on_hover_text(pin.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>().join(" / "));
1135 self.hit(prefix, &format!("pinned:{index}"), &button);
1136 if button.clicked() {
1137 go_to = Some(pin.clone());
1138 }
1139 }
1140 });
1141 }
1142 if let Some(pin) = go_to {
1143 self.go_to_pin(plm, &pin);
1144 }
1145 if let Some(depth) = up_to {
1146 self.up_to(plm, depth);
1147 }
1148 if paste {
1149 self.paste(plm);
1150 }
1151 }
1152
1153 fn rows(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str, outcome: &mut WorkspaceOutcome) {
1154 let Some(entries) = self.entries.clone() else {
1155 ui.label(egui::RichText::new("Reading the workspace…").weak());
1156 return;
1157 };
1158 if entries.is_empty() {
1159 let empty = if self.read_only() { "Empty." } else { "Empty. Make a folder, link a part, or drop a file here." };
1160 ui.label(egui::RichText::new(empty).weak());
1161 }
1162 let mut activated: Option<Entry> = None;
1163 egui::ScrollArea::vertical().id_salt((prefix, "ws-rows")).max_height(260.0).show(ui, |ui| {
1164 for entry in &entries {
1165 let selected = self.selected.as_deref() == Some(entry.id.as_str());
1166 let text = format!("{} {} {}", icon(entry), entry.name, entry.detail());
1167 let row = ui.horizontal(|ui| {
1168 let url = if self.list_pending.is_some() { "" } else {
1169 entry.link.as_ref().map_or("", |link| link.thumbnail_url.as_str())
1170 };
1171 let preview = self.thumbnails.show(ui, url, |url| plm.thumbnail(url));
1172 ui.add_sized(
1173 [ui.available_width(), crate::plm::thumbnail_view::SIDE],
1174 egui::Button::selectable(selected, text).wrap_mode(egui::TextWrapMode::Truncate),
1175 ).union(preview)
1176 }).inner;
1177 self.hit(prefix, &format!("entry:{}", entry.name), &row);
1178 if row.clicked() {
1180 self.select_named(&entry.name);
1181 }
1182 if row.double_clicked() {
1183 activated = Some(entry.clone());
1184 }
1185 }
1186 });
1187 if let Some(entry) = activated {
1188 self.activate(plm, &entry, outcome);
1189 }
1190 }
1191
1192 fn selection_verbs(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1193 let Some(entry) = self.selection().cloned() else { return };
1194 let writable = !self.read_only();
1195 ui.separator();
1196 ui.label(egui::RichText::new(format!("{} {}", icon(&entry), entry.name)).strong());
1197 ui.horizontal_wrapped(|ui| {
1198 if writable {
1199 let rename = ui.small_button("Rename");
1200 self.hit(prefix, "sel:rename", &rename);
1201 if rename.clicked() {
1202 self.rename = Some(entry.name.clone());
1203 }
1204 let cut = ui.small_button("Move…");
1205 self.hit(prefix, "sel:cut", &cut);
1206 if cut.clicked() {
1207 self.cut(&entry.id);
1208 }
1209 let label = if self.delete_armed {
1210 "Really delete?"
1211 } else if entry.kind == "link" {
1212 "Remove link"
1213 } else {
1214 "Delete"
1215 };
1216 let delete = ui.small_button(label).on_hover_text(match entry.kind.as_str() {
1217 "link" => "Removes the link only: the part and its revisions are untouched.",
1218 "folder" => "Deletes the folder and everything in it (links, files). No part is touched.",
1219 _ => "Deletes the file and every version of it.",
1220 });
1221 self.hit(prefix, if self.delete_armed { "sel:delete-confirm" } else { "sel:delete" }, &delete);
1222 if delete.clicked() {
1223 if self.delete_armed || entry.kind == "link" {
1224 self.delete(plm, &entry.id);
1225 } else {
1226 self.delete_armed = true;
1227 }
1228 }
1229 }
1230 if let Some(link) = entry.link.clone() {
1231 if writable && !link.part_missing {
1232 if link.pinned {
1233 let follow = ui.small_button("Follow newest");
1234 self.hit(prefix, "sel:follow", &follow);
1235 if follow.clicked() {
1236 self.pin(plm, &entry.id, "");
1237 }
1238 } else if !link.revision_label.is_empty() {
1239 let pin = ui.small_button(format!("Pin to {}", link.revision_label));
1240 self.hit(prefix, "sel:pin", &pin);
1241 if pin.clicked() {
1242 self.pin(plm, &entry.id, &link.revision_id);
1243 }
1244 }
1245 }
1246 }
1247 if entry.kind == "file" {
1248 let download = ui.small_button("Download");
1249 self.hit(prefix, "sel:download", &download);
1250 if download.clicked() {
1251 self.download(plm, &entry, 0);
1252 }
1253 let versions = ui.small_button("Versions");
1254 self.hit(prefix, "sel:versions", &versions);
1255 if versions.clicked() {
1256 self.load_versions(plm, &entry.id);
1257 }
1258 if writable {
1259 let promote = ui.small_button("Promote to attachment…");
1260 self.hit(prefix, "sel:promote", &promote);
1261 if promote.clicked() {
1262 self.promote = Some(PromoteTo { kind: "other".into(), ..PromoteTo::default() });
1263 }
1264 }
1265 }
1266 });
1267 if let Some(mut name) = self.rename.take() {
1268 let mut keep = true;
1269 ui.horizontal(|ui| {
1270 let field = ui.text_edit_singleline(&mut name);
1271 self.hit(prefix, "sel:rename-name", &field);
1272 let go = ui.small_button("Rename");
1273 self.hit(prefix, "sel:rename-go", &go);
1274 if go.clicked() {
1275 self.rename(plm, &entry.id, &name);
1276 keep = false;
1277 }
1278 });
1279 if keep {
1280 self.rename = Some(name);
1281 }
1282 }
1283 if let Some((id, versions)) = self.versions.clone().filter(|(id, _)| *id == entry.id) {
1284 for v in versions.iter().rev() {
1285 ui.horizontal(|ui| {
1286 let restored = v.restored_from.map(|n| format!(", restored from v{n}")).unwrap_or_default();
1287 ui.label(format!(
1288 "v{}{} · {} · {}{restored}",
1289 v.version,
1290 if v.current { " (current)" } else { "" },
1291 human_size(v.size),
1292 v.uploaded_by_name
1293 ));
1294 let download = ui.small_button("Download");
1295 self.hit(prefix, &format!("version:{}:download", v.version), &download);
1296 if download.clicked() {
1297 self.download(plm, &entry, v.version);
1298 }
1299 if writable && !v.current {
1300 let restore = ui.small_button("Restore");
1301 self.hit(prefix, &format!("version:{}:restore", v.version), &restore);
1302 if restore.clicked() {
1303 self.restore(plm, &id, v.version);
1304 }
1305 }
1306 });
1307 }
1308 }
1309 if let Some(mut to) = self.promote.take() {
1310 let mut keep = true;
1311 ui.horizontal_wrapped(|ui| {
1312 ui.label("Part");
1313 let part = ui.add(egui::TextEdit::singleline(&mut to.part).desired_width(100.0).hint_text("number"));
1314 self.hit(prefix, "sel:promote-part", &part);
1315 ui.label("Revision");
1316 let revision = ui.add(egui::TextEdit::singleline(&mut to.revision).desired_width(50.0).hint_text("part"));
1317 self.hit(prefix, "sel:promote-revision", &revision);
1318 egui::ComboBox::from_id_salt((prefix, "ws-promote-kind")).selected_text(&to.kind).show_ui(ui, |ui| {
1319 for kind in ["drawing", "datasheet", "spec", "image", "other"] {
1320 ui.selectable_value(&mut to.kind, kind.to_string(), kind);
1321 }
1322 });
1323 let go = ui.add_enabled(!to.part.trim().is_empty(), egui::Button::new("Promote"));
1324 self.hit(prefix, "sel:promote-go", &go);
1325 if go.clicked() {
1326 self.promote(plm, &entry.id, &to);
1327 }
1328 if ui.small_button("Cancel").clicked() {
1329 keep = false;
1330 }
1331 });
1332 if keep {
1333 self.promote = Some(to);
1334 }
1335 }
1336 }
1337
1338 fn make_row(&mut self, ui: &mut egui::Ui, plm: &dyn Workspaces, prefix: &str) {
1339 ui.horizontal(|ui| {
1340 let name = ui.add(egui::TextEdit::singleline(&mut self.new_folder_name).desired_width(120.0).hint_text("folder name"));
1341 self.hit(prefix, "new-folder-name", &name);
1342 let go = ui.add_enabled(!self.new_folder_name.trim().is_empty(), egui::Button::new("New folder"));
1343 self.hit(prefix, "new-folder", &go);
1344 if go.clicked() {
1345 let name = std::mem::take(&mut self.new_folder_name);
1346 self.new_folder(plm, &name);
1347 }
1348 });
1349 ui.horizontal(|ui| {
1350 let part = ui.add(egui::TextEdit::singleline(&mut self.link_part).desired_width(100.0).hint_text("part number"));
1351 self.hit(prefix, "link-part", &part);
1352 let revision = ui.add(egui::TextEdit::singleline(&mut self.link_revision).desired_width(50.0).hint_text("newest"));
1353 self.hit(prefix, "link-revision", &revision);
1354 let go = ui.add_enabled(!self.link_part.trim().is_empty(), egui::Button::new("Link here"));
1355 self.hit(prefix, "link-go", &go);
1356 if go.clicked() {
1357 let (part, revision) = (std::mem::take(&mut self.link_part), std::mem::take(&mut self.link_revision));
1358 self.link(plm, &part, &revision);
1359 }
1360 });
1361 ui.horizontal_wrapped(|ui| {
1362 if self.can_pick_files {
1363 let add = ui.button("Add file\u{2026}").on_hover_text("Choose a file on this machine to add to this folder");
1364 self.hit(prefix, "add-file", &add);
1365 self.pick_asked |= add.clicked();
1366 }
1367 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());
1368 });
1369 }
1370}
1371