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;