From 60c23df28923dc881eaac3ea12ea53650e65e57e Mon Sep 17 00:00:00 2001 From: dmiesopust Date: Wed, 2 Sep 2026 11:21:02 +0200 Subject: [PATCH] fix(workspace): add single-level undo for the Delete tool The Delete tool removed a wall/test point/AP permanently with no confirmation and no way back. Adds one-level undo, scoped to the last deletion only: an InfoBar with an Undo button appears after a delete, overwritten by the next one and cleared on floor switch. --- CLAUDE.md | 3 + .../Services/LocalizationService.cs | 6 ++ src/Pulsemap.App/Strings/en-US/Resources.resw | 3 + src/Pulsemap.App/Strings/pl-PL/Resources.resw | 3 + .../ViewModels/WorkspaceViewModel.cs | 61 +++++++++++++- src/Pulsemap.App/Views/WorkspacePage.xaml | 11 +++ .../ViewModels/WorkspaceViewModelTests.cs | 84 +++++++++++++++++++ 7 files changed, 169 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dcd3c0c..f4e770c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,3 +60,6 @@ Don't invent a convention to fill a gap. Name what's missing, propose the smalle - **Outdoor-area bounds editor (added 2026-09-02, on branch `feature/outdoor-bounds-editor`, not yet merged to staging):** outdoor areas previously had no way to resize/reposition their extent from the UI — a new floor always got a fixed 40x40m default with no follow-up editor. `FloorPlanCanvas` now draws an outdoor floor's bounds as a dashed rectangle with one resize handle at its far corner (deliberately one anchored corner + one dragged corner, not four independent handles — the smallest interaction that still covers both resize and reposition); dragging inside the rectangle moves the whole area instead. A click first checks for an existing test point/AP within the usual 0.5m tolerance so precise clicks on markers placed inside the area still work — only a click that misses every marker starts a bounds drag. Drag state and a live preview stay canvas-local (mirrors the "canvas owns rendering/gesture recognition only" rule already documented on `FloorPlanCanvas`); only on release does a new `OutdoorBoundsChanged` event fire, which `WorkspaceViewModel.UpdateOutdoorBoundsAsync` persists to `Floor.OutdoorBoundsMin`/`Max`. - **Per-wall material editing and guided-walk improvements (added 2026-09-02, on branch `feature/wall-material-and-guided-walk`, not yet merged to staging):** the Select tool (previously a no-op placeholder) now does two things depending on what's clicked: a wall toggles into a multi-select set (highlighted blue), with a toolbar panel to batch-apply one material/thickness to everything selected; a test point instead prompts to recapture its reading (rescans via the currently selected adapter and replaces `Measurements`/`InterferenceReadings`, keeping `Position`). Guided walk gained a Skip button (dequeues without capturing, stays active), renders its remaining queue as faint markers on the canvas, and now persists its pending queue on `Floor.PendingGuidedWalkPoints`/`PendingGuidedWalkBand` — saved on every dequeue/skip/cancel, so closing and reopening the app mid-walk resumes instead of losing the remaining points; `OnSelectedFloorChanged` auto-resumes/pauses as the floor selector switches. 144 tests now on that branch (75 App/69 Core). - **In-app update check (added 2026-09-02, on branch `feature/update-check-notify`, not yet merged to staging — see ADR-0004):** Pulsemap's first-ever outbound network call, explicitly cleared with the PM given ADR-0003 had deliberately left this question open. Home now checks `https://api.github.com/repos/DotifyBIZ/pulsemap/releases/latest` on load (via new `IUpdateCheckService`/`GitHubUpdateCheckService`, App-layer, `IHttpClientFactory`-based) and shows a dismissible banner + release link if the tag is newer than the running build (`SemanticVersionComparer`, Core, pure/testable, compares `Assembly.GetName().Version` against a `vX.Y.Z` tag). Gated behind a new `AppSettings.CheckForUpdatesEnabled` (default on, Core, `FileAppSettingsService` at `%LocalAppData%\Pulsemap\settings.json` — Pulsemap's first app-preferences store, distinct from survey data and from `FileAppLogger`'s logs), with a visible toggle on the Settings page ("Check for updates on launch"). A real prerequisite gap found and fixed along the way: `scripts/build-installer.ps1`'s `dotnet publish` never passed `-p:Version=$Version`, so the published assembly's version was always the SDK's static default regardless of the actual release — without that fix, the whole comparison would have been meaningless. README's "won't phone home" bullet is amended accordingly (telemetry/account/server-free claim still holds; the literal zero-network-calls reading no longer does). 82 Core / 76 App tests on that branch — includes a stub-`HttpMessageHandler`-backed test of the actual HTTP/JSON path, not just the pure version-comparison logic. + +**UX pass (started 2026-09-02, in progress)** — a full-codebase UX review turned up several friction points (Select tool overloaded with undiscoverable per-click-target behavior, no undo anywhere, no onboarding, inconsistent delete confirmation, silent AP-suggestion replacement, no survey-type context in Workspace, the Pivot-clipping bug). Being fixed incrementally, each as its own branch: +- **Undo last delete (added 2026-09-02, on branch `feature/undo-last-delete`, not yet merged to staging):** the Delete tool removed a wall/test point/AP permanently with zero confirmation or recovery — the single highest-risk unconfirmed action in the app. Deliberately not a full undo/redo stack (that would touch nearly every mutation in `WorkspaceViewModel`) — just single-level: `TryFindNearestElement` now also reports *what* it's about to remove, `DeleteNearestElementAsync` stashes `(Floor, object Item)` before removing, and a new `UndoDeleteCommand` re-adds it. An `InfoBar` with an Undo action button appears after a delete (`CanUndoDelete`/`LastDeletedItemDisplay`), overwritten by the next delete and cleared on floor switch so it never fires somewhere other than where the deletion happened. 5 new tests covering restore-after-delete for both a wall and a test point, overwrite-on-second-delete, and floor-switch clearing. diff --git a/src/Pulsemap.App/Services/LocalizationService.cs b/src/Pulsemap.App/Services/LocalizationService.cs index 7645e93..5b26903 100644 --- a/src/Pulsemap.App/Services/LocalizationService.cs +++ b/src/Pulsemap.App/Services/LocalizationService.cs @@ -115,6 +115,9 @@ public string GetString(string key) ["WorkspaceRecaptureDialogContent"] = "This replaces its existing reading with a new scan from here.", ["WorkspaceRecaptureDialogPrimaryButton"] = "Recapture", ["WorkspaceRecaptureDialogCloseButton"] = "Cancel", + ["WorkspaceUndoDeleteWallMessage"] = "Wall deleted.", + ["WorkspaceUndoDeleteTestPointMessage"] = "Test point deleted.", + ["WorkspaceUndoDeleteAccessPointMessage"] = "Access point deleted.", ["HomeUpdateAvailableFormat"] = "Pulsemap {0} is available.", }, @@ -190,6 +193,9 @@ public string GetString(string key) ["WorkspaceRecaptureDialogContent"] = "Zastąpi to jego dotychczasowy odczyt nowym skanem z tego miejsca.", ["WorkspaceRecaptureDialogPrimaryButton"] = "Zarejestruj ponownie", ["WorkspaceRecaptureDialogCloseButton"] = "Anuluj", + ["WorkspaceUndoDeleteWallMessage"] = "Usunięto ścianę.", + ["WorkspaceUndoDeleteTestPointMessage"] = "Usunięto punkt pomiarowy.", + ["WorkspaceUndoDeleteAccessPointMessage"] = "Usunięto punkt dostępowy.", ["HomeUpdateAvailableFormat"] = "Dostępny jest Pulsemap {0}.", }, diff --git a/src/Pulsemap.App/Strings/en-US/Resources.resw b/src/Pulsemap.App/Strings/en-US/Resources.resw index 86cd2c0..71acba6 100644 --- a/src/Pulsemap.App/Strings/en-US/Resources.resw +++ b/src/Pulsemap.App/Strings/en-US/Resources.resw @@ -374,6 +374,9 @@ Clear selection + + Undo + diff --git a/src/Pulsemap.App/Strings/pl-PL/Resources.resw b/src/Pulsemap.App/Strings/pl-PL/Resources.resw index 5baa230..0742093 100644 --- a/src/Pulsemap.App/Strings/pl-PL/Resources.resw +++ b/src/Pulsemap.App/Strings/pl-PL/Resources.resw @@ -374,6 +374,9 @@ Wyczyść zaznaczenie + + Cofnij + diff --git a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs index fde20c0..27e2c08 100644 --- a/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs +++ b/src/Pulsemap.App/ViewModels/WorkspaceViewModel.cs @@ -477,9 +477,25 @@ public async Task UpdateOutdoorBoundsAsync(Point2D min, Point2D max) await SaveAndRefreshAsync(); } + // Single-level undo, scoped to the Delete tool only — the highest-risk edit in the app since + // it's the one permanent, unconfirmed action on the canvas. Not a general undo/redo stack: + // overwritten by the next delete, and cleared on floor switch so it never applies somewhere + // other than where the deletion actually happened. + private (Floor Floor, object Item)? _lastDeletedElement; + + public bool CanUndoDelete => _lastDeletedElement is not null; + + public string LastDeletedItemDisplay => _lastDeletedElement?.Item switch + { + Wall => _localizationService.GetString("WorkspaceUndoDeleteWallMessage"), + TestPoint => _localizationService.GetString("WorkspaceUndoDeleteTestPointMessage"), + AccessPoint => _localizationService.GetString("WorkspaceUndoDeleteAccessPointMessage"), + _ => string.Empty, + }; + public async Task DeleteNearestElementAsync(Point2D at) { - if (SelectedFloor is null || !TryFindNearestElement(SelectedFloor, at, out var remove)) + if (SelectedFloor is null || !TryFindNearestElement(SelectedFloor, at, out var remove, out var removedItem)) { return; } @@ -487,6 +503,38 @@ public async Task DeleteNearestElementAsync(Point2D at) remove(); _selectedWalls.RemoveAll(wall => !SelectedFloor.Walls.Contains(wall)); SelectedWallCount = _selectedWalls.Count; + _lastDeletedElement = removedItem is not null ? (SelectedFloor, removedItem) : null; + OnPropertyChanged(nameof(CanUndoDelete)); + OnPropertyChanged(nameof(LastDeletedItemDisplay)); + UndoDeleteCommand.NotifyCanExecuteChanged(); + await SaveAndRefreshAsync(); + } + + [RelayCommand(CanExecute = nameof(CanUndoDelete))] + private async Task UndoDeleteAsync() + { + if (_lastDeletedElement is not { } deleted) + { + return; + } + + switch (deleted.Item) + { + case Wall wall: + deleted.Floor.Walls.Add(wall); + break; + case TestPoint testPoint: + deleted.Floor.TestPoints.Add(testPoint); + break; + case AccessPoint accessPoint: + deleted.Floor.AccessPoints.Add(accessPoint); + break; + } + + _lastDeletedElement = null; + OnPropertyChanged(nameof(CanUndoDelete)); + OnPropertyChanged(nameof(LastDeletedItemDisplay)); + UndoDeleteCommand.NotifyCanExecuteChanged(); await SaveAndRefreshAsync(); } @@ -716,6 +764,10 @@ partial void OnSelectedFloorChanged(Floor? value) { ClearWallSelection(); + _lastDeletedElement = null; + OnPropertyChanged(nameof(CanUndoDelete)); + UndoDeleteCommand.NotifyCanExecuteChanged(); + _guidedWalkQueue.Clear(); IsGuidedWalkActive = false; CurrentWalkPoint = null; @@ -761,7 +813,7 @@ private void Recompute() : Heatmap.Count(s => s.ValueDbm >= ReliableCoverageThresholdDbm) / (double)Heatmap.Count * 100; } - private static bool TryFindNearestElement(Floor floor, Point2D at, out Action remove) + private static bool TryFindNearestElement(Floor floor, Point2D at, out Action remove, out object? removedItem) { var (nearestTestPoint, nearestTestPointDist) = NearestTestPoint(floor, at); var (nearestAccessPoint, nearestAccessPointDist) = NearestAccessPoint(floor, at); @@ -771,24 +823,29 @@ private static bool TryFindNearestElement(Floor floor, Point2D at, out Action re if (best > DeleteHitToleranceMeters) { remove = static () => { }; + removedItem = null; return false; } if (best == nearestTestPointDist && nearestTestPoint is not null) { remove = () => floor.TestPoints.Remove(nearestTestPoint); + removedItem = nearestTestPoint; } else if (best == nearestAccessPointDist && nearestAccessPoint is not null) { remove = () => floor.AccessPoints.Remove(nearestAccessPoint); + removedItem = nearestAccessPoint; } else if (nearestWall is not null) { remove = () => floor.Walls.Remove(nearestWall); + removedItem = nearestWall; } else { remove = static () => { }; + removedItem = null; return false; } diff --git a/src/Pulsemap.App/Views/WorkspacePage.xaml b/src/Pulsemap.App/Views/WorkspacePage.xaml index b84d300..6cc79bc 100644 --- a/src/Pulsemap.App/Views/WorkspacePage.xaml +++ b/src/Pulsemap.App/Views/WorkspacePage.xaml @@ -48,6 +48,17 @@ + + +