From 5d9246df9f113293ea31d8a39948f52f6b8d7a04 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Tue, 6 Oct 2026 21:15:17 +0800 Subject: [PATCH 1/2] feat(sidebar): add edge hover auto-expand in windowed and fullscreen modes --- docs/feature-checklist.md | 1 + docs/features.md | 1 + .../import/dao_source_detector_unittest.cc | 6 +- .../ui/views/dao_browser_browsertest.cc | 70 +++++++++++++++- .../ui/views/sidebar/dao_sidebar_view.cc | 84 ++++++++++++++----- .../ui/views/sidebar/dao_sidebar_view.h | 15 ++++ 6 files changed, 155 insertions(+), 22 deletions(-) diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 69747c9e..e406f39d 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -325,6 +325,7 @@ sidebar, insets + rounds the content area, and re-homes the toolbar off-screen. | ☐ | macOS titlebar height, frosted-glass translucency, Cmd+S → sidebar | `views/frame/browser_native_widget_mac.mm.patch` | 🔴 | Traffic lights centered; sidebar translucent; Cmd+S handled by sidebar | | ☐ | Native Chromium Side Panel remains clickable across the titlebar strip | `views/frame/browser_view.cc.patch` | 🟡 | Run `DaoNativeSidePanelHitTestBrowserTest.*`; open a native or extension Side Panel and verify its title, Pin, More, and Close controls receive clicks while empty titlebar space remains draggable | | ☐ | Sidebar C++/WebUI core (240px, drag-resize 150–400px, collapse anim) | `src/dao/.../sidebar/` + `dao://dao-sidebar` WebUI | — | Sidebar renders; drag-resize clamps; collapse/expand preserves width | +| ☐ | Sidebar edge hover auto-expand | `src/dao/.../sidebar/dao_sidebar_view.cc` | — | Collapse with Cmd+S; hover the left edge strip and the sidebar expands; move into page content and it collapses again. Repeat in browser fullscreen (left screen edge) including a quick flick in and out; in video/webpage fullscreen the edge does nothing | | ☐ | Incognito sidebar shield indicator | `src/dao/.../sidebar/dao_sidebar_view.cc`, `dao_strings.grd` | 🟢 | Normal window has no shield; Incognito shows it left of collapse with localized privacy tooltip | | ☐ | Move regular tabs to the top | `dao_sidebar_ui.{h,cc}`, `dao_sidebar_app.ts`, `dao_strings.grd` | `sidebar_app.test.ts` | Right-click a background tab and choose Move to Top: it becomes the first ordinary list item without activating it or affecting pinned items. Folder children stay in their folder and move to its first position. Loose split members move together in their existing order. Test duplicate URLs, an already-first tab, a closed target, and sidebar reload/session restore; the saved order must remain stable. | | ☐ | Native tab context-menu actions and shortcut labels | `dao_sidebar_ui.{h,cc}`, `dao_tab_identity.cc`, `dao_strings.grd` | 🟡 | Right-click a regular tab → Duplicate Tab shows ⌘D, Copy Link shows ⌘⇧C, and Close Tab shows ⌘W; each shortcut still performs the matching action. Copy Tab ID is hidden while the MCP master switch is off and in Incognito or Guest windows; when enabled in a regular-profile window, it copies the tab's stable ID and shows localized feedback. Run `DaoSidebarBrowserTest.CloseDuplicateTabs*`; Close Duplicate Tabs keeps the most recently active tab per URL and shows a localized native toast only after all close attempts finish, counting only tabs actually closed (check one and multiple duplicates). While a page leave confirmation is pending, no completion toast appears; cancelling excludes that tab from the count. With no duplicates the action is disabled and no toast appears. | diff --git a/docs/features.md b/docs/features.md index ba125792..e13c7989 100644 --- a/docs/features.md +++ b/docs/features.md @@ -105,6 +105,7 @@ An Arc-inspired vertical sidebar replaces Chromium's top tab strip — the singl ### 1.1 Sidebar Core (C++ Views) - **DaoSidebarView** (`sidebar/dao_sidebar_view.{h,cc}`) — Main container, 240px default, collapsible to 4px with animation - **Drag-to-resize** — Mouse drag, 150–400px range, ignored while collapsed; width preserved across collapse/expand cycles +- **Edge hover auto-expand** — Hovering the collapsed sidebar's left-edge strip temporarily expands it; leaving collapses it again. A window-wide mouse monitor keeps the edge active in fullscreen (where the collapsed sidebar has zero width) and collapses even when the cursor leaves before entering the expanded sidebar. Webpage fullscreen never reveals it. Manual collapse state is unchanged - **DaoSidebarUIHandler** — Mojo bridge between sidebar C++ and the WebUI (media state, folder persistence, tab commands) - **Incognito indicator** — Native shield-check status icon beside the sidebar collapse button in Incognito windows, with a localized privacy tooltip - **Sidebar context menu** — Native right-click menu support with registered diff --git a/src/dao/browser/import/dao_source_detector_unittest.cc b/src/dao/browser/import/dao_source_detector_unittest.cc index 65efeb43..99c08c99 100644 --- a/src/dao/browser/import/dao_source_detector_unittest.cc +++ b/src/dao/browser/import/dao_source_detector_unittest.cc @@ -5,6 +5,7 @@ #include "dao/browser/import/dao_source_detector.h" #include +#include #include #include "base/base_paths.h" @@ -24,14 +25,15 @@ TEST(DaoSourceDetectorTest, DetectsProfilesFromDefaultPlatformRoots) { #if BUILDFLAG(IS_WIN) base::ScopedPathOverride override_app_data(base::DIR_LOCAL_APP_DATA, app_data.GetPath()); - const char* roots[] = { + const std::array roots = { "Google/Chrome/User Data", "Packages/TheBrowserCompany.Arc_test/LocalCache/Local/Arc/User Data", "Microsoft/Edge/User Data"}; #else base::ScopedPathOverride override_app_data(base::DIR_APP_DATA, app_data.GetPath()); - const char* roots[] = {"Google/Chrome", "Arc/User Data", "Microsoft Edge"}; + const std::array roots = {"Google/Chrome", "Arc/User Data", + "Microsoft Edge"}; #endif for (const char* relative : roots) { const base::FilePath root = app_data.GetPath().AppendASCII(relative); diff --git a/src/dao/browser/ui/views/dao_browser_browsertest.cc b/src/dao/browser/ui/views/dao_browser_browsertest.cc index 061d5d6f..3cfa8417 100644 --- a/src/dao/browser/ui/views/dao_browser_browsertest.cc +++ b/src/dao/browser/ui/views/dao_browser_browsertest.cc @@ -1631,6 +1631,43 @@ IN_PROC_BROWSER_TEST_F(DaoSidebarBrowserTest, SidebarToggleExpandRestore) { EXPECT_FALSE(sidebar->collapsed()); } +// Hover enter/exit must reach DaoSidebarView through RootView dispatch, where +// the collapsed edge strip hit-tests to a child view rather than the sidebar. +IN_PROC_BROWSER_TEST_F(DaoSidebarBrowserTest, CollapsedEdgeHoverAutoExpands) { + BrowserView* browser_view = GetBrowserView(browser()); + DaoSidebarView* sidebar = browser_view->dao_sidebar(); + ASSERT_NE(nullptr, sidebar); + views::Widget* widget = sidebar->GetWidget(); + ASSERT_NE(nullptr, widget); + + auto settle = [&]() { + ASSERT_TRUE(base::test::RunUntil( + [&]() { return !sidebar->layer()->GetAnimator()->is_animating(); })); + widget->LayoutRootViewIfNecessary(); + }; + auto move_to = [&](views::View* target, const gfx::Point& point) { + gfx::Point p = point; + views::View::ConvertPointToWidget(target, &p); + ui::MouseEvent move(ui::EventType::kMouseMoved, p, p, + ui::EventTimeForNow(), ui::EF_NONE, ui::EF_NONE); + widget->GetRootView()->OnMouseMoved(move); + }; + views::View* contents = browser_view->contents_container(); + ASSERT_NE(nullptr, contents); + + sidebar->ToggleCollapsed(); + ASSERT_TRUE(sidebar->collapsed()); + settle(); + + move_to(contents, contents->GetLocalBounds().CenterPoint()); + move_to(sidebar, gfx::Point(1, sidebar->height() / 2)); + EXPECT_FALSE(sidebar->collapsed()); + + settle(); + move_to(contents, contents->GetLocalBounds().CenterPoint()); + EXPECT_TRUE(sidebar->collapsed()); +} + IN_PROC_BROWSER_TEST_F(DaoSidebarBrowserTest, WebUIStartsCloseToHeader) { DaoSidebarView* sidebar = GetBrowserView(browser())->dao_sidebar(); ASSERT_NE(nullptr, sidebar); @@ -1700,6 +1737,37 @@ IN_PROC_BROWSER_TEST_F(DaoSidebarFullscreenBrowserTest, ui_test_utils::ToggleFullscreenModeAndWait(browser()); } + +// In fullscreen the collapsed sidebar has zero width, so the left edge must +// still reveal it, and leaving must collapse it even if the cursor never +// entered the expanded sidebar. +IN_PROC_BROWSER_TEST_F(DaoSidebarFullscreenBrowserTest, + FullscreenEdgeHoverAutoExpands) { + BrowserView* browser_view = GetBrowserView(browser()); + DaoSidebarView* sidebar = browser_view->dao_sidebar(); + ASSERT_NE(nullptr, sidebar); + + ui_test_utils::ToggleFullscreenModeAndWait(browser()); + ASSERT_TRUE(browser_view->IsFullscreen()); + sidebar->ToggleCollapsed(); + ASSERT_TRUE(sidebar->collapsed()); + ASSERT_TRUE(base::test::RunUntil( + [&]() { return !sidebar->layer()->GetAnimator()->is_animating(); })); + sidebar->GetWidget()->LayoutRootViewIfNecessary(); + ASSERT_EQ(0, sidebar->width()); + ASSERT_GT(sidebar->height(), 0); + + const gfx::Rect sidebar_bounds = sidebar->GetBoundsInScreen(); + sidebar->UpdateAutoExpandForScreenPoint( + gfx::Point(sidebar_bounds.x(), sidebar_bounds.CenterPoint().y())); + EXPECT_FALSE(sidebar->collapsed()); + + sidebar->UpdateAutoExpandForScreenPoint( + browser_view->GetBoundsInScreen().CenterPoint()); + EXPECT_TRUE(sidebar->collapsed()); + + ui_test_utils::ToggleFullscreenModeAndWait(browser()); +} #endif #if BUILDFLAG(IS_MAC) @@ -5962,7 +6030,7 @@ IN_PROC_BROWSER_TEST_F(DaoTabBrowserTest, ForegroundAndBackgroundLinksOpenAtTop) : AddTabTypes::ADD_NONE, params.group); EXPECT_EQ(1, model->GetIndexOfWebContents(added)); - EXPECT_EQ(opener, model->GetOpenerOfWebContentsAt(1)); + EXPECT_EQ(model->GetTabForWebContents(opener), model->GetOpenerOfTabAt(1)); EXPECT_FALSE(model->GetTabGroupForTab(1).has_value()); EXPECT_EQ(foreground ? added : opener, model->GetActiveWebContents()); } diff --git a/src/dao/browser/ui/views/sidebar/dao_sidebar_view.cc b/src/dao/browser/ui/views/sidebar/dao_sidebar_view.cc index 0636bc7a..763fa08f 100644 --- a/src/dao/browser/ui/views/sidebar/dao_sidebar_view.cc +++ b/src/dao/browser/ui/views/sidebar/dao_sidebar_view.cc @@ -10,8 +10,11 @@ #include "components/input/native_web_keyboard_event.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_window/public/browser_window_features.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/exclusive_access/exclusive_access_manager.h" +#include "chrome/browser/ui/exclusive_access/fullscreen_controller.h" #include "chrome/browser/ui/navigator/browser_navigator.h" #include "chrome/browser/ui/navigator/browser_navigator_params.h" #include "chrome/browser/ui/views/frame/browser_view.h" @@ -54,6 +57,7 @@ #include "ui/views/background.h" #include "ui/views/controls/button/button.h" #include "ui/views/controls/webview/webview.h" +#include "ui/views/event_monitor.h" #include "ui/views/layout/box_layout.h" #include "ui/views/layout/flex_layout.h" #include "ui/views/layout/flex_layout_types.h" @@ -255,6 +259,9 @@ DaoSidebarView::DaoSidebarView(Browser* browser) collapse_animation_(base::Milliseconds(50), 60, this) { SetPaintToLayer(); layer()->SetMasksToBounds(true); + // Children (inner container, WebUI) cover the whole sidebar, so hover + // enter/exit only reaches this view when forwarded from descendants. + SetNotifyEnterExitOnChild(true); // Inner container always keeps full width; outer view clips it inner_container_ = AddChildView(std::make_unique()); @@ -833,6 +840,9 @@ void DaoSidebarView::AddedToWidget() { EnsureWebUILoaded(); } + event_monitor_ = views::EventMonitor::CreateWindowMonitor( + this, GetWidget()->GetNativeWindow(), {ui::EventType::kMouseMoved}); + // Wire toggle callback to address bar (deferred to here because address // bar is created after sidebar during BrowserView construction). BrowserView* bv = BrowserView::GetBrowserViewForBrowser(browser_); @@ -864,6 +874,7 @@ void DaoSidebarView::AddedToWidget() { } void DaoSidebarView::RemovedFromWidget() { + event_monitor_.reset(); if (GetFocusManager()) { #if BUILDFLAG(IS_WIN) GetFocusManager()->UnregisterAccelerator( @@ -919,31 +930,66 @@ bool DaoSidebarView::AcceleratorPressed( // --- Edge hover auto-expand ---------------------------------------------- void DaoSidebarView::OnMouseEntered(const ui::MouseEvent& event) { - if (collapsed_ && !layer()->GetAnimator()->is_animating()) { - auto_expanded_ = true; - collapsed_ = false; - int old_width = current_width_; - current_width_ = user_width_; - target_width_ = user_width_; - collapse_animation_.Stop(); - PreferredSizeChanged(); - AnimateLayerSlide(old_width, user_width_); - } + AutoExpand(); } void DaoSidebarView::OnMouseExited(const ui::MouseEvent& event) { NotifySidebarPointerExited(); + AutoCollapse(); +} - if (auto_expanded_) { - auto_expanded_ = false; - collapsed_ = true; - int old_width = current_width_; - current_width_ = kCollapsedWidth; - target_width_ = kCollapsedWidth; - collapse_animation_.Stop(); - PreferredSizeChanged(); - AnimateLayerSlide(old_width, kCollapsedWidth); +void DaoSidebarView::OnEvent(const ui::Event& event) { + UpdateAutoExpandForScreenPoint(event_monitor_->GetLastMouseLocation()); +} + +void DaoSidebarView::UpdateAutoExpandForScreenPoint( + const gfx::Point& screen_point) { + gfx::Point point = screen_point; + views::View::ConvertPointFromScreen(this, &point); + if (collapsed_) { + // Webpage fullscreen (e.g. video) must not reveal the sidebar. + const bool tab_fullscreen = browser_->GetFeatures() + .exclusive_access_manager() + ->fullscreen_controller() + ->IsTabFullscreen(); + if (!tab_fullscreen && + gfx::Rect(kCollapsedWidth, height()).Contains(point)) { + AutoExpand(); + } + } else if (auto_expanded_ && + !gfx::Rect(current_width_, height()).Contains(point)) { + // Covers exits RootView never reports, e.g. the cursor leaving before it + // ever hit-tested inside the freshly expanded sidebar. + AutoCollapse(); + } +} + +void DaoSidebarView::AutoExpand() { + if (!collapsed_ || layer()->GetAnimator()->is_animating()) { + return; + } + auto_expanded_ = true; + collapsed_ = false; + int old_width = current_width_; + current_width_ = user_width_; + target_width_ = user_width_; + collapse_animation_.Stop(); + PreferredSizeChanged(); + AnimateLayerSlide(old_width, user_width_); +} + +void DaoSidebarView::AutoCollapse() { + if (!auto_expanded_) { + return; } + auto_expanded_ = false; + collapsed_ = true; + int old_width = current_width_; + current_width_ = kCollapsedWidth; + target_width_ = kCollapsedWidth; + collapse_animation_.Stop(); + PreferredSizeChanged(); + AnimateLayerSlide(old_width, kCollapsedWidth); } void DaoSidebarView::NotifySidebarPointerExited() { diff --git a/src/dao/browser/ui/views/sidebar/dao_sidebar_view.h b/src/dao/browser/ui/views/sidebar/dao_sidebar_view.h index ed5a5bc5..3138e7a5 100644 --- a/src/dao/browser/ui/views/sidebar/dao_sidebar_view.h +++ b/src/dao/browser/ui/views/sidebar/dao_sidebar_view.h @@ -5,6 +5,7 @@ #ifndef DAO_BROWSER_UI_VIEWS_SIDEBAR_DAO_SIDEBAR_VIEW_H_ #define DAO_BROWSER_UI_VIEWS_SIDEBAR_DAO_SIDEBAR_VIEW_H_ +#include #include #include "base/files/file_path.h" @@ -19,6 +20,7 @@ #include "third_party/blink/public/common/page/drag_operation.h" #include "ui/base/clipboard/clipboard_format_type.h" #include "ui/compositor/layer_animation_observer.h" +#include "ui/events/event_observer.h" #include "ui/gfx/animation/animation_delegate.h" #include "ui/gfx/animation/linear_animation.h" #include "ui/native_theme/native_theme.h" @@ -32,6 +34,7 @@ class Browser; namespace views { class Button; +class EventMonitor; class WebView; } @@ -48,6 +51,7 @@ class DaoSidebarView : public views::View, public views::ResizeAreaDelegate, public ui::ImplicitAnimationObserver, public ui::NativeThemeObserver, + public ui::EventObserver, public content::WebContentsDelegate { METADATA_HEADER(DaoSidebarView, views::View) @@ -92,6 +96,10 @@ class DaoSidebarView : public views::View, // Tell the sidebar WebUI to clear pointer-driven hover/tooltip state. void NotifySidebarPointerExited(); + // Edge hover driven by window-wide mouse moves, so it also works where the + // collapsed sidebar has no width (fullscreen) or RootView misses the exit. + void UpdateAutoExpandForScreenPoint(const gfx::Point& screen_point); + gfx::Size CalculatePreferredSize( const views::SizeBounds& available_size) const override; void Layout(PassKey) override; @@ -109,6 +117,10 @@ class DaoSidebarView : public views::View, // ui::NativeThemeObserver: void OnNativeThemeUpdated(ui::NativeTheme* observed_theme) override; + // ui::EventObserver: + using views::View::OnEvent; + void OnEvent(const ui::Event& event) override; + // views::View: bool AcceleratorPressed(const ui::Accelerator& accelerator) override; void AddedToWidget() override; @@ -165,6 +177,8 @@ class DaoSidebarView : public views::View, raw_ptr sidebar_web_view_ = nullptr; void AnimateLayerSlide(int old_width, int new_width); + void AutoExpand(); + void AutoCollapse(); void EnsureWebUILoaded(); void DoStartFileDrag(const base::FilePath& path); void ApplyTheme(); @@ -184,6 +198,7 @@ class DaoSidebarView : public views::View, int target_width_ = kDefaultWidth; int resize_start_width_ = kDefaultWidth; gfx::LinearAnimation collapse_animation_; + std::unique_ptr event_monitor_; int drop_target_index_ = -1; // Tab model index where file will be inserted int webui_drop_insert_index_ = -1; // Drop index set by WebUI JS From d2cecd93530b9bc57f50135fd11301beffc7b0cf Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Wed, 7 Oct 2026 18:18:03 +0800 Subject: [PATCH 2/2] fix(installer): type uninstall test locales as literal keys --- scripts/commands/__tests__/windows-installer-web.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/commands/__tests__/windows-installer-web.test.ts b/scripts/commands/__tests__/windows-installer-web.test.ts index 1774b178..d5c91675 100644 --- a/scripts/commands/__tests__/windows-installer-web.test.ts +++ b/scripts/commands/__tests__/windows-installer-web.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { vi.useFakeTimers(); document.body.replaceChildren(); }); afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); }); describe('Windows installer WebView UI', () => { - it.each(['en', 'zh-CN'])('hands an existing installation to the uninstaller once in %s', async language => { + it.each(['en', 'zh-CN'] as const)('hands an existing installation to the uninstaller once in %s', async language => { const {messages, locales} = await mount(language, {locked: true}); expect(button('uninstall'), 'Existing installations expose an uninstall action').not.toBeNull(); expect(button('uninstall').hidden).toBe(false);