diff --git a/docs/UX-BENCHMARKS.md b/docs/UX-BENCHMARKS.md
index d529520..7be32f7 100644
--- a/docs/UX-BENCHMARKS.md
+++ b/docs/UX-BENCHMARKS.md
@@ -90,8 +90,8 @@ Scheduled(有日期的)。
清除日期同时清除时间,避免任务残留不可见的 due_time。
- ✅ Linux:基于 GTK 原生多选(#33)增加可见的批量完成和批量移动操作栏,
同时适用于当前列表和全局搜索。刷新时按任务 ID 保留选择。
-- 🔜 **尚未完成**:Windows 批量删除及撤销、行内详情展开;
- Linux 批量排期/删除及撤销;以及三平台按真实快捷键进行实机回归。
+- ✅ Windows 单条/批量删除采用 6 秒延后提交:先隐藏选中任务,期间撤销可直接恢复原任务 ID 与元数据,超时才发送后端删除 RPC;失败时恢复任务。
+- 🔜 **尚未完成**:Windows 行内详情展开;Linux 批量删除及撤销;以及三平台按真实快捷键进行实机回归。
### 2.5 Quick Find
diff --git a/scripts/e2e/e2e-windows.ps1 b/scripts/e2e/e2e-windows.ps1
index cc78518..a148790 100644
--- a/scripts/e2e/e2e-windows.ps1
+++ b/scripts/e2e/e2e-windows.ps1
@@ -264,6 +264,40 @@ try {
return $false
})
+ # A batch deletion must be fully reversible without giving tasks
+ # replacement IDs (including their list ownership and due metadata).
+ $undoName1 = 'Undo safety task one'
+ $undoName2 = 'Undo safety task two'
+ foreach ($title in @($undoName1, $undoName2)) {
+ $quickAdd.GetCurrentPattern(
+ [System.Windows.Automation.ValuePattern]::Pattern).SetValue($title)
+ $quickAdd.SetFocus()
+ [System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
+ [void](Wait-Until ("saved task: {0}" -f $title) {
+ Find-ByName $window $title
+ })
+ }
+ Select-TaskForBatch $undoName1 $false
+ Select-TaskForBatch $undoName2 $true
+ $deleteBatch = Wait-Until 'bulk-delete action' {
+ Find-ById $window 'bulk-delete'
+ }
+ $deleteBatch.GetCurrentPattern(
+ [System.Windows.Automation.InvokePattern]::Pattern).Invoke()
+ $undo = Wait-Until 'undo-delete action' {
+ $item = Find-ById $window 'undo-delete'
+ if ($null -ne $item -and
+ -not $item.Current.IsOffscreen) { return $item }
+ return $null
+ }
+ $undo.GetCurrentPattern(
+ [System.Windows.Automation.InvokePattern]::Pattern).Invoke()
+ foreach ($title in @($undoName1, $undoName2)) {
+ [void](Wait-Until ("task restored without backend delete: {0}" -f $title) {
+ Find-ByName $window $title
+ })
+ }
+
$nodes = $window.FindAll(
[System.Windows.Automation.TreeScope]::Descendants,
[System.Windows.Automation.Condition]::TrueCondition)
@@ -302,6 +336,7 @@ try {
'view switches preserve task list ownership',
'global search shows the real owning list for every task',
'Ctrl/Shift multi-selection completes two tasks in one command',
+ 'bulk deletion undo restores original rows before backend mutation',
'batch scheduling is visible and enabled when multiple tasks are selected',
'native window close stopped the process'
)
diff --git a/windows/MainWindow.xaml b/windows/MainWindow.xaml
index 99deb27..1447efc 100644
--- a/windows/MainWindow.xaml
+++ b/windows/MainWindow.xaml
@@ -355,6 +355,9 @@
+
@@ -499,12 +502,21 @@
-
+
+
+
+
diff --git a/windows/MainWindow.xaml.cpp b/windows/MainWindow.xaml.cpp
index 9ebedd5..e82b518 100644
--- a/windows/MainWindow.xaml.cpp
+++ b/windows/MainWindow.xaml.cpp
@@ -755,6 +755,8 @@ void MainWindow::ApplyLanguage() {
BulkCompleteButton().Content(winrt::box_value(t("bulkComplete")));
BulkMoveButton().Content(winrt::box_value(t("menuMoveToList")));
BulkScheduleButton().Content(winrt::box_value(t("bulkSchedule")));
+ BulkDeleteButton().Content(winrt::box_value(t("listDelete")));
+ UndoDeleteButton().Content(winrt::box_value(t("bannerUndo")));
RenderSidebar();
RenderTasks();
}
@@ -977,6 +979,7 @@ void MainWindow::RenderTasks() {
}
TaskList().Items().Clear();
for (auto const& task : tasks_) {
+ if (suppressed_deleted_task_ids_.count(task.id) != 0) continue;
auto row = MakeTaskRow(task);
TaskList().Items().Append(row);
if (selected_ids.count(task.id) != 0) {
@@ -1011,6 +1014,7 @@ void MainWindow::RenderSearchResults() {
}
SearchResults().Items().Clear();
for (auto const& task : search_results_) {
+ if (suppressed_deleted_task_ids_.count(task.id) != 0) continue;
auto row = MakeTaskRow(task, /*search_result=*/true);
SearchResults().Items().Append(row);
if (selected_ids.count(task.id) != 0) {
@@ -1563,6 +1567,9 @@ winrt::fire_and_forget MainWindow::OpenDatabaseAsync(std::wstring const& path) {
if (backend_ == nullptr || !backend_->running() || path.empty()) {
co_return;
}
+ // Pending deletions must never commit against another database that
+ // happens to contain the same numeric task IDs.
+ if (!pending_delete_ids_.empty()) UndoPendingDeletion();
auto const dispatcher = DispatcherQueue();
auto const weak = get_weak();
auto backend = backend_;
@@ -1575,6 +1582,7 @@ winrt::fire_and_forget MainWindow::OpenDatabaseAsync(std::wstring const& path) {
auto const snapshot = result.get();
dispatcher.TryEnqueue([weak, snapshot] {
if (auto window = weak.get()) {
+ window->suppressed_deleted_task_ids_.clear();
// Restore the last-selected list when it still exists —
// checked against the incoming snapshot because ApplySnapshot
// owns the render pass.
@@ -1843,22 +1851,30 @@ winrt::fire_and_forget MainWindow::DeleteTaskAsync(std::int64_t id) {
try {
rivet_app::API api(*backend);
(void)api.delete_task_async(
- id, [dispatcher, weak](rivet_app::Result result) {
+ id, [dispatcher, weak, id](rivet_app::Result result) {
try {
- result.get();
+ if (!result.get()) {
+ throw std::runtime_error("task deletion was not acknowledged");
+ }
dispatcher.TryEnqueue([weak] {
if (auto window = weak.get()) window->RefreshSearchIfActive();
});
} catch (std::exception const& e) {
auto message = std::string(e.what());
- dispatcher.TryEnqueue([weak, message = std::move(message)] {
+ dispatcher.TryEnqueue([weak, id, message = std::move(message)] {
if (auto window = weak.get()) {
+ window->suppressed_deleted_task_ids_.erase(id);
+ window->RenderTasks();
+ window->RenderSearchResults();
window->SetError(message);
}
});
}
});
} catch (std::exception const& e) {
+ suppressed_deleted_task_ids_.erase(id);
+ RenderTasks();
+ RenderSearchResults();
SetError(e.what());
}
}
@@ -2068,6 +2084,7 @@ void MainWindow::OnCloseDatabase(
if (backend_ == nullptr || !backend_->running()) {
return;
}
+ if (!pending_delete_ids_.empty()) UndoPendingDeletion();
auto const dispatcher = DispatcherQueue();
auto const weak = get_weak();
auto backend = backend_;
@@ -2080,6 +2097,7 @@ void MainWindow::OnCloseDatabase(
if (auto window = weak.get()) {
window->lists_.clear();
window->tasks_.clear();
+ window->suppressed_deleted_task_ids_.clear();
window->current_counts_ = {};
// The next snapshot must render even if it matches the one the
// closed database ended with.
@@ -2419,6 +2437,78 @@ void MainWindow::OnBulkScheduleClicked(
menu.ShowAt(BulkScheduleButton());
}
+void MainWindow::StageTaskDeletion(
+ std::vector const& ids) {
+ if (backend_ == nullptr || !backend_->running()) return;
+ // Only one undo batch at a time. Starting another deletion commits
+ // the previous one and opens a fresh six-second window.
+ if (!pending_delete_ids_.empty()) CommitPendingDeletion();
+ for (auto const id : ids) {
+ if (FindTaskForAction(id) == nullptr) continue;
+ if (suppressed_deleted_task_ids_.insert(id).second) {
+ pending_delete_ids_.push_back(id);
+ }
+ }
+ if (pending_delete_ids_.empty()) return;
+ if (!delete_undo_timer_) {
+ delete_undo_timer_ = DispatcherQueue().CreateTimer();
+ delete_undo_timer_.Interval(std::chrono::seconds{6});
+ delete_undo_timer_.IsRepeating(false);
+ delete_undo_timer_.Tick([weak = get_weak()](auto&&, auto&&) {
+ if (auto window = weak.get()) window->CommitPendingDeletion();
+ });
+ }
+ delete_undo_timer_.Stop();
+ delete_undo_timer_.Start();
+ UndoDeleteButton().Visibility(mx::Visibility::Visible);
+ SetStatus(tf("bannerTasksDeleted",
+ std::to_wstring(pending_delete_ids_.size())));
+ RenderTasks();
+ RenderSearchResults();
+}
+
+void MainWindow::CommitPendingDeletion() {
+ if (delete_undo_timer_) delete_undo_timer_.Stop();
+ if (backend_ == nullptr || !backend_->running()) {
+ // Deletion has not been persisted, so restore every staged row.
+ UndoPendingDeletion();
+ SetStatus(t("taskListEmptyHint"));
+ return;
+ }
+ auto const ids = std::move(pending_delete_ids_);
+ pending_delete_ids_.clear();
+ UndoDeleteButton().Visibility(mx::Visibility::Collapsed);
+ for (auto const id : ids) {
+ // Keep rows visually hidden while their deletion RPCs complete.
+ // A failure removes suppression and makes the task visible again.
+ DeleteTaskAsync(id);
+ }
+}
+
+void MainWindow::UndoPendingDeletion() {
+ if (delete_undo_timer_) delete_undo_timer_.Stop();
+ for (auto const id : pending_delete_ids_) {
+ suppressed_deleted_task_ids_.erase(id);
+ }
+ pending_delete_ids_.clear();
+ UndoDeleteButton().Visibility(mx::Visibility::Collapsed);
+ SetStatus(t("bannerUndo"));
+ RenderTasks();
+ RenderSearchResults();
+}
+
+void MainWindow::OnUndoDeleteClicked(
+ winrt::Windows::Foundation::IInspectable const&,
+ Microsoft::UI::Xaml::RoutedEventArgs const&) {
+ UndoPendingDeletion();
+}
+
+void MainWindow::OnBulkDeleteClicked(
+ winrt::Windows::Foundation::IInspectable const&,
+ Microsoft::UI::Xaml::RoutedEventArgs const&) {
+ StageTaskDeletion(SelectedTaskIds());
+}
+
void MainWindow::OnTaskListKeyDown(
winrt::Windows::Foundation::IInspectable const& sender,
Microsoft::UI::Xaml::Input::KeyRoutedEventArgs const& args) {
@@ -2666,7 +2756,7 @@ void MainWindow::OnDeleteTask(
Microsoft::UI::Xaml::RoutedEventArgs const&) {
auto const id = winrt::unbox_value(
sender.as().Tag());
- DeleteTaskAsync(id);
+ StageTaskDeletion({id});
}
} // namespace winrt::RivetHost::implementation
diff --git a/windows/MainWindow.xaml.h b/windows/MainWindow.xaml.h
index 8d81aab..132ed37 100644
--- a/windows/MainWindow.xaml.h
+++ b/windows/MainWindow.xaml.h
@@ -1,5 +1,7 @@
#pragma once
+#include
+
#include "pch.h"
#include "GeneratedBackend.hpp"
#include "MainWindow.g.h"
@@ -84,6 +86,10 @@ struct MainWindow : MainWindowT {
Microsoft::UI::Xaml::RoutedEventArgs const&);
void OnBulkScheduleClicked(winrt::Windows::Foundation::IInspectable const&,
Microsoft::UI::Xaml::RoutedEventArgs const&);
+ void OnBulkDeleteClicked(winrt::Windows::Foundation::IInspectable const&,
+ Microsoft::UI::Xaml::RoutedEventArgs const&);
+ void OnUndoDeleteClicked(winrt::Windows::Foundation::IInspectable const&,
+ Microsoft::UI::Xaml::RoutedEventArgs const&);
// Update download action wired from XAML; event handlers must be public
// because generated Connect() binds them through MainWindowT.
@@ -123,6 +129,9 @@ struct MainWindow : MainWindowT {
std::int64_t target_list_id);
void ScheduleSelectedTasks(std::vector const& ids,
std::wstring const& due);
+ void StageTaskDeletion(std::vector const& ids);
+ void CommitPendingDeletion();
+ void UndoPendingDeletion();
void RefreshQuickAddPreview();
void ApplyTitleBarTheme();
void OnEffectiveThemeChanged();
@@ -202,6 +211,10 @@ struct MainWindow : MainWindowT {
std::vector lists_;
std::vector tasks_;
std::vector search_results_;
+ // Deletions are staged for six seconds, retaining original task IDs.
+ std::set suppressed_deleted_task_ids_;
+ std::vector pending_delete_ids_;
+ Microsoft::UI::Dispatching::DispatcherQueueTimer delete_undo_timer_{nullptr};
std::wstring current_search_;
ViewKind view_{ViewKind::All};
std::int64_t view_list_id_{0};