diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 7d2cb5fb..af7003ef 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -78,7 +78,7 @@ sidebar, insets + rounds the content area, and re-homes the toolbar off-screen. | ☐ | Native tab context-menu shortcut labels | `dao_sidebar_ui.cc` | 🟡 | Right-click a regular tab → Duplicate Tab shows ⌘D, Copy Link shows ⌘⇧C, and Close Tab shows ⌘W; each shortcut still performs the matching action | | ☐ | Pinned sites/tabs grid, stable backing identity, and dormant pinned items | `src/dao/.../sidebar/dao_pinned_tabs_grid.ts`, `dao_pinned_tab_model.{h,cc}`, `dao_pinned_tab_storage.{h,cc}`, `dao_tab_identity.{h,cc}`, `dao_sidebar_ui.cc`, `sessions/session_service.cc.patch` | `DaoPinnedTabModelTest.*`, `DaoPinnedTabStorageTest.*`, `DaoSidebarBrowserTest.*Pinned*`, `DaoSidebarBrowserTest.ActivatingPinnedItemReusesTabFromAnotherWindow`, `DaoSidebarBrowserTest.ClosingPinnedItemFromAnotherWindowMakesItDormantAndReopenable`, `DaoSidebarBrowserTest.UnpinningPinnedItemFromAnotherWindowMovesItHere`, `DaoTabBrowserTest.SidebarTabIdentity*`, `pinned_tabs_grid.test.ts` | Pin and activate without changing tab count; navigate; discard/replace WebContents; replace then navigate; restore the session after command compaction and preserve the backing identity; activate the same Pin from another window and focus the existing tab without increasing either window's tab count; close it remotely and synchronize dormant state across windows; drag a remote Pin into the current ordinary tab list and move the backing tab into this window; block clicks and unpin drags until restore completes; close then reopen once; double-click; create two identical-URL tabs and preserve exact ownership; create a legacy identity conflict and verify active tab/tab count remain unchanged; reject partially invalid migration data without dropping Pins; verify failed atomic persistence preserves the previous file; drag/reorder does not corrupt state | | ☐ | Active sidebar tab stays visible after tab switches | `dao_sidebar_app.ts`, `dao_tab_item.ts`, `dao_folder_item.ts`, sidebar WebUI tests | — | Scroll the tab list away from a visible tab, activate it by keyboard or another surface, and verify it smoothly enters the nearest viewport edge; activate a child of a collapsed folder and verify the folder expands, persists the expanded state, and scrolls the child into view | -| ☐ | Sidebar folder model + configurable stale-tab actions | `src/dao/.../sidebar/dao_folder_model.ts`, `dao_sidebar_app.ts`, `dao_sidebar_ui.cc`, Settings Dao page patches | `DaoSidebarBrowserTest.StaleTabExpirationPrefDefaultsTo24Hours`, `sidebar_app.test.ts`, `dao_page_test.ts` | "You and Dao" accepts only integer hours from 1–720 and defaults to 24; invalid input is not saved; "Move Stale Tabs to stale" reads the current profile value, creates/updates the `stale` folder only for qualifying ordinary tabs, expands it, and persists folder membership. Right-click `stale` → Clear Stale Tabs is present; ordinary folders omit it; the Dao native system confirmation dialog appears; Cancel preserves tabs/folder; Clear closes only current `stale` children and deletes the folder. | +| ☐ | Sidebar folder model, Unfolder/Delete Folder, and configurable stale-tab archiving | `src/dao/.../sidebar/dao_folder_model.ts`, `dao_sidebar_app.ts`, `dao_sidebar_ui.cc`, Settings Dao page patches | `DaoSidebarBrowserTest.StaleTabExpirationPrefDefaultsTo24Hours`, `DaoSidebarBrowserTest.DeleteFolderUsesDaoNativeDialog`, `sidebar_app.test.ts`, `folder_model.test.ts`, `dao_page_test.ts` | "You and Dao" accepts only integer hours from 1–720 and defaults to 24; invalid input is not saved; "Move Stale Tabs to stale" reads the current profile value, creates/updates the `stale` folder only for qualifying ordinary tabs, expands it, and persists folder membership. Every folder, including `stale`, exposes the same context menu: Unfolder removes the folder and releases children in place without closing tabs; Delete Folder opens the Dao native confirmation dialog. Cancel preserves tabs/folder; confirmation closes only current matched children and persists deletion only after all of them actually close; cancelling a page's beforeunload prompt keeps the folder and remaining tabs. Verify ordinary, stale, empty, missing, renamed, changed-membership, ordered-child, and cancelled-close cases. | | ☐ | Sidebar utility controls: downloads, media controls, update button | `dao_download_button.ts`, `dao_download_hover_details.*`, `dao_tab_tooltip_view.*`, `dao_media_control.ts`, `dao_update_button.ts`, `dao_sidebar_ui.cc` | — | Active download rows retain progress/cancel behavior; a settled 400ms hover shows a native popup beyond the sidebar boundary with full filename, known transferred/total size and percentage, positive speed, and reliable remaining time. Long filenames wrap without truncation; hovering the lowest row flips the popup above its anchor and keeps all lines inside the browser window. Moving resets the delay; leaving, cancelling, or removing the download hides it. Unknown totals degrade to transferred size only; unavailable speed/time are omitted. Verify light/dark themes and confirm the recent-files popup still works. Tab media play/pause works; ready update state appears and `applyReadyUpdate` is invoked | | ☐ | Sidebar close/reorder motion | `src/dao/.../sidebar/dao_flip_motion.ts`, sidebar WebUI tests | — | Closing, moving, pinning, and foldering tabs animates surviving rows without duplicate placeholders or stale transforms | diff --git a/docs/features.md b/docs/features.md index 7d9e3bfb..58df49b1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -46,10 +46,12 @@ An Arc-inspired vertical sidebar replaces Chromium's top tab strip — the singl the tab count. Pin state is serialized through a shared sequenced writer and atomically replaces the previous profile file. - **dao_folder_item.ts** / **dao_folder_model.ts** — Folder grouping with - profile-path persistence (load/save round-trip). Only a folder named exactly - `stale` exposes a cleanup action through a Dao native system confirmation - dialog: confirming closes only its currently matched child tabs and deletes - the folder; Cancel preserves both. + profile-path persistence (load/save round-trip). Every folder context menu + exposes Unfolder, which removes the folder and releases its child tabs in + place, and Delete Folder, which uses a Dao native system confirmation dialog + before closing the folder's currently matched child tabs. The folder is + deleted only after those tabs actually close; cancelling either confirmation + or a page's beforeunload prompt preserves the folder and remaining tabs. - **Configurable stale-tab expiration** — The "You and Dao" Settings page stores a profile-scoped integer from 1–720 hours (24 hours by default); the sidebar's "Move Stale Tabs to stale" action reads that value when run, diff --git a/src/dao/browser/strings/dao_strings.grd b/src/dao/browser/strings/dao_strings.grd index 58430e32..1478b17c 100644 --- a/src/dao/browser/strings/dao_strings.grd +++ b/src/dao/browser/strings/dao_strings.grd @@ -292,23 +292,23 @@ Rename - - Delete Folder + + Unfolder - - Clear Stale Tabs + + Delete Folder - - Clear stale tabs? + + Delete folder? - - This will close all tabs in the stale folder and delete the folder. + + This will close all tabs in the folder and delete the folder. - + Cancel - - Clear + + Delete diff --git a/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb b/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb index 4c54f48d..e6112b56 100644 --- a/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb +++ b/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb @@ -3,6 +3,7 @@ 重命名 取消网站静音 +这会关闭文件夹中的所有标签并删除该文件夹。 已清除此网站缓存 二维码扫描结果 删除文件夹 @@ -15,17 +16,16 @@ 复制标签页 Dao i18n 管线正常 该功能仅在系统深色模式下生效 +删除文件夹? 安全 没有新的标签页被归档 关闭标签页 -清除过期标签页? 在 Dao 中打开 输入网址或搜索... 返回 停止加载 将过期标签页移动到“过期” 应用中 -这将关闭“过期”文件夹中的所有标签页,并删除该文件夹。 已归档过去24小时未活动的标签页 取消固定 关闭其他标签页 @@ -42,10 +42,8 @@ 重新加载 关闭下方标签页 未找到二维码 -清除过期标签页 更多 This is a legacy (Manifest V2) extension. Dao supports it; Chrome and most other browsers no longer do. -清除 地址 清除 Cookie 复制 @@ -55,7 +53,9 @@ 再次按切换侧边栏 取消 检查更新 +解散文件夹 管理扩展 +删除 返回 复制链接 已归档不活跃的标签 diff --git a/src/dao/browser/ui/views/dao_browser_browsertest.cc b/src/dao/browser/ui/views/dao_browser_browsertest.cc index f735d6e4..2b163da0 100644 --- a/src/dao/browser/ui/views/dao_browser_browsertest.cc +++ b/src/dao/browser/ui/views/dao_browser_browsertest.cc @@ -93,6 +93,7 @@ #include "content/public/test/download_test_observer.h" #include "content/public/test/slow_download_http_response.h" #include "content/public/test/test_navigation_observer.h" +#include "content/public/test/test_web_ui.h" #include "dao/browser/agent/dao_agent_memory_service.h" #include "dao/browser/agent/dao_agent_memory_service_factory.h" #include "dao/browser/agent/dao_agent_memory_store.h" @@ -743,6 +744,11 @@ void AttachSidebarHandlerForTesting(Browser* browser, handler->SetBrowser(browser); } +class TestDaoSidebarUIHandler : public DaoSidebarUIHandler { + public: + using DaoSidebarUIHandler::set_web_ui; +}; + class CountingDialogDelegate : public views::DialogDelegate { public: CountingDialogDelegate() { @@ -1952,12 +1958,16 @@ IN_PROC_BROWSER_TEST_F(DaoSidebarBrowserTest, CloseTabsByStableIdentity) { } IN_PROC_BROWSER_TEST_F(DaoSidebarBrowserTest, - ClearStaleTabsUsesDaoNativeDialog) { - DaoSidebarUIHandler handler; + DeleteFolderUsesDaoNativeDialog) { + content::TestWebUI web_ui; + TestDaoSidebarUIHandler handler; + handler.set_web_ui(&web_ui); AttachSidebarHandlerForTesting(browser(), &handler); + handler.AllowJavascriptForTesting(); + web_ui.ClearTrackedCalls(); views::Widget* widget = - handler.ShowClearStaleTabsDialogForTesting("stale-folder"); + handler.ShowDeleteFolderDialogForTesting("folder-id"); ScopedWidgetCloser close_widget(widget); ASSERT_NE(nullptr, widget); EXPECT_TRUE(widget->IsVisible()); @@ -1965,15 +1975,27 @@ IN_PROC_BROWSER_TEST_F(DaoSidebarBrowserTest, views::DialogDelegate* dialog = widget->widget_delegate()->AsDialogDelegate(); ASSERT_NE(nullptr, dialog); EXPECT_TRUE(dialog->use_dao_system_dialog_style()); - EXPECT_EQ(l10n_util::GetStringUTF16(IDS_DAO_CLEAR_STALE_TABS_DIALOG_TITLE), + EXPECT_EQ(l10n_util::GetStringUTF16(IDS_DAO_DELETE_FOLDER_DIALOG_TITLE), dialog->GetWindowTitle()); - EXPECT_EQ(l10n_util::GetStringUTF16(IDS_DAO_CLEAR_STALE_TABS_DIALOG_CONFIRM), + EXPECT_EQ(l10n_util::GetStringUTF16(IDS_DAO_DELETE_FOLDER_DIALOG_CONFIRM), dialog->GetDialogButtonLabel(ui::mojom::DialogButton::kOk)); - EXPECT_EQ(l10n_util::GetStringUTF16(IDS_DAO_CLEAR_STALE_TABS_DIALOG_CANCEL), + EXPECT_EQ(l10n_util::GetStringUTF16(IDS_DAO_DELETE_FOLDER_DIALOG_CANCEL), dialog->GetDialogButtonLabel(ui::mojom::DialogButton::kCancel)); EXPECT_TRUE(HasDescendantLabelText( dialog->GetContentsView(), - l10n_util::GetStringUTF16(IDS_DAO_CLEAR_STALE_TABS_DIALOG_DESCRIPTION))); + l10n_util::GetStringUTF16(IDS_DAO_DELETE_FOLDER_DIALOG_DESCRIPTION))); + + dialog->AcceptDialog(); + + ASSERT_EQ(1u, web_ui.call_data().size()); + const content::TestWebUI::CallData& call = *web_ui.call_data().front(); + EXPECT_EQ("cr.webUIListenerCallback", call.function_name()); + ASSERT_NE(nullptr, call.arg1()); + ASSERT_NE(nullptr, call.arg2()); + ASSERT_NE(nullptr, call.arg3()); + EXPECT_EQ("folderContextMenuCommand", call.arg1()->GetString()); + EXPECT_EQ("folder-id", call.arg2()->GetString()); + EXPECT_EQ("deleteConfirmed", call.arg3()->GetString()); } IN_PROC_BROWSER_TEST_F(DaoSidebarBrowserTest, diff --git a/src/dao/browser/ui/webui/dao_sidebar_ui.cc b/src/dao/browser/ui/webui/dao_sidebar_ui.cc index dc20e921..0fef0167 100644 --- a/src/dao/browser/ui/webui/dao_sidebar_ui.cc +++ b/src/dao/browser/ui/webui/dao_sidebar_ui.cc @@ -95,29 +95,25 @@ namespace dao { -class ClearStaleTabsDialog : public views::DialogDelegate { +class DeleteFolderDialog : public views::DialogDelegate { public: - explicit ClearStaleTabsDialog(base::OnceClosure accept_callback) { + explicit DeleteFolderDialog(base::OnceClosure accept_callback) { SetOwnedByWidget(OwnedByWidgetPassKey()); SetModalType(ui::mojom::ModalType::kWindow); SetShowCloseButton(false); - SetTitle( - l10n_util::GetStringUTF16(IDS_DAO_CLEAR_STALE_TABS_DIALOG_TITLE)); + SetTitle(l10n_util::GetStringUTF16(IDS_DAO_DELETE_FOLDER_DIALOG_TITLE)); SetButtonLabel( ui::mojom::DialogButton::kOk, - l10n_util::GetStringUTF16( - IDS_DAO_CLEAR_STALE_TABS_DIALOG_CONFIRM)); + l10n_util::GetStringUTF16(IDS_DAO_DELETE_FOLDER_DIALOG_CONFIRM)); SetButtonLabel( ui::mojom::DialogButton::kCancel, - l10n_util::GetStringUTF16( - IDS_DAO_CLEAR_STALE_TABS_DIALOG_CANCEL)); + l10n_util::GetStringUTF16(IDS_DAO_DELETE_FOLDER_DIALOG_CANCEL)); SetButtonStyle(ui::mojom::DialogButton::kOk, ui::ButtonStyle::kProminent); SetAcceptCallback(std::move(accept_callback)); - auto description = std::make_unique( - l10n_util::GetStringUTF16( - IDS_DAO_CLEAR_STALE_TABS_DIALOG_DESCRIPTION)); + auto description = std::make_unique(l10n_util::GetStringUTF16( + IDS_DAO_DELETE_FOLDER_DIALOG_DESCRIPTION)); description->SetMultiLine(true); description->SetHorizontalAlignment(gfx::ALIGN_LEFT); description->SetMaximumWidth(360); @@ -125,10 +121,10 @@ class ClearStaleTabsDialog : public views::DialogDelegate { ConfigureDaoSystemDialog(this); } - ClearStaleTabsDialog(const ClearStaleTabsDialog&) = delete; - ClearStaleTabsDialog& operator=(const ClearStaleTabsDialog&) = delete; + DeleteFolderDialog(const DeleteFolderDialog&) = delete; + DeleteFolderDialog& operator=(const DeleteFolderDialog&) = delete; - ~ClearStaleTabsDialog() override = default; + ~DeleteFolderDialog() override = default; }; namespace { @@ -617,9 +613,9 @@ int DaoSidebarUIHandler::CloseTabsByIdForTesting( return CloseTabsById(tab_ids); } -views::Widget* DaoSidebarUIHandler::ShowClearStaleTabsDialogForTesting( +views::Widget* DaoSidebarUIHandler::ShowDeleteFolderDialogForTesting( const std::string& folder_id) { - return ShowClearStaleTabsDialog(folder_id); + return ShowDeleteFolderDialog(folder_id); } int DaoSidebarUIHandler::CloseDuplicateTabsForTesting() { @@ -740,10 +736,9 @@ void DaoSidebarUIHandler::RegisterMessages() { base::BindRepeating(&DaoSidebarUIHandler::HandleShowFolderContextMenu, base::Unretained(this))); web_ui()->RegisterMessageCallback( - "showClearStaleTabsDialog", - base::BindRepeating( - &DaoSidebarUIHandler::HandleShowClearStaleTabsDialog, - base::Unretained(this))); + "showDeleteFolderDialog", + base::BindRepeating(&DaoSidebarUIHandler::HandleShowDeleteFolderDialog, + base::Unretained(this))); web_ui()->RegisterMessageCallback( "showTabTooltip", base::BindRepeating(&DaoSidebarUIHandler::HandleShowTabTooltip, @@ -2665,9 +2660,8 @@ void DaoSidebarUIHandler::HandleShowPinnedItemContextMenu( void DaoSidebarUIHandler::HandleShowFolderContextMenu( const base::ListValue& args) { - if (!browser_ || args.size() < 5 || !args[0].is_string() || - !args[1].is_bool() || !args[2].is_list() || !args[3].is_int() || - !args[4].is_int()) { + if (!browser_ || args.size() < 3 || !args[0].is_string() || + !args[1].is_int() || !args[2].is_int()) { return; } @@ -2676,15 +2670,8 @@ void DaoSidebarUIHandler::HandleShowFolderContextMenu( return; } - const bool is_stale_folder = args[1].GetBool(); - const base::ListValue& tab_ids = args[2].GetList(); - for (const base::Value& tab_id : tab_ids) { - if (!tab_id.is_string() || tab_id.GetString().empty()) { - return; - } - } - const int screen_x = args[3].GetInt(); - const int screen_y = args[4].GetInt(); + const int screen_x = args[1].GetInt(); + const int screen_y = args[2].GetInt(); BrowserView* browser_view = BrowserView::GetBrowserViewForBrowser(browser_); if (!browser_view || !browser_view->dao_sidebar()) { @@ -2706,12 +2693,9 @@ void DaoSidebarUIHandler::HandleShowFolderContextMenu( tab_context_menu_model_->AddItem( kFolderRename, l10n_util::GetStringUTF16(IDS_DAO_FOLDER_CONTEXT_RENAME)); - if (is_stale_folder) { - tab_context_menu_model_->AddItem( - kFolderClearStaleTabs, - l10n_util::GetStringUTF16( - IDS_DAO_FOLDER_CONTEXT_CLEAR_STALE_TABS)); - } + tab_context_menu_model_->AddItem( + kFolderUnfolder, + l10n_util::GetStringUTF16(IDS_DAO_FOLDER_CONTEXT_UNFOLDER)); tab_context_menu_model_->AddItem( kFolderDelete, l10n_util::GetStringUTF16(IDS_DAO_FOLDER_CONTEXT_DELETE)); @@ -2726,7 +2710,7 @@ void DaoSidebarUIHandler::HandleShowFolderContextMenu( ui::mojom::MenuSourceType::kMouse); } -void DaoSidebarUIHandler::HandleShowClearStaleTabsDialog( +void DaoSidebarUIHandler::HandleShowDeleteFolderDialog( const base::ListValue& args) { if (args.empty() || !args[0].is_string()) { return; @@ -2735,7 +2719,7 @@ void DaoSidebarUIHandler::HandleShowClearStaleTabsDialog( if (folder_id.empty()) { return; } - ShowClearStaleTabsDialog(folder_id); + ShowDeleteFolderDialog(folder_id); } void DaoSidebarUIHandler::HandleShowTabTooltip(const base::ListValue& args) { @@ -3164,14 +3148,14 @@ int DaoSidebarUIHandler::CloseTabsById(const base::ListValue& tab_ids) { return static_cast(indices.size()); } -views::Widget* DaoSidebarUIHandler::ShowClearStaleTabsDialog( +views::Widget* DaoSidebarUIHandler::ShowDeleteFolderDialog( const std::string& folder_id) { if (!browser_ || folder_id.empty()) { return nullptr; } - auto dialog = std::make_unique(base::BindOnce( - &DaoSidebarUIHandler::OnClearStaleTabsDialogAccepted, + auto dialog = std::make_unique(base::BindOnce( + &DaoSidebarUIHandler::OnDeleteFolderDialogAccepted, weak_factory_.GetWeakPtr(), folder_id)); views::Widget* widget = @@ -3181,14 +3165,14 @@ views::Widget* DaoSidebarUIHandler::ShowClearStaleTabsDialog( return widget; } -void DaoSidebarUIHandler::OnClearStaleTabsDialogAccepted( +void DaoSidebarUIHandler::OnDeleteFolderDialogAccepted( std::string folder_id) { if (!IsJavascriptAllowed()) { return; } FireWebUIListener("folderContextMenuCommand", base::Value(std::move(folder_id)), - base::Value("clearStaleTabsConfirmed")); + base::Value("deleteConfirmed")); } int DaoSidebarUIHandler::CountDuplicateTabsToClose() const { @@ -3292,8 +3276,8 @@ bool DaoSidebarUIHandler::IsCommandIdEnabled(int command_id) const { if (command_id == kCloseDuplicateTabs) { return CountDuplicateTabsToClose() > 0; } - if (command_id == kFolderRename || command_id == kFolderDelete || - command_id == kFolderClearStaleTabs) { + if (command_id == kFolderRename || command_id == kFolderUnfolder || + command_id == kFolderDelete) { return !context_menu_folder_id_.empty(); } if (command_id == kPinnedOpen || command_id == kPinnedUnpin || @@ -3411,21 +3395,14 @@ void DaoSidebarUIHandler::ExecuteCommand(int command_id, int event_flags) { return; } - if (command_id == kFolderRename || command_id == kFolderDelete) { - if (IsJavascriptAllowed() && !context_menu_folder_id_.empty()) { - FireWebUIListener( - "folderContextMenuCommand", base::Value(context_menu_folder_id_), - base::Value(command_id == kFolderRename ? "rename" : "delete")); - } - ClearContextMenuState(); - return; - } - - if (command_id == kFolderClearStaleTabs) { + if (command_id == kFolderRename || command_id == kFolderUnfolder || + command_id == kFolderDelete) { if (IsJavascriptAllowed() && !context_menu_folder_id_.empty()) { + const char* command = command_id == kFolderRename ? "rename" : + command_id == kFolderUnfolder ? "unfolder" : "delete"; FireWebUIListener( "folderContextMenuCommand", base::Value(context_menu_folder_id_), - base::Value("clearStaleTabs")); + base::Value(command)); } ClearContextMenuState(); return; diff --git a/src/dao/browser/ui/webui/dao_sidebar_ui.h b/src/dao/browser/ui/webui/dao_sidebar_ui.h index 1aff0ba5..a60b3618 100644 --- a/src/dao/browser/ui/webui/dao_sidebar_ui.h +++ b/src/dao/browser/ui/webui/dao_sidebar_ui.h @@ -140,7 +140,7 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, bool LoadPinnedItemsForTesting(const std::string& json); void SetSessionRestoreCompletedForTesting(bool completed); int CloseTabsByIdForTesting(const base::ListValue& tab_ids); - views::Widget* ShowClearStaleTabsDialogForTesting( + views::Widget* ShowDeleteFolderDialogForTesting( const std::string& folder_id); int CloseDuplicateTabsForTesting(); @@ -234,7 +234,7 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, void HandleMovePinnedItem(const base::ListValue& args); void HandleShowPinnedItemContextMenu(const base::ListValue& args); void HandleShowFolderContextMenu(const base::ListValue& args); - void HandleShowClearStaleTabsDialog(const base::ListValue& args); + void HandleShowDeleteFolderDialog(const base::ListValue& args); void HandleShowTabTooltip(const base::ListValue& args); void HandleShowDownloadTooltip(const base::ListValue& args); void HandleHideTabTooltip(const base::ListValue& args); @@ -255,8 +255,8 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, int FindVisualPosition(int tab_index) const; void CloseTabsInVisualRange(int from, int to); int CloseTabsById(const base::ListValue& tab_ids); - views::Widget* ShowClearStaleTabsDialog(const std::string& folder_id); - void OnClearStaleTabsDialogAccepted(std::string folder_id); + views::Widget* ShowDeleteFolderDialog(const std::string& folder_id); + void OnDeleteFolderDialogAccepted(std::string folder_id); int CountDuplicateTabsToClose() const; int CloseDuplicateTabs(); void ClearContextMenuState(); @@ -283,8 +283,8 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, kPinnedCloseTab, kPinnedCopyLink, kFolderRename, + kFolderUnfolder, kFolderDelete, - kFolderClearStaleTabs, }; raw_ptr browser_ = nullptr; diff --git a/src/dao/browser/ui/webui/resources/sidebar/__tests__/folder_model.test.ts b/src/dao/browser/ui/webui/resources/sidebar/__tests__/folder_model.test.ts index a1066b37..5aa15502 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/__tests__/folder_model.test.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/__tests__/folder_model.test.ts @@ -49,7 +49,7 @@ describe('FolderModel', () => { expect(persisted.items[0].children[0]).not.toHaveProperty('tabId'); }); - it('releases folder children at the folder position when deleting', () => { + it('releases folder children at the folder position when unfoldering', () => { const model = new FolderModel(); expect(model.loadFromJson(JSON.stringify({ version: 1, @@ -68,11 +68,51 @@ describe('FolderModel', () => { ], }))).toBe(true); - model.deleteFolder('f1'); + expect(model.unfolder('f1')).toBe(true); expect(model.getOrderedItems().map(item => item.type === 'tab' ? item.title - : item.name)).toEqual(['First', 'Child', 'Last']); + : item.name)).toEqual(['First', 'Child', 'Last']); + }); + + it('deletes a folder together with its child references', () => { + const model = new FolderModel(); + expect(model.loadFromJson(JSON.stringify({ + version: 1, + items: [ + {type: 'tab', url: 'https://first.example', title: 'First'}, + { + type: 'folder', + id: 'f1', + name: 'Folder', + collapsed: false, + children: [ + {type: 'tab', url: 'https://child.example', title: 'Child'}, + ], + }, + {type: 'tab', url: 'https://last.example', title: 'Last'}, + ], + }))).toBe(true); + + expect(model.deleteFolder('f1')).toBe(true); + + expect(model.getOrderedItems().map(item => item.type === 'tab' + ? item.title + : item.name)).toEqual(['First', 'Last']); + }); + + it('reports missing folders without changing the model', () => { + const model = new FolderModel(); + model.loadFromJson(JSON.stringify({ + version: 1, + items: [{type: 'tab', url: 'https://first.example', title: 'First'}], + })); + + expect(model.unfolder('missing')).toBe(false); + expect(model.deleteFolder('missing')).toBe(false); + expect(model.getOrderedItems()).toEqual([ + {type: 'tab', url: 'https://first.example', title: 'First'}, + ]); }); it('finds or creates folders by exact name', () => { diff --git a/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_app.test.ts b/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_app.test.ts index fe6c3d80..6e7ed6b5 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_app.test.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_app.test.ts @@ -83,6 +83,7 @@ function sidebarState(extra: Partial = {}): SidebarState { type SidebarAppInternals = HTMLElement & { folderModel_: FolderModel; foldersLoaded_: boolean; + initialStateReceived_: boolean; unpinnedTabs_: TabData[]; folderModelVersion_: number; updateComplete: Promise; @@ -124,8 +125,8 @@ function fireFolderContextMenuCommand(folderId: string, command: string) { }).cr.webUIListenerCallback('folderContextMenuCommand', folderId, command); } -function fireClearStaleTabsConfirmed(folderId: string) { - fireFolderContextMenuCommand(folderId, 'clearStaleTabsConfirmed'); +function fireDeleteFolderConfirmed(folderId: string) { + fireFolderContextMenuCommand(folderId, 'deleteConfirmed'); } function didSendNative(send: ReturnType, method: string): boolean { @@ -815,46 +816,45 @@ describe('dao-sidebar-app', () => { [ 'kFolderRename', 'IDS_DAO_FOLDER_CONTEXT_RENAME', - '1173894706177603556', + '重命名', ], [ - 'kFolderDelete', - 'IDS_DAO_FOLDER_CONTEXT_DELETE', - '1809939268435598390', + 'kFolderUnfolder', + 'IDS_DAO_FOLDER_CONTEXT_UNFOLDER', + '解散文件夹', ], [ - 'kFolderClearStaleTabs', - 'IDS_DAO_FOLDER_CONTEXT_CLEAR_STALE_TABS', - '6007071648771993435', + 'kFolderDelete', + 'IDS_DAO_FOLDER_CONTEXT_DELETE', + '删除文件夹', ], ]; - for (const [commandId, messageId, translationId] of menuLabels) { + for (const [commandId, messageId, translation] of menuLabels) { expect(grdText).toContain(``); + expect(zhCnText).toContain(`>${translation}`); expect(handlerText).toMatch(new RegExp( `${commandId}[\\s\\S]*?l10n_util::GetStringUTF16\\(\\s*` + `${messageId}\\s*\\)`)); } const dialogStrings = [ - ['IDS_DAO_CLEAR_STALE_TABS_DIALOG_TITLE', '35564291038608079'], - ['IDS_DAO_CLEAR_STALE_TABS_DIALOG_DESCRIPTION', '4029402916766829056'], - ['IDS_DAO_CLEAR_STALE_TABS_DIALOG_CANCEL', '7658239707568436148'], - ['IDS_DAO_CLEAR_STALE_TABS_DIALOG_CONFIRM', '6643016212128521049'], + ['IDS_DAO_DELETE_FOLDER_DIALOG_TITLE', '删除文件夹?'], + [ + 'IDS_DAO_DELETE_FOLDER_DIALOG_DESCRIPTION', + '这会关闭文件夹中的所有标签并删除该文件夹。', + ], + ['IDS_DAO_DELETE_FOLDER_DIALOG_CANCEL', '取消'], + ['IDS_DAO_DELETE_FOLDER_DIALOG_CONFIRM', '删除'], ]; - for (const [messageId, translationId] of dialogStrings) { + for (const [messageId, translation] of dialogStrings) { expect(grdText).toContain(``); + expect(zhCnText).toContain(`>${translation}`); expect(handlerText).toContain(messageId); } - expect(handlerText).toContain('"showClearStaleTabsDialog"'); + expect(handlerText).toContain('"showDeleteFolderDialog"'); expect(handlerText).toContain('ConfigureDaoSystemDialog'); expect(handlerText).toContain('CreateBrowserModalDialogViews'); - - expect(handlerText).toMatch( - /is_stale_folder[\s\S]*?kFolderClearStaleTabs[\s\S]*?IDS_DAO_FOLDER_CONTEXT_CLEAR_STALE_TABS/); - expect(handlerText).toContain('"clearStaleTabs"'); }); it('starts folder rename when native folder menu selects rename', @@ -883,249 +883,254 @@ describe('dao-sidebar-app', () => { expect(startFolderRename).toHaveBeenCalledWith('folder-1'); }); - it('deletes folder when native folder menu selects delete', async () => { + it('unfolders children in place without closing tabs', async () => { vi.useFakeTimers(); const {el, send} = await loadApp(); const app = el as SidebarAppInternals; installFolderModel(app, JSON.stringify({ version: 1, - items: [{ - type: 'folder', - id: 'folder-1', - name: 'Work', - collapsed: false, - children: [], - }], + items: [ + {type: 'tab', url: 'https://first.example/', title: 'First'}, + { + type: 'folder', + id: 'folder-1', + name: 'Work', + collapsed: false, + children: [ + {type: 'tab', url: 'https://a.example/', title: 'A'}, + {type: 'tab', url: 'https://b.example/', title: 'B'}, + ], + }, + {type: 'tab', url: 'https://last.example/', title: 'Last'}, + ], })); - fireFolderContextMenuCommand('folder-1', 'delete'); + fireFolderContextMenuCommand('folder-1', 'unfolder'); vi.advanceTimersByTime(300); expect(app.folderModel_.findFolderByName('Work')).toBeNull(); + expect(app.folderModel_.getOrderedItems().map(item => + item.type === 'tab' ? item.title : item.name)) + .toEqual(['First', 'A', 'B', 'Last']); expect(send).toHaveBeenCalledWith( 'saveFolders', [expect.not.stringContaining('"name": "Work"')]); + expect(didSendNative(send, 'showDeleteFolderDialog')).toBe(false); + expect(didSendNative(send, 'closeTabsById')).toBe(false); }); - it('keeps the stale folder when clear stale tabs is cancelled', async () => { + it('shows the delete dialog without mutating the folder or tabs', async () => { const {el, send} = await loadApp(); const app = el as SidebarAppInternals; installFolderModel(app, JSON.stringify({ version: 1, items: [{ type: 'folder', - id: 'stale-folder', - name: 'stale', + id: 'folder-1', + name: 'Work', collapsed: false, children: [{ - type: 'tab', - tabId: 'stale-a', - url: 'https://stale-a.example/', - title: 'Stale A', + type: 'tab', tabId: 'tab-a', url: 'https://a.example/', title: 'A', }], }], })); app.unpinnedTabs_ = [tab({ - tabId: 'stale-a', - url: 'https://stale-a.example/', - title: 'Stale A', + tabId: 'tab-a', url: 'https://a.example/', title: 'A', })]; - fireFolderContextMenuCommand('stale-folder', 'clearStaleTabs'); + fireFolderContextMenuCommand('folder-1', 'delete'); expect(send).toHaveBeenCalledWith( - 'showClearStaleTabsDialog', ['stale-folder']); + 'showDeleteFolderDialog', ['folder-1']); expect(didSendNative(send, 'closeTabsById')).toBe(false); expect(didSendNative(send, 'saveFolders')).toBe(false); - expect(app.folderModel_.findFolderByName('stale')).not.toBeNull(); + expect(app.folderModel_.findFolderByName('Work')).not.toBeNull(); }); - it('persists before closing every current tab on confirm', - async () => { - const {el, send} = await loadApp(); - const app = el as SidebarAppInternals; - installFolderModel(app, JSON.stringify({ - version: 1, - items: [{ - type: 'folder', - id: 'stale-folder', - name: 'stale', - collapsed: false, - children: [ - { - type: 'tab', - tabId: 'stale-a', - url: 'https://stale-a.example/', - title: 'Stale A', - }, - { - type: 'tab', - tabId: 'stale-b', - url: 'https://stale-b.example/', - title: 'Stale B', - }, - ], - }], - })); - app.unpinnedTabs_ = [ - tab({ - tabId: 'stale-a', - url: 'https://stale-a.example/', - title: 'Stale A', - }), - tab({ - tabId: 'stale-b', - url: 'https://stale-b.example/', - title: 'Stale B', - }), - ]; - - fireFolderContextMenuCommand('stale-folder', 'clearStaleTabs'); - fireClearStaleTabsConfirmed('stale-folder'); - - const persistenceAndCloseCalls = send.mock.calls.filter( - call => call[0] === 'saveFolders' || - call[0] === 'closeTabsById'); - expect(persistenceAndCloseCalls).toEqual([ - [ - 'saveFolders', - [expect.not.stringContaining('"name": "stale"')], - ], - ['closeTabsById', [['stale-a', 'stale-b']]], - ]); - expect(send.mock.calls.filter(call => call[0] === 'saveFolders')) - .toHaveLength(1); - expect(app.folderModel_.findFolderByName('stale')).toBeNull(); - }); - - it('persists before closing the last remaining stale tab', async () => { + it('persists only after every current child is actually closed', async () => { const {el, send} = await loadApp(); const app = el as SidebarAppInternals; installFolderModel(app, JSON.stringify({ version: 1, items: [{ type: 'folder', - id: 'stale-folder', - name: 'stale', + id: 'folder-1', + name: 'Work', collapsed: false, - children: [{ - type: 'tab', - tabId: 'last-tab', - url: 'https://last.example/', - title: 'Last', - }], + children: [ + {type: 'tab', tabId: 'tab-a', url: 'https://a.example/', title: 'A'}, + {type: 'tab', tabId: 'tab-b', url: 'https://b.example/', title: 'B'}, + ], }], })); - app.unpinnedTabs_ = [tab({ - tabId: 'last-tab', - url: 'https://last.example/', - title: 'Last', - })]; + app.unpinnedTabs_ = [ + tab({tabId: 'tab-a', url: 'https://a.example/', title: 'A'}), + tab({tabId: 'tab-b', url: 'https://b.example/', title: 'B'}), + ]; + app.initialStateReceived_ = true; - fireFolderContextMenuCommand('stale-folder', 'clearStaleTabs'); - fireClearStaleTabsConfirmed('stale-folder'); + fireDeleteFolderConfirmed('folder-1'); expect(send.mock.calls.filter( call => call[0] === 'saveFolders' || call[0] === 'closeTabsById')).toEqual([ - [ - 'saveFolders', - [expect.not.stringContaining('"name": "stale"')], - ], - ['closeTabsById', [['last-tab']]], + ['closeTabsById', [['tab-a', 'tab-b']]], ]); + expect(app.folderModel_.findFolderByName('Work')).not.toBeNull(); + + fireSidebarStateChanged(sidebarState({unpinnedTabs: []})); + expect(send.mock.calls.filter(call => call[0] === 'saveFolders')) .toHaveLength(1); - expect(app.folderModel_.findFolderByName('stale')).toBeNull(); + expect(send).toHaveBeenCalledWith( + 'saveFolders', [expect.not.stringContaining('"name": "Work"')]); + expect(app.folderModel_.findFolderByName('Work')).toBeNull(); }); - it('deletes an empty stale folder without closing tabs', async () => { + it('keeps the folder when a child tab close is cancelled', async () => { const {el, send} = await loadApp(); const app = el as SidebarAppInternals; installFolderModel(app, JSON.stringify({ version: 1, items: [{ type: 'folder', - id: 'stale-folder', - name: 'stale', + id: 'folder-1', + name: 'Work', collapsed: false, - children: [], + children: [{ + type: 'tab', tabId: 'tab-a', url: 'https://a.example/', title: 'A', + }], }], })); + const tabA = tab({ + tabId: 'tab-a', url: 'https://a.example/', title: 'A', + }); + app.unpinnedTabs_ = [tabA]; + app.initialStateReceived_ = true; - fireFolderContextMenuCommand('stale-folder', 'clearStaleTabs'); - fireClearStaleTabsConfirmed('stale-folder'); + fireDeleteFolderConfirmed('folder-1'); + fireSidebarStateChanged(sidebarState({unpinnedTabs: [tabA]})); - expect(didSendNative(send, 'closeTabsById')).toBe(false); - expect(app.folderModel_.findFolderByName('stale')).toBeNull(); - expect(send.mock.calls.filter(call => call[0] === 'saveFolders')).toEqual([ - [ - 'saveFolders', - [expect.not.stringContaining('"name": "stale"')], - ], - ]); + expect(send).toHaveBeenCalledWith('closeTabsById', [['tab-a']]); + expect(didSendNative(send, 'saveFolders')).toBe(false); + expect(app.folderModel_.findFolderByName('Work')).not.toBeNull(); + }); + + it('deletes by stable folder ID after the folder is renamed', async () => { + const {el, send} = await loadApp(); + const app = el as SidebarAppInternals; + installFolderModel(app, JSON.stringify({ + version: 1, + items: [{ + type: 'folder', + id: 'folder-1', + name: 'Work', + collapsed: false, + children: [{ + type: 'tab', tabId: 'tab-a', url: 'https://a.example/', title: 'A', + }], + }], + })); + app.unpinnedTabs_ = [ + tab({tabId: 'tab-a', url: 'https://a.example/', title: 'A'}), + ]; + app.initialStateReceived_ = true; + + fireFolderContextMenuCommand('folder-1', 'delete'); + app.folderModel_.renameFolder('folder-1', 'Renamed'); + fireDeleteFolderConfirmed('folder-1'); + + expect(app.folderModel_.findFolderByName('Renamed')).not.toBeNull(); + expect(send).toHaveBeenCalledWith('closeTabsById', [['tab-a']]); + + fireSidebarStateChanged(sidebarState({unpinnedTabs: []})); + + expect(app.folderModel_.findFolderByName('Renamed')).toBeNull(); }); - it('does nothing when clear stale tabs targets a missing folder', async () => { + it('closes only current folder members when confirmation arrives', async () => { + const {el, send} = await loadApp(); + const app = el as SidebarAppInternals; + installFolderModel(app, JSON.stringify({ + version: 1, + items: [{ + type: 'folder', + id: 'folder-1', + name: 'Work', + collapsed: false, + children: [ + {type: 'tab', tabId: 'tab-a', url: 'https://a.example/', title: 'A'}, + {type: 'tab', tabId: 'tab-b', url: 'https://b.example/', title: 'B'}, + ], + }], + })); + const tabA = tab({tabId: 'tab-a', url: 'https://a.example/', title: 'A'}); + const tabB = tab({tabId: 'tab-b', url: 'https://b.example/', title: 'B'}); + const tabC = tab({tabId: 'tab-c', url: 'https://c.example/', title: 'C'}); + app.unpinnedTabs_ = [tabA, tabB, tabC]; + + fireFolderContextMenuCommand('folder-1', 'delete'); + app.folderModel_.removeTabFromFolder(tabA, 'folder-1'); + app.folderModel_.moveTabToFolder(tabC, 'folder-1'); + fireDeleteFolderConfirmed('folder-1'); + + expect(send).toHaveBeenCalledWith('closeTabsById', [['tab-b', 'tab-c']]); + }); + + it('ignores missing folders for delete and confirmation', async () => { const {el, send} = await loadApp(); const app = el as SidebarAppInternals; installFolderModel(app); - fireFolderContextMenuCommand('missing-folder', 'clearStaleTabs'); + fireFolderContextMenuCommand('missing-folder', 'delete'); + fireDeleteFolderConfirmed('missing-folder'); - expect(didSendNative(send, 'showClearStaleTabsDialog')).toBe(false); + expect(didSendNative(send, 'showDeleteFolderDialog')).toBe(false); expect(didSendNative(send, 'closeTabsById')).toBe(false); expect(didSendNative(send, 'saveFolders')).toBe(false); - expect(app.folderModel_.findFolderByName('stale')).toBeNull(); }); - it('does nothing when the stale folder disappears during confirmation', - async () => { - const {el, send} = await loadApp(); - const app = el as SidebarAppInternals; - installFolderModel(app, JSON.stringify({ - version: 1, - items: [{ - type: 'folder', - id: 'stale-folder', - name: 'stale', - collapsed: false, - children: [], - }], - })); + it('deletes an empty folder without sending a close request', async () => { + const {el, send} = await loadApp(); + const app = el as SidebarAppInternals; + installFolderModel(app, JSON.stringify({ + version: 1, + items: [{ + type: 'folder', + id: 'empty-folder', + name: 'Empty', + collapsed: false, + children: [], + }], + })); - fireFolderContextMenuCommand('stale-folder', 'clearStaleTabs'); - app.folderModel_.deleteFolder('stale-folder'); - fireClearStaleTabsConfirmed('stale-folder'); + fireDeleteFolderConfirmed('empty-folder'); - expect(didSendNative(send, 'closeTabsById')).toBe(false); - expect(didSendNative(send, 'saveFolders')).toBe(false); - expect(app.folderModel_.findFolderByName('stale')).toBeNull(); - }); + expect(app.folderModel_.findFolderByName('Empty')).toBeNull(); + expect(send.mock.calls.filter(call => call[0] === 'saveFolders')) + .toHaveLength(1); + expect(didSendNative(send, 'closeTabsById')).toBe(false); + }); - it('does nothing when the stale folder is renamed during confirmation', - async () => { - const {el, send} = await loadApp(); - const app = el as SidebarAppInternals; - installFolderModel(app, JSON.stringify({ - version: 1, - items: [{ - type: 'folder', - id: 'stale-folder', - name: 'stale', - collapsed: false, - children: [], - }], - })); + it('uses the generic delete dialog for the stale folder', async () => { + const {el, send} = await loadApp(); + const app = el as SidebarAppInternals; + installFolderModel(app, JSON.stringify({ + version: 1, + items: [{ + type: 'folder', + id: 'stale-folder', + name: 'stale', + collapsed: false, + children: [], + }], + })); - fireFolderContextMenuCommand('stale-folder', 'clearStaleTabs'); - app.folderModel_.renameFolder('stale-folder', 'Stale'); - fireClearStaleTabsConfirmed('stale-folder'); + fireFolderContextMenuCommand('stale-folder', 'delete'); - expect(didSendNative(send, 'closeTabsById')).toBe(false); - expect(didSendNative(send, 'saveFolders')).toBe(false); - expect(app.folderModel_.findFolderByName('Stale')).not.toBeNull(); - }); + expect(send).toHaveBeenCalledWith( + 'showDeleteFolderDialog', ['stale-folder']); + }); it('reuses existing stale folder and moves stale tabs from other folders', async () => { diff --git a/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_list.test.ts b/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_list.test.ts index 94c014f1..3ca90c34 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_list.test.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_list.test.ts @@ -624,7 +624,7 @@ describe('dao-tab-list', () => { 'showTabContextMenu', [1, 20, 30, [0, 1], [0, 1, 2]]); }); - it('sends matched tab IDs for the exact stale folder menu', async () => { + it('requests the uniform context menu for a stale folder', async () => { const staleTab = tab({ tabId: 'stale-tab', index: 0, @@ -677,11 +677,10 @@ describe('dao-tab-list', () => { })); expect(send).toHaveBeenCalledWith( - 'showFolderContextMenu', - ['stale-id', true, ['stale-tab'], 20, 30]); + 'showFolderContextMenu', ['stale-id', 20, 30]); }); - it('does not mark a differently cased Stale folder as stale', async () => { + it('requests the same context menu for an ordinary folder', async () => { const staleTab = tab({ tabId: 'stale-tab', index: 0, @@ -690,8 +689,8 @@ describe('dao-tab-list', () => { }); const model = createFolderModel([{ type: 'folder', - id: 'stale-id', - name: 'Stale', + id: 'ordinary-id', + name: 'Reading', collapsed: false, children: [{ type: 'tab', @@ -707,15 +706,14 @@ describe('dao-tab-list', () => { bubbles: true, composed: true, detail: { - folderId: 'stale-id', + folderId: 'ordinary-id', screenX: 20, screenY: 30, }, })); expect(send).toHaveBeenCalledWith( - 'showFolderContextMenu', - ['stale-id', false, ['stale-tab'], 20, 30]); + 'showFolderContextMenu', ['ordinary-id', 20, 30]); }); it('activates native tab drag when leaving at the viewport edge', async () => { diff --git a/src/dao/browser/ui/webui/resources/sidebar/dao_folder_model.ts b/src/dao/browser/ui/webui/resources/sidebar/dao_folder_model.ts index 40e51170..53da1cc4 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/dao_folder_model.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/dao_folder_model.ts @@ -174,17 +174,29 @@ export class FolderModel { } /** - * Delete a folder by ID. Child tabs become loose tabs at the + * Remove a folder by ID while releasing its child tabs as loose tabs at the * folder's position in the items array. */ - deleteFolder(folderId: string): void { + unfolder(folderId: string): boolean { const index = this.items_.findIndex( item => item.type === 'folder' && item.id === folderId); - if (index === -1) return; + if (index === -1) return false; const folder = this.items_[index] as FolderData; - // Replace folder with its children (released as loose tabs). this.items_.splice(index, 1, ...folder.children); + return true; + } + + /** + * Delete a folder by ID together with its child tab references. + */ + deleteFolder(folderId: string): boolean { + const index = this.items_.findIndex( + item => item.type === 'folder' && item.id === folderId); + if (index === -1) return false; + + this.items_.splice(index, 1); + return true; } /** diff --git a/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts b/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts index a3ae06d5..c310d073 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts @@ -40,6 +40,11 @@ const NO_NEW_STALE_TABS_ARCHIVED_TOAST_KEY = 'daoSidebarNoNewStaleTabsArchivedToast'; const TOAST_VISIBLE_MS = 3000; +interface PendingFolderDeletion { + folderId: string; + tabIds: Set; +} + function getLocalizedString(id: string): string { return loadTimeData.getString(id); } @@ -282,6 +287,7 @@ export class DaoSidebarApp extends CrLitElement { private initialStateReceived_: boolean = false; private activeTabId_: string = ''; private pendingActiveFolderTabId_: string = ''; + private pendingFolderDeletion_: PendingFolderDeletion | null = null; private boundClosePlusMenu_: ((e: MouseEvent) => void) | null = null; private tabScrollbarHoverTimeout_: number | null = null; private listenerHandles_: Array> = []; @@ -358,7 +364,11 @@ export class DaoSidebarApp extends CrLitElement { // Keep runtime tab identities in sync after duplicate/move/close so // folder operations target the exact rendered tab, not a URL match. this.folderModel_.reconcile(this.unpinnedTabs_); - if (this.expandPendingActiveTabFolder_()) { + const expandedActiveFolder = this.expandPendingActiveTabFolder_(); + if (this.completePendingFolderDeletion_()) { + this.folderModelVersion_++; + saveFoldersImmediately(this.folderModel_.toJson()); + } else if (expandedActiveFolder) { this.saveFolders_(); } else { this.folderModelVersion_++; @@ -515,9 +525,10 @@ export class DaoSidebarApp extends CrLitElement { this.saveFolders_(); break; - case 'delete': - this.folderModel_.deleteFolder(detail.folderId); - this.saveFolders_(); + case 'unfolder': + if (this.folderModel_.unfolder(detail.folderId)) { + this.saveFolders_(); + } break; case 'tabDrop': @@ -707,40 +718,83 @@ export class DaoSidebarApp extends CrLitElement { return; } - if (command === 'delete') { - this.handleFolderAction_({action: 'delete', folderId}); + if (command === 'unfolder') { + this.handleFolderAction_({action: 'unfolder', folderId}); return; } - if (command === 'clearStaleTabs') { - const folder = this.folderModel_.getFolders().find( - item => item.id === folderId); - if (folder?.name !== STALE_TABS_FOLDER_NAME) { - return; + if (command === 'delete') { + if (this.folderModel_.getFolders().some(folder => folder.id === folderId)) { + sendNative('showDeleteFolderDialog', folderId); } - sendNative('showClearStaleTabsDialog', folderId); return; } - if (command === 'clearStaleTabsConfirmed') { - this.clearStaleTabs_(folderId); + if (command === 'deleteConfirmed') { + this.deleteFolder_(folderId); } } - private clearStaleTabs_(folderId: string) { + private deleteFolder_(folderId: string) { const folder = this.folderModel_.getFolders().find( item => item.id === folderId); - if (!folder || folder.name !== STALE_TABS_FOLDER_NAME) { + if (!folder) { return; } const tabs = this.folderModel_.getMatchedTabs(folderId, this.unpinnedTabs_); - this.folderModel_.deleteFolder(folderId); - this.folderModelVersion_++; - saveFoldersImmediately(this.folderModel_.toJson()); if (tabs.length > 0) { + this.pendingFolderDeletion_ = { + folderId, + tabIds: new Set(tabs.map(tab => tab.tabId)), + }; sendNative('closeTabsById', tabs.map(tab => tab.tabId)); + return; } + + if (!this.folderModel_.deleteFolder(folderId)) { + return; + } + this.folderModelVersion_++; + saveFoldersImmediately(this.folderModel_.toJson()); + } + + private completePendingFolderDeletion_(): boolean { + const pending = this.pendingFolderDeletion_; + if (!pending) { + return false; + } + + const openTabIds = new Set(this.unpinnedTabs_.map(tab => tab.tabId)); + if ([...pending.tabIds].some(tabId => openTabIds.has(tabId))) { + const folderExists = this.folderModel_.getFolders().some( + folder => folder.id === pending.folderId); + if (!folderExists) { + this.pendingFolderDeletion_ = null; + } + return false; + } + + const folderExists = this.folderModel_.getFolders().some( + folder => folder.id === pending.folderId); + if (!folderExists) { + // Reconciliation drops folders after their final child disappears. The + // model is already in the desired state; flush that state immediately. + this.pendingFolderDeletion_ = null; + return true; + } + + // A tab added to the folder while a beforeunload dialog was open was not + // part of the confirmed deletion. Keep the folder instead of deleting it + // out from under that tab. + if (this.folderModel_.getMatchedTabs( + pending.folderId, this.unpinnedTabs_).length > 0) { + this.pendingFolderDeletion_ = null; + return false; + } + + this.pendingFolderDeletion_ = null; + return this.folderModel_.deleteFolder(pending.folderId); } private onTabSectionScroll_ = () => { diff --git a/src/dao/browser/ui/webui/resources/sidebar/dao_tab_list.ts b/src/dao/browser/ui/webui/resources/sidebar/dao_tab_list.ts index 5342064f..be4d5aab 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/dao_tab_list.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/dao_tab_list.ts @@ -828,10 +828,7 @@ export class DaoTabList extends CrLitElement { if (!folder || !this.folderModel) { return; } - const matchedTabs = this.folderModel.getMatchedTabs(folderId, this.tabs); - sendNative( - 'showFolderContextMenu', folderId, folder.name === 'stale', - matchedTabs.map(tab => tab.tabId), screenX, screenY); + sendNative('showFolderContextMenu', folderId, screenX, screenY); } /** diff --git a/src/dao/browser/ui/webui/resources/sidebar/sidebar_bridge.ts b/src/dao/browser/ui/webui/resources/sidebar/sidebar_bridge.ts index e1bc9b89..b6806b04 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/sidebar_bridge.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/sidebar_bridge.ts @@ -233,7 +233,7 @@ export function parseTabDragData( export type FolderAction = | {action: 'toggleCollapse'; folderId: string} | {action: 'rename'; folderId: string; name: string} - | {action: 'delete'; folderId: string} + | {action: 'unfolder'; folderId: string} | {action: 'tabDrop'; folderId: string; dragData: string} | {action: 'childReorder'; folderId: string; dragData: string; dropIndex: number} diff --git a/src/patches/ui/views/widget/widget_delegate.h.patch b/src/patches/ui/views/widget/widget_delegate.h.patch index 7517e481..424c91c5 100644 --- a/src/patches/ui/views/widget/widget_delegate.h.patch +++ b/src/patches/ui/views/widget/widget_delegate.h.patch @@ -7,7 +7,7 @@ index cb2d7b2f5c..b415451a48 100644 class TabModalConfirmDialogViews; class TestBaseWidgetDelegate; +namespace dao { -+class ClearStaleTabsDialog; ++class DeleteFolderDialog; +class DaoQrCodeResultDialogView; +} // namespace dao class UpdateRecommendedMessageBox; @@ -18,7 +18,7 @@ index cb2d7b2f5c..b415451a48 100644 friend class ::TabModalConfirmDialogViews; friend class ::TestBaseWidgetDelegate; + // Dao Browser additions. -+ friend class ::dao::ClearStaleTabsDialog; ++ friend class ::dao::DeleteFolderDialog; + friend class ::dao::DaoQrCodeResultDialogView; friend class ::UpdateRecommendedMessageBox; friend class ::WebDialogBrowserTest;