From a8f33bfe406c06bde71e3137d3674406bcc3cd69 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sun, 20 Sep 2026 04:02:59 +0800 Subject: [PATCH] feat(windows): support multi-window tab workflows --- docs/feature-checklist.md | 2 + docs/features.md | 23 +- .../import/dao_chromium_migration_target.cc | 114 +++- .../import/dao_chromium_migration_target.h | 3 + src/dao/browser/ui/dao_ui_sources.gni | 4 + .../ui/views/dao_browser_browsertest.cc | 394 +++++++++++ .../browser/ui/views/dao_cross_window_drag.cc | 123 +++- .../browser/ui/views/dao_cross_window_drag.h | 24 +- .../browser/ui/views/dao_native_util_mac.h | 33 +- .../browser/ui/views/dao_native_util_mac.mm | 111 +++- .../ui/views/dao_tab_drag_mac_browsertest.mm | 110 ++++ src/dao/browser/ui/views/dao_tab_identity.cc | 32 + src/dao/browser/ui/views/dao_tab_identity.h | 5 + .../browser/ui/views/split/dao_split_view.cc | 169 +---- .../browser/ui/webui/dao_folder_storage.cc | 308 +++++++++ src/dao/browser/ui/webui/dao_folder_storage.h | 77 +++ .../ui/webui/dao_folder_storage_unittest.cc | 148 +++++ src/dao/browser/ui/webui/dao_sidebar_ui.cc | 622 ++++++++++++++---- src/dao/browser/ui/webui/dao_sidebar_ui.h | 33 +- .../sidebar/__tests__/folder_model.test.ts | 4 +- .../__tests__/pinned_tabs_grid.test.ts | 26 +- .../sidebar/__tests__/sidebar_app.test.ts | 26 + .../sidebar/__tests__/sidebar_bridge.test.ts | 25 +- .../sidebar/__tests__/tab_item.test.ts | 4 +- .../sidebar/__tests__/tab_list.test.ts | 112 +++- .../resources/sidebar/dao_folder_model.ts | 2 + .../resources/sidebar/dao_pinned_tabs_grid.ts | 30 +- .../resources/sidebar/dao_sidebar_app.ts | 5 +- .../webui/resources/sidebar/dao_tab_item.ts | 3 +- .../webui/resources/sidebar/dao_tab_list.ts | 143 +--- .../webui/resources/sidebar/sidebar_bridge.ts | 37 +- .../browser/sessions/session_service.cc.patch | 10 +- ...b_contents_view_delegate_views_mac.h.patch | 14 + ..._contents_view_delegate_views_mac.mm.patch | 37 ++ .../web_contents_view_cocoa.mm.patch | 110 ++++ .../window_occlusion_browsertest_mac.mm.patch | 14 + .../web_contents_view_mac.h.patch | 23 + .../web_contents_view_mac.mm.patch | 40 +- .../web_contents_ns_view_bridge.mojom.patch | 12 + .../web_contents_view_delegate.cc.patch | 15 + .../web_contents_view_delegate.h.patch | 34 + 41 files changed, 2525 insertions(+), 536 deletions(-) create mode 100644 src/dao/browser/ui/views/dao_tab_drag_mac_browsertest.mm create mode 100644 src/dao/browser/ui/webui/dao_folder_storage.cc create mode 100644 src/dao/browser/ui/webui/dao_folder_storage.h create mode 100644 src/dao/browser/ui/webui/dao_folder_storage_unittest.cc create mode 100644 src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h.patch create mode 100644 src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm.patch create mode 100644 src/patches/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm.patch create mode 100644 src/patches/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm.patch create mode 100644 src/patches/content/browser/web_contents/web_contents_view_mac.h.patch create mode 100644 src/patches/content/common/web_contents_ns_view_bridge.mojom.patch create mode 100644 src/patches/content/public/browser/web_contents_view_delegate.cc.patch create mode 100644 src/patches/content/public/browser/web_contents_view_delegate.h.patch diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 583ba722..9c21df51 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -265,6 +265,8 @@ Flagship feature. C++ services + `dao://dao-agent` WebUI + vendor runtime. |---|---------|--------------------|------|----------------------| | ☐ | Split container / panes / divider | `src/dao/.../split/` + `dao_colors.*`, `dao_corner_overlay_view.cc`, `contents_layout_manager.cc.patch`, `browser_view.cc.patch` | 🟑 | Split creates two panes (wired but off by default); horizontal and vertical dividers and inner pane corner cutouts match the shadowed content-frame edge in light/dark and incognito windows, including theme changes while split; outer corner shadows stay unchanged, single-pane content remains visible, bright pages do not lighten the opaque divider, and blue hover/drag feedback remains visible | | ☐ | Native tab-drag cleanup restores WebContents hit testing | `dao_native_util_mac.mm`, `split/dao_split_view.cc` | 🟑 | Cancel a tab drag over or outside the content area, then verify existing and newly opened tabs still receive mouse input in every window | +| ☐ | New-window and existing-window tab transfer | `dao_cross_window_drag.*`, `dao_native_util_mac.*`, `dao_sidebar_ui.cc`, sidebar WebUI tests | `DaoCrossWindowDragBrowserTest.*`, `DaoTabDragMacBrowserTest.*` | Right-click a background tab and move that exact tab to a new window; the last-tab menu action is disabled. Drag one of several tabs onto the desktop and preserve its contents/history; drag the only tab and move its window. Escape must not create a window or remove folder membership. Successful tab-list and Split View drops create no extra window; closing/reordering the dragged tab never moves a different tab. Test all display arrangements and hidden/minimized windows, including a source display with a different logical height from the primary display, negative screen coordinates, and fractional drop positions. | +| ☐ | Window-scoped folder persistence | `dao_folder_storage.*`, `dao_tab_identity.*`, `dao_folder_model.ts`, `dao_sidebar_ui.cc` | Folder storage and sidebar WebUI tests | Create different folders in two windows, save them alternately, restart with session restore, and verify each window retains its own folders and duplicate-URL tab identities. Opening a new empty window must not claim another window's folders. Move a tab across windows and verify unrelated folders remain intact; reject a foreign-window tab dropped directly onto a folder. Verify legacy file migration, imported-tab folders, and failed atomic writes preserve existing data. Incognito folder changes must remain in memory and leave the regular profile file untouched. | | ☐ | No crash on pane reparent double-detach (macOS) | `content/.../web_contents_view_mac.mm.patch` (null-guard) | 🟑 | Reparenting panes doesn't crash | | ☐ | No fatal paint-chunk-duplicate during reparent | `blink/.../paint/paint_controller.cc.patch` (fatalβ†’`LOG(ERROR)`) | 🟑 | Split deactivation doesn't crash debug build | diff --git a/docs/features.md b/docs/features.md index 2e06d844..4372808d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -52,7 +52,13 @@ 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). Every folder context menu + window-scoped persistence under the profile path. Stable tab identities + preserve folder membership across session restore and distinguish tabs with + identical URLs. Window snapshots merge in shared profile state and are written + atomically so one window cannot overwrite another window's folders. Legacy + folder files remain readable. Off-the-record windows keep folder snapshots in + isolated memory and never read or write the regular profile's folder file. + 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 @@ -72,7 +78,20 @@ An Arc-inspired vertical sidebar replaces Chromium's top tab strip β€” the singl ### 1.3 Tab System Foundations - **DaoTabIdentity** (`dao_tab_identity.h`) β€” Stable cross-window tab IDs decoupled from `TabStripModel` indices, migrated across WebContents replacement and persisted in session extra data - **DaoTabCommands** (`dao_tab_commands.h`) β€” Tab action vocabulary (duplicate, pin, copy URL, close, etc.) -- **DaoCrossWindowDrag** (`dao_cross_window_drag.{h,cc}`) β€” `dao-tab-drag::` pasteboard payload + parser, shared by `dao_tab_item.ts` and the macOS drop handler in `dao_native_util_mac.mm` +- **DaoCrossWindowDrag** (`dao_cross_window_drag.{h,cc}`) β€” Shared parsing and + tab resolution for `dao-tab-drag:::` payloads; + legacy index-only identities remain readable. Stable IDs prevent moving a + different tab when the source window reorders during a drag. Existing-window + and Split View drops use the same transfer helper. +- **Move tab to a new window** β€” The ordinary tab context menu uses Chromium's + native move command and localized label. Dragging a tab onto the desktop + creates a window before transferring its contents; dragging the only tab + moves the existing window. Desktop drop detection and window placement use + primary-display global coordinates, including on displays of different sizes. + Native drag completion controls tear-off, while + successful drops and Escape cancellation only clean up drag state. Cross-window + folder drops are rejected; ordinary tab-list drops never remove a destination + window's folder membership by interpreting a foreign tab index. - **Detach guards** β€” Prevents accidental reordering while dragging - Patches: `tab_strip_model.cc.patch`, `tab_helpers.cc.patch` diff --git a/src/dao/browser/import/dao_chromium_migration_target.cc b/src/dao/browser/import/dao_chromium_migration_target.cc index 6a84fb82..9f4ad183 100644 --- a/src/dao/browser/import/dao_chromium_migration_target.cc +++ b/src/dao/browser/import/dao_chromium_migration_target.cc @@ -21,6 +21,8 @@ #include "chrome/browser/profiles/profile.h" #include "chrome/browser/resource_coordinator/lifecycle_unit_state.mojom.h" #include "chrome/browser/resource_coordinator/tab_lifecycle_unit_external.h" +#include "chrome/browser/sessions/session_service.h" +#include "chrome/browser/sessions/session_service_factory.h" #include "chrome/browser/ui/browser.h" #include "chrome/browser/ui/browser_finder.h" #include "chrome/browser/ui/browser_tabstrip.h" @@ -37,8 +39,8 @@ #include "components/password_manager/core/browser/password_store/password_store_interface.h" #include "components/password_manager/core/common/password_manager_pref_names.h" #include "components/prefs/pref_service.h" +#include "components/sessions/content/session_tab_helper.h" #include "dao/browser/ui/views/dao_tab_identity.h" -#include "dao/browser/ui/webui/dao_pinned_tab_storage.h" #include "dao/browser/ui/webui/dao_sidebar_ui.h" #include "extensions/browser/disable_reason.h" #include "extensions/browser/extension_registrar.h" @@ -62,6 +64,24 @@ std::pair PasswordKey(const PasswordEntry& entry) { return {entry.signon_realm, entry.username}; } +Browser* FindBrowserBySessionId(Profile* profile, SessionID session_id) { + ProfileBrowserCollection* collection = + ProfileBrowserCollection::GetForProfile(profile); + if (!collection) { + return nullptr; + } + Browser* result = nullptr; + collection->ForEach([&](BrowserWindowInterface* browser_window) { + Browser* browser = browser_window->GetBrowserForMigrationOnly(); + if (browser && browser->session_id() == session_id) { + result = browser; + return false; + } + return true; + }); + return result; +} + } // namespace DaoChromiumMigrationTarget::DaoChromiumMigrationTarget(Profile* profile) @@ -321,13 +341,15 @@ bool DaoChromiumMigrationTarget::AddDormantTab(const TabEntry& entry, if (!pending_id || *pending_id != folder_id) { return false; } - BrowserWindowInterface* browser_window = - chrome::FindLastActiveWithProfile(profile_); - if (!browser_window) { + Browser* browser = + pending_folder_window_id_ + ? FindBrowserBySessionId(profile_, *pending_folder_window_id_) + : nullptr; + if (!browser) { return false; } content::WebContents* contents = - chrome::AddAndReturnTabAt(browser_window, entry.url, -1, false); + chrome::AddAndReturnTabAt(browser, entry.url, -1, false); if (!contents) { return false; } @@ -355,18 +377,38 @@ bool DaoChromiumMigrationTarget::FinishImportedTabFolder( if (!pending_id || *pending_id != folder_id) { return false; } - bool persisted = false; - std::string json; - if (base::JSONWriter::WriteWithOptions( - folder_data_, base::JSONWriter::OPTIONS_PRETTY_PRINT, &json)) { - persisted = WritePinnedTabsFileAtomically( - profile_->GetPath().AppendASCII("dao_folders.json"), json); - if (persisted) { - DaoSidebarUIHandler::NotifyFolderDataChanged(profile_); - } + Browser* browser = + pending_folder_window_id_ + ? FindBrowserBySessionId(profile_, *pending_folder_window_id_) + : nullptr; + if (!browser) { + return false; } + std::set tab_ids; + TabStripModel* tab_strip = browser->tab_strip_model(); + for (int index = 0; index < tab_strip->count(); ++index) { + content::WebContents* contents = tab_strip->GetWebContentsAt(index); + tab_ids.insert(GetSidebarTabId(contents)); + } + const bool persisted = DaoSidebarUIHandler::PersistImportedFolder( + profile_, folder_snapshot_id_, std::move(tab_ids), + pending_folder_->Clone()); if (persisted) { + SessionService* session_service = + SessionServiceFactory::GetForProfile(profile_); + for (int index = 0; index < tab_strip->count(); ++index) { + content::WebContents* contents = tab_strip->GetWebContentsAt(index); + SetSidebarFolderSnapshotId(contents, folder_snapshot_id_); + sessions::SessionTabHelper* session_tab_helper = + sessions::SessionTabHelper::FromWebContents(contents); + if (session_service && session_tab_helper) { + session_service->AddTabExtraData( + session_tab_helper->window_id(), session_tab_helper->session_id(), + kSidebarFolderSnapshotSessionKey, folder_snapshot_id_); + } + } pending_folder_ = nullptr; + pending_folder_window_id_.reset(); pending_folder_tab_ids_.clear(); } return persisted; @@ -382,6 +424,7 @@ void DaoChromiumMigrationTarget::AbortImportedTabFolder( }); } pending_folder_ = nullptr; + pending_folder_window_id_.reset(); ProfileBrowserCollection* collection = ProfileBrowserCollection::GetForProfile(profile_); @@ -552,20 +595,49 @@ DaoChromiumMigrationTarget::FindOrCreateBookmarkParent( } bool DaoChromiumMigrationTarget::LoadFolderData() { - std::string contents; - const base::FilePath path = - profile_->GetPath().AppendASCII("dao_folders.json"); - if (base::PathExists(path)) { - if (!base::ReadFileToString(path, &contents)) { - return false; + pending_folder_window_id_.reset(); + folder_snapshot_id_.clear(); + folder_data_.clear(); + folder_items_ = nullptr; + pending_folder_ = nullptr; + pending_folder_tab_ids_.clear(); + std::vector preferred_ids; + std::set tab_ids; + BrowserWindowInterface* browser_window = + chrome::FindLastActiveWithProfile(profile_); + if (!browser_window) { + return false; + } + Browser* browser = browser_window->GetBrowserForMigrationOnly(); + pending_folder_window_id_ = browser->session_id(); + TabStripModel* tab_strip = browser->tab_strip_model(); + std::set seen_snapshot_ids; + for (int index = 0; index < tab_strip->count(); ++index) { + content::WebContents* tab = tab_strip->GetWebContentsAt(index); + tab_ids.insert(GetSidebarTabId(tab)); + std::string snapshot_id = GetSidebarFolderSnapshotId(tab); + if (!snapshot_id.empty() && seen_snapshot_ids.insert(snapshot_id).second) { + preferred_ids.push_back(std::move(snapshot_id)); } + } + + std::string snapshot_json; + if (!DaoSidebarUIHandler::LoadFolderSnapshotForImport( + profile_, preferred_ids, tab_ids, &folder_snapshot_id_, + &snapshot_json)) { + return false; + } + if (!snapshot_json.empty()) { std::optional parsed = - base::JSONReader::ReadDict(contents, base::JSON_PARSE_RFC); + base::JSONReader::ReadDict(snapshot_json, base::JSON_PARSE_RFC); if (!parsed) { return false; } folder_data_ = std::move(*parsed); } else { + if (folder_snapshot_id_.empty()) { + folder_snapshot_id_ = base::Uuid::GenerateRandomV4().AsLowercaseString(); + } folder_data_.Set("version", 1); folder_data_.Set("items", base::ListValue()); } diff --git a/src/dao/browser/import/dao_chromium_migration_target.h b/src/dao/browser/import/dao_chromium_migration_target.h index 2855e82f..632a6615 100644 --- a/src/dao/browser/import/dao_chromium_migration_target.h +++ b/src/dao/browser/import/dao_chromium_migration_target.h @@ -20,6 +20,7 @@ #include "base/task/cancelable_task_tracker.h" #include "base/values.h" #include "components/password_manager/core/browser/password_store/password_store_consumer.h" +#include "components/sessions/core/session_id.h" #include "dao/browser/import/dao_migration_writer.h" class Profile; @@ -112,9 +113,11 @@ class DaoChromiumMigrationTarget ItemWriteCallback pending_password_callback_; std::map> bookmark_folders_; + std::string folder_snapshot_id_; base::DictValue folder_data_; raw_ptr folder_items_ = nullptr; raw_ptr pending_folder_ = nullptr; + std::optional pending_folder_window_id_; std::vector pending_folder_tab_ids_; std::deque extension_queue_; scoped_refptr extension_installer_; diff --git a/src/dao/browser/ui/dao_ui_sources.gni b/src/dao/browser/ui/dao_ui_sources.gni index b7edc939..af10b2fa 100644 --- a/src/dao/browser/ui/dao_ui_sources.gni +++ b/src/dao/browser/ui/dao_ui_sources.gni @@ -215,6 +215,8 @@ dao_browser_ui_sources = [ "//dao/browser/ui/webui/dao_home_ui.h", "//dao/browser/ui/webui/dao_import_ui.cc", "//dao/browser/ui/webui/dao_import_ui.h", + "//dao/browser/ui/webui/dao_folder_storage.cc", + "//dao/browser/ui/webui/dao_folder_storage.h", "//dao/browser/ui/webui/dao_pinned_tab_model.cc", "//dao/browser/ui/webui/dao_pinned_tab_model.h", "//dao/browser/ui/webui/dao_pinned_tab_storage.cc", @@ -269,11 +271,13 @@ dao_browser_ui_test_sources = [ "//dao/browser/automation/dao_page_tools_browsertest.cc", "//dao/browser/automation/dao_tab_tools_browsertest.cc", "//dao/browser/ui/views/dao_browser_browsertest.cc", + "//dao/browser/ui/webui/dao_folder_storage_unittest.cc", "//dao/browser/ui/webui/dao_pinned_tab_model_unittest.cc", ] dao_browser_ui_mac_test_sources = [ "//dao/browser/mcp/dao_mcp_helper_browsertest.cc", + "//dao/browser/ui/views/dao_tab_drag_mac_browsertest.mm", "//dao/browser/ui/views/dao_test_init_mac.mm", ] diff --git a/src/dao/browser/ui/views/dao_browser_browsertest.cc b/src/dao/browser/ui/views/dao_browser_browsertest.cc index d2593945..1758ebf2 100644 --- a/src/dao/browser/ui/views/dao_browser_browsertest.cc +++ b/src/dao/browser/ui/views/dao_browser_browsertest.cc @@ -18,6 +18,7 @@ #include "base/functional/callback_helpers.h" #include "base/i18n/rtl.h" #include "base/i18n/time_formatting.h" +#include "base/json/json_reader.h" #include "base/memory/raw_ptr.h" #include "base/run_loop.h" #include "base/scoped_observation.h" @@ -66,6 +67,7 @@ #include "chrome/browser/ui/startup/startup_browser_creator.h" #include "chrome/browser/ui/startup/startup_browser_creator_impl.h" #include "chrome/browser/ui/startup/startup_types.h" +#include "chrome/browser/ui/tab_contents/chrome_web_contents_view_delegate.h" #include "chrome/browser/ui/tabs/tab_enums.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" #include "chrome/browser/ui/toolbar/back_forward_menu_model.h" @@ -110,6 +112,8 @@ #include "content/public/browser/picture_in_picture_window_controller.h" #include "content/public/browser/render_frame_host.h" #include "content/public/browser/web_contents.h" +#include "content/public/browser/web_contents_view_delegate.h" +#include "content/public/browser/web_ui.h" #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" #include "content/public/test/download_test_observer.h" @@ -172,6 +176,7 @@ #include "dao/browser/ui/views/sidebar/dao_tab_tooltip_view.h" #include "dao/browser/ui/views/split/dao_split_view.h" #include "dao/browser/ui/webui/dao_agent_ui.h" +#include "dao/browser/ui/webui/dao_folder_storage.h" #include "dao/browser/ui/webui/dao_sidebar_ui.h" #include "dao/browser/updater/dao_sparkle_update_session_state.h" #include "dao/browser/updater/dao_updater_service.h" @@ -198,6 +203,7 @@ #include "ui/base/clipboard/clipboard.h" #include "ui/base/clipboard/scoped_clipboard_writer.h" #include "ui/base/clipboard/test/clipboard_test_util.h" +#include "ui/base/dragdrop/mojom/drag_drop_types.mojom.h" #include "ui/base/hit_test.h" #include "ui/base/l10n/l10n_util.h" #include "ui/base/mojom/dialog_button.mojom.h" @@ -214,6 +220,7 @@ #include "ui/events/test/event_generator.h" #include "ui/gfx/animation/animation.h" #include "ui/gfx/canvas.h" +#include "ui/gfx/geometry/point_f.h" #include "ui/gfx/image/image.h" #include "ui/gfx/image/image_skia_rep.h" #include "ui/gfx/range/range.h" @@ -805,6 +812,26 @@ void AttachSidebarHandlerForTesting(Browser* browser, handler->SetBrowser(browser); } +DaoSidebarUIHandler* GetLiveSidebarHandlerForTesting(Browser* browser) { + BrowserView* browser_view = BrowserView::GetBrowserViewForBrowser(browser); + DaoSidebarView* sidebar = browser_view ? browser_view->dao_sidebar() : nullptr; + content::WebContents* contents = + sidebar ? sidebar->sidebar_web_contents() : nullptr; + content::WebUI* web_ui = contents ? contents->GetWebUI() : nullptr; + auto* controller = + web_ui ? static_cast(web_ui->GetController()) : nullptr; + return controller ? controller->handler_for_testing() : nullptr; +} + +bool DetachLiveSidebarHandlerForTesting(Browser* browser) { + DaoSidebarUIHandler* handler = GetLiveSidebarHandlerForTesting(browser); + if (!handler) { + return false; + } + handler->SetBrowser(nullptr); + return true; +} + class TestDaoSidebarUIHandler : public DaoSidebarUIHandler { public: using DaoSidebarUIHandler::set_web_ui; @@ -6374,6 +6401,235 @@ IN_PROC_BROWSER_TEST_F(DaoFolderPersistenceBrowserTest, EXPECT_FALSE(base::PathExists(folder_path)); } +IN_PROC_BROWSER_TEST_F(DaoFolderPersistenceBrowserTest, + IncognitoFoldersDoNotChangeRegularProfileFile) { + base::ScopedAllowBlockingForTesting allow_blocking; + Profile* profile = browser()->profile(); + const base::FilePath folder_path = + profile->GetPath().AppendASCII("dao_folders.json"); + + ASSERT_TRUE(base::test::RunUntil( + [&]() { return DetachLiveSidebarHandlerForTesting(browser()); })); + content::TestWebUI regular_web_ui; + TestDaoSidebarUIHandler regular_handler; + regular_handler.set_web_ui(®ular_web_ui); + AttachSidebarHandlerForTesting(browser(), ®ular_handler); + regular_handler.LoadFoldersForTesting("regularLoad"); + ASSERT_TRUE(base::test::RunUntil([&]() { + return !regular_handler.folder_snapshot_id_for_testing().empty(); + })); + constexpr char kRegularFolders[] = + R"({"version":1,"items":[{"type":"folder","id":"regular-folder","name":"Regular","collapsed":false,"children":[]}]})"; + regular_handler.SaveFoldersForTesting(kRegularFolders); + DaoSidebarUIHandler::WaitForFolderFileTasksForTesting(); + + std::string regular_disk_contents; + ASSERT_TRUE(base::ReadFileToString(folder_path, ®ular_disk_contents)); + + Browser* incognito_browser = CreateIncognitoBrowser(profile); + ASSERT_NE(nullptr, incognito_browser); + ASSERT_TRUE(incognito_browser->profile()->IsOffTheRecord()); + ASSERT_EQ(profile->GetPath(), incognito_browser->profile()->GetPath()); + ASSERT_TRUE(base::test::RunUntil([&]() { + return DetachLiveSidebarHandlerForTesting(incognito_browser); + })); + content::TestWebUI incognito_web_ui; + TestDaoSidebarUIHandler incognito_handler; + incognito_handler.set_web_ui(&incognito_web_ui); + AttachSidebarHandlerForTesting(incognito_browser, &incognito_handler); + incognito_handler.LoadFoldersForTesting("incognitoLoad"); + ASSERT_TRUE(base::test::RunUntil([&]() { + return !incognito_handler.folder_snapshot_id_for_testing().empty(); + })); + EXPECT_TRUE(incognito_handler.folder_json_for_testing().empty()); + + constexpr char kIncognitoFolders[] = + R"({"version":1,"items":[{"type":"folder","id":"incognito-folder","name":"Incognito","collapsed":false,"children":[]}]})"; + incognito_handler.SaveFoldersForTesting(kIncognitoFolders); + DaoSidebarUIHandler::WaitForFolderFileTasksForTesting(); + + std::string final_disk_contents; + ASSERT_TRUE(base::ReadFileToString(folder_path, &final_disk_contents)); + EXPECT_EQ(regular_disk_contents, final_disk_contents); + EXPECT_EQ(kIncognitoFolders, incognito_handler.folder_json_for_testing()); +} + +IN_PROC_BROWSER_TEST_F(DaoFolderPersistenceBrowserTest, + ImportPreservesBothWindowSnapshots) { + base::ScopedAllowBlockingForTesting allow_blocking; + Profile* profile = browser()->profile(); + ASSERT_TRUE(base::test::RunUntil( + [&]() { return DetachLiveSidebarHandlerForTesting(browser()); })); + content::TestWebUI first_web_ui; + TestDaoSidebarUIHandler first_handler; + first_handler.set_web_ui(&first_web_ui); + AttachSidebarHandlerForTesting(browser(), &first_handler); + first_handler.LoadFoldersForTesting("firstLoad"); + ASSERT_TRUE(base::test::RunUntil([&]() { + return !first_handler.folder_snapshot_id_for_testing().empty(); + })); + + constexpr char kFirstFolders[] = + R"({"version":1,"items":[{"type":"folder","id":"first-folder","name":"First","collapsed":false,"children":[]}]})"; + first_handler.SaveFoldersForTesting(kFirstFolders); + + Browser* second_browser = CreateBrowser(profile); + ASSERT_NE(nullptr, second_browser); + ASSERT_TRUE(base::test::RunUntil([&]() { + return DetachLiveSidebarHandlerForTesting(second_browser); + })); + content::TestWebUI second_web_ui; + TestDaoSidebarUIHandler second_handler; + second_handler.set_web_ui(&second_web_ui); + AttachSidebarHandlerForTesting(second_browser, &second_handler); + second_handler.LoadFoldersForTesting("secondLoad"); + ASSERT_TRUE(base::test::RunUntil([&]() { + return !second_handler.folder_snapshot_id_for_testing().empty(); + })); + + const std::string first_snapshot_id = + first_handler.folder_snapshot_id_for_testing(); + const std::string second_snapshot_id = + second_handler.folder_snapshot_id_for_testing(); + ASSERT_FALSE(first_snapshot_id.empty()); + ASSERT_FALSE(second_snapshot_id.empty()); + ASSERT_NE(first_snapshot_id, second_snapshot_id); + + second_browser->window()->Activate(); + base::RunLoop().RunUntilIdle(); + dao::import::DaoChromiumMigrationTarget target(profile); + dao::import::DaoMigrationWriter writer(&target); + dao::import::TabEntry tab; + tab.url = GURL("https://multi-window-import.example/"); + tab.title = u"Imported"; + std::string imported_folder_id; + const dao::import::WriteResult result = + writer.WriteTabsBatch({tab}, u"Imported tabs", &imported_folder_id); + ASSERT_EQ(1u, result.imported); + ASSERT_FALSE(imported_folder_id.empty()); + + // Changing the active window must not retarget the pending import. + browser()->window()->Activate(); + base::RunLoop().RunUntilIdle(); + ASSERT_TRUE(writer.FinishTabs(imported_folder_id)); + + // The second window had only a provisional identity and had never saved. + // The import must keep that identity so the live handler sees the folder. + std::optional live_second_folders = + base::JSONReader::ReadDict(second_handler.folder_json_for_testing(), + base::JSON_PARSE_RFC); + ASSERT_TRUE(live_second_folders); + const base::ListValue* live_second_items = + live_second_folders->FindList("items"); + ASSERT_NE(nullptr, live_second_items); + ASSERT_NE(nullptr, FindDictByStringField(*live_second_items, "id", + imported_folder_id)); + + std::string persisted_json; + ASSERT_TRUE(base::ReadFileToString( + profile->GetPath().AppendASCII("dao_folders.json"), &persisted_json)); + DaoFolderStorage storage; + ASSERT_TRUE(storage.LoadFromJson(persisted_json)); + const DaoFolderWindowSnapshot* first_snapshot = + storage.FindById(first_snapshot_id); + ASSERT_NE(nullptr, first_snapshot); + EXPECT_EQ(kFirstFolders, first_snapshot->json); + const DaoFolderWindowSnapshot* second_snapshot = + storage.FindById(second_snapshot_id); + ASSERT_NE(nullptr, second_snapshot); + + std::optional second_folders = + base::JSONReader::ReadDict(second_snapshot->json, base::JSON_PARSE_RFC); + ASSERT_TRUE(second_folders); + const base::ListValue* items = second_folders->FindList("items"); + ASSERT_NE(nullptr, items); + EXPECT_NE(nullptr, FindDictByStringField(*items, "id", imported_folder_id)); +} + +IN_PROC_BROWSER_TEST_F(DaoFolderPersistenceBrowserTest, + CrossWindowTransferRebindsFolderSnapshotIdentity) { + ASSERT_TRUE(base::test::RunUntil( + [&]() { return DetachLiveSidebarHandlerForTesting(browser()); })); + content::TestWebUI first_web_ui; + TestDaoSidebarUIHandler first_handler; + first_handler.set_web_ui(&first_web_ui); + AttachSidebarHandlerForTesting(browser(), &first_handler); + first_handler.LoadFoldersForTesting("firstLoad"); + ASSERT_TRUE(base::test::RunUntil([&]() { + return !first_handler.folder_snapshot_id_for_testing().empty(); + })); + + chrome::AddTabAt(browser(), GURL("about:blank"), -1, true); + TabStripModel* first_model = browser()->tab_strip_model(); + content::WebContents* moving = first_model->GetActiveWebContents(); + ASSERT_NE(nullptr, moving); + const std::string moving_tab_id = GetSidebarTabId(moving); + constexpr char kFirstFolders[] = + R"({"version":1,"items":[{"type":"folder","id":"first-folder","name":"First","collapsed":false,"children":[]}]})"; + first_handler.SaveFoldersForTesting(kFirstFolders); + + Browser* second_browser = CreateBrowser(browser()->profile()); + ASSERT_NE(nullptr, second_browser); + ASSERT_TRUE(base::test::RunUntil([&]() { + return DetachLiveSidebarHandlerForTesting(second_browser); + })); + content::TestWebUI second_web_ui; + TestDaoSidebarUIHandler second_handler; + second_handler.set_web_ui(&second_web_ui); + AttachSidebarHandlerForTesting(second_browser, &second_handler); + second_handler.LoadFoldersForTesting("secondLoad"); + ASSERT_TRUE(base::test::RunUntil([&]() { + return !second_handler.folder_snapshot_id_for_testing().empty(); + })); + ASSERT_TRUE(second_handler.folder_json_for_testing().empty()); + + const std::string first_snapshot_id = + first_handler.folder_snapshot_id_for_testing(); + const std::string second_snapshot_id = + second_handler.folder_snapshot_id_for_testing(); + ASSERT_NE(first_snapshot_id, second_snapshot_id); + ASSERT_EQ(first_snapshot_id, GetSidebarFolderSnapshotId(moving)); + + const int moving_index = first_model->GetIndexOfWebContents(moving); + ASSERT_NE(TabStripModel::kNoTab, moving_index); + ASSERT_TRUE(ExecuteCrossWindowTabMove(second_browser, + browser()->session_id().id(), + moving_index, 0, moving_tab_id)); + ASSERT_EQ(moving, second_browser->tab_strip_model()->GetWebContentsAt(0)); + EXPECT_EQ(second_snapshot_id, GetSidebarFolderSnapshotId(moving)); + + std::string import_snapshot_id; + std::string import_folder_json; + ASSERT_TRUE(DaoSidebarUIHandler::LoadFolderSnapshotForImport( + browser()->profile(), {second_snapshot_id}, {moving_tab_id}, + &import_snapshot_id, &import_folder_json)); + EXPECT_EQ(second_snapshot_id, import_snapshot_id); + EXPECT_TRUE(import_folder_json.empty()); + + std::map extra_data; + PopulateSidebarTabIdentityExtraData(moving, &extra_data); + EXPECT_EQ(second_snapshot_id, extra_data[kSidebarFolderSnapshotSessionKey]); + + SessionService* session_service = + SessionServiceFactory::GetForProfile(browser()->profile()); + ASSERT_NE(nullptr, session_service); + session_service->ResetFromCurrentBrowsers(); + SessionServiceTestHelper session_service_helper(session_service); + sessions::SessionTabHelper* session_tab_helper = + sessions::SessionTabHelper::FromWebContents(moving); + ASSERT_NE(nullptr, session_tab_helper); + std::unique_ptr expected_command = + sessions::CreateAddTabExtraDataCommand(session_tab_helper->session_id(), + kSidebarFolderSnapshotSessionKey, + second_snapshot_id); + EXPECT_TRUE(std::ranges::any_of( + session_service_helper.command_storage_manager()->pending_commands(), + [&expected_command](const auto& command) { + return command->id() == expected_command->id() && + command->contents() == expected_command->contents(); + })); +} + // ============================================================================= // DaoPipTopBarOverlayBrowserTest // @@ -10581,6 +10837,108 @@ IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, ParsePayload_Valid) { EXPECT_EQ(5, idx); } +IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, + StableIdentitySurvivesSourceReorder) { + int sid = 0; + int index = -1; + std::string tab_id; + ASSERT_TRUE(dao::ParseDaoTabDragPayload("dao-tab-drag:1234:1:dragged-tab", + &sid, &index, &tab_id)); + EXPECT_EQ("dragged-tab", tab_id); + EXPECT_FALSE(dao::ParseDaoTabDragPayload("dao-tab-drag:1234:-1:tab", &sid, + &index, &tab_id)); + EXPECT_FALSE(dao::ParseDaoTabDragPayload("dao-tab-drag:1234:1:", &sid, &index, + &tab_id)); + EXPECT_FALSE(dao::ParseDaoTabDragPayload("dao-tab-drag:+1234:1:tab", &sid, + &index, &tab_id)); + EXPECT_FALSE(dao::ParseDaoTabDragPayload("dao-tab-drag:1234:+1:tab", &sid, + &index, &tab_id)); + + chrome::AddTabAt(browser(), GURL("about:blank"), -1, true); + auto* model = browser()->tab_strip_model(); + auto* moving = model->GetWebContentsAt(1); + dao::SetSidebarTabId(moving, "dragged-tab"); + model->MoveWebContentsAt(1, 0, true); + Browser* target = CreateBrowser(browser()->profile()); + ASSERT_TRUE(dao::ExecuteCrossWindowTabMove( + target, browser()->session_id().id(), 1, 0, "dragged-tab")); + EXPECT_EQ(moving, target->tab_strip_model()->GetWebContentsAt(0)); + EXPECT_FALSE(dao::ExecuteCrossWindowTabMove( + target, browser()->session_id().id(), 0, 0, "dragged-tab")); + EXPECT_EQ(1, model->count()); +} + +IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, + TearOffPreservesContentsAndMovesOnlyWindowForLastTab) { + auto* model = browser()->tab_strip_model(); + ASSERT_EQ(1, model->count()); + auto* original = model->GetWebContentsAt(0); + const std::string original_id = dao::GetOrCreateSidebarTabId(original); + EXPECT_EQ(browser(), dao::DetachTabToNewWindow(browser(), original_id, + gfx::Point(400, 300))); + EXPECT_EQ(original, model->GetWebContentsAt(0)); + + auto* moving = + chrome::AddAndReturnTabAt(browser(), GURL("about:blank"), -1, true); + ASSERT_NE(nullptr, moving); + ASSERT_NE(original, moving); + const std::string moving_id = dao::GetOrCreateSidebarTabId(moving); + Browser* target = + dao::DetachTabToNewWindow(browser(), moving_id, gfx::Point(500, 300)); + ASSERT_NE(nullptr, target); + ASSERT_NE(browser(), target); + EXPECT_EQ(1, model->count()); + EXPECT_EQ(original, model->GetWebContentsAt(0)); + EXPECT_EQ(moving, target->tab_strip_model()->GetActiveWebContents()); + EXPECT_EQ(nullptr, dao::DetachTabToNewWindow(browser(), moving_id, + gfx::Point(500, 300))); +} + +IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, + NativeCompletionRequiresPhysicalReleaseAndSettlesOnce) { + content::WebContents* web_contents = + browser()->tab_strip_model()->GetActiveWebContents(); + ASSERT_NE(nullptr, web_contents); + std::unique_ptr view_delegate = + CreateWebContentsViewDelegate(web_contents); + ASSERT_TRUE(view_delegate); + const gfx::Point outside_point(-10000, -10000); + int tear_off_count = 0; + gfx::Point tear_off_point; + + auto observe_completion = [&] { + dao::ObserveTabDragNativeCompletion( + web_contents, + base::BindLambdaForTesting([&](const gfx::Point& screen_point) mutable { + ++tear_off_count; + tear_off_point = screen_point; + })); + }; + auto end_drag = [&](bool drop_accepted, bool ended_by_mouse_release) { + view_delegate->WebContentsDragEnded( + gfx::PointF(outside_point.x(), outside_point.y()), + drop_accepted ? ui::mojom::DragOperation::kMove + : ui::mojom::DragOperation::kNone, + ended_by_mouse_release); + }; + + observe_completion(); + end_drag(/*drop_accepted=*/false, /*ended_by_mouse_release=*/false); + end_drag(/*drop_accepted=*/false, /*ended_by_mouse_release=*/true); + EXPECT_EQ(0, tear_off_count); + + observe_completion(); + end_drag(/*drop_accepted=*/true, /*ended_by_mouse_release=*/true); + end_drag(/*drop_accepted=*/false, /*ended_by_mouse_release=*/true); + EXPECT_EQ(0, tear_off_count); + + observe_completion(); + end_drag(/*drop_accepted=*/false, /*ended_by_mouse_release=*/true); + end_drag(/*drop_accepted=*/false, /*ended_by_mouse_release=*/true); + EXPECT_EQ(1, tear_off_count); + EXPECT_EQ(outside_point, tear_off_point); +} + IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, ParsePayload_MissingPrefix) { int sid = 0, idx = 0; @@ -10675,6 +11033,42 @@ IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, EXPECT_EQ(2, split->PaneCount()); } +IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, + CrossWindowDropAddsPaneToExistingSplit) { + chrome::AddTabAt(browser(), GURL(url::kAboutBlankURL), -1, true); + auto* source_model = browser()->tab_strip_model(); + auto* moving = source_model->GetWebContentsAt(1); + const std::string moving_id = dao::GetOrCreateSidebarTabId(moving); + + Browser* target = CreateBrowser(browser()->profile()); + chrome::AddTabAt(target, GURL(url::kAboutBlankURL), -1, true); + auto* target_model = target->tab_strip_model(); + auto* first = target_model->GetWebContentsAt(0); + auto* second = target_model->GetWebContentsAt(1); + target_model->ActivateTabAt(0); + BrowserView* target_view = GetBrowserView(target); + DaoSplitView* split = target_view->dao_split_view(); + ASSERT_NE(nullptr, split); + ASSERT_TRUE( + split->SplitPane(first, SplitDirection::kHorizontal, false, second)); + target_view->DeprecatedLayoutImmediately(); + + const std::string payload = base::StrCat( + {dao::kDaoTabDragPrefix, + base::NumberToString(browser()->session_id().id()), ":1:", moving_id}); + ASSERT_TRUE(split->ProcessNativeTabDrop( + gfx::Point(split->width() / 4, split->height() / 2), payload)); + ASSERT_TRUE( + base::test::RunUntil([&] { return split->IsActiveSplitTab(moving); })); + + EXPECT_EQ(3, split->PaneCount()); + EXPECT_TRUE(split->IsActiveSplitTab(second)); + EXPECT_TRUE(split->IsActiveSplitTab(first)); + EXPECT_EQ(moving, target_model->GetActiveWebContents()); + EXPECT_EQ(1, source_model->count()); + EXPECT_EQ(3, target_model->count()); +} + IN_PROC_BROWSER_TEST_F(DaoCrossWindowDragBrowserTest, NullTargetReturnsFalse) { EXPECT_FALSE(dao::ExecuteCrossWindowTabMove( nullptr, browser()->session_id().id(), 0, 0)); diff --git a/src/dao/browser/ui/views/dao_cross_window_drag.cc b/src/dao/browser/ui/views/dao_cross_window_drag.cc index 3b51e8ea..d9a5d124 100644 --- a/src/dao/browser/ui/views/dao_cross_window_drag.cc +++ b/src/dao/browser/ui/views/dao_cross_window_drag.cc @@ -5,20 +5,19 @@ #include "dao/browser/ui/views/dao_cross_window_drag.h" #include -#include -#include #include "base/strings/string_number_conversions.h" #include "base/strings/string_util.h" #include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_commands.h" #include "chrome/browser/ui/browser_window.h" #include "chrome/browser/ui/browser_window/public/browser_window_interface.h" #include "chrome/browser/ui/browser_window/public/profile_browser_collection.h" -#include "chrome/browser/ui/tabs/tab_enums.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" #include "chrome/browser/ui/views/frame/browser_view.h" #include "components/sessions/core/session_id.h" #include "content/public/browser/web_contents.h" +#include "dao/browser/ui/views/dao_tab_identity.h" #include "dao/browser/ui/views/split/dao_split_view.h" #include "ui/gfx/geometry/point.h" #include "ui/gfx/geometry/rect.h" @@ -28,7 +27,8 @@ namespace dao { bool ParseDaoTabDragPayload(const std::string& payload, int* source_session_id, - int* tab_index) { + int* tab_index, + std::string* tab_id) { if (!base::StartsWith(payload, kDaoTabDragPrefix, base::CompareCase::SENSITIVE)) { return false; @@ -36,14 +36,27 @@ bool ParseDaoTabDragPayload(const std::string& payload, const std::string body = payload.substr(std::string(kDaoTabDragPrefix).size()); const size_t colon = body.find(':'); - if (colon == std::string::npos || colon == 0 || - colon + 1 >= body.size()) { + if (colon == std::string::npos || colon == 0 || colon + 1 >= body.size()) { return false; } int sid = 0; int idx = 0; - if (!base::StringToInt(body.substr(0, colon), &sid) || - !base::StringToInt(body.substr(colon + 1), &idx)) { + const size_t identity_colon = body.find(':', colon + 1); + const std::string identity = identity_colon == std::string::npos + ? std::string() + : body.substr(identity_colon + 1); + const std::string session_part = body.substr(0, colon); + const std::string index_part = + body.substr(colon + 1, identity_colon == std::string::npos + ? std::string::npos + : identity_colon - colon - 1); + if (!base::ContainsOnlyChars(session_part, "0123456789") || + !base::ContainsOnlyChars(index_part, "0123456789") || + !base::StringToInt(session_part, &sid) || + !base::StringToInt(index_part, &idx) || sid <= 0 || idx < 0 || + (identity_colon != std::string::npos && + (identity.empty() || + identity.find_first_of(": \t\r\n\f\v") != std::string::npos))) { return false; } if (source_session_id) { @@ -52,9 +65,57 @@ bool ParseDaoTabDragPayload(const std::string& payload, if (tab_index) { *tab_index = idx; } + if (tab_id) { + *tab_id = identity; + } return true; } +int ResolveDraggedTabIndex(Browser* browser, + int tab_index, + const std::string& tab_id) { + if (!browser) { + return TabStripModel::kNoTab; + } + TabStripModel* model = browser->tab_strip_model(); + if (!tab_id.empty()) { + for (int i = 0; i < model->count(); ++i) { + if (GetSidebarTabId(model->GetWebContentsAt(i)) == tab_id) { + return i; + } + } + return TabStripModel::kNoTab; + } + return model->ContainsIndex(tab_index) ? tab_index : TabStripModel::kNoTab; +} + +Browser* DetachTabToNewWindow(Browser* source_browser, + const std::string& tab_id, + const gfx::Point& screen_point) { + const int index = ResolveDraggedTabIndex(source_browser, -1, tab_id); + if (index == TabStripModel::kNoTab || !source_browser->is_type_normal()) { + return nullptr; + } + gfx::Rect bounds = source_browser->window()->GetBounds(); + bounds.set_origin( + gfx::Point(screen_point.x() - bounds.width() / 4, screen_point.y() - 40)); + if (source_browser->tab_strip_model()->count() == 1) { + source_browser->window()->SetBounds(bounds); + source_browser->window()->Activate(); + return source_browser; + } + Browser::CreateParams params(source_browser->profile(), true); + params.initial_bounds = bounds; + Browser* target = Browser::Create(params); + if (!target) { + return nullptr; + } + chrome::MoveTabsToExistingWindow(source_browser, target, {index}); + target->window()->Show(); + target->window()->Activate(); + return target; +} + namespace { DaoSplitView* SplitViewFor(Browser* browser) { @@ -108,7 +169,8 @@ bool PerformSplitTabDrop(Browser* target_browser, bool ExecuteCrossWindowTabMove(Browser* target_browser, int source_session_id, int source_tab_index, - int target_insert_index) { + int target_insert_index, + const std::string& tab_id) { if (!target_browser) { return false; } @@ -119,40 +181,35 @@ bool ExecuteCrossWindowTabMove(Browser* target_browser, collection ? collection->FindBrowserWithID( SessionID::FromSerializedValue(source_session_id)) : nullptr; - Browser* source_browser = source_browser_window - ? source_browser_window - ->GetBrowserForMigrationOnly() - : nullptr; + Browser* source_browser = + source_browser_window + ? source_browser_window->GetBrowserForMigrationOnly() + : nullptr; if (!source_browser || source_browser == target_browser) { return false; } TabStripModel* source_model = source_browser->tab_strip_model(); - if (source_tab_index < 0 || - source_tab_index >= source_model->count()) { - return false; - } - - std::unique_ptr detached = - source_model->DetachWebContentsAtForInsertion(source_tab_index); - if (!detached) { + source_tab_index = + ResolveDraggedTabIndex(source_browser, source_tab_index, tab_id); + if (source_tab_index < 0 || source_tab_index >= source_model->count()) { return false; } + content::WebContents* contents = + source_model->GetWebContentsAt(source_tab_index); TabStripModel* target_model = target_browser->tab_strip_model(); - int insert_at = target_insert_index; - if (insert_at < 0) { - insert_at = 0; - } - if (insert_at > target_model->count()) { - insert_at = target_model->count(); - } - target_model->InsertWebContentsAt(insert_at, std::move(detached), - AddTabTypes::ADD_ACTIVE); - - if (source_model->count() == 0) { - source_browser->window()->Close(); + const int insert_at = + target_insert_index < 0 + ? target_model->count() + : std::min(target_insert_index, target_model->count()); + chrome::MoveTabsToExistingWindow(source_browser, target_browser, + {source_tab_index}); + const int moved_index = target_model->GetIndexOfWebContents(contents); + if (moved_index == TabStripModel::kNoTab) { + return false; } + target_model->MoveWebContentsAt(moved_index, insert_at, true); return true; } diff --git a/src/dao/browser/ui/views/dao_cross_window_drag.h b/src/dao/browser/ui/views/dao_cross_window_drag.h index 31037281..863d964c 100644 --- a/src/dao/browser/ui/views/dao_cross_window_drag.h +++ b/src/dao/browser/ui/views/dao_cross_window_drag.h @@ -21,13 +21,25 @@ namespace dao { // the drop handler (dao_native_util_mac.mm) stay in sync. inline constexpr char kDaoTabDragPrefix[] = "dao-tab-drag:"; -// Parse a "dao-tab-drag::" payload into components. +// Parse "dao-tab-drag::[:]". // Returns true on success; false if the prefix is missing, the body is // malformed, or either integer fails to parse. On failure, the out // parameters are left unchanged. bool ParseDaoTabDragPayload(const std::string& payload, int* source_session_id, - int* tab_index); + int* tab_index, + std::string* tab_id = nullptr); + +// A supplied stable ID is authoritative; never fall back to a stale index. +int ResolveDraggedTabIndex(Browser* browser, + int tab_index, + const std::string& tab_id); + +// Move the only tab's window, or create a window before transferring the tab. +// Returns the destination window, or nullptr if the tab no longer exists. +Browser* DetachTabToNewWindow(Browser* source_browser, + const std::string& tab_id, + const gfx::Point& screen_point); // Called from macOS DaoEventInterceptor's draggingUpdated: to refresh the // native split-drop indicator overlay. |point_in_browser_view| is in the @@ -53,9 +65,8 @@ bool PerformSplitTabDrop(Browser* target_browser, // // Given a target Browser and the source's session_id + tab_index, detaches // the WebContents from the source browser's TabStripModel and inserts it -// into the target browser's TabStripModel at target_insert_index. Clamps -// target_insert_index into [0, target.tab_count]. Closes the source -// browser window when its tab strip becomes empty. +// into the target browser's TabStripModel at target_insert_index. Negative or +// oversized target indices append. Chromium closes an emptied source window. // // Returns true on success. Returns false if: // - target_browser is null @@ -67,7 +78,8 @@ bool PerformSplitTabDrop(Browser* target_browser, bool ExecuteCrossWindowTabMove(Browser* target_browser, int source_session_id, int source_tab_index, - int target_insert_index); + int target_insert_index, + const std::string& tab_id = {}); } // namespace dao diff --git a/src/dao/browser/ui/views/dao_native_util_mac.h b/src/dao/browser/ui/views/dao_native_util_mac.h index 6b65886e..5d89f150 100644 --- a/src/dao/browser/ui/views/dao_native_util_mac.h +++ b/src/dao/browser/ui/views/dao_native_util_mac.h @@ -5,6 +5,10 @@ #ifndef DAO_BROWSER_UI_VIEWS_DAO_NATIVE_UTIL_MAC_H_ #define DAO_BROWSER_UI_VIEWS_DAO_NATIVE_UTIL_MAC_H_ +#include "base/functional/callback.h" +#include "ui/base/dragdrop/mojom/drag_drop_types.mojom-forward.h" +#include "ui/gfx/geometry/point.h" +#include "ui/gfx/geometry/point_f.h" #include "ui/gfx/native_ui_types.h" namespace content { @@ -13,6 +17,9 @@ class WebContents; namespace dao { +using TabDragTearOffCallback = + base::OnceCallback; + // Place a transparent event-intercepting NSView on top of the web content's // native view. Mouse events are captured by the interceptor and forwarded // to the compositor view (BridgedContentView), which dispatches them through @@ -23,6 +30,25 @@ void BlockWebContentNativeEvents(content::WebContents* web_contents); // Remove the interceptor, restoring normal event flow to the web content. void UnblockWebContentNativeEvents(content::WebContents* web_contents); +// Observe the native drag session started by |source_web_contents|. The +// callback runs once only when AppKit ends an unaccepted drag with a physical +// mouse release and the release is outside every Dao browser window. Successful +// drops, Escape cancellation, non-mouse termination, and source destruction +// only clean up drag state. A later call replaces any pending observation. +void ObserveTabDragNativeCompletion(content::WebContents* source_web_contents, + TabDragTearOffCallback tear_off_callback); + +// Settle the pending native tab drag for |source_web_contents|. The completion +// is ignored when it belongs to another WebContents or no drag is pending. +void CompleteTabDragNativeCompletion(content::WebContents* source_web_contents, + const gfx::PointF& screen_point, + ui::mojom::DragOperation operation, + bool ended_by_mouse_release); + +// Cancel the pending native tab drag when its source WebContents is destroyed. +// The completion is ignored when it belongs to another WebContents. +void CancelTabDragNativeCompletion(content::WebContents* source_web_contents); + // Finish a native tab drag in every browser window. This removes native event // interceptors and resets DaoSplitView's drag-only hit-testing state even when // the WebUI dragend event was not delivered. @@ -33,11 +59,12 @@ void EndTabDragNativeEvents(); // sticky DaoSplitView::tab_drag_active_ state: every logical drag-end path // (native drop, draggingEnded, WebUI dragend, cancel, source-window teardown) // shares one physical invariant β€” the mouse button comes up. Idempotent: arming -// while already armed is a no-op, so fanning this out across all windows is safe. +// while already armed is a no-op, so fanning this out across all windows is +// safe. void ArmTabDragWatchdog(); -// Stop the tab-drag watchdog. Idempotent. Called from EndTabDragNativeEvents via -// SetTabDragActive(false), and directly when a drag ends cleanly. +// Stop the tab-drag watchdog. Idempotent. Called from EndTabDragNativeEvents +// via SetTabDragActive(false), and directly when a drag ends cleanly. void StopTabDragWatchdog(); // Move macOS traffic light buttons (close/minimize/zoom) to the given diff --git a/src/dao/browser/ui/views/dao_native_util_mac.mm b/src/dao/browser/ui/views/dao_native_util_mac.mm index 5389c605..45373fec 100644 --- a/src/dao/browser/ui/views/dao_native_util_mac.mm +++ b/src/dao/browser/ui/views/dao_native_util_mac.mm @@ -6,7 +6,11 @@ #import +#include +#include + #include "base/logging.h" +#include "base/memory/raw_ptr.h" #include "base/no_destructor.h" #include "base/strings/string_number_conversions.h" #include "base/strings/string_util.h" @@ -23,7 +27,9 @@ #include "dao/browser/ui/views/dao_cross_window_drag.h" #include "dao/browser/ui/views/sidebar/dao_sidebar_view.h" #include "dao/browser/ui/views/split/dao_split_view.h" +#include "ui/base/dragdrop/mojom/drag_drop_types.mojom.h" #include "ui/gfx/geometry/point.h" +#include "ui/gfx/geometry/point_conversions.h" #include "ui/gfx/geometry/rect.h" namespace { @@ -34,6 +40,9 @@ // contentView after a cross-window drag and block later WebContents events. void ClearAllDaoEventInterceptors(); +class TabDragNativeCompletion; +std::unique_ptr& TabDragNativeCompletionStorage(); + // Drive DaoSplitView's drop-zone overlay from the native interceptor. // Looks up the Browser that owns |window|, converts the Cocoa drag // location into DaoSplitView-local coords, and forwards to @@ -122,7 +131,8 @@ bool HandleDaoTabDrop(NSWindow* target_window, const std::string text = base::SysNSStringToUTF8(payload); int source_sid = 0; int tab_index = 0; - if (!dao::ParseDaoTabDragPayload(text, &source_sid, &tab_index)) { + std::string tab_id; + if (!dao::ParseDaoTabDragPayload(text, &source_sid, &tab_index, &tab_id)) { LOG(ERROR) << "[Dao-Xwin] HandleDaoTabDrop: malformed payload"; return false; } @@ -153,7 +163,7 @@ bool HandleDaoTabDrop(NSWindow* target_window, const int insert_at = ComputeCrossWindowInsertIndex( target_browser, target_window, cursor_in_window_cocoa); const bool ok = dao::ExecuteCrossWindowTabMove(target_browser, source_sid, - tab_index, insert_at); + tab_index, insert_at, tab_id); LOG(ERROR) << "[Dao-Xwin] HandleDaoTabDrop: moved tab " << tab_index << " from sid=" << source_sid << " (insert_at=" << insert_at << ", success=" << ok << ")"; @@ -404,6 +414,74 @@ void HideNativeSplitIndicatorForWindow(NSWindow* window) { } } +bool IsOutsideEveryBrowserWindow(const gfx::Point& screen_point) { + for (BrowserWindowInterface* browser_window : + GetAllBrowserWindowInterfaces()) { + Browser* browser = + browser_window ? browser_window->GetBrowserForMigrationOnly() : nullptr; + if (browser && browser->window() && !browser->window()->IsMinimized() && + browser->window()->IsVisibleOnScreen() && + browser->window()->GetBounds().Contains(screen_point)) { + return false; + } + } + return true; +} + +class TabDragNativeCompletion final { + public: + TabDragNativeCompletion(content::WebContents* source_web_contents, + dao::TabDragTearOffCallback tear_off_callback) + : source_web_contents_(source_web_contents), + tear_off_callback_(std::move(tear_off_callback)) {} + + void Complete(content::WebContents* source_web_contents, + const gfx::PointF& screen_point, + ui::mojom::DragOperation operation, + bool ended_by_mouse_release) { + if (source_web_contents != source_web_contents_) { + return; + } + const gfx::Point rounded_screen_point = gfx::ToRoundedPoint(screen_point); + const bool should_tear_off = + operation == ui::mojom::DragOperation::kNone && + ended_by_mouse_release && + IsOutsideEveryBrowserWindow(rounded_screen_point); + Settle(should_tear_off ? &rounded_screen_point : nullptr); + } + + void Cancel(content::WebContents* source_web_contents) { + if (source_web_contents == source_web_contents_) { + Settle(nullptr); + } + } + + private: + void Settle(const gfx::Point* tear_off_point) { + if (settled_) { + return; + } + settled_ = true; + source_web_contents_ = nullptr; + + dao::TabDragTearOffCallback callback = std::move(tear_off_callback_); + dao::EndTabDragNativeEvents(); + if (tear_off_point && callback) { + std::move(callback).Run(*tear_off_point); + } + } + + raw_ptr source_web_contents_; + dao::TabDragTearOffCallback tear_off_callback_; + bool settled_ = false; +}; + +std::unique_ptr& TabDragNativeCompletionStorage() { + static base::NoDestructor> + completion; + return *completion; +} + NSRect TargetFrameForWebContentsInContentView(NSView* native, NSView* content_view) { if (!native || !content_view) { @@ -439,6 +517,35 @@ void ClearAllDaoEventInterceptors() { namespace dao { +void ObserveTabDragNativeCompletion(content::WebContents* source_web_contents, + TabDragTearOffCallback tear_off_callback) { + auto& completion = TabDragNativeCompletionStorage(); + completion.reset(); + if (!source_web_contents || !tear_off_callback) { + return; + } + completion = std::make_unique( + source_web_contents, std::move(tear_off_callback)); +} + +void CompleteTabDragNativeCompletion(content::WebContents* source_web_contents, + const gfx::PointF& screen_point, + ui::mojom::DragOperation operation, + bool ended_by_mouse_release) { + auto& completion = TabDragNativeCompletionStorage(); + if (completion) { + completion->Complete(source_web_contents, screen_point, operation, + ended_by_mouse_release); + } +} + +void CancelTabDragNativeCompletion(content::WebContents* source_web_contents) { + auto& completion = TabDragNativeCompletionStorage(); + if (completion) { + completion->Cancel(source_web_contents); + } +} + void BlockWebContentNativeEvents(content::WebContents* web_contents) { if (!web_contents) { LOG(ERROR) << "[Dao-Xwin] BlockWebContentNativeEvents: null WebContents"; diff --git a/src/dao/browser/ui/views/dao_tab_drag_mac_browsertest.mm b/src/dao/browser/ui/views/dao_tab_drag_mac_browsertest.mm new file mode 100644 index 00000000..0fea68ff --- /dev/null +++ b/src/dao/browser/ui/views/dao_tab_drag_mac_browsertest.mm @@ -0,0 +1,110 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +#include + +#import "base/apple/scoped_objc_class_swizzler.h" +#include "chrome/test/base/in_process_browser_test.h" +#import "content/app_shim_remote_cocoa/web_contents_view_cocoa.h" +#include "content/browser/web_contents/web_contents_view_mac.h" +#include "content/public/browser/web_contents_view_delegate.h" +#include "content/public/test/browser_test.h" +#include "ui/gfx/geometry/point_f.h" + +@interface DaoDragPrimaryScreenFrame : NSObject +- (NSRect)frame; +@end + +@implementation DaoDragPrimaryScreenFrame +- (NSRect)frame { + return NSMakeRect(0, 0, 1440, 900); +} +@end + +@interface DaoDragSecondaryScreen : NSObject +- (NSRect)frame; +@end + +@implementation DaoDragSecondaryScreen +- (NSRect)frame { + return NSMakeRect(1440, -180, 1920, 1080); +} + +- (id)forwardingTargetForSelector:(SEL)selector { + return NSScreen.screens.firstObject; +} +@end + +@interface DaoDragSecondaryWindow : NSWindow +@end + +@implementation DaoDragSecondaryWindow +- (NSScreen*)screen { + return (NSScreen*)[[DaoDragSecondaryScreen alloc] init]; +} +@end + +namespace dao { +namespace { + +class DragEndCapture : public content::WebContentsViewMac { + public: + DragEndCapture() : WebContentsViewMac(nullptr, nullptr) {} + + void EndDrag(uint32_t operation, + const gfx::PointF& local_point, + const gfx::PointF& screen_point, + bool ended_by_mouse_release) override { + screen_point_ = screen_point; + } + + std::optional screen_point_; +}; + +using DaoTabDragMacBrowserTest = InProcessBrowserTest; + +IN_PROC_BROWSER_TEST_F(DaoTabDragMacBrowserTest, + DragEndUsesPrimaryScreenCoordinates) { + DaoDragSecondaryWindow* window = [[DaoDragSecondaryWindow alloc] + initWithContentRect:NSMakeRect(1500, 100, 400, 300) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + WebContentsViewCocoa* view = + [[WebContentsViewCocoa alloc] initWithViewsHostableView:nullptr]; + window.contentView = view; + DragEndCapture host; + [view setHost:&host]; + NSDraggingSession* session = [[NSDraggingSession alloc] init]; + + { + base::apple::ScopedObjCClassSwizzler primary_screen_frame( + [NSScreen class], [DaoDragPrimaryScreenFrame class], @selector(frame)); + // Points on the secondary display and above/left of the primary display. + // Fractional coordinates must survive the native-to-Mojo conversion. + const struct { + NSPoint cocoa; + gfx::PointF expected; + } cases[] = { + {NSMakePoint(1600.5, 700.25), gfx::PointF(1600.5, 199.75)}, + {NSMakePoint(1600, -100), gfx::PointF(1600, 1000)}, + {NSMakePoint(-100, 1000), gfx::PointF(-100, -100)}, + }; + for (const auto& test : cases) { + host.screen_point_.reset(); + [view draggingSession:session + endedAtPoint:test.cocoa + operation:NSDragOperationNone]; + EXPECT_EQ(std::make_optional(test.expected), host.screen_point_); + } + } + + [view setHost:nullptr]; + window.contentView = nil; +} + +} // namespace +} // namespace dao diff --git a/src/dao/browser/ui/views/dao_tab_identity.cc b/src/dao/browser/ui/views/dao_tab_identity.cc index bf40420d..d503061b 100644 --- a/src/dao/browser/ui/views/dao_tab_identity.cc +++ b/src/dao/browser/ui/views/dao_tab_identity.cc @@ -18,6 +18,7 @@ namespace dao { namespace { const char kDaoSidebarTabIdentityKey = 0; +const char kDaoSidebarFolderSnapshotKey = 0; class DaoSidebarTabIdentityData : public base::SupportsUserData::Data { public: @@ -62,6 +63,24 @@ void SetSidebarTabId(content::WebContents* contents, const std::string& id) { std::make_unique(id)); } +std::string GetSidebarFolderSnapshotId(content::WebContents* contents) { + if (!contents) { + return std::string(); + } + auto* snapshot = static_cast( + contents->GetUserData(&kDaoSidebarFolderSnapshotKey)); + return snapshot ? snapshot->id() : std::string(); +} + +void SetSidebarFolderSnapshotId(content::WebContents* contents, + const std::string& id) { + if (!contents || id.empty()) { + return; + } + contents->SetUserData(&kDaoSidebarFolderSnapshotKey, + std::make_unique(id)); +} + void RepairDuplicateSidebarTabIds( const std::vector& contents) { std::set seen; @@ -85,6 +104,11 @@ void CopySidebarTabId(content::WebContents* old_contents, if (identity) { SetSidebarTabId(new_contents, identity->id()); } + const std::string folder_snapshot_id = + GetSidebarFolderSnapshotId(old_contents); + if (!folder_snapshot_id.empty()) { + SetSidebarFolderSnapshotId(new_contents, folder_snapshot_id); + } } void PopulateSidebarTabIdentityExtraData( @@ -94,6 +118,10 @@ void PopulateSidebarTabIdentityExtraData( return; } (*extra_data)[kSidebarTabIdentitySessionKey] = GetSidebarTabId(contents); + const std::string folder_snapshot_id = GetSidebarFolderSnapshotId(contents); + if (!folder_snapshot_id.empty()) { + (*extra_data)[kSidebarFolderSnapshotSessionKey] = folder_snapshot_id; + } } void RestoreSidebarTabIdentityFromExtraData( @@ -103,6 +131,10 @@ void RestoreSidebarTabIdentityFromExtraData( if (it != extra_data.end()) { SetSidebarTabId(contents, it->second); } + it = extra_data.find(kSidebarFolderSnapshotSessionKey); + if (it != extra_data.end()) { + SetSidebarFolderSnapshotId(contents, it->second); + } } } // namespace dao diff --git a/src/dao/browser/ui/views/dao_tab_identity.h b/src/dao/browser/ui/views/dao_tab_identity.h index 1ca8bafc..c0648c79 100644 --- a/src/dao/browser/ui/views/dao_tab_identity.h +++ b/src/dao/browser/ui/views/dao_tab_identity.h @@ -16,10 +16,15 @@ class WebContents; namespace dao { inline constexpr char kSidebarTabIdentitySessionKey[] = "dao.sidebar_tab_id"; +inline constexpr char kSidebarFolderSnapshotSessionKey[] = + "dao.sidebar_folder_snapshot_id"; std::string GetOrCreateSidebarTabId(content::WebContents* contents); std::string GetSidebarTabId(content::WebContents* contents); void SetSidebarTabId(content::WebContents* contents, const std::string& id); +std::string GetSidebarFolderSnapshotId(content::WebContents* contents); +void SetSidebarFolderSnapshotId(content::WebContents* contents, + const std::string& id); void RepairDuplicateSidebarTabIds( const std::vector& contents); void CopySidebarTabId(content::WebContents* old_contents, diff --git a/src/dao/browser/ui/views/split/dao_split_view.cc b/src/dao/browser/ui/views/split/dao_split_view.cc index 8d7b739f..133f0e2c 100644 --- a/src/dao/browser/ui/views/split/dao_split_view.cc +++ b/src/dao/browser/ui/views/split/dao_split_view.cc @@ -12,9 +12,9 @@ #include "base/functional/bind.h" #include "base/location.h" -#include "base/task/single_thread_task_runner.h" #include "base/strings/string_number_conversions.h" #include "base/strings/utf_string_conversions.h" +#include "base/task/single_thread_task_runner.h" #include "base/time/time.h" #include "cc/paint/paint_flags.h" #include "chrome/browser/profiles/profile.h" @@ -23,8 +23,8 @@ #include "chrome/browser/ui/browser_window/public/browser_window_interface.h" #include "chrome/browser/ui/browser_window/public/profile_browser_collection.h" #include "chrome/browser/ui/tabs/tab_enums.h" -#include "chrome/browser/ui/views/frame/browser_view.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" +#include "chrome/browser/ui/views/frame/browser_view.h" #include "components/prefs/pref_service.h" #include "components/prefs/scoped_user_pref_update.h" #include "components/sessions/content/session_tab_helper.h" @@ -33,6 +33,7 @@ #include "dao/browser/dao_pref_names.h" #include "dao/browser/ui/views/dao_address_bar_view.h" #include "dao/browser/ui/views/dao_colors.h" +#include "dao/browser/ui/views/dao_cross_window_drag.h" #include "dao/browser/ui/views/dao_native_util_mac.h" #include "dao/browser/ui/views/sidebar/dao_sidebar_view.h" #include "dao/browser/ui/views/sidebar/dao_tab_tooltip_view.h" @@ -1273,21 +1274,10 @@ bool DaoSplitView::ProcessNativeTabDrop(const gfx::Point& location_in_view, std::optional target_zone = DetectDropZone(target_leaf, location_in_view); - // Parse "dao-tab-drag::". - const std::string kPrefix = "dao-tab-drag:"; - if (payload.size() <= kPrefix.size() || - payload.compare(0, kPrefix.size(), kPrefix) != 0) { - return false; - } - std::string body = payload.substr(kPrefix.size()); - size_t colon = body.find(':'); - if (colon == std::string::npos) { - return false; - } int source_sid = 0; int tab_index = 0; - if (!base::StringToInt(body.substr(0, colon), &source_sid) || - !base::StringToInt(body.substr(colon + 1), &tab_index)) { + std::string tab_id; + if (!ParseDaoTabDragPayload(payload, &source_sid, &tab_index, &tab_id)) { return false; } @@ -1304,6 +1294,7 @@ bool DaoSplitView::ProcessNativeTabDrop(const gfx::Point& location_in_view, } TabStripModel* source_model = source_browser->tab_strip_model(); + tab_index = ResolveDraggedTabIndex(source_browser, tab_index, tab_id); if (tab_index < 0 || tab_index >= source_model->count()) { return false; } @@ -1324,21 +1315,12 @@ bool DaoSplitView::ProcessNativeTabDrop(const gfx::Point& location_in_view, existing_at_target = tab_strip_model_->GetActiveWebContents(); } - // Cross-window: detach and insert into our model first. if (is_cross_window) { - std::unique_ptr detached = - source_model->DetachWebContentsAtForInsertion(tab_index); - if (!detached) { + if (!ExecuteCrossWindowTabMove(browser_, source_sid, tab_index, + tab_strip_model_->count(), tab_id)) { return false; } - dragged_contents = detached.get(); - tab_strip_model_->InsertWebContentsAt(tab_strip_model_->count(), - std::move(detached), - AddTabTypes::ADD_ACTIVE); tab_index = tab_strip_model_->GetIndexOfWebContents(dragged_contents); - if (source_model->count() == 0 && source_browser->window()) { - source_browser->window()->Close(); - } } if (target_zone.has_value()) { @@ -1394,135 +1376,24 @@ void DaoSplitView::OnDrop( const ui::DropTargetEvent& event, ui::mojom::DragOperation& output_drag_op, std::unique_ptr drag_image_layer_owner) { - LOG(ERROR) << "[Dao-Xwin] DaoSplitView::OnDrop ENTRY at (" - << event.location().x() << "," << event.location().y() - << ") tab_drag_active=" << tab_drag_active_; drop_overlay_->SetVisible(false); BlockAllNativeEvents(false); - - DaoSplitLeafNode* target_leaf = FindLeafAtPoint(event.location()); - std::optional target_zone = - target_leaf ? DetectDropZone(target_leaf, event.location()) : std::nullopt; - - if (!target_leaf || !tab_strip_model_) { - output_drag_op = ui::mojom::DragOperation::kNone; + output_drag_op = ui::mojom::DragOperation::kNone; + const auto text = event.data().GetString(); + if (!text.has_value()) { return; } - - // Parse drag data: "dao-tab-drag::" or legacy - // "dao-tab-drag:". - auto text = event.data().GetString(); - if (!text.has_value() || !text->starts_with(u"dao-tab-drag:")) { - output_drag_op = ui::mojom::DragOperation::kNone; - return; + std::string payload = base::UTF16ToUTF8(*text); + // Legacy local drags carried only an index. + const std::string prefix = kDaoTabDragPrefix; + if (payload.starts_with(prefix) && + payload.find(':', prefix.size()) == std::string::npos) { + payload = prefix + base::NumberToString(browser_->session_id().id()) + ":" + + payload.substr(prefix.size()); } - - std::u16string payload = text->substr(13); // len("dao-tab-drag:") = 13 - int tab_index = 0; - bool is_cross_window = false; - Browser* source_browser = browser_; - - size_t colon_pos = payload.find(u':'); - if (colon_pos != std::u16string::npos) { - // New format: ":" - int source_session_id = 0; - if (!base::StringToInt(payload.substr(0, colon_pos), - &source_session_id) || - !base::StringToInt(payload.substr(colon_pos + 1), &tab_index)) { - output_drag_op = ui::mojom::DragOperation::kNone; - return; - } - if (static_cast(browser_->session_id().id()) != - source_session_id) { - is_cross_window = true; - source_browser = FindBrowserBySessionId(browser_->profile(), - source_session_id); - if (!source_browser) { - output_drag_op = ui::mojom::DragOperation::kNone; - return; - } - } - } else { - // Legacy format: "" - if (!base::StringToInt(payload, &tab_index)) { - output_drag_op = ui::mojom::DragOperation::kNone; - return; - } + if (ProcessNativeTabDrop(event.location(), payload)) { + output_drag_op = ui::mojom::DragOperation::kMove; } - - TabStripModel* source_model = source_browser->tab_strip_model(); - content::WebContents* dragged_contents = - source_model->GetWebContentsAt(tab_index); - if (!dragged_contents) { - output_drag_op = ui::mojom::DragOperation::kNone; - return; - } - - // Cross-window: detach from source and insert into local model. - if (is_cross_window) { - std::unique_ptr detached = - source_model->DetachWebContentsAtForInsertion(tab_index); - if (!detached) { - output_drag_op = ui::mojom::DragOperation::kNone; - return; - } - dragged_contents = detached.get(); - tab_strip_model_->InsertWebContentsAt( - tab_strip_model_->count(), std::move(detached), - AddTabTypes::ADD_ACTIVE); - tab_index = tab_strip_model_->GetIndexOfWebContents(dragged_contents); - // Auto-close source window if empty. - if (source_model->count() == 0) { - source_browser->window()->Close(); - } - } - - if (target_zone.has_value()) { - // Split the target pane. - content::WebContents* existing = target_leaf->web_contents(); - if (!existing && !IsSplitActive() && tab_strip_model_) { - existing = tab_strip_model_->GetActiveWebContents(); - target_leaf->set_web_contents(existing); - } - if (existing && existing != dragged_contents) { - base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask( - FROM_HERE, - base::BindOnce(&DaoSplitView::PerformDeferredSplit, - weak_factory_.GetWeakPtr(), existing, - target_zone.value(), - target_zone.value() == SplitDirection::kHorizontal - ? event.location().x() <= - target_leaf->bounds().CenterPoint().x() - : event.location().y() <= - target_leaf->bounds().CenterPoint().y(), - dragged_contents), - kDeferredDropActionDelay); - } - } else { - // Center drop β€” just activate the dragged tab in the model. - // In single-pane mode this simply switches the active tab. - // In split mode it swaps the pane's content. - if (IsSplitActive()) { - if (content::WebContents* target_contents = target_leaf->web_contents(); - target_contents && target_contents != dragged_contents) { - base::SingleThreadTaskRunner::GetCurrentDefault()->PostDelayedTask( - FROM_HERE, - base::BindOnce(&DaoSplitView::PerformDeferredSwap, - weak_factory_.GetWeakPtr(), target_contents, - dragged_contents), - kDeferredDropActionDelay); - } - } else { - // Single-pane: update tree leaf + activate tab, no view rebuild. - target_leaf->set_web_contents(dragged_contents); - tab_strip_model_->ActivateTabAt(tab_index); - } - } - - output_drag_op = ui::mojom::DragOperation::kMove; - drop_target_leaf_ = nullptr; - drop_zone_direction_.reset(); - SetTabDragActive(false); } // --- Private ----------------------------------------------------------------- diff --git a/src/dao/browser/ui/webui/dao_folder_storage.cc b/src/dao/browser/ui/webui/dao_folder_storage.cc new file mode 100644 index 00000000..8c2587a0 --- /dev/null +++ b/src/dao/browser/ui/webui/dao_folder_storage.cc @@ -0,0 +1,308 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/ui/webui/dao_folder_storage.h" + +#include +#include +#include +#include + +#include "base/files/file_util.h" +#include "base/json/json_reader.h" +#include "base/json/json_writer.h" +#include "base/no_destructor.h" +#include "base/synchronization/lock.h" +#include "base/task/sequenced_task_runner.h" +#include "base/task/thread_pool.h" +#include "base/values.h" + +namespace dao { +namespace { + +constexpr int kFolderStorageVersion = 2; +constexpr char kLegacySnapshotId[] = "legacy"; + +struct FolderFileWriteState { + base::Lock lock; + uint64_t next_generation = 0; + std::map generations; + std::map pending_writes; +}; + +FolderFileWriteState& GetFolderFileWriteState() { + static base::NoDestructor state; + return *state; +} + +bool ReplaceFolderFileAtomically(const base::FilePath& file_path, + const std::string& data) { + base::FilePath temporary_path; + if (!base::CreateTemporaryFileInDir(file_path.DirName(), &temporary_path)) { + return false; + } + if (!base::WriteFile(temporary_path, data) || + !base::ReplaceFile(temporary_path, file_path, nullptr)) { + base::DeleteFile(temporary_path); + return false; + } + return true; +} + +void CollectLegacyTabIds(const base::ListValue& items, + std::set* tab_ids) { + for (const base::Value& item_value : items) { + const base::DictValue* item = item_value.GetIfDict(); + if (!item) { + continue; + } + if (const std::string* tab_id = item->FindString("tabId")) { + tab_ids->insert(*tab_id); + } + if (const base::ListValue* children = item->FindList("children")) { + CollectLegacyTabIds(*children, tab_ids); + } + } +} + +} // namespace + +DaoFolderWindowSnapshot::DaoFolderWindowSnapshot() = default; +DaoFolderWindowSnapshot::~DaoFolderWindowSnapshot() = default; +DaoFolderWindowSnapshot::DaoFolderWindowSnapshot( + const DaoFolderWindowSnapshot&) = default; +DaoFolderWindowSnapshot& DaoFolderWindowSnapshot::operator=( + const DaoFolderWindowSnapshot&) = default; +DaoFolderWindowSnapshot::DaoFolderWindowSnapshot(DaoFolderWindowSnapshot&&) = + default; +DaoFolderWindowSnapshot& DaoFolderWindowSnapshot::operator=( + DaoFolderWindowSnapshot&&) = default; + +DaoFolderStorage::DaoFolderStorage() = default; +DaoFolderStorage::~DaoFolderStorage() = default; +DaoFolderStorage::DaoFolderStorage(const DaoFolderStorage&) = default; +DaoFolderStorage& DaoFolderStorage::operator=(const DaoFolderStorage&) = + default; +DaoFolderStorage::DaoFolderStorage(DaoFolderStorage&&) = default; +DaoFolderStorage& DaoFolderStorage::operator=(DaoFolderStorage&&) = default; + +bool DaoFolderStorage::LoadFromJson(const std::string& json) { + snapshots_.clear(); + if (json.empty()) { + return true; + } + + std::optional root = + base::JSONReader::ReadDict(json, base::JSON_PARSE_RFC); + if (!root) { + return false; + } + + if (root->FindInt("version").value_or(0) != kFolderStorageVersion) { + const base::ListValue* items = root->FindList("items"); + if (!items) { + return false; + } + DaoFolderWindowSnapshot snapshot; + snapshot.id = kLegacySnapshotId; + snapshot.json = json; + snapshot.legacy = true; + CollectLegacyTabIds(*items, &snapshot.tab_ids); + snapshots_.push_back(std::move(snapshot)); + return true; + } + + const base::ListValue* windows = root->FindList("windows"); + if (!windows) { + return false; + } + std::set ids; + for (const base::Value& window_value : *windows) { + const base::DictValue* window = window_value.GetIfDict(); + const std::string* id = window ? window->FindString("id") : nullptr; + const std::string* data = window ? window->FindString("data") : nullptr; + const base::ListValue* tab_ids = + window ? window->FindList("tabIds") : nullptr; + if (!id || id->empty() || !data || !tab_ids || !ids.insert(*id).second) { + snapshots_.clear(); + return false; + } + + DaoFolderWindowSnapshot snapshot; + snapshot.id = *id; + snapshot.json = *data; + snapshot.legacy = window->FindBool("legacy").value_or(false); + for (const base::Value& tab_id_value : *tab_ids) { + const std::string* tab_id = tab_id_value.GetIfString(); + if (!tab_id) { + snapshots_.clear(); + return false; + } + snapshot.tab_ids.insert(*tab_id); + } + snapshots_.push_back(std::move(snapshot)); + } + return true; +} + +std::string DaoFolderStorage::ToJson() const { + base::DictValue root; + root.Set("version", kFolderStorageVersion); + base::ListValue windows; + for (const DaoFolderWindowSnapshot& snapshot : snapshots_) { + base::DictValue window; + window.Set("id", snapshot.id); + base::ListValue tab_ids; + for (const std::string& tab_id : snapshot.tab_ids) { + tab_ids.Append(tab_id); + } + window.Set("tabIds", std::move(tab_ids)); + window.Set("data", snapshot.json); + window.Set("legacy", snapshot.legacy); + windows.Append(std::move(window)); + } + root.Set("windows", std::move(windows)); + + std::string json; + base::JSONWriter::WriteWithOptions( + root, base::JSONWriter::OPTIONS_PRETTY_PRINT, &json); + return json; +} + +const DaoFolderWindowSnapshot* DaoFolderStorage::FindById( + const std::string& id) const { + auto it = std::ranges::find(snapshots_, id, &DaoFolderWindowSnapshot::id); + return it == snapshots_.end() ? nullptr : &*it; +} + +const DaoFolderWindowSnapshot* DaoFolderStorage::FindClaimableSnapshot( + const std::vector& preferred_ids, + const std::set& current_tab_ids, + const std::set& claimed_ids) const { + for (const std::string& id : preferred_ids) { + const DaoFolderWindowSnapshot* snapshot = FindById(id); + if (snapshot && !claimed_ids.contains(snapshot->id)) { + return snapshot; + } + } + + if (preferred_ids.empty()) { + const DaoFolderWindowSnapshot* best = nullptr; + size_t best_overlap = 0; + for (const DaoFolderWindowSnapshot& snapshot : snapshots_) { + if (snapshot.legacy || claimed_ids.contains(snapshot.id)) { + continue; + } + size_t overlap = 0; + for (const std::string& tab_id : current_tab_ids) { + overlap += snapshot.tab_ids.contains(tab_id) ? 1 : 0; + } + if (overlap > best_overlap) { + best = &snapshot; + best_overlap = overlap; + } + } + if (best) { + return best; + } + } + + for (const DaoFolderWindowSnapshot& snapshot : snapshots_) { + if (snapshot.legacy && !claimed_ids.contains(snapshot.id)) { + return &snapshot; + } + } + return nullptr; +} + +void DaoFolderStorage::UpsertSnapshot(const std::string& id, + std::set tab_ids, + std::string json) { + auto it = std::ranges::find(snapshots_, id, &DaoFolderWindowSnapshot::id); + if (it == snapshots_.end()) { + DaoFolderWindowSnapshot snapshot; + snapshot.id = id; + snapshot.tab_ids = std::move(tab_ids); + snapshot.json = std::move(json); + snapshots_.push_back(std::move(snapshot)); + return; + } + it->tab_ids = std::move(tab_ids); + it->json = std::move(json); + it->legacy = false; +} + +void DaoFolderStorage::RemoveSnapshot(const std::string& id) { + std::erase_if(snapshots_, [&id](const DaoFolderWindowSnapshot& snapshot) { + return snapshot.id == id; + }); +} + +bool WriteFolderFileAtomically(const base::FilePath& file_path, + const std::string& data) { + FolderFileWriteState& state = GetFolderFileWriteState(); + base::AutoLock lock(state.lock); + if (!ReplaceFolderFileAtomically(file_path, data)) { + return false; + } + state.generations[file_path.value()] = ++state.next_generation; + state.pending_writes.erase(file_path.value()); + return true; +} + +bool ReadFolderFileWithPendingWrite(const base::FilePath& file_path, + std::string* data) { + FolderFileWriteState& state = GetFolderFileWriteState(); + base::AutoLock lock(state.lock); + auto pending_it = state.pending_writes.find(file_path.value()); + if (pending_it != state.pending_writes.end()) { + *data = pending_it->second; + return true; + } + if (!base::PathExists(file_path)) { + data->clear(); + return true; + } + return base::ReadFileToString(file_path, data); +} + +uint64_t ReserveFolderFileWrite(const base::FilePath& file_path, + const std::string& data) { + FolderFileWriteState& state = GetFolderFileWriteState(); + base::AutoLock lock(state.lock); + const uint64_t generation = ++state.next_generation; + state.generations[file_path.value()] = generation; + state.pending_writes[file_path.value()] = data; + return generation; +} + +bool WriteReservedFolderFileAtomically(const base::FilePath& file_path, + const std::string& data, + uint64_t generation) { + // ponytail: Folder snapshots are small profile-local writes. Keep the + // generation check and replace under one lock; use per-path locks if this + // ever becomes a measurable contention point. + FolderFileWriteState& state = GetFolderFileWriteState(); + base::AutoLock lock(state.lock); + auto generation_it = state.generations.find(file_path.value()); + if (generation_it == state.generations.end() || + generation_it->second != generation) { + return true; + } + + if (!ReplaceFolderFileAtomically(file_path, data)) { + return false; + } + state.pending_writes.erase(file_path.value()); + return true; +} + +scoped_refptr GetFolderFileTaskRunner() { + static base::NoDestructor> + task_runner(base::ThreadPool::CreateSequencedTaskRunner( + {base::MayBlock(), base::TaskPriority::USER_VISIBLE})); + return *task_runner; +} + +} // namespace dao diff --git a/src/dao/browser/ui/webui/dao_folder_storage.h b/src/dao/browser/ui/webui/dao_folder_storage.h new file mode 100644 index 00000000..6ea3b9ed --- /dev/null +++ b/src/dao/browser/ui/webui/dao_folder_storage.h @@ -0,0 +1,77 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_UI_WEBUI_DAO_FOLDER_STORAGE_H_ +#define DAO_BROWSER_UI_WEBUI_DAO_FOLDER_STORAGE_H_ + +#include +#include +#include +#include + +#include "base/files/file_path.h" +#include "base/memory/scoped_refptr.h" + +namespace base { +class SequencedTaskRunner; +} // namespace base + +namespace dao { + +struct DaoFolderWindowSnapshot { + DaoFolderWindowSnapshot(); + ~DaoFolderWindowSnapshot(); + + DaoFolderWindowSnapshot(const DaoFolderWindowSnapshot&); + DaoFolderWindowSnapshot& operator=(const DaoFolderWindowSnapshot&); + DaoFolderWindowSnapshot(DaoFolderWindowSnapshot&&); + DaoFolderWindowSnapshot& operator=(DaoFolderWindowSnapshot&&); + + std::string id; + std::set tab_ids; + std::string json; + bool legacy = false; +}; + +class DaoFolderStorage { + public: + DaoFolderStorage(); + ~DaoFolderStorage(); + + DaoFolderStorage(const DaoFolderStorage&); + DaoFolderStorage& operator=(const DaoFolderStorage&); + DaoFolderStorage(DaoFolderStorage&&); + DaoFolderStorage& operator=(DaoFolderStorage&&); + + bool LoadFromJson(const std::string& json); + std::string ToJson() const; + + const DaoFolderWindowSnapshot* FindById(const std::string& id) const; + const DaoFolderWindowSnapshot* FindClaimableSnapshot( + const std::vector& preferred_ids, + const std::set& current_tab_ids, + const std::set& claimed_ids) const; + void UpsertSnapshot(const std::string& id, + std::set tab_ids, + std::string json); + void RemoveSnapshot(const std::string& id); + + private: + std::vector snapshots_; +}; + +bool WriteFolderFileAtomically(const base::FilePath& file_path, + const std::string& data); +bool ReadFolderFileWithPendingWrite(const base::FilePath& file_path, + std::string* data); +uint64_t ReserveFolderFileWrite(const base::FilePath& file_path, + const std::string& data); +bool WriteReservedFolderFileAtomically(const base::FilePath& file_path, + const std::string& data, + uint64_t generation); +scoped_refptr GetFolderFileTaskRunner(); + +} // namespace dao + +#endif // DAO_BROWSER_UI_WEBUI_DAO_FOLDER_STORAGE_H_ diff --git a/src/dao/browser/ui/webui/dao_folder_storage_unittest.cc b/src/dao/browser/ui/webui/dao_folder_storage_unittest.cc new file mode 100644 index 00000000..7f6dbe7d --- /dev/null +++ b/src/dao/browser/ui/webui/dao_folder_storage_unittest.cc @@ -0,0 +1,148 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/ui/webui/dao_folder_storage.h" + +#include "base/files/file_util.h" +#include "base/files/scoped_temp_dir.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace dao { + +TEST(DaoFolderStorageTest, LoadsLegacyFolderFileOnce) { + constexpr char kLegacyJson[] = + R"({"version":1,"items":[{"type":"tab","tabId":"tab-a","url":"https://a.example","title":"A"}]})"; + DaoFolderStorage storage; + ASSERT_TRUE(storage.LoadFromJson(kLegacyJson)); + + const DaoFolderWindowSnapshot* snapshot = + storage.FindClaimableSnapshot({}, {}, {}); + ASSERT_TRUE(snapshot); + EXPECT_TRUE(snapshot->legacy); + EXPECT_EQ(kLegacyJson, snapshot->json); + EXPECT_EQ(std::set({"tab-a"}), snapshot->tab_ids); + + EXPECT_FALSE(storage.FindClaimableSnapshot({}, {}, {snapshot->id})); + + DaoFolderStorage restored; + ASSERT_TRUE(restored.LoadFromJson(storage.ToJson())); + ASSERT_TRUE(restored.FindClaimableSnapshot({}, {}, {})); + EXPECT_TRUE(restored.FindClaimableSnapshot({}, {}, {})->legacy); +} + +TEST(DaoFolderStorageTest, PreservesOtherWindowWhenUpdatingOneSnapshot) { + DaoFolderStorage storage; + storage.UpsertSnapshot("window-a", {"tab-a"}, R"({"items":["a"]})"); + storage.UpsertSnapshot("window-b", {"tab-b"}, R"({"items":["b"]})"); + storage.UpsertSnapshot("window-b", {"tab-b2"}, R"({"items":["updated"]})"); + + DaoFolderStorage restored; + ASSERT_TRUE(restored.LoadFromJson(storage.ToJson())); + ASSERT_TRUE(restored.FindById("window-a")); + EXPECT_EQ(R"({"items":["a"]})", restored.FindById("window-a")->json); + ASSERT_TRUE(restored.FindById("window-b")); + EXPECT_EQ(std::set({"tab-b2"}), + restored.FindById("window-b")->tab_ids); +} + +TEST(DaoFolderStorageTest, DoesNotGiveNewWindowAnUnclaimedV2Snapshot) { + DaoFolderStorage storage; + storage.UpsertSnapshot("window-a", {"tab-a"}, R"({"items":[]})"); + + EXPECT_FALSE(storage.FindClaimableSnapshot({}, {}, {})); + EXPECT_EQ("window-a", + storage.FindClaimableSnapshot({"window-a"}, {}, {})->id); + EXPECT_FALSE(storage.FindClaimableSnapshot({"window-a"}, {}, {"window-a"})); +} + +TEST(DaoFolderStorageTest, FallsBackToStableTabIdentityOverlap) { + DaoFolderStorage storage; + storage.UpsertSnapshot("window-a", {"tab-a", "tab-shared"}, + R"({"items":[]})"); + storage.UpsertSnapshot("window-b", {"tab-b"}, R"({"items":[]})"); + + EXPECT_FALSE( + storage.FindClaimableSnapshot({"provisional-window"}, {"tab-b"}, {})); + + const DaoFolderWindowSnapshot* snapshot = + storage.FindClaimableSnapshot({}, {"tab-b"}, {}); + ASSERT_TRUE(snapshot); + EXPECT_EQ("window-b", snapshot->id); +} + +TEST(DaoFolderStorageTest, AtomicWriteReplacesExistingFile) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath path = temp_dir.GetPath().AppendASCII("folders.json"); + ASSERT_TRUE(base::WriteFile(path, "old")); + + ASSERT_TRUE(WriteFolderFileAtomically(path, "new")); + + std::string contents; + ASSERT_TRUE(base::ReadFileToString(path, &contents)); + EXPECT_EQ("new", contents); +} + +TEST(DaoFolderStorageTest, SupersededWriteDoesNotReplaceNewerData) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath path = temp_dir.GetPath().AppendASCII("folders.json"); + const uint64_t old_generation = ReserveFolderFileWrite(path, "old"); + const uint64_t new_generation = ReserveFolderFileWrite(path, "new"); + + ASSERT_TRUE(WriteReservedFolderFileAtomically(path, "old", old_generation)); + EXPECT_FALSE(base::PathExists(path)); + std::string pending_contents; + ASSERT_TRUE(ReadFolderFileWithPendingWrite(path, &pending_contents)); + EXPECT_EQ("new", pending_contents); + ASSERT_TRUE(WriteReservedFolderFileAtomically(path, "new", new_generation)); + + std::string contents; + ASSERT_TRUE(base::ReadFileToString(path, &contents)); + EXPECT_EQ("new", contents); +} + +TEST(DaoFolderStorageTest, PendingWriteIsReadableBeforeDiskWrite) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath path = temp_dir.GetPath().AppendASCII("folders.json"); + + ReserveFolderFileWrite(path, "pending"); + + std::string contents; + ASSERT_TRUE(ReadFolderFileWithPendingWrite(path, &contents)); + EXPECT_EQ("pending", contents); + EXPECT_FALSE(base::PathExists(path)); +} + +TEST(DaoFolderStorageTest, SuccessfulWriteClearsPendingData) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath path = temp_dir.GetPath().AppendASCII("folders.json"); + const uint64_t generation = ReserveFolderFileWrite(path, "reserved"); + ASSERT_TRUE(WriteReservedFolderFileAtomically(path, "reserved", generation)); + ASSERT_TRUE(base::WriteFile(path, "on-disk")); + + std::string contents; + ASSERT_TRUE(ReadFolderFileWithPendingWrite(path, &contents)); + EXPECT_EQ("on-disk", contents); +} + +TEST(DaoFolderStorageTest, FailedSynchronousWritePreservesPendingData) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath not_a_directory = + temp_dir.GetPath().AppendASCII("not-a-directory"); + ASSERT_TRUE(base::WriteFile(not_a_directory, "file")); + const base::FilePath path = not_a_directory.AppendASCII("folders.json"); + ReserveFolderFileWrite(path, "before"); + + EXPECT_FALSE(WriteFolderFileAtomically(path, "imported")); + + std::string contents; + ASSERT_TRUE(ReadFolderFileWithPendingWrite(path, &contents)); + EXPECT_EQ("before", contents); +} + +} // namespace dao diff --git a/src/dao/browser/ui/webui/dao_sidebar_ui.cc b/src/dao/browser/ui/webui/dao_sidebar_ui.cc index 16ddf6c9..9c1bd79d 100644 --- a/src/dao/browser/ui/webui/dao_sidebar_ui.cc +++ b/src/dao/browser/ui/webui/dao_sidebar_ui.cc @@ -18,6 +18,9 @@ #include "base/files/file_enumerator.h" #include "base/files/file_util.h" #include "base/functional/bind.h" +#include "base/functional/callback_helpers.h" +#include "base/json/json_reader.h" +#include "base/json/json_writer.h" #include "base/logging.h" #include "base/no_destructor.h" #include "base/strings/utf_string_conversions.h" @@ -25,6 +28,7 @@ #include "base/task/sequenced_task_runner.h" #include "base/task/thread_pool.h" #include "base/time/time.h" +#include "base/uuid.h" #include "chrome/app/chrome_command_ids.h" #include "chrome/browser/browser_process.h" #include "chrome/browser/devtools/devtools_window.h" @@ -34,6 +38,7 @@ #include "chrome/browser/prefs/session_startup_pref.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/sessions/session_restore.h" +#include "chrome/browser/sessions/session_service.h" #include "chrome/browser/sessions/session_service_factory.h" #include "chrome/browser/ui/accelerator_utils.h" #include "chrome/browser/ui/browser.h" @@ -52,6 +57,7 @@ #include "chrome/common/webui_url_constants.h" #include "chrome/grit/dao_sidebar_resources.h" #include "chrome/grit/dao_sidebar_resources_map.h" +#include "chrome/grit/generated_resources.h" #include "components/constrained_window/constrained_window_views.h" #include "components/download/public/common/download_item.h" #include "components/prefs/pref_service.h" @@ -71,6 +77,7 @@ #include "dao/browser/ui/views/dao_command_bar_view.h" #include "dao/browser/ui/views/dao_cross_window_drag.h" #include "dao/browser/ui/views/dao_lucide_icons.h" +#include "dao/browser/ui/views/dao_native_util_mac.h" #include "dao/browser/ui/views/dao_system_dialog.h" #include "dao/browser/ui/views/dao_tab_commands.h" #include "dao/browser/ui/views/dao_tab_identity.h" @@ -80,6 +87,7 @@ #include "dao/browser/ui/views/sidebar/dao_sidebar_view.h" #include "dao/browser/ui/views/sidebar/dao_tab_tooltip_view.h" #include "dao/browser/ui/views/split/dao_split_view.h" +#include "dao/browser/ui/webui/dao_folder_storage.h" #include "dao/browser/ui/webui/dao_pinned_tab_storage.h" #include "dao/browser/updater/dao_updater_service.h" #include "services/network/public/mojom/content_security_policy.mojom.h" @@ -247,13 +255,49 @@ struct PinnedItemsProfileState { std::set handlers; std::string json; bool initialized = false; + DaoFolderStorage folder_storage; + std::map folder_claims; + bool folder_storage_initialized = false; + bool folder_storage_writable = true; }; +struct FolderFileReadResult { + bool success = false; + std::string contents; +}; + +FolderFileReadResult ReadFolderFile(const base::FilePath& path) { + FolderFileReadResult result; + result.success = ReadFolderFileWithPendingWrite(path, &result.contents); + return result; +} + std::map& GetPinnedItemsProfileStates() { static base::NoDestructor> states; return *states; } +PinnedItemsProfileState* GetInitializedFolderProfileState(Profile* profile) { + PinnedItemsProfileState& state = GetPinnedItemsProfileStates()[profile]; + if (state.folder_storage_initialized) { + return state.folder_storage_writable ? &state : nullptr; + } + if (profile->IsOffTheRecord()) { + state.folder_storage_initialized = true; + return &state; + } + const FolderFileReadResult result = + ReadFolderFile(profile->GetPath().AppendASCII("dao_folders.json")); + state.folder_storage_initialized = true; + state.folder_storage_writable = + result.success && state.folder_storage.LoadFromJson(result.contents); + if (!state.folder_storage_writable) { + state.folder_storage = DaoFolderStorage(); + return nullptr; + } + return &state; +} + bool IsReplaceableFirstRunWelcomeURL(const GURL& url) { if (url.is_empty() || url == GURL(url::kAboutBlankURL) || url == GURL(chrome::kChromeUINewTabURL) || @@ -590,6 +634,11 @@ void DaoSidebarUIHandler::SetBrowser(Browser* browser) { UnregisterPinnedItemsProfileHandler(); weak_factory_.InvalidateWeakPtrs(); duplicate_tab_close_observers_.clear(); + ++folder_load_generation_; + folder_json_.clear(); + folder_snapshot_id_.clear(); + folders_loaded_ = false; + folder_snapshot_matched_ = false; pinned_tab_model_.LoadFromJson(std::string()); pinned_items_loaded_ = false; pinned_items_load_pending_ = false; @@ -682,18 +731,145 @@ void DaoSidebarUIHandler::NotifyFolderDataChanged(Profile* profile) { if (state_it == GetPinnedItemsProfileStates().end()) { return; } + state_it->second.folder_storage = DaoFolderStorage(); + state_it->second.folder_claims.clear(); + state_it->second.folder_storage_initialized = false; + state_it->second.folder_storage_writable = true; for (DaoSidebarUIHandler* handler : state_it->second.handlers) { + ++handler->folder_load_generation_; handler->folder_json_.clear(); + handler->folder_snapshot_id_.clear(); + handler->folders_loaded_ = false; + handler->folder_snapshot_matched_ = false; + if (handler->IsJavascriptAllowed()) { + handler->FireWebUIListener("folderDataChanged"); + } + } +} + +// static +bool DaoSidebarUIHandler::LoadFolderSnapshotForImport( + Profile* profile, + const std::vector& preferred_ids, + const std::set& tab_ids, + std::string* snapshot_id, + std::string* json) { + PinnedItemsProfileState* state = GetInitializedFolderProfileState(profile); + if (!state) { + return false; + } + const DaoFolderWindowSnapshot* snapshot = + state->folder_storage.FindClaimableSnapshot(preferred_ids, tab_ids, {}); + if (snapshot) { + *snapshot_id = snapshot->id; + *json = snapshot->json; + } else if (!preferred_ids.empty()) { + // A sidebar can already have a provisional per-window identity even when + // it has not saved its first snapshot. Keep the import associated with + // that window so its live handler receives the imported folder. + *snapshot_id = preferred_ids.front(); + json->clear(); + } else { + snapshot_id->clear(); + json->clear(); + } + return true; +} + +// static +bool DaoSidebarUIHandler::PersistImportedFolder(Profile* profile, + const std::string& snapshot_id, + std::set tab_ids, + base::DictValue folder) { + PinnedItemsProfileState* state = GetInitializedFolderProfileState(profile); + if (!state) { + return false; + } + + DaoFolderStorage updated_storage = state->folder_storage; + base::DictValue folder_data; + const DaoFolderWindowSnapshot* snapshot = + updated_storage.FindById(snapshot_id); + if (snapshot) { + std::optional parsed = + base::JSONReader::ReadDict(snapshot->json, base::JSON_PARSE_RFC); + if (!parsed || !parsed->FindList("items")) { + return false; + } + folder_data = std::move(*parsed); + tab_ids.insert(snapshot->tab_ids.begin(), snapshot->tab_ids.end()); + } else { + folder_data.Set("version", 1); + folder_data.Set("items", base::ListValue()); + } + + const std::string* folder_id = folder.FindString("id"); + base::ListValue* items = folder_data.FindList("items"); + if (!folder_id || !items) { + return false; + } + items->EraseIf([folder_id](const base::Value& item) { + const base::DictValue* dict = item.GetIfDict(); + const std::string* id = dict ? dict->FindString("id") : nullptr; + return id && *id == *folder_id; + }); + items->Append(std::move(folder)); + + std::string json; + if (!base::JSONWriter::WriteWithOptions( + folder_data, base::JSONWriter::OPTIONS_PRETTY_PRINT, &json)) { + return false; + } + updated_storage.UpsertSnapshot(snapshot_id, std::move(tab_ids), json); + + if (!profile->IsOffTheRecord()) { + const base::FilePath folder_path = + profile->GetPath().AppendASCII("dao_folders.json"); + const std::string storage_json = updated_storage.ToJson(); + if (!WriteFolderFileAtomically(folder_path, storage_json)) { + return false; + } + } + + state->folder_storage = std::move(updated_storage); + for (DaoSidebarUIHandler* handler : state->handlers) { + if (handler->folder_snapshot_id_ != snapshot_id) { + continue; + } + handler->folder_json_ = json; + handler->folder_snapshot_matched_ = true; + handler->PersistFolderSnapshotIdentity(); if (handler->IsJavascriptAllowed()) { handler->FireWebUIListener("folderDataChanged"); } } + return true; } base::ListValue DaoSidebarUIHandler::GetPinnedItemsForTesting() { return BuildPinnedItems(); } +void DaoSidebarUIHandler::LoadFoldersForTesting( + const std::string& callback_id) { + AllowJavascript(); + LoadFoldersForCallback(callback_id); +} + +void DaoSidebarUIHandler::SaveFoldersForTesting(const std::string& json) { + base::ListValue args; + args.Append(json); + HandleSaveFolders(args); +} + +// static +void DaoSidebarUIHandler::WaitForFolderFileTasksForTesting() { + base::RunLoop run_loop; + GetFolderFileTaskRunner()->PostTaskAndReply(FROM_HERE, base::DoNothing(), + run_loop.QuitClosure()); + run_loop.Run(); +} + base::DictValue DaoSidebarUIHandler::GetSidebarStateForTesting() { return BuildSidebarState(); } @@ -1003,6 +1179,24 @@ void DaoSidebarUIHandler::OnTabStripModelChanged( if (pinned_state_changed && pinned_items_auto_save_enabled_) { SavePinnedItems(); } + if (change.type() != TabStripModelChange::kSelectionOnly) { + const bool adopted_restored_snapshot = MaybeAdoptRestoredFolderSnapshot(); + bool has_folder_items = false; + if (!folder_json_.empty()) { + std::optional folder_data = + base::JSONReader::ReadDict(folder_json_, base::JSON_PARSE_RFC); + const base::ListValue* items = + folder_data ? folder_data->FindList("items") : nullptr; + has_folder_items = items && !items->empty(); + } + // Preserve incoming snapshot IDs while session restore is still adding + // tabs. Normal structural changes belong to this live window. + if (!adopted_restored_snapshot && folders_loaded_ && + (folder_snapshot_matched_ || has_folder_items || + (browser_ && !SessionRestore::IsRestoring(browser_->profile())))) { + PersistFolderSnapshotIdentity(); + } + } if (!stale_tab_selected_after_close.empty()) { base::SequencedTaskRunner::GetCurrentDefault()->PostTask( @@ -1234,6 +1428,7 @@ base::ListValue DaoSidebarUIHandler::BuildPinnedItems() { dict.Set("isOpen", open_browser != nullptr); dict.Set("openTabIndex", open_browser == browser_ ? open_tab_index : -1); + dict.Set("openTabId", contents ? GetSidebarTabId(contents) : std::string()); dict.Set("isActive", open_model && open_tab_index == open_model->active_index()); dict.Set("isFaviconLight", contents ? IsFaviconLight(contents) : false); @@ -1476,11 +1671,164 @@ void DaoSidebarUIHandler::UnregisterPinnedItemsProfileHandler() { return; } state_it->second.handlers.erase(this); + if (!folder_snapshot_id_.empty()) { + auto claim_it = state_it->second.folder_claims.find(folder_snapshot_id_); + if (claim_it != state_it->second.folder_claims.end() && + claim_it->second == this) { + state_it->second.folder_claims.erase(claim_it); + } + } + std::vector remaining_handlers( + state_it->second.handlers.begin(), state_it->second.handlers.end()); + for (DaoSidebarUIHandler* handler : remaining_handlers) { + handler->MaybeAdoptRestoredFolderSnapshot(); + } if (state_it->second.handlers.empty()) { states.erase(state_it); } } +bool DaoSidebarUIHandler::AdoptFoldersFromProfileState() { + Profile* profile = browser_ ? browser_->profile() : nullptr; + if (!profile) { + return false; + } + auto state_it = GetPinnedItemsProfileStates().find(profile); + if (state_it == GetPinnedItemsProfileStates().end() || + !state_it->second.folder_storage_initialized) { + return false; + } + + PinnedItemsProfileState& state = state_it->second; + std::set claimed_ids; + for (const auto& [id, handler] : state.folder_claims) { + if (handler != this) { + claimed_ids.insert(id); + } + } + const DaoFolderWindowSnapshot* snapshot = + state.folder_storage.FindClaimableSnapshot( + GetCurrentFolderSnapshotIds(), GetCurrentFolderTabIds(), claimed_ids); + if (snapshot) { + folder_snapshot_id_ = snapshot->id; + folder_json_ = snapshot->json; + folder_snapshot_matched_ = true; + } else { + folder_snapshot_id_ = base::Uuid::GenerateRandomV4().AsLowercaseString(); + folder_json_.clear(); + folder_snapshot_matched_ = false; + } + state.folder_claims[folder_snapshot_id_] = this; + folders_loaded_ = true; + PersistFolderSnapshotIdentity(); + return true; +} + +bool DaoSidebarUIHandler::MaybeAdoptRestoredFolderSnapshot() { + if (!folders_loaded_ || folder_snapshot_matched_ || !browser_) { + return false; + } + if (!folder_json_.empty()) { + std::optional current_data = + base::JSONReader::ReadDict(folder_json_, base::JSON_PARSE_RFC); + const base::ListValue* current_items = + current_data ? current_data->FindList("items") : nullptr; + if (!current_items || !current_items->empty()) { + return false; + } + } + auto state_it = GetPinnedItemsProfileStates().find(browser_->profile()); + if (state_it == GetPinnedItemsProfileStates().end() || + !state_it->second.folder_storage_initialized) { + return false; + } + + PinnedItemsProfileState& state = state_it->second; + std::optional restored_snapshot; + for (const std::string& id : GetCurrentFolderSnapshotIds()) { + if (id == folder_snapshot_id_) { + continue; + } + const DaoFolderWindowSnapshot* candidate = + state.folder_storage.FindById(id); + auto claim_it = state.folder_claims.find(id); + if (candidate && + (claim_it == state.folder_claims.end() || claim_it->second == this)) { + restored_snapshot = *candidate; + break; + } + } + if (!restored_snapshot) { + return false; + } + + auto old_claim_it = state.folder_claims.find(folder_snapshot_id_); + if (old_claim_it != state.folder_claims.end() && + old_claim_it->second == this) { + state.folder_claims.erase(old_claim_it); + } + state.folder_storage.RemoveSnapshot(folder_snapshot_id_); + folder_snapshot_id_ = restored_snapshot->id; + folder_json_ = restored_snapshot->json; + folder_snapshot_matched_ = true; + state.folder_claims[folder_snapshot_id_] = this; + PersistFolderSnapshotIdentity(); + if (IsJavascriptAllowed()) { + FireWebUIListener("folderDataChanged"); + } + return true; +} + +std::set DaoSidebarUIHandler::GetCurrentFolderTabIds() const { + std::set tab_ids; + if (!browser_) { + return tab_ids; + } + TabStripModel* tab_strip = browser_->tab_strip_model(); + for (int index = 0; index < tab_strip->count(); ++index) { + tab_ids.insert(GetSidebarTabId(tab_strip->GetWebContentsAt(index))); + } + return tab_ids; +} + +std::vector DaoSidebarUIHandler::GetCurrentFolderSnapshotIds() + const { + std::vector ids; + std::set seen; + if (!browser_) { + return ids; + } + TabStripModel* tab_strip = browser_->tab_strip_model(); + for (int index = 0; index < tab_strip->count(); ++index) { + std::string id = + GetSidebarFolderSnapshotId(tab_strip->GetWebContentsAt(index)); + if (!id.empty() && seen.insert(id).second) { + ids.push_back(std::move(id)); + } + } + return ids; +} + +void DaoSidebarUIHandler::PersistFolderSnapshotIdentity() { + if (!browser_ || folder_snapshot_id_.empty()) { + return; + } + SessionService* session_service = + SessionServiceFactory::GetForProfile(browser_->profile()); + TabStripModel* tab_strip = browser_->tab_strip_model(); + for (int index = 0; index < tab_strip->count(); ++index) { + content::WebContents* contents = tab_strip->GetWebContentsAt(index); + SetSidebarFolderSnapshotId(contents, folder_snapshot_id_); + sessions::SessionTabHelper* session_tab_helper = + sessions::SessionTabHelper::FromWebContents(contents); + if (session_service && session_tab_helper) { + session_service->AddTabExtraData( + session_tab_helper->window_id(), session_tab_helper->session_id(), + kSidebarFolderSnapshotSessionKey, folder_snapshot_id_); + } + } +} + bool DaoSidebarUIHandler::AdoptPinnedItemsFromProfileState() { Profile* profile = browser_ ? browser_->profile() : nullptr; if (!profile) { @@ -2548,6 +2896,13 @@ void DaoSidebarUIHandler::HandleTabDragActive(const base::ListValue& args) { return; } bool active = args[0].GetIfBool().value_or(false); + if (active && args.size() > 1 && args[1].is_string() && + ResolveDraggedTabIndex(browser_, -1, args[1].GetString()) >= 0) { + ObserveTabDragNativeCompletion( + web_ui()->GetWebContents(), + base::BindOnce(&DaoSidebarUIHandler::OnTabDragEnded, + weak_factory_.GetWeakPtr(), args[1].GetString())); + } // Activate/deactivate tab drag on ALL windows' split views so any // window can receive the cross-window drop. if (ProfileBrowserCollection* collection = @@ -2567,125 +2922,41 @@ void DaoSidebarUIHandler::HandleTabDragActive(const base::ListValue& args) { } } -void DaoSidebarUIHandler::HandleMoveTabCrossWindow( - const base::ListValue& args) { - LOG(ERROR) << "[Dao-Xwin] HandleMoveTabCrossWindow ENTRY: browser_=" - << (browser_ ? "ok" : "null") << " args.size()=" << args.size(); - if (!browser_ || args.size() < 3) { - LOG(ERROR) << "[Dao-Xwin] early-return: bad browser_/args"; +void DaoSidebarUIHandler::OnTabDragEnded(std::string tab_id, + const gfx::Point& screen_point) { + if (!browser_ || browser_->window()->GetBounds().Contains(screen_point)) { return; } - int source_session_id = args[0].GetIfInt().value_or(-1); - int source_tab_index = args[1].GetIfInt().value_or(-1); - int target_insert_index = args[2].GetIfInt().value_or(-1); - LOG(ERROR) << "[Dao-Xwin] args: sourceSessionId=" << source_session_id - << " sourceTabIndex=" << source_tab_index - << " targetInsertIndex=" << target_insert_index - << " this.sessionId=" << browser_->session_id().id(); - if (source_session_id < 0 || source_tab_index < 0 || - target_insert_index < 0) { - LOG(ERROR) << "[Dao-Xwin] early-return: negative arg"; - return; - } - - // Find source browser by session ID. - Browser* source_browser = nullptr; - int n_browsers = 0; - if (ProfileBrowserCollection* collection = - ProfileBrowserCollection::GetForProfile(browser_->profile())) { - collection->ForEach( - [&source_browser, &n_browsers, - source_session_id](BrowserWindowInterface* browser_window) { - Browser* candidate = browser_window->GetBrowserForMigrationOnly(); - n_browsers++; - LOG(ERROR) << "[Dao-Xwin] candidate browser sessionId=" - << candidate->session_id().id(); - if (static_cast(candidate->session_id().id()) == - source_session_id) { - source_browser = candidate; - return false; - } - return true; - }); - } - LOG(ERROR) << "[Dao-Xwin] scanned " << n_browsers - << " browsers, source_browser=" - << (source_browser ? "FOUND" : "NOT FOUND") - << " same-as-this=" << (source_browser == browser_ ? "yes" : "no"); - if (!source_browser || source_browser == browser_) { - return; - } - - TabStripModel* source_model = source_browser->tab_strip_model(); - if (source_tab_index >= source_model->count()) { - LOG(ERROR) << "[Dao-Xwin] tab_index out of range in source (" - << source_tab_index << " >= " << source_model->count() << ")"; - return; - } - - std::unique_ptr detached = - source_model->DetachWebContentsAtForInsertion(source_tab_index); - LOG(ERROR) << "[Dao-Xwin] detach result: " << (detached ? "ok" : "FAILED"); - if (!detached) { - return; - } - - TabStripModel* target_model = browser_->tab_strip_model(); - int clamped_index = std::min(target_insert_index, target_model->count()); - target_model->InsertWebContentsAt(clamped_index, std::move(detached), - AddTabTypes::ADD_ACTIVE); - LOG(ERROR) << "[Dao-Xwin] inserted at index " << clamped_index - << " target now has " << target_model->count() << " tabs"; - - // Auto-close source window if empty. - if (source_model->count() == 0) { - source_browser->window()->Close(); - } + DetachTabToNewWindow(browser_, tab_id, screen_point); } -void DaoSidebarUIHandler::HandleDetachTabToNewWindow( +void DaoSidebarUIHandler::HandleMoveTabCrossWindow( const base::ListValue& args) { if (!browser_ || args.size() < 3) { return; } - int tab_index = args[0].GetIfInt().value_or(-1); - int screen_x = args[1].GetIfInt().value_or(0); - int screen_y = args[2].GetIfInt().value_or(0); - if (tab_index < 0) { - return; - } - - TabStripModel* model = browser_->tab_strip_model(); - if (tab_index >= model->count()) { - return; - } - - // Don't detach the last tab β€” just move the window instead. - if (model->count() <= 1) { - return; - } + ExecuteCrossWindowTabMove( + browser_, args[0].GetIfInt().value_or(-1), + args[1].GetIfInt().value_or(-1), args[2].GetIfInt().value_or(-1), + args.size() > 3 && args[3].is_string() ? args[3].GetString() + : std::string()); +} - std::unique_ptr detached = - model->DetachWebContentsAtForInsertion(tab_index); - if (!detached) { +void DaoSidebarUIHandler::HandleDetachTabToNewWindow( + const base::ListValue& args) { + if (!browser_ || args.size() < 3 || !args[0].is_int() || !args[1].is_int() || + !args[2].is_int()) { return; } - - // Use the source window's size so the new window feels natural. - gfx::Rect source_bounds = browser_->window()->GetBounds(); - Browser::CreateParams params(browser_->profile(), /*user_gesture=*/true); - params.initial_bounds = - gfx::Rect(screen_x - source_bounds.width() / 4, screen_y - 40, - source_bounds.width(), source_bounds.height()); - Browser* new_browser = Browser::Create(params); - if (!new_browser) { + const int index = ResolveDraggedTabIndex(browser_, args[0].GetInt(), {}); + if (index < 0) { return; } - - new_browser->tab_strip_model()->InsertWebContentsAt(-1, std::move(detached), - AddTabTypes::ADD_ACTIVE); - new_browser->window()->Show(); - new_browser->window()->Activate(); + DetachTabToNewWindow( + browser_, + GetOrCreateSidebarTabId( + browser_->tab_strip_model()->GetWebContentsAt(index)), + gfx::Point(args[1].GetInt(), args[2].GetInt())); } void DaoSidebarUIHandler::HandleLoadFolders(const base::ListValue& args) { @@ -2694,36 +2965,65 @@ void DaoSidebarUIHandler::HandleLoadFolders(const base::ListValue& args) { if (args.size() < 1 || !args[0].is_string()) { return; } - const std::string callback_id = args[0].GetString(); + LoadFoldersForCallback(args[0].GetString()); +} - // If we already have in-memory data for this window, return it directly. - if (!folder_json_.empty()) { +void DaoSidebarUIHandler::LoadFoldersForCallback(std::string callback_id) { + if (folders_loaded_ || AdoptFoldersFromProfileState()) { FireWebUIListener(callback_id, base::Value(folder_json_)); return; } - // Otherwise, read from the shared profile file on a background thread. - Profile* profile = Profile::FromWebUI(web_ui()); - base::FilePath path = profile->GetPath().AppendASCII("dao_folders.json"); - - base::ThreadPool::PostTaskAndReplyWithResult( - FROM_HERE, {base::MayBlock(), base::TaskPriority::USER_VISIBLE}, - base::BindOnce( - [](base::FilePath file_path) -> std::string { - std::string contents; - base::ReadFileToString(file_path, &contents); - return contents; - }, - path), + Profile* profile = browser_ ? browser_->profile() : nullptr; + if (!profile) { + FireWebUIListener(callback_id, base::Value(std::string())); + return; + } + if (profile->IsOffTheRecord()) { + GetInitializedFolderProfileState(profile); + AdoptFoldersFromProfileState(); + FireWebUIListener(callback_id, base::Value(folder_json_)); + return; + } + const uint64_t generation = folder_load_generation_; + GetFolderFileTaskRunner()->PostTaskAndReplyWithResult( + FROM_HERE, + base::BindOnce(&ReadFolderFile, + profile->GetPath().AppendASCII("dao_folders.json")), base::BindOnce( [](base::WeakPtr self, std::string callback_id, - std::string contents) { + uint64_t generation, FolderFileReadResult result) { if (!self || !self->IsJavascriptAllowed()) { return; } - self->FireWebUIListener(callback_id, base::Value(contents)); + if (self->folder_load_generation_ != generation) { + self->LoadFoldersForCallback(std::move(callback_id)); + return; + } + Profile* profile = + self->browser_ ? self->browser_->profile() : nullptr; + if (!profile) { + self->FireWebUIListener(callback_id, base::Value(std::string())); + return; + } + PinnedItemsProfileState& state = + GetPinnedItemsProfileStates()[profile]; + if (!state.folder_storage_initialized) { + state.folder_storage_initialized = true; + state.folder_storage_writable = + result.success && + state.folder_storage.LoadFromJson(result.contents); + if (!state.folder_storage_writable) { + state.folder_storage = DaoFolderStorage(); + LOG(ERROR) << "Failed to load Dao folder storage; refusing " + "to overwrite it"; + } + } + self->AdoptFoldersFromProfileState(); + self->FireWebUIListener(callback_id, + base::Value(self->folder_json_)); }, - weak_factory_.GetWeakPtr(), callback_id)); + weak_factory_.GetWeakPtr(), std::move(callback_id), generation)); } void DaoSidebarUIHandler::HandleSaveFolders(const base::ListValue& args) { @@ -2735,18 +3035,44 @@ void DaoSidebarUIHandler::HandleSaveFolders(const base::ListValue& args) { return; } - // Update in-memory cache for this window. + Profile* profile = browser_ ? browser_->profile() : nullptr; + if (!profile || !folders_loaded_ || folder_snapshot_id_.empty()) { + return; + } + auto state_it = GetPinnedItemsProfileStates().find(profile); + if (state_it == GetPinnedItemsProfileStates().end() || + !state_it->second.folder_storage_initialized || + !state_it->second.folder_storage_writable) { + return; + } + folder_json_ = *json; + PinnedItemsProfileState& state = state_it->second; + state.folder_claims[folder_snapshot_id_] = this; + state.folder_storage.UpsertSnapshot(folder_snapshot_id_, + GetCurrentFolderTabIds(), folder_json_); + PersistFolderSnapshotIdentity(); - // Persist to shared profile file on a background thread. - Profile* profile = Profile::FromWebUI(web_ui()); - base::FilePath path = profile->GetPath().AppendASCII("dao_folders.json"); + if (profile->IsOffTheRecord()) { + return; + } - base::ThreadPool::PostTask( - FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT}, - base::BindOnce([](base::FilePath file_path, - std::string data) { base::WriteFile(file_path, data); }, - path, *json)); + const base::FilePath folder_path = + profile->GetPath().AppendASCII("dao_folders.json"); + const std::string storage_json = state.folder_storage.ToJson(); + const uint64_t write_generation = + ReserveFolderFileWrite(folder_path, storage_json); + GetFolderFileTaskRunner()->PostTaskAndReplyWithResult( + FROM_HERE, + base::BindOnce(&WriteReservedFolderFileAtomically, folder_path, + storage_json, write_generation), + base::BindOnce( + [](base::WeakPtr self, bool success) { + if (self && !success) { + LOG(ERROR) << "Failed to persist Dao folder storage"; + } + }, + weak_factory_.GetWeakPtr())); } void DaoSidebarUIHandler::HandleSetStaleTabIds( @@ -2794,6 +3120,8 @@ void DaoSidebarUIHandler::HandleShowTabContextMenu( return; } + context_menu_tab_id_ = GetOrCreateSidebarTabId(contents); + // Parse folder tab indices (arg[3] is an optional array of indices). folder_tab_indices_.clear(); if (args.size() > 3 && args[3].is_list()) { @@ -2821,6 +3149,9 @@ void DaoSidebarUIHandler::HandleShowTabContextMenu( tab_context_menu_model_->AddItem( kDuplicateTab, l10n_util::GetStringUTF16(IDS_DAO_TAB_CONTEXT_DUPLICATE_TAB)); + tab_context_menu_model_->AddItem( + kMoveTabToNewWindow, + l10n_util::GetStringUTF16(IDS_MOVE_TAB_TO_NEW_WINDOW)); tab_context_menu_model_->AddItem( kCopyLink, l10n_util::GetStringUTF16(IDS_DAO_TAB_CONTEXT_COPY_LINK)); PrefService* local_state = g_browser_process->local_state(); @@ -2904,8 +3235,11 @@ void DaoSidebarUIHandler::HandlePinTab(const base::ListValue& args) { return; } - PinTabAtIndex(args[0].GetIfInt().value_or(-1), - args.size() > 1 ? args[1].GetIfInt().value_or(-1) : -1); + const int index = ResolveDraggedTabIndex( + browser_, args[0].GetIfInt().value_or(-1), + args.size() > 2 && args[2].is_string() ? args[2].GetString() + : std::string()); + PinTabAtIndex(index, args.size() > 1 ? args[1].GetIfInt().value_or(-1) : -1); } void DaoSidebarUIHandler::HandleUnpinPinnedItem(const base::ListValue& args) { @@ -3655,6 +3989,7 @@ void DaoSidebarUIHandler::OnDuplicateTabsClosed(std::vector closed) { void DaoSidebarUIHandler::ClearContextMenuState() { context_menu_tab_index_ = -1; + context_menu_tab_id_.clear(); context_menu_pinned_item_id_.clear(); context_menu_folder_id_.clear(); folder_tab_indices_.clear(); @@ -3665,6 +4000,11 @@ bool DaoSidebarUIHandler::IsCommandIdEnabled(int command_id) const { if (!browser_) { return false; } + if (command_id == kMoveTabToNewWindow) { + const int index = + ResolveDraggedTabIndex(browser_, -1, context_menu_tab_id_); + return index >= 0 && chrome::CanMoveTabsToNewWindow(browser_, {index}); + } if (command_id == kInspectSidebar) { return true; } @@ -3778,6 +4118,16 @@ void DaoSidebarUIHandler::ExecuteCommand(int command_id, int event_flags) { return; } + if (command_id == kMoveTabToNewWindow) { + const int index = + ResolveDraggedTabIndex(browser_, -1, context_menu_tab_id_); + if (index >= 0 && chrome::CanMoveTabsToNewWindow(browser_, {index})) { + chrome::MoveTabsToNewWindow(browser_, {index}); + } + ClearContextMenuState(); + return; + } + if (command_id == kMoveStaleTabsToFolder) { if (IsJavascriptAllowed()) { const int expiration_hours = browser_->profile()->GetPrefs()->GetInteger( diff --git a/src/dao/browser/ui/webui/dao_sidebar_ui.h b/src/dao/browser/ui/webui/dao_sidebar_ui.h index 3442a1c7..e982951d 100644 --- a/src/dao/browser/ui/webui/dao_sidebar_ui.h +++ b/src/dao/browser/ui/webui/dao_sidebar_ui.h @@ -86,6 +86,16 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, // Invalidates folder caches for all sidebar windows of `profile` after a // profile-level writer updates dao_folders.json. static void NotifyFolderDataChanged(Profile* profile); + static bool LoadFolderSnapshotForImport( + Profile* profile, + const std::vector& preferred_ids, + const std::set& tab_ids, + std::string* snapshot_id, + std::string* json); + static bool PersistImportedFolder(Profile* profile, + const std::string& snapshot_id, + std::set tab_ids, + base::DictValue folder); // content::WebUIMessageHandler: void RegisterMessages() override; @@ -150,6 +160,13 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, void SetSessionRestoreCompletedForTesting(bool completed); void SetStaleTabIdsForTesting(std::set tab_ids); int CloseTabsByIdForTesting(const base::ListValue& tab_ids); + void LoadFoldersForTesting(const std::string& callback_id); + void SaveFoldersForTesting(const std::string& json); + static void WaitForFolderFileTasksForTesting(); + const std::string& folder_snapshot_id_for_testing() const { + return folder_snapshot_id_; + } + const std::string& folder_json_for_testing() const { return folder_json_; } views::Widget* ShowDeleteFolderDialogForTesting( const std::string& folder_id); int CloseDuplicateTabsForTesting(); @@ -183,6 +200,12 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, bool IsPinnedSessionRestoreComplete() const; void RegisterPinnedItemsProfileHandler(); void UnregisterPinnedItemsProfileHandler(); + bool AdoptFoldersFromProfileState(); + bool MaybeAdoptRestoredFolderSnapshot(); + void LoadFoldersForCallback(std::string callback_id); + std::set GetCurrentFolderTabIds() const; + std::vector GetCurrentFolderSnapshotIds() const; + void PersistFolderSnapshotIdentity(); bool AdoptPinnedItemsFromProfileState(); void PublishPinnedItemsToProfileHandlers(); void SchedulePinnedItemsProfileRefresh(); @@ -238,6 +261,7 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, void HandleCancelDownload(const base::ListValue& args); void HandleStartFileDrag(const base::ListValue& args); void HandleTabDragActive(const base::ListValue& args); + void OnTabDragEnded(std::string tab_id, const gfx::Point& screen_point); void HandleMoveTabCrossWindow(const base::ListValue& args); void HandleDetachTabToNewWindow(const base::ListValue& args); void HandleLoadFolders(const base::ListValue& args); @@ -304,6 +328,7 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, kFolderRename, kFolderUnfolder, kFolderDelete, + kMoveTabToNewWindow, }; raw_ptr browser_ = nullptr; @@ -327,7 +352,11 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, std::set in_progress_download_ids_; std::optional> pending_download_animation_; std::vector recent_file_paths_; - std::string folder_json_; // Per-window folder data (in-memory) + std::string folder_json_; + std::string folder_snapshot_id_; + bool folders_loaded_ = false; + bool folder_snapshot_matched_ = false; + uint64_t folder_load_generation_ = 0; std::set stale_tab_ids_; DaoPinnedTabModel pinned_tab_model_; bool pinned_items_loaded_ = false; @@ -345,6 +374,7 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, // Context menu state. int context_menu_tab_index_ = -1; + std::string context_menu_tab_id_; std::string context_menu_pinned_item_id_; std::string context_menu_folder_id_; std::set folder_tab_indices_; @@ -363,6 +393,7 @@ class DaoSidebarUI : public content::WebUIController { ~DaoSidebarUI() override; void SetBrowser(Browser* browser); + DaoSidebarUIHandler* handler_for_testing() { return handler_; } private: raw_ptr handler_ = 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 5aa15502..3f9d314a 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 @@ -33,7 +33,7 @@ describe('FolderModel', () => { }); }); - it('serializes folders without volatile runtime tab ids', () => { + it('serializes folders with stable tab ids', () => { const model = new FolderModel(); const folder = model.addFolder('Research'); @@ -43,10 +43,10 @@ describe('FolderModel', () => { const persisted = JSON.parse(model.toJson()); expect(persisted.items[0].children[0]).toEqual({ type: 'tab', + tabId: 'runtime-1', url: 'https://example.com/a', title: 'A', }); - expect(persisted.items[0].children[0]).not.toHaveProperty('tabId'); }); it('releases folder children at the folder position when unfoldering', () => { diff --git a/src/dao/browser/ui/webui/resources/sidebar/__tests__/pinned_tabs_grid.test.ts b/src/dao/browser/ui/webui/resources/sidebar/__tests__/pinned_tabs_grid.test.ts index f8edaeff..036e3db0 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/__tests__/pinned_tabs_grid.test.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/__tests__/pinned_tabs_grid.test.ts @@ -28,6 +28,7 @@ function item(extra: Partial = {}): PinnedItemData { faviconUrl: '', isOpen: true, openTabIndex: 0, + openTabId: 'pinned-tab', isActive: false, ...extra, }; @@ -509,9 +510,9 @@ describe('dao-pinned-tabs-grid', () => { expect(dataTransfer.setData).toHaveBeenCalledWith( PINNED_ITEM_DRAG_MIME_TYPE, 'pin-a'); expect(dataTransfer.setData).toHaveBeenCalledWith( - TAB_DRAG_MIME_TYPE, 'dao-tab-drag:9:4'); + TAB_DRAG_MIME_TYPE, 'dao-tab-drag:9:4:pinned-tab'); expect(dataTransfer.setData).toHaveBeenCalledWith( - 'text/plain', 'dao-tab-drag:9:4'); + 'text/plain', 'dao-tab-drag:9:4:pinned-tab'); expect(dataTransfer.effectAllowed).toBe('move'); const dragOver = dragEvent('dragover', dataTransfer); @@ -547,7 +548,7 @@ describe('dao-pinned-tabs-grid', () => { const grid = el.shadowRoot!.querySelector('.grid') as HTMLElement; tile.dispatchEvent(dragEvent('dragstart', dataTransfer)); - expect(send).toHaveBeenCalledWith('tabDragActive', [true]); + expect(send).toHaveBeenCalledWith('tabDragActive', [true, 'pinned-tab']); grid.dispatchEvent(dragEvent('dragleave', dataTransfer, { clientX: 240, @@ -559,6 +560,21 @@ describe('dao-pinned-tabs-grid', () => { expect(send).toHaveBeenCalledWith('tabDragActive', [false]); }); + it('does not register a dormant pinned item as a native tab drag', async () => { + const {el, send} = await loadGrid(); + el.items = [item({ + id: 'dormant', isOpen: false, openTabIndex: -1, openTabId: undefined, + })]; + await el.updateComplete; + const dataTransfer = fakeDataTransfer(); + const tile = el.shadowRoot!.querySelector('.tile') as HTMLElement; + tile.dispatchEvent(dragEvent('dragstart', dataTransfer)); + + expect(dataTransfer.getData(TAB_DRAG_MIME_TYPE)).toBe(''); + expect(send.mock.calls.some(call => call[0] === 'tabDragActive')).toBe(false); + tile.dispatchEvent(dragEvent('dragend', dataTransfer)); + }); + it('shows a grid placeholder at the pinned item drop position', async () => { const {el} = await loadGrid(); el.items = [ @@ -671,7 +687,7 @@ describe('dao-pinned-tabs-grid', () => { await el.updateComplete; const tile = el.shadowRoot!.querySelector('.tile') as HTMLElement; - const dataTransfer = protectedTabDragDataTransfer('dao-tab-drag:7:3'); + const dataTransfer = protectedTabDragDataTransfer('dao-tab-drag:7:3:normal-tab'); dataTransfer.getData.mockReturnValueOnce(''); const dragOver = dragEvent('dragover', dataTransfer); tile.dispatchEvent(dragOver); @@ -682,7 +698,7 @@ describe('dao-pinned-tabs-grid', () => { tile.dispatchEvent(drop); expect(drop.defaultPrevented).toBe(true); - expect(send).toHaveBeenCalledWith('pinTab', [3, 0]); + expect(send).toHaveBeenCalledWith('pinTab', [3, 0, 'normal-tab']); }); it('shows an end placeholder for a same-window tab dragged into the pinned grid', async () => { 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 8bfa0633..6666589e 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 @@ -181,6 +181,32 @@ describe('dao-sidebar-app', () => { delete (globalThis as unknown as {loadTimeData?: unknown}).loadTimeData; }); + it('ignores foreign folder drops and resolves local drops by tab identity', + async () => { + const {el} = await loadApp(); + const app = el as unknown as SidebarAppInternals; + const first = tab({tabId: 'first', index: 0}); + const second = tab({tabId: 'second', index: 1}); + fireSidebarStateChanged(sidebarState({unpinnedTabs: [first, second]})); + const model = installFolderModel(app, JSON.stringify({ + version: 1, + items: [{ + type: 'folder', id: 'folder-a', name: 'Work', collapsed: false, + children: [], + }], + })); + const drop = (dragData: string) => el.dispatchEvent(new CustomEvent( + 'folder-action', { + detail: {action: 'tabDrop', folderId: 'folder-a', dragData}, + })); + + drop('dao-tab-drag:8:0'); + expect(model.findTabFolder(first)).toBeNull(); + drop('dao-tab-drag:7:0:second'); + expect(model.findTabFolder(first)).toBeNull(); + expect(model.findTabFolder(second)).toBe('folder-a'); + }); + it('renders pinned tabs above the new tab button', async () => { const {el} = await loadApp(); el.pinnedItems_ = [pinnedItem()]; diff --git a/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_bridge.test.ts b/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_bridge.test.ts index 2279640e..4a9ff24f 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_bridge.test.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/__tests__/sidebar_bridge.test.ts @@ -31,23 +31,24 @@ describe('sidebar_bridge', () => { sessionId: 12, tabIndex: 3, }); + expect(bridge.parseTabDragData('dao-tab-drag:12:3:tab-a')).toEqual({ + sessionId: 12, + tabIndex: 3, + tabId: 'tab-a', + }); + for (const payload of [ + 'dao-tab-drag:12junk:3', 'dao-tab-drag:12:-1', + 'dao-tab-drag:0:3', 'dao-tab-drag:12:2147483648', + 'dao-tab-drag:12:3:', 'dao-tab-drag:12:3:tab:a', + 'dao-tab-drag:12:3:tab a', + ]) { + expect(bridge.parseTabDragData(payload)).toBeNull(); + } expect(bridge.parseTabDragData('dao-tab-drag:abc:3')).toBeNull(); expect(bridge.parseTabDragData('dao-tab-drag:12:x')).toBeNull(); expect(bridge.parseTabDragData('other:12:3')).toBeNull(); }); - it('treats drag points at the viewport edge as leaving', async () => { - const {bridge} = await loadBridge(); - - expect(bridge.isPointOutsideViewport(12, 24, 240, 800)).toBe(false); - expect(bridge.isPointOutsideViewport(0, 0, 240, 800)).toBe(false); - expect(bridge.isPointOutsideViewport(239, 799, 240, 800)).toBe(false); - expect(bridge.isPointOutsideViewport(-1, 24, 240, 800)).toBe(true); - expect(bridge.isPointOutsideViewport(12, -1, 240, 800)).toBe(true); - expect(bridge.isPointOutsideViewport(240, 24, 240, 800)).toBe(true); - expect(bridge.isPointOutsideViewport(12, 800, 240, 800)).toBe(true); - }); - it('dispatches and removes WebUI listeners by event id', async () => { const {bridge} = await loadBridge(); const first = vi.fn(); diff --git a/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_item.test.ts b/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_item.test.ts index 017aae91..1a950f55 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_item.test.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/__tests__/tab_item.test.ts @@ -131,9 +131,9 @@ describe('dao-tab-item', () => { .onDragStart_(event); expect(dataTransfer.setData).toHaveBeenCalledWith( - TAB_DRAG_MIME_TYPE, 'dao-tab-drag:7:3'); + TAB_DRAG_MIME_TYPE, 'dao-tab-drag:7:3:tab-1'); expect(dataTransfer.setData).toHaveBeenCalledWith( - 'text/plain', 'dao-tab-drag:7:3'); + 'text/plain', 'dao-tab-drag:7:3:tab-1'); expect(dataTransfer.effectAllowed).toBe('move'); }); 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 12ffa7fa..225a6819 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 @@ -521,6 +521,22 @@ describe('dao-tab-list', () => { expect(send).toHaveBeenCalledWith('unpinPinnedItem', ['pin-docs', 2]); }); + it.each(['pin-docs', 'dao-tab-drag:8:0:source'])( + 'moves a pinned item into an empty normal tab list (%s)', async payload => { + const {el, send} = createList(); + el.tabs = []; + await el.updateComplete; + const dataTransfer = fakeDataTransfer({ + [PINNED_ITEM_DRAG_MIME_TYPE]: 'pin-docs', + 'text/plain': payload, + }); + + el.dispatchEvent(dragEvent('dragover', dataTransfer, {clientY: 0})); + el.dispatchEvent(dragEvent('drop', dataTransfer)); + + expect(send).toHaveBeenCalledWith('unpinPinnedItem', ['pin-docs', 0]); + }); + it('unpins a pinned item when drag data hides the custom MIME payload', async () => { const {el, send} = createList(); @@ -558,12 +574,102 @@ describe('dao-tab-list', () => { const dataTransfer = fakeDataTransfer({'text/plain': 'dao-tab-drag:7:1'}); tabItem.dispatchEvent(dragEvent('dragstart', dataTransfer)); - expect(send).toHaveBeenCalledWith('tabDragActive', [true]); + expect(send).toHaveBeenCalledWith('tabDragActive', [true, 'tab-a']); tabItem.dispatchEvent(dragEvent('dragend', dataTransfer)); expect(send).toHaveBeenCalledWith('tabDragActive', [false]); }); + it('leaves canceled drag detachment and folder cleanup to native completion', + async () => { + const source = tab({tabId: 'tab-a', index: 1}); + const model = createFolderModel([{ + type: 'folder', id: 'folder-a', name: 'Work', collapsed: false, + children: [{type: 'tab', ...source}], + }]); + const {el, send} = createModelList(model, [source]); + await el.updateComplete; + const actions = vi.fn(); + el.addEventListener('folder-action', actions); + // Folder children live in a nested shadow root. + const tabItem = document.createElement('dao-tab-item') as + HTMLElement & {tabData: TabData}; + tabItem.tabData = source; + el.appendChild(tabItem); + const dataTransfer = fakeDataTransfer({}); + dataTransfer.dropEffect = 'none'; + tabItem.dispatchEvent(dragEvent('dragstart', dataTransfer)); + tabItem.dispatchEvent(dragEvent('dragend', dataTransfer, { + clientX: -100, clientY: -100, screenX: 800, screenY: 500, + })); + + expect(send.mock.calls.some(call => + call[0] === 'detachTabToNewWindow')).toBe(false); + expect(actions).not.toHaveBeenCalled(); + expect(model.findTabFolder(source)).toBe('folder-a'); + }); + + it.each(['', ':source'])( + 'moves a foreign tab without changing local folder membership (%s)', + async suffix => { + const target = tab({tabId: 'target', index: 1}); + const loose = tab({tabId: 'loose', index: 2}); + const model = createFolderModel([{ + type: 'folder', id: 'folder-a', name: 'Work', collapsed: false, + children: [{type: 'tab', ...target}], + }, {type: 'tab', ...loose}]); + const {el, send} = createModelList(model, [target, loose]); + await el.updateComplete; + const actions = vi.fn(); + el.addEventListener('folder-action', actions); + const dataTransfer = fakeDataTransfer({ + 'text/plain': `dao-tab-drag:8:1${suffix}`, + }); + el.dispatchEvent(dragEvent('dragover', dataTransfer, {clientY: 100})); + el.dispatchEvent(dragEvent('drop', dataTransfer)); + + expect(send).toHaveBeenCalledWith( + 'moveTabCrossWindow', [8, 1, 3, suffix ? 'source' : '']); + expect(actions).not.toHaveBeenCalled(); + expect(model.findTabFolder(target)).toBe('folder-a'); + }); + + it('accepts a foreign tab when the target list is empty', async () => { + const {el, send} = createList(); + el.tabs = []; + await el.updateComplete; + const dataTransfer = fakeDataTransfer({ + 'text/plain': 'dao-tab-drag:8:0:source', + }); + el.dispatchEvent(dragEvent('dragover', dataTransfer, {clientY: 0})); + el.dispatchEvent(dragEvent('drop', dataTransfer)); + + expect(send).toHaveBeenCalledWith( + 'moveTabCrossWindow', [8, 0, -1, 'source']); + }); + + it('resolves a same-window tab by identity after its index changes', + async () => { + const {el, send} = createList(); + await el.updateComplete; + const dataTransfer = fakeDataTransfer({ + 'text/plain': 'dao-tab-drag:7:1:tab-b', + }); + const first = el.shadowRoot!.querySelector('dao-tab-item') as HTMLElement; + setTabItemBounds(first, 30, 36); + first.dispatchEvent(dragEvent('dragover', dataTransfer, {clientY: 0})); + first.dispatchEvent(dragEvent('drop', dataTransfer)); + + expect(send).toHaveBeenCalledWith('moveTab', [2, 1]); + send.mockClear(); + const removed = fakeDataTransfer({ + 'text/plain': 'dao-tab-drag:7:1:removed', + }); + first.dispatchEvent(dragEvent('dragover', removed, {clientY: 0})); + first.dispatchEvent(dragEvent('drop', removed)); + expect(send.mock.calls.some(call => call[0] === 'moveTab')).toBe(false); + }); + it('keeps duplicate URL tabs distinct when computing context menu order', async () => { const send = vi.fn(); @@ -720,7 +826,7 @@ describe('dao-tab-list', () => { 'showFolderContextMenu', ['ordinary-id', 20, 30]); }); - it('activates native tab drag when leaving at the viewport edge', async () => { + it('does not activate a native tab drag for an unrelated dragleave', async () => { const {el, send} = createList(); await el.updateComplete; @@ -740,6 +846,6 @@ describe('dao-tab-list', () => { })); expect(send).toHaveBeenCalledWith('setDropInsertIndex', [-1]); - expect(send).toHaveBeenCalledWith('tabDragActive', [true]); + expect(send.mock.calls.some(call => call[0] === 'tabDragActive')).toBe(false); }); }); 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 53da1cc4..09c27e82 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 @@ -105,6 +105,7 @@ export class FolderModel { if (item.type === 'tab') { return { type: 'tab', + tabId: item.tabId, url: item.url, title: item.title, }; @@ -117,6 +118,7 @@ export class FolderModel { collapsed: item.collapsed, children: item.children.map(child => ({ type: 'tab', + tabId: child.tabId, url: child.url, title: child.title, })), diff --git a/src/dao/browser/ui/webui/resources/sidebar/dao_pinned_tabs_grid.ts b/src/dao/browser/ui/webui/resources/sidebar/dao_pinned_tabs_grid.ts index 178869b7..81e850d3 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/dao_pinned_tabs_grid.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/dao_pinned_tabs_grid.ts @@ -17,7 +17,6 @@ import { setActivePinnedItemDragId, TAB_DRAG_MIME_TYPE, TAB_DRAG_PREFIX, - isPointOutsideViewport, parseTabDragData, sendNative, } from './sidebar_bridge.js'; @@ -287,12 +286,13 @@ export class DaoPinnedTabsGrid extends CrLitElement { setActivePinnedItemDragId(item.id); if (e.dataTransfer) { e.dataTransfer.setData(PINNED_ITEM_DRAG_MIME_TYPE, item.id); - if (item.isOpen && item.openTabIndex >= 0) { + if (item.isOpen && item.openTabIndex >= 0 && item.openTabId) { const tabPayload = - `${TAB_DRAG_PREFIX}${this.sessionId}:${item.openTabIndex}`; + `${TAB_DRAG_PREFIX}${this.sessionId}:${item.openTabIndex}:` + + item.openTabId; e.dataTransfer.setData(TAB_DRAG_MIME_TYPE, tabPayload); e.dataTransfer.setData('text/plain', tabPayload); - this.activateNativeTabDrag_(); + this.activateNativeTabDrag_(item.openTabId); } else { e.dataTransfer.setData('text/plain', item.id); } @@ -378,20 +378,14 @@ export class DaoPinnedTabsGrid extends CrLitElement { return; } this.clearTabDragPlaceholder_(); - - if (this.hasTabDrag_(e) && - isPointOutsideViewport( - e.clientX, e.clientY, window.innerWidth, window.innerHeight)) { - this.activateNativeTabDrag_(); - } } - private activateNativeTabDrag_() { + private activateNativeTabDrag_(tabId: string) { if (this.tabDragActivated_) { return; } this.tabDragActivated_ = true; - sendNative('tabDragActive', true); + sendNative('tabDragActive', true, tabId); } private onGridDrop_(e: DragEvent) { @@ -403,14 +397,16 @@ export class DaoPinnedTabsGrid extends CrLitElement { return; } - const tabIndex = this.getSameWindowDraggedTabIndex_(e); - if (tabIndex === null) { + const tab = this.getSameWindowDraggedTab_(e); + if (!tab) { return; } e.preventDefault(); e.stopPropagation(); - sendNative('pinTab', tabIndex, this.getPinnedDropIndex_()); + const args: unknown[] = [tab.tabIndex, this.getPinnedDropIndex_()]; + if (tab.tabId) args.push(tab.tabId); + sendNative('pinTab', ...args); this.clearTabDragPlaceholder_(); } @@ -521,14 +517,14 @@ export class DaoPinnedTabsGrid extends CrLitElement { return markedId || getActivePinnedItemDragId(); } - private getSameWindowDraggedTabIndex_(e: DragEvent): number|null { + private getSameWindowDraggedTab_(e: DragEvent) { const dragData = e.dataTransfer?.getData(TAB_DRAG_MIME_TYPE) || e.dataTransfer?.getData('text/plain') || ''; const parsed = parseTabDragData(dragData); if (!parsed || parsed.sessionId !== this.sessionId) { return null; } - return parsed.tabIndex; + return parsed; } private hasTabDrag_(e: DragEvent): boolean { 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 0850f6e7..678d75ac 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 @@ -677,8 +677,9 @@ export class DaoSidebarApp extends CrLitElement { */ private resolveTabFromDrag_(dragData: string): TabData | null { const parsed = parseTabDragData(dragData); - if (!parsed) return null; - return this.unpinnedTabs_.find(t => t.index === parsed.tabIndex) || null; + if (!parsed || parsed.sessionId !== this.sessionId_) return null; + return this.unpinnedTabs_.find(tab => parsed.tabId ? + tab.tabId === parsed.tabId : tab.index === parsed.tabIndex) || null; } private findUnpinnedTabById_(tabId: string): TabData | null { diff --git a/src/dao/browser/ui/webui/resources/sidebar/dao_tab_item.ts b/src/dao/browser/ui/webui/resources/sidebar/dao_tab_item.ts index 1c88dab9..5c399373 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/dao_tab_item.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/dao_tab_item.ts @@ -401,7 +401,8 @@ export class DaoTabItem extends CrLitElement { if (!e.dataTransfer) return; this.clearTooltip_(this.tooltipVisible_); const payload = - `${TAB_DRAG_PREFIX}${this.sessionId}:${this.tabData.index}`; + `${TAB_DRAG_PREFIX}${this.sessionId}:${this.tabData.index}:` + + this.tabData.tabId; console.error('[Dao-Xwin-JS] dragstart: payload=' + JSON.stringify(payload) + ' sessionId=' + this.sessionId); e.dataTransfer.setData(TAB_DRAG_MIME_TYPE, payload); 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 be4d5aab..f17d1f76 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 @@ -9,9 +9,10 @@ import { sendNative, getActivePinnedItemDragId, TAB_DRAG_PREFIX, + TAB_DRAG_MIME_TYPE, + parseTabDragData, FOLDER_MIME_TYPE, PINNED_ITEM_DRAG_MIME_TYPE, - isPointOutsideViewport, } from './sidebar_bridge.js'; import type {TabData, FolderData, FolderAction} from './sidebar_bridge.js'; import {createTabRefMatchPool} from './dao_folder_model.js'; @@ -117,7 +118,6 @@ export class DaoTabList extends CrLitElement { private dropInsertIndex_: number = -1; private dropModelIndex_: number = -1; private tabDragActivated_: boolean = false; - private draggedTabIndex_: number = -1; private previousFlipSnapshot_: FlipMotionSnapshot | null = null; private previousFolderChildCounts_: Map | null = null; @@ -398,17 +398,13 @@ export class DaoTabList extends CrLitElement { } private onDragStart_(e: DragEvent) { - // Capture the dragged tab index from the bubbled event data. + // Capture the dragged tab identity from the composed event. if (!e.dataTransfer) return; - // The data is set by dao-tab-item; extract the tab index from the - // composed event. We can't read dataTransfer in dragstart due to - // protection, so find the source tab-item element. const target = e.composedPath().find( el => (el as HTMLElement).tagName === 'DAO-TAB-ITEM') as (HTMLElement & {tabData: TabData}) | undefined; if (target) { - this.draggedTabIndex_ = target.tabData.index; - this.activateNativeTabDrag_(); + this.activateNativeTabDrag_(target.tabData.tabId); } } @@ -596,72 +592,27 @@ export class DaoTabList extends CrLitElement { this.dropInsertIndex_ = -1; this.dropModelIndex_ = -1; sendNative('setDropInsertIndex', -1); - - // Fallback for tab drags that did not originate from dao-tab-item. - if (this.isPointOutsideSidebar_(e.clientX, e.clientY)) { - this.activateNativeTabDrag_(); - } } - private activateNativeTabDrag_() { - if (this.tabDragActivated_) { + private activateNativeTabDrag_(tabId: string) { + if (this.tabDragActivated_ || !tabId) { return; } - console.error('[Dao-Xwin-JS] activating tabDragActive'); this.tabDragActivated_ = true; - sendNative('tabDragActive', true); + sendNative('tabDragActive', true, tabId); } - private onDragEnd_(e: DragEvent) { - console.error('[Dao-Xwin-JS] onDragEnd_: dropEffect=' + - (e.dataTransfer?.dropEffect ?? 'null') + - ' tabDragActivated=' + this.tabDragActivated_ + - ' draggedTabIndex=' + this.draggedTabIndex_ + - ' outside=' + this.isPointOutsideSidebar_(e.clientX, e.clientY)); - // Defensive cleanup: ensure drag-over state is always cleared - // when any drag ends, preventing the sidebar from getting stuck. + private onDragEnd_() { + // Native completion distinguishes a desktop drop from cancellation. this.classList.remove('drag-over'); this.dropInsertIndex_ = -1; this.dropModelIndex_ = -1; - - // If no target accepted the drop, detach to a new window at cursor. - // Two guards prevent a fast drag+release inside the sidebar from - // being mistaken for a drag-out: - // 1. tabDragActivated_ must be true β€” this is a Dao tab drag. - // 2. The release point must be outside the sidebar's viewport β€” - // if the pointer is still inside, the user was reordering - // within the sidebar and released before any dragover target - // had a chance to preventDefault. - if (e.dataTransfer && e.dataTransfer.dropEffect === 'none' && - this.draggedTabIndex_ >= 0 && - this.tabDragActivated_ && - this.isPointOutsideSidebar_(e.clientX, e.clientY)) { - // Remove from folder model before detaching so the folder - // membership doesn't persist after the tab leaves this window. - this.maybeRemoveFromFolder_(this.draggedTabIndex_); - sendNative('detachTabToNewWindow', this.draggedTabIndex_, - e.screenX, e.screenY); - } - this.draggedTabIndex_ = -1; - if (this.tabDragActivated_) { this.tabDragActivated_ = false; sendNative('tabDragActive', false); } } - /** - * Returns true if (clientX, clientY) is outside the sidebar - * WebContents viewport. A release inside the viewport means the - * user did not drag out of the sidebar, so the tab should not be - * detached. Negative coordinates indicate the release was outside - * the WebContents entirely. - */ - private isPointOutsideSidebar_(clientX: number, clientY: number): boolean { - return isPointOutsideViewport( - clientX, clientY, window.innerWidth, window.innerHeight); - } - private onDrop_(e: DragEvent) { this.classList.remove('drag-over'); @@ -672,75 +623,39 @@ export class DaoTabList extends CrLitElement { if (pinnedItemId) { e.preventDefault(); e.stopPropagation(); - sendNative('unpinPinnedItem', pinnedItemId, this.dropInsertIndex_); + // Native unpin treats -1 as a menu action without a target window move. + const targetIndex = this.dropInsertIndex_ >= 0 ? this.dropInsertIndex_ : + Math.max(0, ...this.tabs.map(tab => tab.index + 1)); + sendNative('unpinPinnedItem', pinnedItemId, targetIndex); clearActivePinnedItemDragId(); this.dropInsertIndex_ = -1; this.dropModelIndex_ = -1; return; } - const data = e.dataTransfer.getData('text/plain'); - console.error('[Dao-Xwin-JS] onDrop_: data=' + JSON.stringify(data) + - ' thisSessionId=' + this.sessionId + - ' dropInsertIndex=' + this.dropInsertIndex_); + const data = e.dataTransfer.getData(TAB_DRAG_MIME_TYPE) || + e.dataTransfer.getData('text/plain'); if (data.startsWith(TAB_DRAG_PREFIX)) { e.preventDefault(); e.stopPropagation(); - - const useModel = this.folderModel && this.folderModel.hasData(); - - const parts = data.substring(TAB_DRAG_PREFIX.length).split(':'); - console.error('[Dao-Xwin-JS] parts=' + JSON.stringify(parts)); - if (parts.length === 2) { - const sourceSessionId = parseInt(parts[0]!, 10); - const fromIndex = parseInt(parts[1]!, 10); - console.error('[Dao-Xwin-JS] parsed sourceSessionId=' + - sourceSessionId + ' fromIndex=' + fromIndex + - ' sameWindow=' + (sourceSessionId === this.sessionId)); - if (!isNaN(sourceSessionId) && !isNaN(fromIndex) && - this.dropInsertIndex_ >= 0) { - // Check if the dragged tab is inside a folder β€” if so, - // remove it from the folder first. + const parsed = parseTabDragData(data); + if (parsed && parsed.sessionId !== this.sessionId) { + // Source indices never describe this window's folder membership. + sendNative('moveTabCrossWindow', parsed.sessionId, parsed.tabIndex, + this.dropInsertIndex_, parsed.tabId || ''); + } else if (parsed && this.dropInsertIndex_ >= 0) { + const source = this.tabs.find(tab => parsed.tabId ? + tab.tabId === parsed.tabId : tab.index === parsed.tabIndex); + if (source) { + const fromIndex = source.index; const wasInFolder = this.maybeRemoveFromFolder_(fromIndex); - - if (sourceSessionId === this.sessionId) { - // Same window: reorder in Chromium tab strip - let toIndex = this.dropInsertIndex_; - if (fromIndex < toIndex) { - toIndex--; - } - if (fromIndex !== toIndex) { - sendNative('moveTab', fromIndex, toIndex); - } - - // Also reorder in the folder model if active. - if (useModel && !wasInFolder && this.dropModelIndex_ >= 0) { - this.dispatchModelReorder_(fromIndex, this.dropModelIndex_); - } - } else { - // Cross-window: move tab from source window to this window - console.error('[Dao-Xwin-JS] firing moveTabCrossWindow ' + - sourceSessionId + ' ' + fromIndex + ' ' + - this.dropInsertIndex_); - sendNative('moveTabCrossWindow', sourceSessionId, fromIndex, - this.dropInsertIndex_); - } - } - } else if (parts.length === 1) { - // Legacy format: "" (same-window only) - const fromIndex = parseInt(parts[0]!, 10); - if (!isNaN(fromIndex) && this.dropInsertIndex_ >= 0) { - const wasInFolder = this.maybeRemoveFromFolder_(fromIndex); - - let toIndex = this.dropInsertIndex_; - if (fromIndex < toIndex) { - toIndex--; - } + const toIndex = this.dropInsertIndex_ - + (fromIndex < this.dropInsertIndex_ ? 1 : 0); if (fromIndex !== toIndex) { sendNative('moveTab', fromIndex, toIndex); } - - if (useModel && !wasInFolder && this.dropModelIndex_ >= 0) { + if (this.folderModel?.hasData() && !wasInFolder && + this.dropModelIndex_ >= 0) { this.dispatchModelReorder_(fromIndex, this.dropModelIndex_); } } 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 6ea2c152..01ff93a1 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/sidebar_bridge.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/sidebar_bridge.ts @@ -96,6 +96,7 @@ export interface PinnedItemData { faviconUrl: string; isOpen: boolean; openTabIndex: number; + openTabId?: string; isActive: boolean; isFaviconLight?: boolean; } @@ -202,31 +203,23 @@ export function clearActivePinnedItemDragId() { } /** - * Returns true when a drag event point has left a viewport. + * Parse a window/tab drag, optionally carrying its stable tab identity. */ -export function isPointOutsideViewport( - clientX: number, clientY: number, viewportWidth: number, - viewportHeight: number): boolean { - if (clientX < 0 || clientY < 0) return true; - if (clientX >= viewportWidth || clientY >= viewportHeight) { - return true; - } - return false; -} - -/** - * Parse a tab drag data string ("dao-tab-drag::"). - * Returns {sessionId, tabIndex} or null if the format is invalid. - */ -export function parseTabDragData( - data: string): {sessionId: number; tabIndex: number} | null { +export function parseTabDragData(data: string): + {sessionId: number; tabIndex: number; tabId?: string} | null { if (!data.startsWith(TAB_DRAG_PREFIX)) return null; const parts = data.substring(TAB_DRAG_PREFIX.length).split(':'); - if (parts.length < 2) return null; - const sessionId = parseInt(parts[0]!, 10); - const tabIndex = parseInt(parts[1]!, 10); - if (isNaN(sessionId) || isNaN(tabIndex)) return null; - return {sessionId, tabIndex}; + if (parts.length !== 2 && parts.length !== 3) return null; + if (!/^\d+$/.test(parts[0]!) || !/^\d+$/.test(parts[1]!)) return null; + const sessionId = Number(parts[0]); + const tabIndex = Number(parts[1]); + if (sessionId <= 0 || sessionId > 0x7fffffff || tabIndex > 0x7fffffff) { + return null; + } + const tabId = parts[2]; + if (tabId !== undefined && (!tabId || /\s/.test(tabId))) return null; + return tabId === undefined ? {sessionId, tabIndex} : + {sessionId, tabIndex, tabId}; } // ---- Folder Action Types (discriminated union) ---- diff --git a/src/patches/chrome/browser/sessions/session_service.cc.patch b/src/patches/chrome/browser/sessions/session_service.cc.patch index e812391e..100191d7 100644 --- a/src/patches/chrome/browser/sessions/session_service.cc.patch +++ b/src/patches/chrome/browser/sessions/session_service.cc.patch @@ -10,7 +10,7 @@ index d7454d758f..3110999faf 100644 #include "build/build_config.h" #include "chrome/browser/apps/app_service/launch_utils.h" #include "chrome/browser/browser_process.h" -@@ -608,5 +609,10 @@ void SessionService::BuildCommandsForTab( +@@ -608,5 +609,18 @@ void SessionService::BuildCommandsForTab( sessions::SessionTabHelper* session_tab_helper = sessions::SessionTabHelper::FromWebContents(tab); SessionID session_id(session_tab_helper->session_id()); @@ -19,5 +19,13 @@ index d7454d758f..3110999faf 100644 + sessions::CreateAddTabExtraDataCommand( + session_id, dao::kSidebarTabIdentitySessionKey, + dao::GetSidebarTabId(tab))); ++ const std::string folder_snapshot_id = ++ dao::GetSidebarFolderSnapshotId(tab); ++ if (!folder_snapshot_id.empty()) { ++ command_storage_manager()->AppendRebuildCommand( ++ sessions::CreateAddTabExtraDataCommand( ++ session_id, dao::kSidebarFolderSnapshotSessionKey, ++ folder_snapshot_id)); ++ } const blink::UserAgentOverride& ua_override = tab->GetUserAgentOverride(); diff --git a/src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h.patch b/src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h.patch new file mode 100644 index 00000000..36c543f6 --- /dev/null +++ b/src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h.patch @@ -0,0 +1,14 @@ +diff --git a/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h b/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h +--- a/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h ++++ b/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.h +@@ -58,6 +58,10 @@ + bool TakeFocus(bool reverse) override; + void OnPerformingDrop(const content::DropData& drop_data, + DropCompletionCallback callback) override; ++ void WebContentsDragEnded( ++ const gfx::PointF& screen_point, ++ ui::mojom::DragOperation operation, ++ bool ended_by_mouse_release) override; + + // ContextMenuDelegate: + std::unique_ptr BuildMenu( diff --git a/src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm.patch b/src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm.patch new file mode 100644 index 00000000..66b73493 --- /dev/null +++ b/src/patches/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm.patch @@ -0,0 +1,37 @@ +diff --git a/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm b/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm +--- a/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm ++++ b/chrome/browser/ui/views/tab_contents/chrome_web_contents_view_delegate_views_mac.mm +@@ -20,6 +20,7 @@ + #include "chrome/browser/ui/views/tab_contents/chrome_web_contents_view_focus_helper.h" + #include "components/remote_cocoa/browser/window.h" + #include "components/tabs/public/tab_interface.h" ++#include "dao/browser/ui/views/dao_native_util_mac.h" + #include "content/public/browser/browser_context.h" + #include "content/public/browser/render_frame_host.h" + #include "content/public/browser/render_widget_host_view.h" +@@ -40,7 +41,9 @@ + } + + ChromeWebContentsViewDelegateViewsMac:: +- ~ChromeWebContentsViewDelegateViewsMac() = default; ++ ~ChromeWebContentsViewDelegateViewsMac() { ++ dao::CancelTabDragNativeCompletion(web_contents_); ++} + + gfx::NativeWindow ChromeWebContentsViewDelegateViewsMac::GetNativeWindow() { + BrowserWindowInterface* browser = +@@ -125,6 +128,14 @@ + HandleOnPerformingDrop(web_contents_, drop_data, std::move(callback)); + } + ++void ChromeWebContentsViewDelegateViewsMac::WebContentsDragEnded( ++ const gfx::PointF& screen_point, ++ ui::mojom::DragOperation operation, ++ bool ended_by_mouse_release) { ++ dao::CompleteTabDragNativeCompletion( ++ web_contents_, screen_point, operation, ended_by_mouse_release); ++} ++ + std::unique_ptr + ChromeWebContentsViewDelegateViewsMac::BuildMenu( + content::RenderFrameHost& render_frame_host, diff --git a/src/patches/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm.patch b/src/patches/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm.patch new file mode 100644 index 00000000..71469006 --- /dev/null +++ b/src/patches/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm.patch @@ -0,0 +1,110 @@ +diff --git a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm +index da2786e0c5..6fc8a28b91 100644 +--- a/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm ++++ b/content/app_shim_remote_cocoa/web_contents_view_cocoa.mm +@@ -5,6 +5,7 @@ + #import "content/app_shim_remote_cocoa/web_contents_view_cocoa.h" + + #include ++#import + + #include "base/files/file_util.h" + #include "base/files/scoped_temp_dir.h" +@@ -121,6 +122,9 @@ + // Drag variables. + WebDragSource* __strong _dragSource; + NSDragOperation _dragOperation; ++ id __strong _dragTerminationEventMonitor; ++ BOOL _dragSawEscape; ++ BOOL _dragSawLeftMouseUp; + + BOOL _willSetWebContentsOccludedAfterDelay; + } +@@ -151,6 +155,7 @@ + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self cancelDelayedSetWebContentsOccluded]; ++ [self stopDragTerminationEventMonitor]; + } + + - (void)enableDroppedScreenShotCopier { +@@ -221,6 +226,36 @@ + // saves us the effort of overriding this method in every possible + // subview. + return _mouseDownCanMoveWindow; ++} ++ ++- (void)recordDragTerminationEvent:(NSEvent*)event { ++ if (event.type == NSEventTypeKeyDown && event.keyCode == kVK_Escape) { ++ _dragSawEscape = YES; ++ } else if (event.type == NSEventTypeLeftMouseUp) { ++ _dragSawLeftMouseUp = YES; ++ } ++} ++ ++- (void)startDragTerminationEventMonitor { ++ [self stopDragTerminationEventMonitor]; ++ _dragSawEscape = NO; ++ _dragSawLeftMouseUp = NO; ++ WebContentsViewCocoa* __weak weakSelf = self; ++ _dragTerminationEventMonitor = [NSEvent ++ addLocalMonitorForEventsMatchingMask:(NSEventMaskKeyDown | ++ NSEventMaskLeftMouseUp) ++ handler:^NSEvent*(NSEvent* event) { ++ [weakSelf ++ recordDragTerminationEvent:event]; ++ return event; ++ }]; ++} ++ ++- (void)stopDragTerminationEventMonitor { ++ if (_dragTerminationEventMonitor) { ++ [NSEvent removeMonitor:_dragTerminationEventMonitor]; ++ _dragTerminationEventMonitor = nil; ++ } + } + + - (void)startDragWithDropData:(const DropData&)dropData +@@ -273,6 +308,9 @@ + + _dragOperation = operationMask; + ++ // Observe termination in the process that owns the AppKit drag session. ++ [self startDragTerminationEventMonitor]; ++ + // Run the drag operation. + [self beginDraggingSessionWithItems:@[ draggingItem ] + event:dragEvent +@@ -290,6 +328,9 @@ + - (void)draggingSession:(NSDraggingSession*)session + endedAtPoint:(NSPoint)screenPoint + operation:(NSDragOperation)operation { ++ [self recordDragTerminationEvent:NSApp.currentEvent]; ++ const bool endedByMouseRelease = _dragSawLeftMouseUp && !_dragSawEscape; ++ [self stopDragTerminationEventMonitor]; + if (!_host) { + return; + } +@@ -303,10 +344,12 @@ + // Flip the two points as per Cocoa's coordinate system. + NSRect viewFrame = self.frame; +- NSRect screenFrame = self.window.screen.frame; ++ // Global screen coordinates use the primary display's top edge. + _host->EndDrag( + operation, + gfx::PointF(localPoint.x, viewFrame.size.height - localPoint.y), +- gfx::PointF(screenPoint.x, screenFrame.size.height - screenPoint.y)); ++ gfx::PointF(screenPoint.x, ++ NSMaxY(NSScreen.screens.firstObject.frame) - screenPoint.y), ++ endedByMouseRelease); + + // The drag is complete. Disconnect the drag source. + [_dragSource webContentsIsGone]; +@@ -369,6 +412,7 @@ + + - (void)setHost:(remote_cocoa::mojom::WebContentsNSViewHost*)host { + if (!host) { ++ [self stopDragTerminationEventMonitor]; + [_dragSource webContentsIsGone]; + } + _host = host; diff --git a/src/patches/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm.patch b/src/patches/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm.patch new file mode 100644 index 00000000..c0560483 --- /dev/null +++ b/src/patches/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm.patch @@ -0,0 +1,14 @@ +diff --git a/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm b/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm +index 0ef8bdf23e..36d1d2baf1 100644 +--- a/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm ++++ b/content/app_shim_remote_cocoa/window_occlusion_browsertest_mac.mm +@@ -322,7 +322,8 @@ + + void EndDrag(uint32_t drag_operation, + const ::gfx::PointF& local_point, +- const ::gfx::PointF& screen_point) override {} ++ const ::gfx::PointF& screen_point, ++ bool ended_by_mouse_release) override {} + + private: + remote_cocoa::mojom::Visibility _visibility; diff --git a/src/patches/content/browser/web_contents/web_contents_view_mac.h.patch b/src/patches/content/browser/web_contents/web_contents_view_mac.h.patch new file mode 100644 index 00000000..e6a3e032 --- /dev/null +++ b/src/patches/content/browser/web_contents/web_contents_view_mac.h.patch @@ -0,0 +1,23 @@ +diff --git a/content/browser/web_contents/web_contents_view_mac.h b/content/browser/web_contents/web_contents_view_mac.h +index b9868665c8..8de3e6da9c 100644 +--- a/content/browser/web_contents/web_contents_view_mac.h ++++ b/content/browser/web_contents/web_contents_view_mac.h +@@ -183,14 +183,16 @@ + base::FilePath* out_file_path) override; + void EndDrag(uint32_t drag_operation, + const gfx::PointF& local_point, +- const gfx::PointF& screen_point) override; ++ const gfx::PointF& screen_point, ++ bool ended_by_mouse_release) override; + + // Helper used by `EndDrag` to pass a callback to `drag_dest_`. This can be + // called either synchronously or asynchronously depending on if `drag_dest_` + // delays firing "dragend" or not. + void PerformEndDrag(uint32_t drag_operation, + const gfx::PointF& local_point, +- const gfx::PointF& screen_point); ++ const gfx::PointF& screen_point, ++ bool ended_by_mouse_release); + + // remote_cocoa::mojom::WebContentsNSViewHost, synchronous methods: + void DraggingEntered(remote_cocoa::mojom::DraggingInfoPtr dragging_info, diff --git a/src/patches/content/browser/web_contents/web_contents_view_mac.mm.patch b/src/patches/content/browser/web_contents/web_contents_view_mac.mm.patch index a1c3d451..edfdbf16 100644 --- a/src/patches/content/browser/web_contents/web_contents_view_mac.mm.patch +++ b/src/patches/content/browser/web_contents/web_contents_view_mac.mm.patch @@ -1,8 +1,44 @@ diff --git a/content/browser/web_contents/web_contents_view_mac.mm b/content/browser/web_contents/web_contents_view_mac.mm -index 7927a3920f..af58648aef 100644 +index 712da37eaef7c..e4d6564192228 100644 --- a/content/browser/web_contents/web_contents_view_mac.mm +++ b/content/browser/web_contents/web_contents_view_mac.mm -@@ -777,7 +777,16 @@ ClipboardEndpoint source_endpoint( +@@ -656,16 +656,19 @@ void SetReadWritePermissionsForFile(base::File& file) { + + void WebContentsViewMac::EndDrag(uint32_t drag_operation, + const gfx::PointF& local_point, +- const gfx::PointF& screen_point) { ++ const gfx::PointF& screen_point, ++ bool ended_by_mouse_release) { + [drag_dest_ + endDrag:base::BindOnce(&WebContentsViewMac::PerformEndDrag, + deferred_close_weak_ptr_factory_.GetWeakPtr(), +- drag_operation, local_point, screen_point)]; ++ drag_operation, local_point, screen_point, ++ ended_by_mouse_release)]; + } + + void WebContentsViewMac::PerformEndDrag(uint32_t drag_operation, + const gfx::PointF& local_point, +- const gfx::PointF& screen_point) { ++ const gfx::PointF& screen_point, ++ bool ended_by_mouse_release) { + // Validate internal members are non-null as this method can be called + // asynchronously. + if (!web_contents_ || !drag_source_start_rwh_) { +@@ -697,6 +700,12 @@ void SetReadWritePermissionsForFile(base::File& file) { + static_cast(drag_operation), + drag_source_start_rwh_.get()); + ++ if (delegate_) { ++ delegate_->WebContentsDragEnded( ++ screen_point, static_cast(drag_operation), ++ ended_by_mouse_release); ++ } ++ + drag_source_start_rwh_.reset(); + } + +@@ -783,7 +792,16 @@ void SetReadWritePermissionsForFile(base::File& file) { } void WebContentsViewMac::ViewsHostableDetach() { diff --git a/src/patches/content/common/web_contents_ns_view_bridge.mojom.patch b/src/patches/content/common/web_contents_ns_view_bridge.mojom.patch new file mode 100644 index 00000000..39119ee9 --- /dev/null +++ b/src/patches/content/common/web_contents_ns_view_bridge.mojom.patch @@ -0,0 +1,12 @@ +diff --git a/content/common/web_contents_ns_view_bridge.mojom b/content/common/web_contents_ns_view_bridge.mojom +index aca5e10365..e30fc4e9b8 100644 +--- a/content/common/web_contents_ns_view_bridge.mojom ++++ b/content/common/web_contents_ns_view_bridge.mojom +@@ -160,5 +160,6 @@ + // Called in to the -draggedImage: method being called on the NSView. + EndDrag(uint32 drag_operation, + gfx.mojom.PointF local_point, +- gfx.mojom.PointF screen_point); ++ gfx.mojom.PointF screen_point, ++ bool ended_by_mouse_release); + }; diff --git a/src/patches/content/public/browser/web_contents_view_delegate.cc.patch b/src/patches/content/public/browser/web_contents_view_delegate.cc.patch new file mode 100644 index 00000000..86dc7207 --- /dev/null +++ b/src/patches/content/public/browser/web_contents_view_delegate.cc.patch @@ -0,0 +1,15 @@ +diff --git a/content/public/browser/web_contents_view_delegate.cc b/content/public/browser/web_contents_view_delegate.cc +--- a/content/public/browser/web_contents_view_delegate.cc ++++ b/content/public/browser/web_contents_view_delegate.cc +@@ -70,7 +70,10 @@ + return std::move(callback).Run(drop_data); + } + +-void WebContentsViewDelegate::WebContentsDragEnded() {} ++void WebContentsViewDelegate::WebContentsDragEnded( ++ const gfx::PointF& screen_point, ++ ui::mojom::DragOperation operation, ++ bool ended_by_mouse_release) {} + + #if BUILDFLAG(IS_ANDROID) + bool WebContentsViewDelegate::ShouldShowBlurTransitionAnimation( diff --git a/src/patches/content/public/browser/web_contents_view_delegate.h.patch b/src/patches/content/public/browser/web_contents_view_delegate.h.patch new file mode 100644 index 00000000..193a0b37 --- /dev/null +++ b/src/patches/content/public/browser/web_contents_view_delegate.h.patch @@ -0,0 +1,34 @@ +diff --git a/content/public/browser/web_contents_view_delegate.h b/content/public/browser/web_contents_view_delegate.h +--- a/content/public/browser/web_contents_view_delegate.h ++++ b/content/public/browser/web_contents_view_delegate.h +@@ -14,6 +14,7 @@ + + #include "base/functional/callback_forward.h" + #include "content/common/content_export.h" ++#include "ui/base/dragdrop/mojom/drag_drop_types.mojom-forward.h" + #include "ui/gfx/native_ui_types.h" + + #if defined(__OBJC__) +@@ -22,6 +23,10 @@ + #endif + #endif + ++namespace gfx { ++class PointF; ++} ++ + namespace content { + class NavigationHandle; + class RenderFrameHost; +@@ -106,7 +111,10 @@ + DropCompletionCallback callback); + + // Notifies the delegate that the drag operation has ended. +- virtual void WebContentsDragEnded(); ++ virtual void WebContentsDragEnded( ++ const gfx::PointF& screen_point, ++ ui::mojom::DragOperation operation, ++ bool ended_by_mouse_release); + + #if BUILDFLAG(IS_ANDROID) + virtual bool ShouldShowBlurTransitionAnimation(