Skip to content

Commit 2327d90

Browse files
phmatrayclaude
andcommitted
feat(demo): add undo/redo UI with history drawer and keyboard shortcuts
Implement undo/redo functionality in the Blazor demo app by exposing the existing IChangeHistory system through a user-friendly interface: - Add UndoRedoButtons component with Undo/Redo buttons in TopBar - Add HistoryDrawer slide-out panel showing operation timeline - Add UndoRedoService wrapper integrating ChangeHistory with toasts - Add keyboard shortcuts (Ctrl+Z/Ctrl+Y) via JavaScript interop - Add visual highlight animation for recently affected items - Fix folder name display bug when creating folders at root path Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 862ee43 commit 2327d90

17 files changed

Lines changed: 717 additions & 6 deletions

File tree

.playwright-mcp/before-undo.png

Loading

.playwright-mcp/error-handling.png

Loading

.playwright-mcp/folder-name-fixed.png

Loading
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
@* History Drawer - Slide-out panel showing operation timeline *@
2+
@inject UndoRedoService UndoRedoService
3+
@implements IDisposable
4+
5+
<div class="fixed inset-0 z-40" @onclick="HandleBackdropClick">
6+
@* Backdrop *@
7+
<div class="fixed inset-0 bg-black/30 animate-fade-in"></div>
8+
9+
@* Drawer *@
10+
<div class="fixed inset-y-0 right-0 w-80 bg-white shadow-xl animate-slide-in-right flex flex-col"
11+
@onclick:stopPropagation="true">
12+
@* Header *@
13+
<div class="flex items-center justify-between p-4 border-b border-gray-200">
14+
<h2 class="text-lg font-semibold text-gray-900">History</h2>
15+
<button class="btn-icon" @onclick="Close" title="Close">
16+
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
17+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
18+
</svg>
19+
</button>
20+
</div>
21+
22+
@* Content *@
23+
<div class="flex-1 overflow-y-auto custom-scrollbar">
24+
@if (!_historyEntries.Any())
25+
{
26+
@* Empty state *@
27+
<div class="flex flex-col items-center justify-center h-full p-8 text-center">
28+
<svg class="w-16 h-16 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
29+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
30+
</svg>
31+
<p class="text-gray-500 font-medium">No history yet</p>
32+
<p class="text-sm text-gray-400 mt-1">Your actions will appear here</p>
33+
</div>
34+
}
35+
else
36+
{
37+
<div class="p-2">
38+
@* Undo stack section *@
39+
@if (_historyEntries.Any())
40+
{
41+
<div class="mb-4">
42+
<div class="px-2 py-1 text-xs font-medium text-gray-500 uppercase tracking-wider">
43+
Recent Actions
44+
</div>
45+
@foreach (var entry in _historyEntries)
46+
{
47+
<div class="flex items-start gap-3 p-2 rounded-lg hover:bg-gray-50 transition-colors">
48+
@* Icon *@
49+
<div class="flex-shrink-0 w-8 h-8 rounded-full @entry.ColorClass flex items-center justify-center">
50+
@((MarkupString)entry.IconSvg)
51+
</div>
52+
53+
@* Description *@
54+
<div class="flex-1 min-w-0">
55+
<p class="text-sm text-gray-900 truncate" title="@entry.Description">
56+
@entry.Description
57+
</p>
58+
<p class="text-xs text-gray-500 mt-0.5">
59+
@entry.RelativeTime
60+
</p>
61+
</div>
62+
</div>
63+
}
64+
</div>
65+
}
66+
67+
@* Redo stack section *@
68+
@if (_redoEntries.Any())
69+
{
70+
<div class="mt-4 pt-4 border-t border-gray-200">
71+
<div class="px-2 py-1 text-xs font-medium text-gray-500 uppercase tracking-wider">
72+
Undone Actions
73+
</div>
74+
@foreach (var entry in _redoEntries)
75+
{
76+
<div class="flex items-start gap-3 p-2 rounded-lg hover:bg-gray-50 transition-colors opacity-60">
77+
@* Icon *@
78+
<div class="flex-shrink-0 w-8 h-8 rounded-full @entry.ColorClass flex items-center justify-center">
79+
@((MarkupString)entry.IconSvg)
80+
</div>
81+
82+
@* Description *@
83+
<div class="flex-1 min-w-0">
84+
<p class="text-sm text-gray-900 truncate" title="@entry.Description">
85+
@entry.Description
86+
</p>
87+
<p class="text-xs text-gray-500 mt-0.5">
88+
@entry.RelativeTime
89+
</p>
90+
</div>
91+
</div>
92+
}
93+
</div>
94+
}
95+
</div>
96+
}
97+
</div>
98+
99+
@* Footer with actions *@
100+
<div class="border-t border-gray-200 p-4 space-y-2">
101+
<div class="flex gap-2">
102+
<button class="btn-secondary flex-1"
103+
disabled="@(!UndoRedoService.CanUndo)"
104+
@onclick="() => UndoRedoService.Undo()">
105+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
106+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
107+
</svg>
108+
Undo
109+
</button>
110+
<button class="btn-secondary flex-1"
111+
disabled="@(!UndoRedoService.CanRedo)"
112+
@onclick="() => UndoRedoService.Redo()">
113+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
114+
<path stroke-linecap="round" stroke-linejoin="round" d="m15 15 6-6m0 0-6-6m6 6H9a6 6 0 0 0 0 12h3" />
115+
</svg>
116+
Redo
117+
</button>
118+
</div>
119+
</div>
120+
</div>
121+
</div>
122+
123+
@code {
124+
[Parameter] public EventCallback OnClose { get; set; }
125+
126+
private List<Models.HistoryEntry> _historyEntries = new();
127+
private List<Models.HistoryEntry> _redoEntries = new();
128+
129+
protected override void OnInitialized()
130+
{
131+
UndoRedoService.OnHistoryChanged += RefreshHistory;
132+
RefreshHistory();
133+
}
134+
135+
private void RefreshHistory()
136+
{
137+
_historyEntries = UndoRedoService.GetHistory().ToList();
138+
_redoEntries = UndoRedoService.GetRedoHistory().ToList();
139+
InvokeAsync(StateHasChanged);
140+
}
141+
142+
private void HandleBackdropClick()
143+
{
144+
Close();
145+
}
146+
147+
private void Close()
148+
{
149+
OnClose.InvokeAsync();
150+
}
151+
152+
public void Dispose()
153+
{
154+
UndoRedoService.OnHistoryChanged -= RefreshHistory;
155+
}
156+
}

src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/FileBrowser/FileCard.razor

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@* Grid View File/Folder Card *@
22
@inject VFSStateService StateService
3+
@inject UndoRedoService UndoRedoService
34

45
<div class="@CardClass"
56
tabindex="0"
@@ -58,6 +59,8 @@
5859
private bool IsSelected => StateService.IsItemSelected(Path);
5960
private bool IsStarred => StateService.IsStarred(Path);
6061

62+
private bool IsHighlighted => UndoRedoService.IsRecentlyAffected(Path);
63+
6164
private string CardClass
6265
{
6366
get
@@ -66,7 +69,8 @@
6669
var selectedClass = IsSelected
6770
? "bg-cloud-50 border-cloud-300"
6871
: "bg-white border-transparent hover:border-gray-200";
69-
return $"{baseClass} {selectedClass}";
72+
var highlightClass = IsHighlighted ? "file-item-highlight" : "";
73+
return $"{baseClass} {selectedClass} {highlightClass}";
7074
}
7175
}
7276

src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/FileBrowser/FileRow.razor

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@* List View File/Folder Row *@
22
@inject VFSStateService StateService
3+
@inject UndoRedoService UndoRedoService
34

45
<div class="@RowClass"
56
tabindex="0"
@@ -70,6 +71,7 @@
7071

7172
private bool IsSelected => StateService.IsItemSelected(Path);
7273
private bool IsStarred => StateService.IsStarred(Path);
74+
private bool IsHighlighted => UndoRedoService.IsRecentlyAffected(Path);
7375

7476
private string RowClass
7577
{
@@ -79,7 +81,8 @@
7981
var selectedClass = IsSelected
8082
? "bg-cloud-50"
8183
: "hover:bg-gray-50";
82-
return $"{baseClass} {selectedClass}";
84+
var highlightClass = IsHighlighted ? "file-item-highlight" : "";
85+
return $"{baseClass} {selectedClass} {highlightClass}";
8386
}
8487
}
8588

src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Layout/MainLayout.razor

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
@inherits LayoutComponentBase
22
@inject VFSStateService StateService
3+
@inject UndoRedoService UndoRedoService
4+
@inject IJSRuntime JS
35
@implements IDisposable
46

57
<div class="h-screen flex flex-col bg-gray-50">
68
@* Top Bar *@
7-
<TopBar OnToggleSidebar="ToggleSidebar" />
9+
<TopBar OnToggleSidebar="ToggleSidebar" OnToggleHistory="ToggleHistory" />
810

911
@* Main Content Area *@
1012
<div class="flex-1 flex overflow-hidden">
@@ -31,6 +33,12 @@
3133
<ToastContainer />
3234
</div>
3335

36+
@* History Drawer *@
37+
@if (_showHistory)
38+
{
39+
<HistoryDrawer OnClose="() => _showHistory = false" />
40+
}
41+
3442
@* Mobile Sidebar Overlay *@
3543
@if (_sidebarOpen)
3644
{
@@ -50,19 +58,50 @@
5058

5159
@code {
5260
private bool _sidebarOpen = false;
61+
private bool _showHistory = false;
62+
private DotNetObjectReference<MainLayout>? _jsRef;
5363

5464
protected override void OnInitialized()
5565
{
5666
StateService.OnStateChanged += StateHasChanged;
5767
}
5868

69+
protected override async Task OnAfterRenderAsync(bool firstRender)
70+
{
71+
if (firstRender)
72+
{
73+
_jsRef = DotNetObjectReference.Create(this);
74+
await JS.InvokeVoidAsync("cloudDrive.keyboard.registerUndoRedo", _jsRef);
75+
}
76+
}
77+
5978
private void ToggleSidebar()
6079
{
6180
_sidebarOpen = !_sidebarOpen;
6281
}
6382

83+
private void ToggleHistory()
84+
{
85+
_showHistory = !_showHistory;
86+
}
87+
88+
[JSInvokable]
89+
public void HandleUndo()
90+
{
91+
UndoRedoService.Undo();
92+
InvokeAsync(StateHasChanged);
93+
}
94+
95+
[JSInvokable]
96+
public void HandleRedo()
97+
{
98+
UndoRedoService.Redo();
99+
InvokeAsync(StateHasChanged);
100+
}
101+
64102
public void Dispose()
65103
{
66104
StateService.OnStateChanged -= StateHasChanged;
105+
_jsRef?.Dispose();
67106
}
68107
}

src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Layout/TopBar.razor

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
@* CloudDrive Top Navigation Bar *@
22
@inject VFSStateService StateService
33
@inject NavigationManager Navigation
4+
@inject UndoRedoService UndoRedoService
5+
@implements IDisposable
46

57
<header class="h-topbar bg-white border-b border-gray-200 flex items-center px-4 gap-4">
68
@* Logo and Brand *@
@@ -55,6 +57,24 @@
5557
</button>
5658
</div>
5759

60+
@* Undo/Redo Buttons *@
61+
<UndoRedoButtons />
62+
63+
@* History Toggle *@
64+
<button class="btn-icon hidden md:flex relative"
65+
@onclick="() => OnToggleHistory.InvokeAsync()"
66+
title="History">
67+
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
68+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
69+
</svg>
70+
@if (UndoRedoService.UndoCount > 0)
71+
{
72+
<span class="absolute -top-1 -right-1 bg-cloud-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
73+
@(UndoRedoService.UndoCount > 9 ? "9+" : UndoRedoService.UndoCount)
74+
</span>
75+
}
76+
</button>
77+
5878
@* Preview Panel Toggle *@
5979
<button class="btn-icon hidden md:flex @(StateService.ShowPreviewPanel ? "bg-gray-100" : "")"
6080
@onclick="() => StateService.ShowPreviewPanel = !StateService.ShowPreviewPanel"
@@ -73,14 +93,25 @@
7393

7494
@code {
7595
[Parameter] public EventCallback OnToggleSidebar { get; set; }
96+
[Parameter] public EventCallback OnToggleHistory { get; set; }
7697

7798
private string SearchQuery { get; set; } = "";
7899

100+
protected override void OnInitialized()
101+
{
102+
UndoRedoService.OnHistoryChanged += StateHasChanged;
103+
}
104+
79105
private void HandleSearchKeyDown(KeyboardEventArgs e)
80106
{
81107
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(SearchQuery))
82108
{
83109
Navigation.NavigateTo($"/search?q={Uri.EscapeDataString(SearchQuery)}");
84110
}
85111
}
112+
113+
public void Dispose()
114+
{
115+
UndoRedoService.OnHistoryChanged -= StateHasChanged;
116+
}
86117
}

src/Atypical.VirtualFileSystem.DemoBlazorApp/Components/Pages/Home.razor

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@
378378

379379
try
380380
{
381-
var newPath = StateService.CurrentPath.TrimEnd('/') + "/" + _newFolderName;
381+
var newPath = BuildChildPath(StateService.CurrentPath, _newFolderName);
382382
VFS.CreateDirectory(newPath);
383383
StateService.NotifyDataChanged();
384384
Toast.ShowSuccess($"Created folder '{_newFolderName}'");
@@ -396,7 +396,7 @@
396396

397397
try
398398
{
399-
var newPath = StateService.CurrentPath.TrimEnd('/') + "/" + _newFileName;
399+
var newPath = BuildChildPath(StateService.CurrentPath, _newFileName);
400400
VFS.CreateFile(newPath, _newFileContent);
401401
StateService.NotifyDataChanged();
402402
Toast.ShowSuccess($"Created file '{_newFileName}'");
@@ -408,6 +408,15 @@
408408
}
409409
}
410410

411+
private static string BuildChildPath(string parentPath, string childName)
412+
{
413+
// Handle root path specially to preserve "vfs://" prefix
414+
if (parentPath is "vfs://" or "vfs:" or "vfs:/")
415+
return $"vfs://{childName}";
416+
417+
return $"{parentPath.TrimEnd('/')}/{childName}";
418+
}
419+
411420
private void PerformRename()
412421
{
413422
if (string.IsNullOrWhiteSpace(_renameNewName)) return;

0 commit comments

Comments
 (0)