all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Thomas Lamprecht <t.lamprecht@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [RFC PATCH datacenter-manager v3 12/12] ui: registry: add Add-and-Assign wizard from Assign Key dialog
Date: Fri, 15 May 2026 09:43:22 +0200	[thread overview]
Message-ID: <20260515074623.766766-13-t.lamprecht@proxmox.com> (raw)
In-Reply-To: <20260515074623.766766-1-t.lamprecht@proxmox.com>

Wire a small two-step wizard reachable from the per-node Assign Key
dialog's "Add new key..." button: paste a key, click Next, and land
back on the Assign selector with the just-added key pre-selected
and the original (remote, node) target preserved.

Optional UX shortcut for the empty-pool case; see the post-`---`
RFC note for the keep-vs-drop trade-off.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
---

New in v3, sent as RFC and probably should be skipped on apply: it
crowds the Assign Key dialog with an extra "Add new key..." button and a
separate two-step wizard that competes with the natural left-panel Add
path (i.e., just add a key on the left, the selection on the right stays
intact and thus one can trivially continue there afterwards). Two
discoverability paths for the same outcome is worse than one slightly
longer path.

 ui/src/configuration/subscription_assign.rs   | 437 +++++++++++++++++-
 ui/src/configuration/subscription_registry.rs |  41 ++
 2 files changed, 471 insertions(+), 7 deletions(-)

diff --git a/ui/src/configuration/subscription_assign.rs b/ui/src/configuration/subscription_assign.rs
index 16936b7f..9aba0111 100644
--- a/ui/src/configuration/subscription_assign.rs
+++ b/ui/src/configuration/subscription_assign.rs
@@ -1,8 +1,10 @@
-//! Node-first Assign Key dialog opened from the Subscription Registry's node tree panel.
+//! Node-first Assign Key dialog and a small two-step Add-and-Assign wizard, both opened from
+//! the Subscription Registry's node tree panel.
 
+use std::cell::RefCell;
 use std::rc::Rc;
 
-use anyhow::Error;
+use anyhow::{bail, Error};
 use serde_json::json;
 
 use yew::html::IntoEventCallback;
@@ -10,17 +12,18 @@ use yew::virtual_dom::{Key, VComp, VNode};
 
 use pwt::css::FlexFit;
 use pwt::prelude::*;
-use pwt::props::{ContainerBuilder, WidgetBuilder};
+use pwt::props::{ContainerBuilder, FieldBuilder, WidgetBuilder};
 use pwt::state::{Selection, Store};
 use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
-use pwt::widget::{Button, Column, Container, Dialog, Row};
+use pwt::widget::form::Hidden;
+use pwt::widget::{Button, Column, Container, Dialog, GridPicker, Row, TabBarItem};
 
-use proxmox_yew_comp::http_post;
 use proxmox_yew_comp::percent_encoding::percent_encode_component;
+use proxmox_yew_comp::{http_post, http_post_full, Wizard, WizardPageRenderInfo};
 
 use pdm_api_types::remotes::RemoteType;
 use pdm_api_types::subscription::{
-    pick_best_pve_socket_key, socket_count_from_key, SubscriptionKeyEntry,
+    pick_best_pve_socket_key, socket_count_from_key, ProductType, SubscriptionKeyEntry,
 };
 
 const KEYS_URL: &str = "/subscriptions/keys";
@@ -124,6 +127,11 @@ pub struct AssignKeyToNodeDialog {
 
     #[prop_or_default]
     pub on_done: Option<Callback<()>>,
+
+    /// Invoked when the operator clicks "Add new key..." in the body. The parent is expected
+    /// to close this dialog and open the Add-and-Assign wizard with the same target.
+    #[prop_or_default]
+    pub on_request_wizard: Option<Callback<()>>,
 }
 
 impl AssignKeyToNodeDialog {
@@ -142,6 +150,7 @@ impl AssignKeyToNodeDialog {
             pool_keys,
             pool_digest: None,
             on_done: None,
+            on_request_wizard: None,
         }
     }
 
@@ -154,6 +163,11 @@ impl AssignKeyToNodeDialog {
         self.on_done = cb.into_event_callback();
         self
     }
+
+    pub fn on_request_wizard(mut self, cb: impl IntoEventCallback<()>) -> Self {
+        self.on_request_wizard = cb.into_event_callback();
+        self
+    }
 }
 
 impl From<AssignKeyToNodeDialog> for VNode {
@@ -258,7 +272,7 @@ impl yew::Component for AssignKeyToNodeComp {
             Container::new()
                 .padding(2)
                 .with_child(tr!(
-                    "No matching free keys in the pool. Add one via the Key Pool panel first."
+                    "No matching free keys in the pool. Use \"Add new key\" to import one."
                 ))
                 .into()
         } else {
@@ -274,6 +288,14 @@ impl yew::Component for AssignKeyToNodeComp {
             .padding_top(2)
             .gap(2)
             .class(pwt::css::JustifyContent::FlexEnd)
+            .with_child(Button::new(tr!("Add new key...")).on_activate({
+                let cb = props.on_request_wizard.clone();
+                move |_| {
+                    if let Some(cb) = &cb {
+                        cb.emit(());
+                    }
+                }
+            }))
             .with_flex_spacer()
             .with_child(Button::new(tr!("Cancel")).on_activate({
                 let cb = props.on_done.clone();
@@ -330,3 +352,404 @@ impl yew::Component for AssignKeyToNodeComp {
     }
 }
 
+/// Two-step "Add and Assign" wizard.
+#[derive(Properties, Clone, PartialEq)]
+pub struct AddAndAssignWizard {
+    pub remote: String,
+    pub node: String,
+    pub ty: RemoteType,
+    pub node_sockets: Option<i64>,
+
+    #[prop_or_default]
+    pub pool_digest: Option<String>,
+
+    #[prop_or_default]
+    pub on_done: Option<Callback<()>>,
+}
+
+impl AddAndAssignWizard {
+    pub fn new(
+        remote: impl Into<String>,
+        node: impl Into<String>,
+        ty: RemoteType,
+        node_sockets: Option<i64>,
+    ) -> Self {
+        Self {
+            remote: remote.into(),
+            node: node.into(),
+            ty,
+            node_sockets,
+            pool_digest: None,
+            on_done: None,
+        }
+    }
+
+    pub fn pool_digest(mut self, digest: Option<String>) -> Self {
+        self.pool_digest = digest;
+        self
+    }
+
+    pub fn on_done(mut self, cb: impl IntoEventCallback<()>) -> Self {
+        self.on_done = cb.into_event_callback();
+        self
+    }
+}
+
+impl From<AddAndAssignWizard> for VNode {
+    fn from(val: AddAndAssignWizard) -> Self {
+        VComp::new::<AddAndAssignWizardComp>(Rc::new(val), None).into()
+    }
+}
+
+pub struct AddAndAssignWizardComp {
+    /// Shared mutable digest cell. The Add step writes the post-POST digest the server settled
+    /// on; the on_submit closure reads it for the Assign POST. Kept on the Component (not
+    /// recreated in `view()`) so it survives re-renders triggered by parent prop changes - if
+    /// the cell were instantiated inside `view()`, a re-render would detach the Add step's
+    /// already-registered `on_next` closure (which captured the old cell) from the new cell the
+    /// `on_submit` closure would read.
+    digest_cell: Rc<RefCell<Option<String>>>,
+}
+
+impl yew::Component for AddAndAssignWizardComp {
+    type Message = ();
+    type Properties = AddAndAssignWizard;
+
+    fn create(ctx: &yew::Context<Self>) -> Self {
+        Self {
+            digest_cell: Rc::new(RefCell::new(ctx.props().pool_digest.clone())),
+        }
+    }
+
+    fn view(&self, ctx: &yew::Context<Self>) -> Html {
+        let props = ctx.props();
+        let remote = props.remote.clone();
+        let node = props.node.clone();
+        let ty = props.ty;
+        let node_sockets = props.node_sockets;
+
+        let add_cell = self.digest_cell.clone();
+        let submit_cell = self.digest_cell.clone();
+        let assign_remote = remote.clone();
+        let assign_node = node.clone();
+
+        Wizard::new(tr!(
+            "Add and Assign Key on {remote}/{node}",
+            remote = remote.clone(),
+            node = node.clone()
+        ))
+        .width(700)
+        .on_done(props.on_done.clone())
+        .with_page(
+            TabBarItem::new().key("add").label(tr!("Add Key")),
+            move |p: &WizardPageRenderInfo| add_step(p.clone(), add_cell.clone()),
+        )
+        .with_page(
+            TabBarItem::new().key("assign").label(tr!("Assign")),
+            move |p: &WizardPageRenderInfo| {
+                assign_step(p.clone(), assign_remote.clone(), assign_node.clone(), ty, node_sockets)
+            },
+        )
+        .submit_text(tr!("Assign"))
+        .on_submit(move |data: serde_json::Value| {
+            let remote = remote.clone();
+            let node = node.clone();
+            let digest = submit_cell.borrow().clone();
+            async move {
+                let key = data
+                    .get("key")
+                    .and_then(|v| v.as_str())
+                    .unwrap_or_default()
+                    .to_string();
+                if key.is_empty() {
+                    bail!("no key selected");
+                }
+                submit_assignment(&key, &remote, &node, digest.as_deref()).await
+            }
+        })
+        .into()
+    }
+}
+
+/// Step 1 of the Add-and-Assign wizard. A small Yew component so the failure path of the
+/// underlying POST can surface an error into the page (`on_next` is a sync callback that
+/// dispatches the actual network work into a future).
+#[derive(Properties, Clone)]
+struct AddStepProps {
+    info: WizardPageRenderInfo,
+    /// Shared cell with the wizard's idea of the current pool digest: read here to pin the Add
+    /// POST, updated here after the POST succeeds so the on_submit closure that fires the
+    /// Assign POST picks up the post-Add value instead of the now-stale at-open digest.
+    digest_cell: Rc<RefCell<Option<String>>>,
+}
+
+impl PartialEq for AddStepProps {
+    fn eq(&self, other: &Self) -> bool {
+        // The `info` carries the wizard's render context; the cell is a stable shared pointer.
+        // PartialEq is required by `Properties` but the inner `RefCell` is interior-mutable, so
+        // compare by Rc identity to keep equality cheap and avoid panicking on a borrow.
+        self.info == other.info && Rc::ptr_eq(&self.digest_cell, &other.digest_cell)
+    }
+}
+
+impl From<AddStepProps> for VNode {
+    fn from(val: AddStepProps) -> Self {
+        VComp::new::<AddStepComp>(Rc::new(val), None).into()
+    }
+}
+
+enum AddStepMsg {
+    AddFailed(String),
+    AddSucceeded,
+}
+
+struct AddStepComp {
+    last_error: Option<String>,
+}
+
+impl yew::Component for AddStepComp {
+    type Message = AddStepMsg;
+    type Properties = AddStepProps;
+
+    fn create(ctx: &yew::Context<Self>) -> Self {
+        let page = ctx.props().info.clone();
+        let form_ctx = page.form_ctx.clone();
+        let digest_cell = ctx.props().digest_cell.clone();
+        let link = ctx.link().clone();
+        page.clone().on_next(Callback::from(move |()| -> bool {
+            // Parse, validate, POST. Advance only after the add succeeds, so a failed add keeps
+            // the operator on step 1 with the same input intact and the error visible.
+            let raw = form_ctx.read().get_field_text("keys");
+            let keys: Vec<String> = raw
+                .split(|c: char| c.is_whitespace() || c == ',')
+                .map(str::trim)
+                .filter(|s| !s.is_empty())
+                .map(str::to_string)
+                .collect();
+            if keys.is_empty() {
+                link.send_message(AddStepMsg::AddFailed(tr!(
+                    "Enter at least one subscription key."
+                )));
+                return false;
+            }
+            let page = page.clone();
+            let form = form_ctx.clone();
+            let link = link.clone();
+            let digest_cell = digest_cell.clone();
+            wasm_bindgen_futures::spawn_local(async move {
+                let pinned = digest_cell.borrow().clone();
+                let mut body = json!({ "keys": keys.clone() });
+                if let Some(d) = &pinned {
+                    body["digest"] = d.clone().into();
+                }
+                // `http_post_full` returns the response's `attribs`, which carries the digest
+                // the server settled on after this write. Pin that into the shared cell so the
+                // `on_submit` closure (which fires the Assign POST) uses the post-Add value
+                // rather than the now-stale at-open digest. Closes the race window a chained
+                // POST+GET would otherwise leave open: a parallel admin's mutation cannot land
+                // between the two calls because there is only one call.
+                match http_post_full::<()>(KEYS_URL, Some(body)).await {
+                    Ok(resp) => {
+                        let new_digest = resp
+                            .attribs
+                            .get("digest")
+                            .and_then(|v| v.as_str())
+                            .map(str::to_string);
+                        *digest_cell.borrow_mut() = new_digest;
+                        // Stash the just-added keys in step 1's Hidden field so step 2 can read
+                        // them via `info.lookup_form_context(&Key::from("add"))`. Step 2 has its
+                        // own `FormContext` and would not otherwise see step 1's data.
+                        form.write().set_field_value("__added_keys", json!(keys));
+                        link.send_message(AddStepMsg::AddSucceeded);
+                        page.go_to_next_page();
+                    }
+                    Err(err) => link.send_message(AddStepMsg::AddFailed(err.to_string())),
+                }
+            });
+            false
+        }));
+        Self { last_error: None }
+    }
+
+    fn update(&mut self, _ctx: &yew::Context<Self>, msg: Self::Message) -> bool {
+        match msg {
+            AddStepMsg::AddFailed(err) => self.last_error = Some(err),
+            AddStepMsg::AddSucceeded => self.last_error = None,
+        }
+        true
+    }
+
+    fn view(&self, _ctx: &yew::Context<Self>) -> Html {
+        use pwt::widget::form::TextArea;
+
+        // Render into the wizard's per-page FormContext (provided by the outer Form set up by
+        // the Wizard widget). Wrapping in another `Form::new()` here would create a separate,
+        // unparented FormContext that `on_next` cannot read - the textarea's `keys` value would
+        // never reach the validation closure and Next would always report "no keys".
+        //
+        // A plain Column keeps the layout to a single-column flow; InputPanel's CSS grid sized
+        // its track to the textarea's intrinsic `cols` value, which overflowed the wizard's
+        // 700px dialog and forced a horizontal scrollbar on the wider screens.
+        let mut column = Column::new()
+            .padding(4)
+            .gap(2)
+            .class("pwt-w-100")
+            // `__added_keys` carries the just-added keys forward to step 2's FormContext lookup
+            // via `info.lookup_form_context(&Key::from("add"))`; rendered as Hidden so it is
+            // registered on the page context but takes no visual space.
+            .with_child(
+                Hidden::new()
+                    .name("__added_keys")
+                    .submit_empty(false),
+            )
+            .with_child(
+                TextArea::new()
+                    .name("keys")
+                    .submit_empty(false)
+                    .required(true)
+                    .attribute("rows", "6")
+                    .attribute("cols", "1")
+                    .attribute("placeholder", tr!("Subscription key(s)"))
+                    .class("pwt-w-100"),
+            )
+            .with_child(
+                Container::new()
+                    .class(pwt::css::FontStyle::TitleSmall)
+                    .class(pwt::css::Opacity::Quarter)
+                    .with_child(tr!(
+                        "One key per line, or comma-separated. The keys are added to the \
+                         pool when you click Next. Step 2 will pick which one to assign; \
+                         cancelling on step 2 leaves the just-added keys in the pool as \
+                         free entries."
+                    )),
+            );
+        if let Some(err) = &self.last_error {
+            column = column.with_child(
+                Container::new()
+                    .class(pwt::css::FontColor::Error)
+                    .with_child(err.clone()),
+            );
+        }
+        column.into()
+    }
+}
+
+fn add_step(p: WizardPageRenderInfo, digest_cell: Rc<RefCell<Option<String>>>) -> Html {
+    AddStepProps {
+        info: p,
+        digest_cell,
+    }
+    .into()
+}
+
+fn assign_step(
+    p: WizardPageRenderInfo,
+    remote: String,
+    node: String,
+    ty: RemoteType,
+    node_sockets: Option<i64>,
+) -> Html {
+    let form_ctx = p.form_ctx.clone();
+    let columns = key_columns();
+
+    // The wizard keeps one FormContext per page, so step 2 cannot read step 1's field directly:
+    // look up the "add" page's context and read `__added_keys` from there. The lookup can
+    // return None on the first render after `go_to_next_page()` if step 1's context is not yet
+    // mounted; render a transient "Loading..." instead of the friendly "no keys" message so the
+    // operator does not get a false-negative flash before the real list appears.
+    let Some(add_form) = p.lookup_form_context(&Key::from("add")) else {
+        return Container::new()
+            .padding(4)
+            .with_child(tr!("Loading..."))
+            .into();
+    };
+    let added: Vec<String> = match add_form.read().get_field_value("__added_keys") {
+        Some(v) => serde_json::from_value(v.clone()).unwrap_or_default(),
+        None => Vec::new(),
+    };
+    let mut candidates: Vec<SubscriptionKeyEntry> = added
+        .iter()
+        .filter_map(|k| {
+            let product_type = ProductType::from_key(k)?;
+            if !product_type.matches_remote_type(ty) {
+                return None;
+            }
+            Some(SubscriptionKeyEntry {
+                key: k.clone(),
+                product_type,
+                level: pdm_api_types::subscription::SubscriptionLevel::from_key(Some(k)),
+                ..Default::default()
+            })
+        })
+        .collect();
+    candidates.sort_by(|a, b| {
+        let sa = socket_count_from_key(&a.key);
+        let sb = socket_count_from_key(&b.key);
+        sa.cmp(&sb).then_with(|| a.key.cmp(&b.key))
+    });
+
+    if candidates.is_empty() {
+        return Container::new()
+            .padding(4)
+            .with_child(tr!(
+                "Step 1 did not yield any keys compatible with this node's product type."
+            ))
+            .into();
+    }
+
+    // Preserve user's pick across re-renders by reading back from step 2's form ctx.
+    let prior_pick: Option<String> = form_ctx
+        .read()
+        .get_field_value("key")
+        .and_then(|v| v.as_str().map(str::to_string));
+    let first_render = prior_pick.is_none();
+    let default = prior_pick.or(default_candidate(&candidates, ty, node_sockets));
+
+    let store = Store::with_extract_key(|e: &SubscriptionKeyEntry| Key::from(e.key.as_str()));
+    store.set_data(candidates);
+
+    let form_for_select = form_ctx.clone();
+    let selection = Selection::new().on_select(move |sel: Selection| {
+        if let Some(key) = sel.selected_key() {
+            form_for_select
+                .write()
+                .set_field_value("key", json!(key.to_string()));
+        }
+    });
+    if let Some(d) = &default {
+        selection.select(Key::from(d.clone()));
+        if first_render {
+            // Only seed the form on the first render. Re-writing on every render would mask the
+            // operator's later pick if FormContext signalled change on equal-value writes and we
+            // ended up in a render loop.
+            form_ctx
+                .write()
+                .set_field_value("key", json!(d.clone()));
+        }
+    }
+
+    Column::new()
+        .gap(2)
+        .padding(2)
+        // Register `key` as a real Hidden field so the wizard's merged submit data carries the
+        // selected pool key all the way through to `on_submit`. Without this, the selection
+        // handler's `set_field_value("key", ...)` writes to a free-form slot the wizard would
+        // not include in the submitted Value.
+        .with_child(Hidden::new().name("key").submit_empty(false))
+        .with_child(
+            Row::new()
+                .gap(2)
+                .with_child(Container::new().with_child(tr!("Target:")))
+                .with_child(Container::new().with_child(format!("{remote}/{node}"))),
+        )
+        .with_child(
+            GridPicker::new(
+                DataTable::new(columns, store)
+                    .min_width(500)
+                    .header_focusable(false)
+                    .class(FlexFit),
+            )
+            .selection(selection),
+        )
+        .into()
+}
diff --git a/ui/src/configuration/subscription_registry.rs b/ui/src/configuration/subscription_registry.rs
index 1a70013c..9d2b19cc 100644
--- a/ui/src/configuration/subscription_registry.rs
+++ b/ui/src/configuration/subscription_registry.rs
@@ -306,6 +306,13 @@ pub enum ViewState {
         ty: pdm_api_types::remotes::RemoteType,
         node_sockets: Option<i64>,
     },
+    /// Two-step "Add and Assign" wizard launched from the AssignKeyToNode dialog.
+    AddAndAssignWizard {
+        remote: String,
+        node: String,
+        ty: pdm_api_types::remotes::RemoteType,
+        node_sockets: Option<i64>,
+    },
 }
 
 #[doc(hidden)]
@@ -1077,6 +1084,13 @@ impl LoadableComponent for SubscriptionRegistryComp {
             } => {
                 use super::subscription_assign::AssignKeyToNodeDialog;
                 let close_link = ctx.link().clone();
+                let wizard_link = ctx.link().clone();
+                let wizard_target = (
+                    remote.clone(),
+                    node.clone(),
+                    *ty,
+                    *node_sockets,
+                );
                 Some(
                     AssignKeyToNodeDialog::new(
                         remote.clone(),
@@ -1090,9 +1104,36 @@ impl LoadableComponent for SubscriptionRegistryComp {
                         close_link.change_view(None);
                         close_link.send_reload();
                     }))
+                    .on_request_wizard(Callback::from(move |_| {
+                        let (remote, node, ty, node_sockets) = wizard_target.clone();
+                        wizard_link.change_view(Some(ViewState::AddAndAssignWizard {
+                            remote,
+                            node,
+                            ty,
+                            node_sockets,
+                        }));
+                    }))
                     .into(),
                 )
             }
+            ViewState::AddAndAssignWizard {
+                remote,
+                node,
+                ty,
+                node_sockets,
+            } => {
+                use super::subscription_assign::AddAndAssignWizard;
+                let close_link = ctx.link().clone();
+                Some(
+                    AddAndAssignWizard::new(remote.clone(), node.clone(), *ty, *node_sockets)
+                        .pool_digest(self.pool_digest.clone())
+                        .on_done(Callback::from(move |_| {
+                            close_link.change_view(None);
+                            close_link.send_reload();
+                        }))
+                        .into(),
+                )
+            }
         }
     }
 }
-- 
2.47.3





      parent reply	other threads:[~2026-05-15  7:47 UTC|newest]

Thread overview: 16+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-15  7:43 [PATCH datacenter-manager v3 00/12] subscription key pool registry Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 01/12] api types: subscription level: render full names Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 02/12] pdm-client: add wait_for_local_task helper Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 03/12] subscription: pool: add data model and config layer Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 04/12] subscription: api: add key pool and node status endpoints Thomas Lamprecht
2026-05-15 15:21   ` Wolfgang Bumiller
2026-05-15  7:43 ` [PATCH datacenter-manager v3 05/12] ui: registry: add view with key pool and node status Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 06/12] cli: client: add subscription key pool management subcommands Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 07/12] docs: add subscription registry chapter Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 08/12] subscription: add Clear Key action and per-node revert Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 09/12] subscription: add Adopt Key action for foreign live subscriptions Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 10/12] subscription: add Adopt All bulk action Thomas Lamprecht
2026-05-15  7:43 ` [PATCH datacenter-manager v3 11/12] subscription: add Check Subscription action Thomas Lamprecht
2026-05-15  7:43 ` Thomas Lamprecht [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260515074623.766766-13-t.lamprecht@proxmox.com \
    --to=t.lamprecht@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal