Skip to main content

brep_app/panels/
plm.rs

1//! Lifecycle in the CAD UI: the revision picker and the checkout verbs for a
2//! document opened from the PLM.
3//!
4//! # What it decides
5//!
6//! - **Session editing is always allowed.** [`access_for`] turns a revision's
7//!   server view into save permissions: saving to that revision requires a
8//!   draft or in-review revision checked out by this user. Released revisions,
9//!   another user's checkout, and pending permissions only restrict saving.
10//!   The session copy stays editable and can be saved to another destination.
11//! - **The verbs** a revision offers ([`verbs`]): Check out on an editable
12//!   revision nobody holds; Check in on one this user holds; Break lock only to
13//!   the check-in group, on one someone else holds; Release on an editable one to
14//!   the check-in group; New revision to authors. The server decides every one
15//!   of them again, and its refusal is shown as it sent it.
16//! - **Which revision opens** (D13): the NEWEST, drafts included — the one
17//!   created last ([`newest`]); the picker lets the user choose another.
18//!
19//! # How it talks to the server
20//!
21//! Through [`PlmLifecycle`], one method per route, so the panel is tested
22//! against a fake and the real client (`crate::plm::client`) implements the
23//! same trait. Every call returns a [`PlmFuture`]; the panel keeps at most one
24//! in flight and polls it each frame ([`PlmPanel::poll`]), because a panel
25//! draws synchronously. Each answer re-reads the part, so what the picker shows
26//! is always the server's word.
27//!
28//! # With no server
29//!
30//! None of this exists: the shell builds a [`PlmPanel`] only for a document
31//! that came from a PLM, and a file-based document has none.
32
33use std::task::{Context, Poll, Waker};
34
35use eframe::egui;
36use serde::Deserialize;
37
38use crate::document::Access;
39use crate::plm::PlmFuture;
40
41/// The `client_id` the app takes a lock under, so a lock says which client of
42/// a user holds it (the server defaults to `"web"`).
43pub const CLIENT_ID: &str = "brep-app";
44
45// ===========================================================================
46// What the server answers (`GET /api/parts/:id`), read tolerantly
47// ===========================================================================
48
49/// One part and its revisions, as `GET /api/parts/:id` answers. Fields this
50/// app does not read are ignored; a missing one takes its default.
51#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
52#[serde(default)]
53pub struct PartDetail {
54    pub id: String,
55    pub number: String,
56    pub name: String,
57    /// `normal`, `family` or `template`.
58    pub document_class: String,
59    /// Creation order, oldest first.
60    pub revision_views: Vec<RevisionView>,
61    /// The label a new revision gets when nobody types one.
62    pub suggested_label: String,
63    /// Whether another revision may start while one is in work.
64    pub multiple_open_drafts: bool,
65}
66
67/// One revision with its lock, review, change order, bake and provenance.
68#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
69#[serde(default)]
70pub struct RevisionView {
71    pub id: String,
72    pub label: String,
73    /// `draft`, `inreview`, `released`, `superseded` or `obsolete`.
74    pub lifecycle: String,
75    /// A draft or in review: its document may change, under a lock.
76    pub editable: bool,
77    pub created_at: u64,
78    pub modified_at: u64,
79    /// Who holds the lock, as a name a person reads.
80    pub locked_by: Option<String>,
81    pub locked_by_me: bool,
82    pub locked_at: Option<u64>,
83    pub released_by: Option<String>,
84    pub released_at: Option<u64>,
85    /// `part/<part>/rev/<revision>` — the store key its document lives at.
86    pub document_key: String,
87    /// `authored`, `imported` or `generated`.
88    pub origin: String,
89    pub family: Option<Provenance>,
90    pub template: Option<Provenance>,
91    /// `pending`, `claimed`, `failed` or `done`; absent when never queued.
92    pub bake: Option<String>,
93    pub bake_error: String,
94    /// A generated member whose document changed since Generate wrote it.
95    pub hand_edited: bool,
96    /// Lines in its uses list: more than none makes it an assembly.
97    pub uses: usize,
98    pub review: Option<ReviewSummary>,
99    pub comments: usize,
100    pub eco: Option<EcoRef>,
101}
102
103/// Where a generated or spun-out revision came from.
104#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
105#[serde(default)]
106pub struct Provenance {
107    pub part_id: String,
108    pub number: String,
109    pub revision_label: String,
110}
111
112/// The current review round, in brief.
113#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
114#[serde(default)]
115pub struct ReviewSummary {
116    pub status: String,
117    pub live: bool,
118    pub approvals: usize,
119    pub required_approvals: u32,
120    pub may_decide: bool,
121}
122
123/// The open change order naming a revision.
124#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
125#[serde(default)]
126pub struct EcoRef {
127    pub id: String,
128    pub number: String,
129    pub state: String,
130    /// `release` or `obsolete`.
131    pub action: String,
132}
133
134/// A new revision, as `POST /api/parts/:id/revisions` answers.
135#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
136#[serde(default)]
137pub struct NewRevision {
138    pub id: String,
139    pub label: String,
140}
141
142/// One line of `GET /api/parts/:id/history`, the part's audit trail. A change
143/// the app made with a token is logged as the token's owner, `via: token`.
144#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
145#[serde(default)]
146pub struct HistoryEvent {
147    pub id: u64,
148    pub at: u64,
149    pub actor: HistoryActor,
150    pub action: String,
151    pub entity: HistoryEntity,
152    pub detail: String,
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
156#[serde(default)]
157pub struct HistoryActor {
158    pub username: String,
159    /// `session`, `token` or `system`.
160    pub via: String,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
164#[serde(default)]
165pub struct HistoryEntity {
166    pub kind: String,
167    pub id: String,
168    pub label: String,
169}
170
171/// What the signed-in user may do, from `/api/me`.
172#[derive(Debug, Clone, Copy, Default, PartialEq)]
173pub struct Rights {
174    pub can_author: bool,
175    pub can_checkin: bool,
176}
177
178// ===========================================================================
179// The server, as the panel needs it
180// ===========================================================================
181
182/// The lifecycle routes, one method each. Errors are the server's sentence.
183pub trait PlmLifecycle {
184    /// What the signed-in user may do: `/api/me`'s `can_author` and
185    /// `can_checkin`, resolved by the server from the groups.
186    fn rights(&self) -> PlmFuture<Rights>;
187    /// `GET /api/parts/:id`.
188    fn part(&self, part: &str) -> PlmFuture<PartDetail>;
189    /// `POST /api/parts/:id/revisions/:rev/checkout` with [`CLIENT_ID`].
190    fn checkout(&self, part: &str, revision: &str) -> PlmFuture<()>;
191    /// `POST …/checkin`; `force` breaks someone else's lock (check-in group).
192    fn checkin(&self, part: &str, revision: &str, force: bool) -> PlmFuture<()>;
193    /// `POST …/state` with `{"to": to}`; answers the release's warnings.
194    fn set_state(&self, part: &str, revision: &str, to: &str) -> PlmFuture<Vec<String>>;
195    /// `POST /api/parts/:id/revisions` with `{"label"}`; empty takes the
196    /// server's suggestion.
197    fn create_revision(&self, part: &str, label: &str) -> PlmFuture<NewRevision>;
198    /// `GET /api/parts/:id/history`, newest first.
199    fn history(&self, part: &str) -> PlmFuture<Vec<HistoryEvent>>;
200}
201
202// ===========================================================================
203// The rules, as pure functions
204// ===========================================================================
205
206/// The revision that opens when a part is opened by number (D13): the one
207/// created last, drafts included. Ties keep the later one in the list, which
208/// is creation order.
209pub fn newest(part: &PartDetail) -> Option<&RevisionView> {
210    part.revision_views
211        .iter()
212        .enumerate()
213        .max_by_key(|(i, r)| (r.created_at, *i))
214        .map(|(_, r)| r)
215}
216
217/// Whether the session copy may be saved to `revision`, and if not, why.
218pub fn access_for(revision: &RevisionView) -> Access {
219    let label = &revision.label;
220    if !revision.editable {
221        let state = match revision.lifecycle.as_str() {
222            "released" => "released",
223            "superseded" => "superseded",
224            "obsolete" => "obsolete",
225            other => other,
226        };
227        return Access::read_only(format!("revision {label} is {state} and cannot be saved to"));
228    }
229    match (&revision.locked_by, revision.locked_by_me) {
230        (Some(_), true) => Access::Editable,
231        (Some(who), false) => Access::read_only(format!("revision {label} is checked out by {who}")),
232        (None, _) => Access::read_only(format!("revision {label} is not checked out — saving requires checkout")),
233    }
234}
235
236/// A lifecycle action the panel offers.
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum Verb {
239    CheckOut,
240    CheckIn,
241    BreakLock,
242    Release,
243    NewRevision,
244    /// Open the review pane's submit form (S4 owns the form and the route).
245    Submit,
246    /// Open the review pane on the revision's current round.
247    Review,
248    /// A release was refused and a change order holds the revision: open it.
249    OpenChangeOrder,
250}
251
252impl Verb {
253    /// The verb's hit key in the PLM pane: `plm/lifecycle:verb:<key>`.
254    pub fn key(self) -> &'static str {
255        match self {
256            Verb::CheckOut => "check-out",
257            Verb::CheckIn => "check-in",
258            Verb::BreakLock => "break-lock",
259            Verb::Release => "release",
260            Verb::NewRevision => "new-revision",
261            Verb::Submit => "submit",
262            Verb::Review => "review",
263            Verb::OpenChangeOrder => "open-change-order",
264        }
265    }
266
267    pub fn label(self) -> &'static str {
268        match self {
269            Verb::CheckOut => "Check out",
270            Verb::CheckIn => "Check in",
271            Verb::BreakLock => "Break lock",
272            Verb::Release => "Release",
273            Verb::NewRevision => "New revision",
274            Verb::Submit => "Submit for review",
275            Verb::Review => "Review…",
276            Verb::OpenChangeOrder => "Open change order",
277        }
278    }
279}
280
281/// The verbs `revision` of `part` offers to a user with `rights`, in the order
282/// the panel draws them.
283pub fn verbs(part: &PartDetail, revision: &RevisionView, rights: Rights) -> Vec<Verb> {
284    let mut out = Vec::new();
285    if revision.editable {
286        match (&revision.locked_by, revision.locked_by_me) {
287            (None, _) if rights.can_author => out.push(Verb::CheckOut),
288            (Some(_), true) => out.push(Verb::CheckIn),
289            (Some(_), false) if rights.can_checkin => out.push(Verb::BreakLock),
290            _ => {}
291        }
292        // Held by someone else, a release would land on their work in progress.
293        let held_by_other = revision.locked_by.is_some() && !revision.locked_by_me;
294        if rights.can_checkin && !held_by_other {
295            out.push(Verb::Release);
296        }
297    }
298    let open_draft = part.revision_views.iter().any(|r| r.editable);
299    if rights.can_author && (part.multiple_open_drafts || !open_draft) {
300        out.push(Verb::NewRevision);
301    }
302    let live_review = revision.review.as_ref().is_some_and(|r| r.live);
303    // Only the lock holder submits, or any author while nobody holds it (ruling 2026-09-25).
304    let held_by_other = revision.locked_by.is_some() && !revision.locked_by_me;
305    if revision.editable && revision.lifecycle == "draft" && rights.can_author && !live_review && !held_by_other {
306        out.push(Verb::Submit);
307    }
308    if revision.review.is_some() {
309        out.push(Verb::Review);
310    }
311    out
312}
313
314/// One line of the picker: the label and everything the server says about the
315/// revision that a person deciding which one to open needs.
316pub fn describe(revision: &RevisionView) -> String {
317    let mut parts = vec![format!("{} — {}", revision.label, revision.lifecycle)];
318    match (&revision.locked_by, revision.locked_by_me) {
319        (Some(_), true) => parts.push("checked out by you".into()),
320        (Some(who), false) => parts.push(format!("checked out by {who}")),
321        (None, _) => {}
322    }
323    if let Some(review) = &revision.review {
324        if review.live {
325            parts.push(format!("in review {}/{}", review.approvals, review.required_approvals));
326        } else {
327            parts.push(format!("review {}", review.status));
328        }
329    }
330    if let Some(eco) = &revision.eco {
331        parts.push(format!("{} ({} {})", eco.number, eco.action, eco.state));
332    }
333    if let Some(bake) = &revision.bake {
334        if revision.bake_error.is_empty() {
335            parts.push(format!("bake {bake}"));
336        } else {
337            parts.push(format!("bake {bake}: {}", revision.bake_error));
338        }
339    }
340    if revision.hand_edited {
341        parts.push("hand-edited".into());
342    }
343    if revision.uses > 0 {
344        parts.push(format!("{} uses", revision.uses));
345    }
346    if revision.origin != "authored" && !revision.origin.is_empty() {
347        parts.push(revision.origin.clone());
348    }
349    if let Some(family) = &revision.family {
350        parts.push(format!("from family {} {}", family.number, family.revision_label));
351    }
352    if let Some(template) = &revision.template {
353        parts.push(format!("from template {} {}", template.number, template.revision_label));
354    }
355    parts.join(" · ")
356}
357
358// ===========================================================================
359// The panel
360// ===========================================================================
361
362/// A request in flight, polled once a frame.
363struct Pending<T> {
364    future: PlmFuture<T>,
365}
366
367impl<T> Pending<T> {
368    fn new(future: PlmFuture<T>) -> Self {
369        Self { future }
370    }
371
372    fn poll(&mut self) -> Option<Result<T, String>> {
373        let mut cx = Context::from_waker(Waker::noop());
374        match self.future.as_mut().poll(&mut cx) {
375            Poll::Ready(result) => Some(result),
376            Poll::Pending => None,
377        }
378    }
379}
380
381/// What is in flight.
382enum Work {
383    Part(Pending<PartDetail>),
384    /// A verb; on success the part is re-read.
385    Verb(Verb, Pending<String>),
386    History(Pending<Vec<HistoryEvent>>),
387}
388
389/// What happened this frame that the shell acts on.
390#[derive(Debug, Clone, PartialEq)]
391pub enum PanelEvent {
392    /// The shown revision's permission to save changed.
393    Access(Access),
394    /// The user chose another revision to open (its store key).
395    Open(String),
396    /// Open the review pane on a revision: its submit form, or its round
397    /// (`panels::plm_review`, S4).
398    OpenReview { part: String, revision: String },
399    /// Open a change order, by id — the one holding a revision whose release
400    /// was refused.
401    OpenChangeOrder { eco: String },
402}
403
404/// The lifecycle panel for ONE document opened from the PLM.
405pub struct PlmPanel {
406    part_id: String,
407    /// The revision the document shows.
408    revision_id: String,
409    rights: Rights,
410    part: Option<PartDetail>,
411    history: Option<Vec<HistoryEvent>>,
412    work: Option<Work>,
413    /// The last sentence to show: a refusal, a release warning, a success.
414    message: Option<String>,
415    /// Typed into the New revision field; empty takes the suggestion.
416    new_label: String,
417    /// The access last reported, so an unchanged one is not re-sent.
418    reported: Option<Access>,
419    /// A new revision was started: open the newest once the part is re-read.
420    open_newest_after: bool,
421    /// New revision was pressed in the pane: show what uses the part, then
422    /// start only on the user's confirmation.
423    confirm_new: bool,
424    /// The last Release was refused, so a change order holding the revision is
425    /// offered ([`Verb::OpenChangeOrder`]).
426    release_refused: bool,
427    /// Events a press raised without a server call, handed out by the next poll.
428    queued: Vec<PanelEvent>,
429}
430
431impl PlmPanel {
432    /// A panel for `part_id` showing `revision_id`, which asks for the part at
433    /// once.
434    pub fn new(server: &dyn PlmLifecycle, part_id: &str, revision_id: &str, rights: Rights) -> Self {
435        let mut panel = Self {
436            part_id: part_id.to_string(),
437            revision_id: revision_id.to_string(),
438            rights,
439            part: None,
440            history: None,
441            work: None,
442            message: None,
443            new_label: String::new(),
444            reported: None,
445            open_newest_after: false,
446            confirm_new: false,
447            release_refused: false,
448            queued: Vec::new(),
449        };
450        panel.work = Some(Work::Part(Pending::new(server.part(part_id))));
451        panel
452    }
453
454    pub(crate) fn matches_document(&self, part: &str, revision: &str) -> bool {
455        self.part_id == part && self.revision_id == revision
456    }
457
458    pub fn part(&self) -> Option<&PartDetail> {
459        self.part.as_ref()
460    }
461
462    pub fn revision(&self) -> Option<&RevisionView> {
463        self.part.as_ref()?.revision_views.iter().find(|r| r.id == self.revision_id)
464    }
465
466    pub fn message(&self) -> Option<&str> {
467        self.message.as_deref()
468    }
469
470    pub fn busy(&self) -> bool {
471        self.work.is_some()
472    }
473
474    fn refreshing(&self) -> bool {
475        self.part.is_some() && matches!(self.work, Some(Work::Part(_)))
476    }
477
478    /// Keep the revision's actions mounted during read-only background refreshes.
479    /// Mutating requests still suppress duplicate actions.
480    pub fn offered(&self) -> Vec<Verb> {
481        match (&self.part, self.revision()) {
482            (Some(part), Some(revision)) if self.work.is_none() || self.refreshing() => {
483                let mut offered = verbs(part, revision, self.rights);
484                if self.release_refused && revision.eco.is_some() {
485                    offered.push(Verb::OpenChangeOrder);
486                }
487                offered
488            }
489            _ => Vec::new(),
490        }
491    }
492
493    /// Start `verb` on the shown revision. Ignored while a request is in flight
494    /// or when the revision does not offer it.
495    pub fn press(&mut self, server: &dyn PlmLifecycle, verb: Verb) {
496        if !self.offered().contains(&verb) {
497            return;
498        }
499        if self.refreshing() { self.work = None; }
500        let (part, rev) = (self.part_id.clone(), self.revision_id.clone());
501        // The review pane and the change order pane are S4's: these open them.
502        match verb {
503            Verb::Submit | Verb::Review => {
504                self.queued.push(PanelEvent::OpenReview { part, revision: rev });
505                return;
506            }
507            Verb::OpenChangeOrder => {
508                if let Some(eco) = self.revision().and_then(|r| r.eco.clone()) {
509                    self.queued.push(PanelEvent::OpenChangeOrder { eco: eco.id });
510                }
511                return;
512            }
513            _ => {}
514        }
515        self.release_refused = false;
516        let done = |label: &'static str| move |_| label.to_string();
517        let future: PlmFuture<String> = match verb {
518            Verb::CheckOut => map(server.checkout(&part, &rev), done("Checked out")),
519            Verb::CheckIn => map(server.checkin(&part, &rev, false), done("Checked in")),
520            Verb::BreakLock => map(server.checkin(&part, &rev, true), done("Lock broken")),
521            Verb::Release => map(server.set_state(&part, &rev, "released"), |warnings: Vec<String>| {
522                if warnings.is_empty() { "Released".to_string() } else { format!("Released — {}", warnings.join("; ")) }
523            }),
524            Verb::NewRevision => map(server.create_revision(&part, self.new_label.trim()), |r: NewRevision| {
525                format!("Revision {} started", r.label)
526            }),
527            Verb::Submit | Verb::Review | Verb::OpenChangeOrder => unreachable!("opened above"),
528        };
529        self.message = None;
530        self.work = Some(Work::Verb(verb, Pending::new(future)));
531    }
532
533    /// Re-read the part: another client may have changed the revision (a
534    /// submit, a release, a broken lock). Ignored while a request is in flight.
535    pub fn refresh(&mut self, server: &dyn PlmLifecycle) {
536        if self.work.is_none() {
537            self.work = Some(Work::Part(Pending::new(server.part(&self.part_id))));
538        }
539    }
540
541    /// Ask for the part's history.
542    pub fn load_history(&mut self, server: &dyn PlmLifecycle) {
543        if self.refreshing() { self.work = None; }
544        if self.work.is_none() {
545            self.work = Some(Work::History(Pending::new(server.history(&self.part_id))));
546        }
547    }
548
549    pub fn history(&self) -> Option<&[HistoryEvent]> {
550        self.history.as_deref()
551    }
552
553    /// Drive the request in flight one step. Returns what the shell must act
554    /// on: the document's new access, or a revision to open.
555    pub fn poll(&mut self, server: &dyn PlmLifecycle) -> Vec<PanelEvent> {
556        let mut events = std::mem::take(&mut self.queued);
557        let Some(work) = self.work.as_mut() else { return events };
558        match work {
559            Work::Part(pending) => match pending.poll() {
560                None => return events,
561                Some(Ok(part)) => {
562                    self.part = Some(part);
563                    self.work = None;
564                }
565                Some(Err(error)) => {
566                    self.message = Some(error);
567                    self.work = None;
568                }
569            },
570            Work::Verb(verb, pending) => {
571                let verb = *verb;
572                match pending.poll() {
573                    None => return events,
574                    Some(result) => {
575                        let new_revision = verb == Verb::NewRevision;
576                        self.work = Some(Work::Part(Pending::new(server.part(&self.part_id))));
577                        match result {
578                            Ok(sentence) => {
579                                self.message = Some(sentence);
580                                if new_revision {
581                                    self.new_label.clear();
582                                }
583                            }
584                            Err(error) => {
585                                self.release_refused = verb == Verb::Release;
586                                self.message = Some(error);
587                            }
588                        }
589                        // A new revision is opened once the part is re-read.
590                        self.open_newest_after = new_revision;
591                        return events;
592                    }
593                }
594            }
595            Work::History(pending) => match pending.poll() {
596                None => return events,
597                Some(result) => {
598                    self.work = None;
599                    match result {
600                        Ok(history) => self.history = Some(history),
601                        Err(error) => self.message = Some(error),
602                    }
603                }
604            },
605        }
606        if std::mem::take(&mut self.open_newest_after) {
607            if let Some(revision) = self.part.as_ref().and_then(newest) {
608                if revision.id != self.revision_id {
609                    events.push(PanelEvent::Open(revision.document_key.clone()));
610                }
611            }
612        }
613        if let Some(revision) = self.revision() {
614            let access = access_for(revision);
615            if self.reported.as_ref() != Some(&access) {
616                self.reported = Some(access.clone());
617                events.push(PanelEvent::Access(access));
618            }
619        }
620        events
621    }
622
623    /// Poll, then draw. Returns what the shell must act on, as [`Self::poll`].
624    pub fn ui(&mut self, ui: &mut egui::Ui, server: &dyn PlmLifecycle) -> Vec<PanelEvent> {
625        let mut events = self.poll(server);
626        let mut hits = std::collections::HashMap::new();
627        events.extend(self.draw(ui, server, &mut crate::panels::plm_host::SectionHits::new("lifecycle", &mut hits)));
628        events
629    }
630
631    /// Draw the panel without polling — for a host that polls every panel
632    /// itself ([`crate::panels::plm_host::PlmHost::sync`]). Returns the picks
633    /// made on this frame (another revision to open); a pressed verb's answer
634    /// arrives through the next [`Self::poll`].
635    ///
636    /// Hit keys (under `plm/`): `lifecycle:revision:<label>` for each picker
637    /// row, `lifecycle:verb:<Verb::key>` for each verb, `lifecycle:new-label`,
638    /// `lifecycle:history:load`.
639    pub fn draw(
640        &mut self,
641        ui: &mut egui::Ui,
642        server: &dyn PlmLifecycle,
643        hits: &mut crate::panels::plm_host::SectionHits<'_>,
644    ) -> Vec<PanelEvent> {
645        self.draw_with(ui, server, hits, &mut |_| {})
646    }
647
648    /// [`Self::draw`], with `before_new_revision` drawn in the New revision
649    /// confirmation — the host shows where the part is used there, so a user
650    /// sees what a change reaches before starting one (S6's where-used).
651    ///
652    /// Extra hit keys: `lifecycle:new-revision:start`, `lifecycle:new-revision:cancel`.
653    pub fn draw_with(
654        &mut self,
655        ui: &mut egui::Ui,
656        server: &dyn PlmLifecycle,
657        hits: &mut crate::panels::plm_host::SectionHits<'_>,
658        before_new_revision: &mut dyn FnMut(&mut egui::Ui),
659    ) -> Vec<PanelEvent> {
660        let mut events = Vec::new();
661        if self.busy() {
662            ui.ctx().request_repaint();
663        }
664        let Some(part) = self.part.clone() else {
665            ui.horizontal(|ui| {
666                ui.spinner();
667                ui.label("Reading the part from the PLM…");
668            });
669            if let Some(message) = &self.message {
670                ui.colored_label(ui.visuals().error_fg_color, message);
671            }
672            return events;
673        };
674        ui.heading(format!("{} {}", part.number, part.name));
675        if let Some(revision) = self.revision() {
676            let access = access_for(revision);
677            match &access {
678                Access::Editable => ui.label(format!("Revision {} — checked out by you", revision.label)),
679                Access::ReadOnly { reason } => ui.label(format!("Read-only: {reason}")),
680            };
681        }
682        ui.separator();
683        ui.label("Revisions");
684        for revision in part.revision_views.iter().rev() {
685            let shown = revision.id == self.revision_id;
686            let response = ui.selectable_label(shown, describe(revision));
687            hits.put(&format!("revision:{}", revision.label), response.rect);
688            if response.clicked() && !shown {
689                events.push(PanelEvent::Open(revision.document_key.clone()));
690            }
691        }
692        ui.separator();
693        let offered = self.offered();
694        ui.horizontal_wrapped(|ui| {
695            for verb in offered.iter().copied().filter(|v| *v != Verb::NewRevision) {
696                let label = match (verb, self.revision().and_then(|r| r.eco.as_ref())) {
697                    (Verb::OpenChangeOrder, Some(eco)) => format!("Open change order {}", eco.number),
698                    _ => verb.label().to_string(),
699                };
700                let button = ui.button(label);
701                hits.put(&format!("verb:{}", verb.key()), button.rect);
702                if button.clicked() {
703                    self.press(server, verb);
704                }
705            }
706        });
707        if offered.contains(&Verb::NewRevision) {
708            ui.horizontal(|ui| {
709                let field = ui.add(egui::TextEdit::singleline(&mut self.new_label).hint_text(part.suggested_label.as_str()).desired_width(80.0));
710                hits.put("new-label", field.rect);
711                let button = ui.button(format!("{}…", Verb::NewRevision.label()));
712                hits.put(&format!("verb:{}", Verb::NewRevision.key()), button.rect);
713                if button.clicked() {
714                    self.confirm_new = true;
715                }
716            });
717            if self.confirm_new {
718                egui::Frame::group(ui.style()).show(ui, |ui| {
719                    ui.label("Before starting a new revision: where this part is used");
720                    before_new_revision(ui);
721                    ui.horizontal(|ui| {
722                        let label = if self.new_label.trim().is_empty() { part.suggested_label.clone() } else { self.new_label.trim().to_string() };
723                        let start = ui.button(format!("Start revision {label}"));
724                        hits.put("new-revision:start", start.rect);
725                        if start.clicked() {
726                            self.confirm_new = false;
727                            self.press(server, Verb::NewRevision);
728                        }
729                        let cancel = ui.button("Cancel");
730                        hits.put("new-revision:cancel", cancel.rect);
731                        if cancel.clicked() {
732                            self.confirm_new = false;
733                        }
734                    });
735                });
736            }
737        } else {
738            self.confirm_new = false;
739        }
740        ui.horizontal(|ui| {
741            let refresh = ui.add_enabled(!self.busy() || self.refreshing(), egui::Button::new("Refresh"));
742            hits.put("refresh", refresh.rect);
743            if refresh.clicked() {
744                self.refresh(server);
745            }
746            if self.busy() && !self.refreshing() {
747                ui.spinner();
748            }
749        });
750        if let Some(message) = &self.message {
751            ui.label(message);
752        }
753        ui.separator();
754        egui::CollapsingHeader::new("History").id_salt("plm-lifecycle-history").show(ui, |ui| {
755            match &self.history {
756                None => {
757                    let button = ui.button("Load history");
758                    hits.put("history:load", button.rect);
759                    if button.clicked() {
760                        self.load_history(server);
761                    }
762                }
763                Some(events) if events.is_empty() => {
764                    ui.label("No changes recorded.");
765                }
766                Some(events) => {
767                    for event in events {
768                        let what = if event.entity.label.is_empty() { &event.entity.kind } else { &event.entity.label };
769                        ui.label(format!("{} {} {} ({})", event.actor.username, event.action, what, event.actor.via));
770                    }
771                }
772            }
773        });
774        events
775    }
776}
777
778// ===========================================================================
779// Over the PLM client
780// ===========================================================================
781
782/// The routes over [`crate::plm::client::PlmClient`]. An `Rc`, because each
783/// answer is a future the panel keeps across frames, and it must hold the
784/// client that sends it.
785impl PlmLifecycle for std::rc::Rc<crate::plm::client::PlmClient> {
786    fn rights(&self) -> PlmFuture<Rights> {
787        #[derive(Deserialize, Default)]
788        #[serde(default)]
789        struct Can {
790            can_author: bool,
791            can_checkin: bool,
792        }
793        let client = self.clone();
794        Box::pin(async move {
795            let can: Can = json(client.call("GET", "/api/me", None).await)?;
796            Ok(Rights { can_author: can.can_author, can_checkin: can.can_checkin })
797        })
798    }
799
800    fn part(&self, part: &str) -> PlmFuture<PartDetail> {
801        let (client, path) = (self.clone(), crate::plm::identity::part_path(&part));
802        Box::pin(async move { json(client.call("GET", &path, None).await) })
803    }
804
805    fn checkout(&self, part: &str, revision: &str) -> PlmFuture<()> {
806        let (client, path) = (self.clone(), format!("{}/checkout", crate::plm::identity::revision_path(&part, &revision)));
807        let body = serde_json::json!({ "client_id": CLIENT_ID }).to_string().into_bytes();
808        Box::pin(async move { unit(client.call("POST", &path, Some(body)).await) })
809    }
810
811    fn checkin(&self, part: &str, revision: &str, force: bool) -> PlmFuture<()> {
812        let (client, path) = (self.clone(), format!("{}/checkin", crate::plm::identity::revision_path(&part, &revision)));
813        let body = serde_json::json!({ "force": force }).to_string().into_bytes();
814        Box::pin(async move { unit(client.call("POST", &path, Some(body)).await) })
815    }
816
817    fn set_state(&self, part: &str, revision: &str, to: &str) -> PlmFuture<Vec<String>> {
818        #[derive(Deserialize, Default)]
819        #[serde(default)]
820        struct Answer {
821            warnings: Vec<String>,
822        }
823        let (client, path) = (self.clone(), format!("{}/state", crate::plm::identity::revision_path(&part, &revision)));
824        let body = serde_json::json!({ "to": to }).to_string().into_bytes();
825        Box::pin(async move { json::<Answer>(client.call("POST", &path, Some(body)).await).map(|a| a.warnings) })
826    }
827
828    fn create_revision(&self, part: &str, label: &str) -> PlmFuture<NewRevision> {
829        let (client, path) = (self.clone(), format!("{}/revisions", crate::plm::identity::part_path(&part)));
830        let body = serde_json::json!({ "label": label }).to_string().into_bytes();
831        Box::pin(async move { json(client.call("POST", &path, Some(body)).await) })
832    }
833
834    fn history(&self, part: &str) -> PlmFuture<Vec<HistoryEvent>> {
835        let (client, path) = (self.clone(), format!("{}/history", crate::plm::identity::part_path(&part)));
836        Box::pin(async move { json(client.call("GET", &path, None).await) })
837    }
838}
839
840/// A 2xx answer's JSON body, or the server's sentence.
841fn json<T: for<'de> Deserialize<'de>>(
842    answer: Result<crate::plm::PlmResponse, crate::plm::client::PlmError>,
843) -> Result<T, String> {
844    let response = answer.map_err(|e| e.to_string())?;
845    serde_json::from_slice(&response.body).map_err(|e| format!("the PLM answered in a shape this app does not read: {e}"))
846}
847
848fn unit(answer: Result<crate::plm::PlmResponse, crate::plm::client::PlmError>) -> Result<(), String> {
849    answer.map(|_| ()).map_err(|e| e.to_string())
850}
851
852/// `future` with its value mapped.
853fn map<T: 'static, U: 'static>(future: PlmFuture<T>, f: impl FnOnce(T) -> U + 'static) -> PlmFuture<U> {
854    Box::pin(async move { future.await.map(f) })
855}
856