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 @@
+
+
+
+
+
+
diff --git a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs
index 3020f66..7ea53ef 100644
--- a/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs
+++ b/tests/Pulsemap.App.Tests/ViewModels/WorkspaceViewModelTests.cs
@@ -150,6 +150,90 @@ public async Task DeleteNearestElementAsync_OutsideTolerance_DoesNothing()
Assert.Single(sut.SelectedFloor!.TestPoints);
}
+ [Fact]
+ public async Task DeleteNearestElementAsync_RemovesElement_CanUndoDeleteBecomesTrue()
+ {
+ var floor = SquareRoomFloor(10);
+ floor.TestPoints.Add(new TestPoint { Position = new Point2D(5, 5) });
+ var sut = await LoadedViewModelAsync(floor);
+
+ await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));
+
+ Assert.True(sut.CanUndoDelete);
+ Assert.True(sut.UndoDeleteCommand.CanExecute(null));
+ }
+
+ [Fact]
+ public async Task UndoDeleteCommand_AfterDeletingTestPoint_RestoresIt()
+ {
+ var floor = SquareRoomFloor(10);
+ var testPoint = new TestPoint { Position = new Point2D(5, 5) };
+ floor.TestPoints.Add(testPoint);
+ var sut = await LoadedViewModelAsync(floor);
+ await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));
+
+ await sut.UndoDeleteCommand.ExecuteAsync(null);
+
+ Assert.Same(testPoint, Assert.Single(sut.SelectedFloor!.TestPoints));
+ Assert.False(sut.CanUndoDelete);
+ }
+
+ [Fact]
+ public async Task UndoDeleteCommand_AfterDeletingWall_RestoresIt()
+ {
+ var floor = SquareRoomFloor(10);
+ var wallToDelete = floor.Walls[0];
+ var midpoint = new Point2D((wallToDelete.Start.X + wallToDelete.End.X) / 2, (wallToDelete.Start.Y + wallToDelete.End.Y) / 2);
+ var sut = await LoadedViewModelAsync(floor);
+ await sut.DeleteNearestElementAsync(midpoint);
+ Assert.DoesNotContain(wallToDelete, sut.SelectedFloor!.Walls);
+
+ await sut.UndoDeleteCommand.ExecuteAsync(null);
+
+ Assert.Contains(wallToDelete, sut.SelectedFloor!.Walls);
+ }
+
+ [Fact]
+ public async Task DeleteNearestElementAsync_SecondDelete_OverwritesUndoWithTheNewerOne()
+ {
+ var floor = SquareRoomFloor(10);
+ var firstPoint = new TestPoint { Position = new Point2D(5, 5) };
+ var secondPoint = new TestPoint { Position = new Point2D(6, 6) };
+ floor.TestPoints.Add(firstPoint);
+ floor.TestPoints.Add(secondPoint);
+ var sut = await LoadedViewModelAsync(floor);
+ await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));
+
+ await sut.DeleteNearestElementAsync(new Point2D(6.1, 6.1));
+ await sut.UndoDeleteCommand.ExecuteAsync(null);
+
+ var remaining = Assert.Single(sut.SelectedFloor!.TestPoints);
+ Assert.Same(secondPoint, remaining);
+ }
+
+ [Fact]
+ public async Task SwitchingFloor_ClearsUndoDeleteState()
+ {
+ var floorA = SquareRoomFloor(10);
+ floorA.TestPoints.Add(new TestPoint { Position = new Point2D(5, 5) });
+ var floorB = new Floor { PlanSource = new RoomListSource() };
+ _surveyFileService.SurveyToReturn = new Survey
+ {
+ Name = "Test Survey",
+ Type = SurveyType.NewDeployment,
+ TargetBands = [Band.TwoPointFourGhz],
+ Floors = [floorA, floorB],
+ };
+ var sut = CreateSut();
+ await sut.LoadAsync(FilePath);
+ await sut.DeleteNearestElementAsync(new Point2D(5.1, 5.1));
+ Assert.True(sut.CanUndoDelete);
+
+ sut.SelectedFloor = floorB;
+
+ Assert.False(sut.CanUndoDelete);
+ }
+
[Fact]
public async Task ToggleWallSelection_TogglesMembershipAndCount()
{