Skip to content
Merged
4 changes: 2 additions & 2 deletions docs/UX-BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ Scheduled(有日期的)。
清除日期同时清除时间,避免任务残留不可见的 due_time。
- ✅ Linux:基于 GTK 原生多选(#33)增加可见的批量完成和批量移动操作栏,
同时适用于当前列表和全局搜索。刷新时按任务 ID 保留选择。
- 🔜 **尚未完成**:Windows 批量删除及撤销、行内详情展开;
Linux 批量排期/删除及撤销;以及三平台按真实快捷键进行实机回归。
- ✅ Windows 单条/批量删除采用 6 秒延后提交:先隐藏选中任务,期间撤销可直接恢复原任务 ID 与元数据,超时才发送后端删除 RPC;失败时恢复任务。
- 🔜 **尚未完成**:Windows 行内详情展开;Linux 批量删除及撤销;以及三平台按真实快捷键进行实机回归。

### 2.5 Quick Find

Expand Down
35 changes: 35 additions & 0 deletions scripts/e2e/e2e-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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'
)
Expand Down
24 changes: 18 additions & 6 deletions windows/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@
<Button x:Name="BulkScheduleButton"
AutomationProperties.AutomationId="bulk-schedule"
Click="OnBulkScheduleClicked" />
<Button x:Name="BulkDeleteButton"
AutomationProperties.AutomationId="bulk-delete"
Click="OnBulkDeleteClicked" />
</StackPanel>

<Grid Grid.Row="2">
Expand Down Expand Up @@ -499,12 +502,21 @@

<!-- Status bar -->
<Border Grid.Row="3" Background="{ThemeResource TasklySidebarBrush}">
<TextBlock
x:Name="StatusText"
Margin="12,4"
FontSize="12"
Foreground="{ThemeResource TasklySecondaryTextBrush}"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock
x:Name="StatusText"
Margin="12,4"
FontSize="12"
VerticalAlignment="Center"
Foreground="{ThemeResource TasklySecondaryTextBrush}"
TextTrimming="CharacterEllipsis" />
<Button x:Name="UndoDeleteButton"
AutomationProperties.AutomationId="undo-delete"
Click="OnUndoDeleteClicked"
Visibility="Collapsed"
MinWidth="58"
Padding="10,2" />
</StackPanel>
</Border>
</Grid>
</Window>
98 changes: 94 additions & 4 deletions windows/MainWindow.xaml.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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_;
Expand All @@ -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.
Expand Down Expand Up @@ -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<bool> result) {
id, [dispatcher, weak, id](rivet_app::Result<bool> 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());
}
}
Expand Down Expand Up @@ -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_;
Expand All @@ -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.
Expand Down Expand Up @@ -2419,6 +2437,78 @@ void MainWindow::OnBulkScheduleClicked(
menu.ShowAt(BulkScheduleButton());
}

void MainWindow::StageTaskDeletion(
std::vector<std::int64_t> 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) {
Expand Down Expand Up @@ -2666,7 +2756,7 @@ void MainWindow::OnDeleteTask(
Microsoft::UI::Xaml::RoutedEventArgs const&) {
auto const id = winrt::unbox_value<std::int64_t>(
sender.as<mxc::MenuFlyoutItem>().Tag());
DeleteTaskAsync(id);
StageTaskDeletion({id});
}

} // namespace winrt::RivetHost::implementation
13 changes: 13 additions & 0 deletions windows/MainWindow.xaml.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include <set>

#include "pch.h"
#include "GeneratedBackend.hpp"
#include "MainWindow.g.h"
Expand Down Expand Up @@ -84,6 +86,10 @@ struct MainWindow : MainWindowT<MainWindow> {
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.
Expand Down Expand Up @@ -123,6 +129,9 @@ struct MainWindow : MainWindowT<MainWindow> {
std::int64_t target_list_id);
void ScheduleSelectedTasks(std::vector<std::int64_t> const& ids,
std::wstring const& due);
void StageTaskDeletion(std::vector<std::int64_t> const& ids);
void CommitPendingDeletion();
void UndoPendingDeletion();
void RefreshQuickAddPreview();
void ApplyTitleBarTheme();
void OnEffectiveThemeChanged();
Expand Down Expand Up @@ -202,6 +211,10 @@ struct MainWindow : MainWindowT<MainWindow> {
std::vector<rivet_app::TodoList> lists_;
std::vector<rivet_app::Task> tasks_;
std::vector<rivet_app::Task> search_results_;
// Deletions are staged for six seconds, retaining original task IDs.
std::set<std::int64_t> suppressed_deleted_task_ids_;
std::vector<std::int64_t> 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};
Expand Down
Loading