diff --git a/InterlinedList/ViewModels/ListColumnDraftViewModel.cs b/InterlinedList/ViewModels/ListColumnDraftViewModel.cs
index fdf722a..f2e2eb5 100644
--- a/InterlinedList/ViewModels/ListColumnDraftViewModel.cs
+++ b/InterlinedList/ViewModels/ListColumnDraftViewModel.cs
@@ -88,6 +88,19 @@ public partial class ListColumnDraftViewModel : ObservableObject
/// The type this column was saved with; null for a new column.
public string? StoredType { get; private init; }
+ /// The label it was saved with — a rename is a safe, non-destructive edit.
+ public string? StoredLabel { get; private init; }
+
+ /// Its saved position, so a reorder can be reported as one.
+ public int? StoredOrder { get; private init; }
+
+ /// True when the label differs from the one on the server.
+ public bool IsRenamed =>
+ IsExistingColumn && !string.Equals((Label ?? "").Trim(), StoredLabel ?? "", StringComparison.Ordinal);
+
+ /// True when the type differs from the one on the server.
+ public bool IsRetyped => IsExistingColumn && StoredType is { } stored && stored != Type;
+
/// True once at least one row holds a value for .
public bool HasRowData { get; set; }
@@ -164,6 +177,8 @@ public static ListColumnDraftViewModel FromField(ListField field, ListProperty?
PropertyId = stored?.Id,
StoredKey = field.Key,
StoredType = field.Type,
+ StoredLabel = field.Label,
+ StoredOrder = field.DisplayOrder,
Validation = field.Validation,
Visibility = field.Visibility,
Key = field.Key,
diff --git a/InterlinedList/ViewModels/ListColumnEditorViewModel.cs b/InterlinedList/ViewModels/ListColumnEditorViewModel.cs
index 17c4562..82a139b 100644
--- a/InterlinedList/ViewModels/ListColumnEditorViewModel.cs
+++ b/InterlinedList/ViewModels/ListColumnEditorViewModel.cs
@@ -106,6 +106,196 @@ public partial class ListColumnEditorViewModel : ObservableObject
+ string.Join(", ", ListFieldType.PropertiesEditable)
+ "). Saving those requires the destructive column rebuild.";
+ // ── The two write paths, deliberately two actions ───────────────────────
+ // PUT /api/lists/{id}/schema is one route with two bodies whose consequences
+ // are nothing alike, so the UI never chooses for the user:
+ //
+ // "Save columns" → { properties: [ … ] } updates columns in place,
+ // row data untouched, column ids kept.
+ // "Rebuild columns…" → { schema: { … } } drops and recreates EVERY
+ // column, overwrites the list's title AND description,
+ // and leaves values whose key no longer has a column
+ // orphaned in rowData.
+ //
+ // Both measured live 2026-09-16. Note the asymmetry in what "removing a
+ // column" costs, which is why the two confirmations read differently:
+ // * properties + ?force=true STRIPS the key from every row (data deleted);
+ // * a rebuild LEAVES the value in rowData, unshown and unvalidated.
+
+ /// True while the destructive rebuild is waiting for confirmation.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(RebuildImpactSummary))]
+ private bool isRebuildConfirmationOpen;
+
+ ///
+ /// The title the rebuild will write. Editable because the rebuild writes it
+ /// whether the user meant to or not: schema.name overwrote a list's
+ /// title in testing, so the box is pre-filled with the current one.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(RebuildImpactSummary))]
+ private string rebuildTitle = "";
+
+ ///
+ /// The description the rebuild will write. Blank CLEARS it — omitting
+ /// schema.description wiped a list's description in testing — so the
+ /// box is pre-filled and the confirmation says so.
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(RebuildImpactSummary))]
+ private string rebuildDescription = "";
+
+ /// What the SAFE save is about to do, in the user's terms.
+ public string SavePlanSummary
+ {
+ get
+ {
+ if (IsNewListMode)
+ return $"{Columns.Count} column(s) will be created together with the list.";
+
+ var parts = new List();
+
+ if (Columns.Count(c => !c.IsExistingColumn) is > 0 and var added)
+ parts.Add($"adds {added}");
+ if (Columns.Count(c => c.IsRenamed) is > 0 and var renamed)
+ parts.Add($"renames {renamed}");
+ if (Columns.Count(c => c.IsRetyped) is > 0 and var retyped)
+ parts.Add($"retypes {retyped}");
+ if (RemovedKeys.Count > 0)
+ parts.Add($"deletes {string.Join(", ", RemovedKeys)}");
+ if (IsReordered)
+ parts.Add("reorders the columns");
+
+ return parts.Count == 0
+ ? "No column changes yet."
+ : $"Save columns {string.Join(", ", parts)} — in place, with every row's data kept.";
+ }
+ }
+
+ /// True when the saved columns are no longer in their saved order.
+ private bool IsReordered
+ {
+ get
+ {
+ var orders = Columns.Where(c => c.IsExistingColumn)
+ .Select(c => c.StoredOrder ?? 0)
+ .ToList();
+ return orders.Zip(orders.Skip(1)).Any(pair => pair.Second < pair.First);
+ }
+ }
+
+ ///
+ /// Exactly what the rebuild costs, with the affected columns named. Says
+ /// nothing about rows being deleted, because they are not: the measured
+ /// cost is orphaned values, new column ids, and an overwritten
+ /// title/description.
+ ///
+ public string RebuildImpactSummary
+ {
+ get
+ {
+ var lines = new List
+ {
+ $"All {Columns.Count} column(s) are dropped and recreated with new ids."
+ };
+
+ lines.Add(RemovedKeys.Count > 0
+ ? "Your rows are NOT deleted — but the values under "
+ + string.Join(", ", RemovedKeys)
+ + " stay in each row with no column to show or validate them."
+ : "Your rows are NOT deleted, and every column here keeps its key, so their values stay reachable.");
+
+ var carried = Columns.Count(c => c.Validation is { IsEmpty: false } || c.Visibility?.Condition is not null);
+ lines.Add(carried > 0
+ ? $"Validation rules and visibility conditions on {carried} column(s) are re-sent as they were read, so they survive — anything added on the web since this editor opened does not."
+ : "Any validation rule or visibility condition a column has that this editor didn't read is lost.");
+
+ lines.Add(string.IsNullOrWhiteSpace(RebuildTitle)
+ ? "The list needs a title — the rebuild writes it from this box."
+ : $"The list's title becomes “{RebuildTitle.Trim()}”.");
+
+ lines.Add(string.IsNullOrWhiteSpace(RebuildDescription)
+ ? "The list's description is CLEARED (a blank box clears it)."
+ : $"The list's description becomes “{RebuildDescription.Trim()}”.");
+
+ return string.Join("\n", lines.Select(line => "• " + line));
+ }
+ }
+
+ private bool CanStartRebuild() => !IsBusy && !IsNewListMode && List is not null && IsDraftSavable;
+
+ ///
+ /// Open the rebuild confirmation. Separate from on
+ /// purpose — a single "Save schema" button that always sent the DSL is the
+ /// data-loss footgun this whole split exists to prevent.
+ ///
+ [RelayCommand(CanExecute = nameof(CanStartRebuild))]
+ private void StartRebuild()
+ {
+ if (List is not { } list) return;
+ if (!ValidateDraft()) return;
+
+ RebuildTitle = list.Title;
+ RebuildDescription = list.Description ?? "";
+ ForceConfirmationMessage = null;
+ _pendingForcedSave = null;
+ ErrorMessage = null;
+ StatusMessage = null;
+ IsRebuildConfirmationOpen = true;
+ }
+
+ [RelayCommand]
+ private void CancelRebuild()
+ {
+ IsRebuildConfirmationOpen = false;
+ StatusMessage = "Nothing was changed.";
+ }
+
+ [RelayCommand]
+ private async Task ConfirmRebuildAsync()
+ {
+ if (List is not { } list) return;
+ if (!ValidateDraft()) return;
+
+ if (string.IsNullOrWhiteSpace(RebuildTitle))
+ {
+ ErrorMessage = "The rebuild writes the list's title — give it one.";
+ return;
+ }
+
+ IsBusy = true;
+ SaveCommand.NotifyCanExecuteChanged();
+ StartRebuildCommand.NotifyCanExecuteChanged();
+ try
+ {
+ var description = string.IsNullOrWhiteSpace(RebuildDescription) ? null : RebuildDescription.Trim();
+ var updated = await _session.Api.RebuildListSchemaDestructiveAsync(
+ list.Id, BuildSchema(RebuildTitle.Trim(), description));
+
+ IsRebuildConfirmationOpen = false;
+ ErrorMessage = null;
+ List = updated;
+ StatusMessage = $"Rebuilt {Columns.Count} column(s). The list is now titled “{updated.Title}”. "
+ + "Rows were kept; any value whose column is gone is still stored but no longer shown.";
+ await ReloadAsync();
+ Saved?.Invoke(this, EventArgs.Empty);
+ }
+ catch (ListSchemaException ex)
+ {
+ AttachIssues(ex);
+ }
+ catch (InterlinedApiException ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ SaveCommand.NotifyCanExecuteChanged();
+ StartRebuildCommand.NotifyCanExecuteChanged();
+ }
+ }
+
public ListColumnEditorViewModel(SessionService session)
{
_session = session;
@@ -173,6 +363,7 @@ private void Reset()
_storedKeys.Clear();
_pendingForcedSave = null;
ForceConfirmationMessage = null;
+ IsRebuildConfirmationOpen = false;
ErrorMessage = null;
StatusMessage = null;
}
@@ -382,7 +573,7 @@ private async Task SaveAsync()
if (!CanUsePropertiesPath)
{
- ErrorMessage = RebuildOnlyWarning;
+ ErrorMessage = RebuildOnlyWarning + " Use “Rebuild columns” for that — it says what the rebuild costs before it runs.";
return;
}
@@ -471,6 +662,9 @@ private void NotifyDraftSetChanged()
OnPropertyChanged(nameof(RebuildOnlyWarning));
OnPropertyChanged(nameof(IsDraftSavable));
OnPropertyChanged(nameof(SaveBlockedReason));
+ OnPropertyChanged(nameof(SavePlanSummary));
+ OnPropertyChanged(nameof(RebuildImpactSummary));
SaveCommand.NotifyCanExecuteChanged();
+ StartRebuildCommand.NotifyCanExecuteChanged();
}
}
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 4979c9b..8888574 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -868,6 +868,59 @@
Margin="0,4,0,0"
Visibility="{Binding ColumnEditor.StatusMessage, Converter={StaticResource NullOrEmptyToVisibility}}"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+