@@ -3,7 +3,9 @@ use leptos_router::hooks::{use_navigate, use_query_map};
33
44use quilt_uri:: S3PackageUri ;
55
6- use crate :: commands:: { self , CommitData , CommitWorkflows , EntryData , WorkflowInfo , WorkflowIntent } ;
6+ use crate :: commands:: {
7+ self , CommitData , CommitWorkflows , EntryData , WorkflowData , WorkflowInfo , WorkflowIntent ,
8+ } ;
79use crate :: components:: buttons;
810use crate :: components:: layout:: { BreadcrumbItem , BreadcrumbLink } ;
911use crate :: components:: {
@@ -100,8 +102,8 @@ fn CommitContent(
100102 // model whose option list carries, per entry, the `WorkflowIntent` it
101103 // submits. The selected index into that list is the whole client-side
102104 // state.
103- let previous_workflow_id = data. workflow . as_ref ( ) . and_then ( |w| w . id . clone ( ) ) ;
104- let wf_view = build_workflow_view ( & data. workflows , previous_workflow_id . as_deref ( ) ) ;
105+ let previous_workflow = PreviousWorkflow :: from_stamp ( data. workflow . as_ref ( ) ) ;
106+ let wf_view = build_workflow_view ( & data. workflows , previous_workflow . preselect_id ( ) ) ;
105107 // `wf_view.initial` is the single source of truth for the starting
106108 // selection: it both seeds this signal (which submit reads) and is passed
107109 // to `WorkflowSection` to render the `selected` attribute, so display and
@@ -114,6 +116,25 @@ fn CommitContent(
114116 let workflow_intents: Vec < WorkflowIntent > =
115117 wf_view. options . iter ( ) . map ( |o| o. intent . clone ( ) ) . collect ( ) ;
116118
119+ // Neutral override note: recomputed as the selection changes, it surfaces
120+ // when the current pick diverges from what the previous revision stamped
121+ // (the bucket default winning over the previous pick in `preselected_index`
122+ // can silently override the user's earlier choice). Only the `Available`
123+ // state declares workflows to name; the other states carry an empty list
124+ // and never render the note.
125+ let note_workflows: Vec < WorkflowInfo > = match & data. workflows {
126+ CommitWorkflows :: Available { workflows, .. } => workflows. clone ( ) ,
127+ CommitWorkflows :: NotConfigured | CommitWorkflows :: Unavailable => Vec :: new ( ) ,
128+ } ;
129+ let note_intents = workflow_intents. clone ( ) ;
130+ let workflow_note = Memo :: new ( move |_| {
131+ let current = note_intents
132+ . get ( selected_workflow. get ( ) )
133+ . cloned ( )
134+ . unwrap_or ( WorkflowIntent :: BucketDefault ) ;
135+ previous_workflow_note ( & previous_workflow, & current, & note_workflows)
136+ } ) ;
137+
117138 // Filtered entries
118139 let entries_for_view = entries. clone ( ) ;
119140 let filtered_entries = Memo :: new ( move |_| {
@@ -202,6 +223,7 @@ fn CommitContent(
202223 <WorkflowSection
203224 view=wf_view
204225 selected=selected_workflow
226+ note=workflow_note
205227 />
206228
207229 // ── Namespace (readonly) ──
@@ -374,6 +396,79 @@ fn workflow_label(w: &WorkflowInfo) -> String {
374396 w. name . clone ( ) . unwrap_or_else ( || w. id . clone ( ) )
375397}
376398
399+ /// The previous revision's workflow stamp, distinguishing the three cases the
400+ /// override note must tell apart.
401+ ///
402+ /// `preselected_index` only needs the concrete `Named` id, but the note also
403+ /// cares whether the previous revision was never pushed versus explicitly
404+ /// stamped with no workflow — so this is the one place that distinction lives.
405+ #[ derive( Clone , Debug , PartialEq , Eq ) ]
406+ enum PreviousWorkflow {
407+ NeverPushed ,
408+ ExplicitNone ,
409+ Named ( String ) ,
410+ }
411+
412+ impl PreviousWorkflow {
413+ /// Derive from the previous revision's stamp: absent → never pushed, present
414+ /// with no id → an explicit no-workflow choice, present with an id → that
415+ /// workflow.
416+ fn from_stamp ( workflow : Option < & WorkflowData > ) -> Self {
417+ match workflow {
418+ None => Self :: NeverPushed ,
419+ Some ( WorkflowData { id : None } ) => Self :: ExplicitNone ,
420+ Some ( WorkflowData { id : Some ( id) } ) => Self :: Named ( id. clone ( ) ) ,
421+ }
422+ }
423+
424+ /// The workflow id `preselected_index` treats as the previous pick: only a
425+ /// concrete named workflow, collapsing never-pushed and explicit-none (the
426+ /// same behavior preselection had before this note existed).
427+ fn preselect_id ( & self ) -> Option < & str > {
428+ match self {
429+ Self :: Named ( id) => Some ( id) ,
430+ Self :: NeverPushed | Self :: ExplicitNone => None ,
431+ }
432+ }
433+ }
434+
435+ /// Neutral note shown under the dropdown when the currently-selected workflow
436+ /// diverges from what the previous revision stamped, so the override (the
437+ /// bucket default winning over the previous pick in `preselected_index`) is
438+ /// visible. `None` means no divergence to report.
439+ ///
440+ /// Pure and signal-free: the render layer recomputes it whenever the selection
441+ /// changes.
442+ fn previous_workflow_note (
443+ previous : & PreviousWorkflow ,
444+ current : & WorkflowIntent ,
445+ workflows : & [ WorkflowInfo ] ,
446+ ) -> Option < String > {
447+ match previous {
448+ // Nothing was ever stamped, so nothing can be overridden.
449+ PreviousWorkflow :: NeverPushed => None ,
450+ PreviousWorkflow :: ExplicitNone => match current {
451+ WorkflowIntent :: NoWorkflow => None ,
452+ WorkflowIntent :: BucketDefault | WorkflowIntent :: Named ( _) => {
453+ Some ( "The previous revision used no workflow." . to_string ( ) )
454+ }
455+ } ,
456+ PreviousWorkflow :: Named ( prev) => {
457+ if matches ! ( current, WorkflowIntent :: Named ( id) if id == prev) {
458+ None
459+ } else {
460+ let label = workflows
461+ . iter ( )
462+ . find ( |w| & w. id == prev)
463+ . map_or_else ( || prev. clone ( ) , workflow_label) ;
464+ Some ( format ! (
465+ "The previous revision used the \" {label}\" workflow."
466+ ) )
467+ }
468+ }
469+ }
470+ }
471+
377472/// Build the dropdown's option list for the [`CommitWorkflows::Available`]
378473/// state, matching the web catalog's order:
379474/// - index 0: `None` → [`WorkflowIntent::NoWorkflow`], disabled when a workflow
@@ -563,7 +658,13 @@ fn build_workflow_view(
563658// ── Workflow section ──
564659
565660#[ component]
566- fn WorkflowSection ( view : WorkflowView , selected : RwSignal < usize > ) -> impl IntoView {
661+ fn WorkflowSection (
662+ view : WorkflowView ,
663+ selected : RwSignal < usize > ,
664+ /// Neutral divergence note, live over `selected`. Rendered only in the
665+ /// `Available` state; the other states have no dropdown to override.
666+ note : Memo < Option < String > > ,
667+ ) -> impl IntoView {
567668 let WorkflowView {
568669 kind,
569670 options,
@@ -573,7 +674,7 @@ fn WorkflowSection(view: WorkflowView, selected: RwSignal<usize>) -> impl IntoVi
573674 match kind {
574675 WorkflowViewKind :: Available {
575676 is_workflow_required,
576- } => workflow_dropdown ( options, selected, initial, is_workflow_required) . into_any ( ) ,
677+ } => workflow_dropdown ( options, selected, initial, is_workflow_required, note ) . into_any ( ) ,
577678 // Ungoverned bucket: a single disabled `None`, plus a hint explaining
578679 // why there is no choice to make. Submit already carries `BucketDefault`
579680 // via `options[0]`.
@@ -611,6 +712,7 @@ fn workflow_dropdown(
611712 selected : RwSignal < usize > ,
612713 initial : usize ,
613714 is_workflow_required : bool ,
715+ note : Memo < Option < String > > ,
614716) -> impl IntoView {
615717 // Intents indexed by option position — used to decide whether the current
616718 // selection is the (disabled) `None` item, which drives the required hint.
@@ -661,6 +763,11 @@ fn workflow_dropdown(
661763 <Show when=show_required_hint>
662764 <span class="error" >"Workflow is required for this bucket." </span>
663765 </Show >
766+ // Neutral note: appears when the current pick diverges from the
767+ // previous revision's stamp, and disappears when they match again.
768+ { move || {
769+ note. get( ) . map( |text| view! { <p class="hint" >{ text} </p> } )
770+ } }
664771 </div>
665772 }
666773}
@@ -968,9 +1075,10 @@ fn JsonEditor(id: &'static str, initial_value: String) -> impl IntoView {
9681075#[ cfg( test) ]
9691076mod tests {
9701077 use super :: {
971- WorkflowOption , WorkflowViewKind , build_workflow_view, preselected_index, workflow_options,
1078+ PreviousWorkflow , WorkflowOption , WorkflowViewKind , build_workflow_view, preselected_index,
1079+ previous_workflow_note, workflow_options,
9721080 } ;
973- use crate :: commands:: { CommitWorkflows , WorkflowInfo , WorkflowIntent } ;
1081+ use crate :: commands:: { CommitWorkflows , WorkflowData , WorkflowInfo , WorkflowIntent } ;
9741082
9751083 fn wf ( id : & str , name : Option < & str > ) -> WorkflowInfo {
9761084 WorkflowInfo {
@@ -1255,6 +1363,139 @@ mod tests {
12551363 assert_eq ! ( opts[ idx] . label, "Alpha WF (default)" ) ;
12561364 }
12571365
1366+ // ── Previous-workflow override note (pure) ──
1367+
1368+ #[ test]
1369+ fn note_never_pushed_is_silent ( ) {
1370+ // No prior stamp → nothing can be overridden, regardless of selection.
1371+ let wfs = vec ! [ wf( "alpha" , Some ( "Alpha WF" ) ) ] ;
1372+ for current in [
1373+ WorkflowIntent :: BucketDefault ,
1374+ WorkflowIntent :: NoWorkflow ,
1375+ WorkflowIntent :: Named ( "alpha" . to_string ( ) ) ,
1376+ ] {
1377+ assert_eq ! (
1378+ previous_workflow_note( & PreviousWorkflow :: NeverPushed , & current, & wfs) ,
1379+ None
1380+ ) ;
1381+ }
1382+ }
1383+
1384+ #[ test]
1385+ fn note_explicit_none_silent_when_current_is_no_workflow ( ) {
1386+ let wfs = vec ! [ wf( "alpha" , Some ( "Alpha WF" ) ) ] ;
1387+ assert_eq ! (
1388+ previous_workflow_note(
1389+ & PreviousWorkflow :: ExplicitNone ,
1390+ & WorkflowIntent :: NoWorkflow ,
1391+ & wfs
1392+ ) ,
1393+ None
1394+ ) ;
1395+ }
1396+
1397+ #[ test]
1398+ fn note_explicit_none_flags_divergence_to_a_workflow ( ) {
1399+ let wfs = vec ! [ wf( "alpha" , Some ( "Alpha WF" ) ) ] ;
1400+ // Current selection is a named workflow → the previous no-workflow is
1401+ // being overridden.
1402+ assert_eq ! (
1403+ previous_workflow_note(
1404+ & PreviousWorkflow :: ExplicitNone ,
1405+ & WorkflowIntent :: Named ( "alpha" . to_string( ) ) ,
1406+ & wfs
1407+ ) ,
1408+ Some ( "The previous revision used no workflow." . to_string( ) )
1409+ ) ;
1410+ // BucketDefault likewise diverges from an explicit no-workflow.
1411+ assert_eq ! (
1412+ previous_workflow_note(
1413+ & PreviousWorkflow :: ExplicitNone ,
1414+ & WorkflowIntent :: BucketDefault ,
1415+ & wfs
1416+ ) ,
1417+ Some ( "The previous revision used no workflow." . to_string( ) )
1418+ ) ;
1419+ }
1420+
1421+ #[ test]
1422+ fn note_named_silent_when_current_matches_previous ( ) {
1423+ let wfs = vec ! [ wf( "alpha" , Some ( "Alpha WF" ) ) ] ;
1424+ assert_eq ! (
1425+ previous_workflow_note(
1426+ & PreviousWorkflow :: Named ( "alpha" . to_string( ) ) ,
1427+ & WorkflowIntent :: Named ( "alpha" . to_string( ) ) ,
1428+ & wfs
1429+ ) ,
1430+ None
1431+ ) ;
1432+ }
1433+
1434+ #[ test]
1435+ fn note_named_uses_workflow_name_when_in_list ( ) {
1436+ let wfs = vec ! [ wf( "alpha" , Some ( "Alpha WF" ) ) , wf( "beta" , None ) ] ;
1437+ // Current selection differs (another named workflow) → show the
1438+ // previous workflow's display name.
1439+ assert_eq ! (
1440+ previous_workflow_note(
1441+ & PreviousWorkflow :: Named ( "alpha" . to_string( ) ) ,
1442+ & WorkflowIntent :: Named ( "beta" . to_string( ) ) ,
1443+ & wfs
1444+ ) ,
1445+ Some ( "The previous revision used the \" Alpha WF\" workflow." . to_string( ) )
1446+ ) ;
1447+ // BucketDefault also diverges from the previous named pick; unnamed
1448+ // workflow falls back to its id as the label.
1449+ assert_eq ! (
1450+ previous_workflow_note(
1451+ & PreviousWorkflow :: Named ( "beta" . to_string( ) ) ,
1452+ & WorkflowIntent :: BucketDefault ,
1453+ & wfs
1454+ ) ,
1455+ Some ( "The previous revision used the \" beta\" workflow." . to_string( ) )
1456+ ) ;
1457+ }
1458+
1459+ #[ test]
1460+ fn note_named_falls_back_to_id_when_absent_from_list ( ) {
1461+ // Previous workflow id no longer declared → use the raw id in the note.
1462+ let wfs = vec ! [ wf( "alpha" , Some ( "Alpha WF" ) ) ] ;
1463+ assert_eq ! (
1464+ previous_workflow_note(
1465+ & PreviousWorkflow :: Named ( "ghost" . to_string( ) ) ,
1466+ & WorkflowIntent :: Named ( "alpha" . to_string( ) ) ,
1467+ & wfs
1468+ ) ,
1469+ Some ( "The previous revision used the \" ghost\" workflow." . to_string( ) )
1470+ ) ;
1471+ }
1472+
1473+ #[ test]
1474+ fn previous_workflow_from_stamp_and_preselect_id ( ) {
1475+ // Never pushed / explicit-none collapse to no preselect id; a named
1476+ // stamp yields exactly that id (preselection behavior is unchanged).
1477+ assert_eq ! (
1478+ PreviousWorkflow :: from_stamp( None ) ,
1479+ PreviousWorkflow :: NeverPushed
1480+ ) ;
1481+ assert_eq ! (
1482+ PreviousWorkflow :: from_stamp( Some ( & WorkflowData { id: None } ) ) ,
1483+ PreviousWorkflow :: ExplicitNone
1484+ ) ;
1485+ assert_eq ! (
1486+ PreviousWorkflow :: from_stamp( Some ( & WorkflowData {
1487+ id: Some ( "alpha" . to_string( ) )
1488+ } ) ) ,
1489+ PreviousWorkflow :: Named ( "alpha" . to_string( ) )
1490+ ) ;
1491+ assert_eq ! ( PreviousWorkflow :: NeverPushed . preselect_id( ) , None ) ;
1492+ assert_eq ! ( PreviousWorkflow :: ExplicitNone . preselect_id( ) , None ) ;
1493+ assert_eq ! (
1494+ PreviousWorkflow :: Named ( "alpha" . to_string( ) ) . preselect_id( ) ,
1495+ Some ( "alpha" )
1496+ ) ;
1497+ }
1498+
12581499 #[ test]
12591500 fn view_non_available_states_submit_bucket_default ( ) {
12601501 // The submit path reads `options[selected].intent`; both non-Available
0 commit comments