From 65fbda9a3db7c830e97af4a06c3f3de6c1d28dcc Mon Sep 17 00:00:00 2001 From: Nagi-ovo <101612750+Nagi-ovo@users.noreply.github.com.> Date: Thu, 12 Mar 2026 14:08:28 +0000 Subject: [PATCH 1/6] feat(sidebarAutoHide): add left-edge hover trigger to expand collapsed sidebar When auto-hide is enabled and the sidebar is collapsed, an invisible 6px zone at the left edge of the viewport allows hovering to expand the sidebar (similar to macOS Dock auto-hide behavior). Co-Authored-By: Claude Opus 4.6 --- src/pages/content/sidebarAutoHide/index.ts | 90 +++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/src/pages/content/sidebarAutoHide/index.ts b/src/pages/content/sidebarAutoHide/index.ts index 797798ae4..54cdc6f02 100644 --- a/src/pages/content/sidebarAutoHide/index.ts +++ b/src/pages/content/sidebarAutoHide/index.ts @@ -9,6 +9,7 @@ const STYLE_ID = 'gv-sidebar-auto-hide-style'; const STORAGE_KEY = 'gvSidebarAutoHide'; +const EDGE_TRIGGER_ID = 'gv-sidebar-edge-trigger'; // Debounce delay to avoid rapid toggling const LEAVE_DELAY_MS = 500; @@ -19,6 +20,8 @@ const SIDENAV_CHECK_INTERVAL_MS = 1000; const RESIZE_DEBOUNCE_MS = 200; // Pause duration after menu item click (wait for dialog to appear) const MENU_CLICK_PAUSE_MS = 1500; +// Width of the invisible edge trigger zone (px) +const EDGE_TRIGGER_WIDTH = 6; const CUSTOM_POPUP_SELECTORS = [ '.gv-folder-dialog', '.gv-folder-dialog-overlay', @@ -32,6 +35,7 @@ let enabled = false; let leaveTimeoutId: number | null = null; let enterTimeoutId: number | null = null; let sidenavElement: HTMLElement | null = null; +let edgeTriggerElement: HTMLElement | null = null; let observer: MutationObserver | null = null; let resizeHandler: (() => void) | null = null; let resizeDebounceTimer: number | null = null; @@ -86,6 +90,83 @@ function removeTransitionStyle(): void { if (style) style.remove(); } +/** + * Handle mouse leaving the edge trigger zone. + * Cancel expand if the mouse didn't move into the sidebar. + */ +function handleEdgeTriggerLeave(e: MouseEvent): void { + if (!enabled) return; + + const related = e.relatedTarget as HTMLElement | null; + if (related) { + const sidenav = getSidenavElement(); + if (sidenav && (sidenav === related || sidenav.contains(related))) { + return; // Mouse entered the sidebar - let it handle expansion + } + } + + // Mouse left the edge without entering sidebar - cancel pending expand + if (enterTimeoutId !== null) { + window.clearTimeout(enterTimeoutId); + enterTimeoutId = null; + } +} + +/** + * Create the invisible edge trigger zone at the left side of the viewport. + * When the sidebar is collapsed, hovering this zone expands the sidebar. + */ +function createEdgeTrigger(): void { + if (edgeTriggerElement) return; + + const el = document.createElement('div'); + el.id = EDGE_TRIGGER_ID; + el.style.cssText = ` + position: fixed; + left: 0; + top: 0; + width: ${EDGE_TRIGGER_WIDTH}px; + height: 100vh; + z-index: 99999; + background: transparent; + display: none; + `; + el.addEventListener('mouseenter', handleMouseEnter); + el.addEventListener('mouseleave', handleEdgeTriggerLeave); + document.documentElement.appendChild(el); + edgeTriggerElement = el; +} + +/** + * Remove the edge trigger zone from the DOM + */ +function removeEdgeTrigger(): void { + if (edgeTriggerElement) { + edgeTriggerElement.removeEventListener('mouseenter', handleMouseEnter); + edgeTriggerElement.removeEventListener('mouseleave', handleEdgeTriggerLeave); + edgeTriggerElement.remove(); + edgeTriggerElement = null; + } +} + +/** + * Show the edge trigger zone (when sidebar is collapsed) + */ +function showEdgeTrigger(): void { + if (edgeTriggerElement) { + edgeTriggerElement.style.display = 'block'; + } +} + +/** + * Hide the edge trigger zone (when sidebar is expanded) + */ +function hideEdgeTrigger(): void { + if (edgeTriggerElement) { + edgeTriggerElement.style.display = 'none'; + } +} + /** * Find the sidebar toggle button * Uses the selector provided by user: side-nav-menu-button button @@ -190,6 +271,9 @@ function isPopupOrDialogOpen(): boolean { * Check if mouse is currently over the sidebar or any related popup element */ function isMouseOverSidebarArea(): boolean { + // Check if mouse is over the edge trigger + if (edgeTriggerElement?.matches(':hover')) return true; + // Check if mouse is over the sidenav if (sidenavElement?.matches(':hover')) return true; @@ -275,6 +359,7 @@ function collapseSidebar(): void { if (!isSidebarCollapsed()) { if (clickToggleButton()) { autoCollapsed = true; + showEdgeTrigger(); } } } @@ -284,6 +369,7 @@ function collapseSidebar(): void { */ function expandSidebar(): void { if (isSidebarCollapsed()) { + hideEdgeTrigger(); clickToggleButton(); autoCollapsed = false; } @@ -467,6 +553,7 @@ function enable(): void { pausedUntil = 0; insertTransitionStyle(); + createEdgeTrigger(); attachEventListeners(); // Start observing for DOM changes (in case sidenav is lazily loaded) @@ -498,7 +585,7 @@ function enable(): void { // Initial collapse if mouse is not on sidebar and no popup is open setTimeout(() => { if (enabled && sidenavElement && !sidenavElement.matches(':hover') && !isPopupOrDialogOpen()) { - collapseSidebar(); + collapseSidebar(); // collapseSidebar() calls showEdgeTrigger() on success } }, 500); } @@ -539,6 +626,7 @@ function disable(): void { pausedUntil = 0; detachEventListeners(); + removeEdgeTrigger(); removeTransitionStyle(); if (observer) { From b66958a5551b3ae378daaa2986bc614f70490cc5 Mon Sep 17 00:00:00 2001 From: Nagi-ovo <101612750+Nagi-ovo@users.noreply.github.com.> Date: Thu, 12 Mar 2026 14:15:46 +0000 Subject: [PATCH 2/6] fix(sidebarAutoHide): show edge trigger when sidebar starts collapsed If the sidebar is already collapsed when auto-hide is enabled (e.g. persisted Gemini UI state), show the edge trigger immediately instead of waiting for a future collapse event. Co-Authored-By: Claude Opus 4.6 --- src/pages/content/sidebarAutoHide/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/content/sidebarAutoHide/index.ts b/src/pages/content/sidebarAutoHide/index.ts index 54cdc6f02..8820ddd64 100644 --- a/src/pages/content/sidebarAutoHide/index.ts +++ b/src/pages/content/sidebarAutoHide/index.ts @@ -582,9 +582,14 @@ function enable(): void { // Start periodic check for sidenav element reappearing startSidenavCheck(); - // Initial collapse if mouse is not on sidebar and no popup is open + // Initial: collapse if expanded, or show edge trigger if already collapsed setTimeout(() => { - if (enabled && sidenavElement && !sidenavElement.matches(':hover') && !isPopupOrDialogOpen()) { + if (!enabled) return; + if (isSidebarCollapsed()) { + // Sidebar already collapsed (e.g. persisted Gemini UI state) — show edge trigger + autoCollapsed = true; + showEdgeTrigger(); + } else if (sidenavElement && !sidenavElement.matches(':hover') && !isPopupOrDialogOpen()) { collapseSidebar(); // collapseSidebar() calls showEdgeTrigger() on success } }, 500); From 93035c75f8840680ad092b7c386b8d6cafc3c39d Mon Sep 17 00:00:00 2001 From: Nagi-ovo <101612750+Nagi-ovo@users.noreply.github.com.> Date: Thu, 12 Mar 2026 15:26:18 +0000 Subject: [PATCH 3/6] fix(sidebarAutoHide): gate edge trigger hide on toggle success and preserve user collapse state - expandSidebar: only hide edge trigger and clear autoCollapsed when clickToggleButton() succeeds, matching collapseSidebar's pattern - Don't set autoCollapsed=true when sidebar was already collapsed before auto-hide was enabled, so disable() won't forcibly expand it Co-Authored-By: Claude Opus 4.6 --- src/pages/content/sidebarAutoHide/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/content/sidebarAutoHide/index.ts b/src/pages/content/sidebarAutoHide/index.ts index 8820ddd64..b3865d9cc 100644 --- a/src/pages/content/sidebarAutoHide/index.ts +++ b/src/pages/content/sidebarAutoHide/index.ts @@ -369,9 +369,10 @@ function collapseSidebar(): void { */ function expandSidebar(): void { if (isSidebarCollapsed()) { - hideEdgeTrigger(); - clickToggleButton(); - autoCollapsed = false; + if (clickToggleButton()) { + hideEdgeTrigger(); + autoCollapsed = false; + } } } @@ -587,7 +588,7 @@ function enable(): void { if (!enabled) return; if (isSidebarCollapsed()) { // Sidebar already collapsed (e.g. persisted Gemini UI state) — show edge trigger - autoCollapsed = true; + // Don't set autoCollapsed: we didn't collapse it, so disable() shouldn't expand it showEdgeTrigger(); } else if (sidenavElement && !sidenavElement.matches(':hover') && !isPopupOrDialogOpen()) { collapseSidebar(); // collapseSidebar() calls showEdgeTrigger() on success From dd1c399e6469c23bb3e46a899dcc48a14082c67a Mon Sep 17 00:00:00 2001 From: Nagi-ovo <101612750+Nagi-ovo@users.noreply.github.com.> Date: Thu, 12 Mar 2026 16:07:25 +0000 Subject: [PATCH 4/6] fix(sidebarAutoHide): sync edge trigger visibility on external sidebar toggle checkAndReattach() now reconciles edge trigger display with actual sidebar state, so manual hamburger clicks or Gemini-initiated toggles no longer leave the edge trigger out of sync. Co-Authored-By: Claude Opus 4.6 --- src/pages/content/sidebarAutoHide/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/pages/content/sidebarAutoHide/index.ts b/src/pages/content/sidebarAutoHide/index.ts index b3865d9cc..b9b97727a 100644 --- a/src/pages/content/sidebarAutoHide/index.ts +++ b/src/pages/content/sidebarAutoHide/index.ts @@ -498,6 +498,16 @@ function checkAndReattach(): void { attachEventListeners(); } } + + // Sync edge trigger visibility with actual sidebar state + // (handles external toggles like user clicking the hamburger button) + if (edgeTriggerElement) { + if (isSidebarCollapsed()) { + showEdgeTrigger(); + } else { + hideEdgeTrigger(); + } + } } /** From 25659389f4afe97f87603e060d2b93263fabd33d Mon Sep 17 00:00:00 2001 From: Nagi-ovo <101612750+Nagi-ovo@users.noreply.github.com.> Date: Thu, 12 Mar 2026 16:35:49 +0000 Subject: [PATCH 5/6] fix(sidebarAutoHide): hide edge trigger when sidebar is not visible Gate showEdgeTrigger() on isSidebarVisible() in checkAndReattach() to prevent the edge trigger from appearing in narrow viewports where the sidebar is hidden (width=0 triggers isSidebarCollapsed() but the sidebar is not interactable). Co-Authored-By: Claude Opus 4.6 --- src/pages/content/sidebarAutoHide/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/content/sidebarAutoHide/index.ts b/src/pages/content/sidebarAutoHide/index.ts index b9b97727a..c96bb383a 100644 --- a/src/pages/content/sidebarAutoHide/index.ts +++ b/src/pages/content/sidebarAutoHide/index.ts @@ -502,7 +502,7 @@ function checkAndReattach(): void { // Sync edge trigger visibility with actual sidebar state // (handles external toggles like user clicking the hamburger button) if (edgeTriggerElement) { - if (isSidebarCollapsed()) { + if (isSidebarVisible() && isSidebarCollapsed()) { showEdgeTrigger(); } else { hideEdgeTrigger(); From c1e0f2b0b70460aa81c748c0af9bc09aa8354339 Mon Sep 17 00:00:00 2001 From: Nagi-ovo <101612750+Nagi-ovo@users.noreply.github.com.> Date: Thu, 12 Mar 2026 17:19:40 +0000 Subject: [PATCH 6/6] feat(sidebarAutoHide): add full-hide mode with left-edge hover trigger Add a separate "Fully hide sidebar" toggle (in the same popup card as auto-hide) that completely hides the collapsed sidebar (width: 0) and shows a 6px edge trigger at the left viewport edge to reveal it. - New storage key gvSidebarFullHide + popup toggle - Full-hide CSS: body:not(.mat-sidenav-opened) bard-sidenav { width: 0 } - Edge trigger driven by full-hide (not auto-hide) - Both features share infrastructure (observer, resize, periodic check) - Works with both auto-hide and manual collapse - Translations added for all 10 locales Co-Authored-By: Claude Opus 4.6 --- src/core/types/common.ts | 1 + src/locales/ar/messages.json | 8 + src/locales/en/messages.json | 8 + src/locales/es/messages.json | 8 + src/locales/fr/messages.json | 8 + src/locales/ja/messages.json | 8 + src/locales/ko/messages.json | 8 + src/locales/pt/messages.json | 8 + src/locales/ru/messages.json | 8 + src/locales/zh/messages.json | 8 + src/locales/zh_TW/messages.json | 8 + src/pages/content/sidebarAutoHide/index.ts | 428 +++++++++------------ src/pages/popup/Popup.tsx | 28 +- 13 files changed, 294 insertions(+), 243 deletions(-) diff --git a/src/core/types/common.ts b/src/core/types/common.ts index 488b28796..bbb73b5c7 100644 --- a/src/core/types/common.ts +++ b/src/core/types/common.ts @@ -76,6 +76,7 @@ export const StorageKeys = { // Sidebar behavior GV_SIDEBAR_AUTO_HIDE: 'gvSidebarAutoHide', + GV_SIDEBAR_FULL_HIDE: 'gvSidebarFullHide', // Folder spacing GV_FOLDER_SPACING: 'gvFolderSpacing', diff --git a/src/locales/ar/messages.json b/src/locales/ar/messages.json index 0974cfe20..d5b4a3099 100644 --- a/src/locales/ar/messages.json +++ b/src/locales/ar/messages.json @@ -1179,6 +1179,14 @@ "message": "طي الشريط الجانبي تلقائياً عند مغادرة الماوس، وتوسيعه عند الدخول", "description": "Auto-hide sidebar feature hint" }, + "sidebarFullHide": { + "message": "إخفاء الشريط الجانبي بالكامل", + "description": "Fully hide sidebar toggle label" + }, + "sidebarFullHideHint": { + "message": "إخفاء الشريط الجانبي المطوي بالكامل، مرر الماوس على الحافة اليسرى لإظهاره", + "description": "Fully hide sidebar feature hint" + }, "folderSpacing": { "message": "تباعد المجلدات", "description": "Folder spacing adjustment label" diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index eaa7896c5..0500a89a2 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -1187,6 +1187,14 @@ "message": "Automatically collapse sidebar when mouse leaves, expand when mouse enters", "description": "Auto-hide sidebar feature hint" }, + "sidebarFullHide": { + "message": "Fully hide sidebar", + "description": "Fully hide sidebar toggle label" + }, + "sidebarFullHideHint": { + "message": "Completely hide the collapsed sidebar, hover left edge to reveal", + "description": "Fully hide sidebar feature hint" + }, "folderSpacing": { "message": "Folder spacing", "description": "Folder spacing adjustment label" diff --git a/src/locales/es/messages.json b/src/locales/es/messages.json index 6201bfa1c..56465380e 100644 --- a/src/locales/es/messages.json +++ b/src/locales/es/messages.json @@ -1179,6 +1179,14 @@ "message": "Contrae automáticamente la barra lateral cuando el ratón sale, la expande cuando entra", "description": "Auto-hide sidebar feature hint" }, + "sidebarFullHide": { + "message": "Ocultar barra lateral completa", + "description": "Fully hide sidebar toggle label" + }, + "sidebarFullHideHint": { + "message": "Ocultar completamente la barra lateral contraída, pasar el ratón por el borde izquierdo para mostrar", + "description": "Fully hide sidebar feature hint" + }, "folderSpacing": { "message": "Espaciado de carpetas", "description": "Folder spacing adjustment label" diff --git a/src/locales/fr/messages.json b/src/locales/fr/messages.json index 34a0de885..e31d01c03 100644 --- a/src/locales/fr/messages.json +++ b/src/locales/fr/messages.json @@ -1179,6 +1179,14 @@ "message": "Réduit automatiquement la barre latérale quand la souris la quitte, l'étend quand elle y entre", "description": "Auto-hide sidebar feature hint" }, + "sidebarFullHide": { + "message": "Masquer complètement la barre latérale", + "description": "Fully hide sidebar toggle label" + }, + "sidebarFullHideHint": { + "message": "Masquer complètement la barre latérale réduite, survoler le bord gauche pour afficher", + "description": "Fully hide sidebar feature hint" + }, "folderSpacing": { "message": "Espacement des dossiers", "description": "Folder spacing adjustment label" diff --git a/src/locales/ja/messages.json b/src/locales/ja/messages.json index 0e9e8d3af..075bf11c3 100644 --- a/src/locales/ja/messages.json +++ b/src/locales/ja/messages.json @@ -1179,6 +1179,14 @@ "message": "マウスが離れると自動でサイドバーを折りたたみ、マウスが入ると展開します", "description": "サイドバー自動非表示機能のヒント" }, + "sidebarFullHide": { + "message": "サイドバー完全非表示", + "description": "サイドバー完全非表示の切り替えラベル" + }, + "sidebarFullHideHint": { + "message": "折りたたみ時にサイドバーを完全に非表示にし、左端にマウスを移動して表示", + "description": "サイドバー完全非表示機能のヒント" + }, "folderSpacing": { "message": "フォルダ間隔", "description": "フォルダ間隔調整ラベル" diff --git a/src/locales/ko/messages.json b/src/locales/ko/messages.json index 7053643ff..c391eba96 100644 --- a/src/locales/ko/messages.json +++ b/src/locales/ko/messages.json @@ -1179,6 +1179,14 @@ "message": "마우스가 벗어나면 사이드바를 자동으로 접고, 마우스가 들어오면 펼칩니다", "description": "Auto-hide sidebar feature hint" }, + "sidebarFullHide": { + "message": "사이드바 완전 숨김", + "description": "Fully hide sidebar toggle label" + }, + "sidebarFullHideHint": { + "message": "접힌 사이드바를 완전히 숨기고, 왼쪽 가장자리에 마우스를 올려 표시", + "description": "Fully hide sidebar feature hint" + }, "folderSpacing": { "message": "폴더 간격", "description": "Folder spacing adjustment label" diff --git a/src/locales/pt/messages.json b/src/locales/pt/messages.json index ec8fcb07c..88dbfe0d8 100644 --- a/src/locales/pt/messages.json +++ b/src/locales/pt/messages.json @@ -1179,6 +1179,14 @@ "message": "Contrai automaticamente a barra lateral quando o mouse sai, expande quando entra", "description": "Auto-hide sidebar feature hint" }, + "sidebarFullHide": { + "message": "Ocultar barra lateral completa", + "description": "Fully hide sidebar toggle label" + }, + "sidebarFullHideHint": { + "message": "Ocultar completamente a barra lateral recolhida, passar o mouse na borda esquerda para mostrar", + "description": "Fully hide sidebar feature hint" + }, "folderSpacing": { "message": "Espaçamento de pastas", "description": "Folder spacing adjustment label" diff --git a/src/locales/ru/messages.json b/src/locales/ru/messages.json index f4e003d4f..1934e8e03 100644 --- a/src/locales/ru/messages.json +++ b/src/locales/ru/messages.json @@ -1179,6 +1179,14 @@ "message": "Автоматически сворачивать боковую панель при уходе мыши, разворачивать при наведении", "description": "Auto-hide sidebar feature hint" }, + "sidebarFullHide": { + "message": "Полностью скрыть боковую панель", + "description": "Fully hide sidebar toggle label" + }, + "sidebarFullHideHint": { + "message": "Полностью скрыть свёрнутую боковую панель, наведите на левый край для показа", + "description": "Fully hide sidebar feature hint" + }, "folderSpacing": { "message": "Расстояние между папками", "description": "Folder spacing adjustment label" diff --git a/src/locales/zh/messages.json b/src/locales/zh/messages.json index f45a28398..58eb539a7 100644 --- a/src/locales/zh/messages.json +++ b/src/locales/zh/messages.json @@ -1187,6 +1187,14 @@ "message": "鼠标离开时自动收起侧边栏,鼠标进入时展开", "description": "侧边栏自动收起功能提示" }, + "sidebarFullHide": { + "message": "完全隐藏侧边栏", + "description": "完全隐藏侧边栏开关标签" + }, + "sidebarFullHideHint": { + "message": "收起时完全隐藏侧边栏,鼠标移到左边缘唤出", + "description": "完全隐藏侧边栏功能提示" + }, "folderSpacing": { "message": "文件夹间距", "description": "文件夹间距调节标签" diff --git a/src/locales/zh_TW/messages.json b/src/locales/zh_TW/messages.json index fd177ba9d..af631a1c3 100644 --- a/src/locales/zh_TW/messages.json +++ b/src/locales/zh_TW/messages.json @@ -1179,6 +1179,14 @@ "message": "滑鼠離開時自動收起側邊欄,滑鼠進入時展開", "description": "側邊欄自動收起功能提示" }, + "sidebarFullHide": { + "message": "完全隱藏側邊欄", + "description": "完全隱藏側邊欄開關標籤" + }, + "sidebarFullHideHint": { + "message": "收起時完全隱藏側邊欄,滑鼠移到左邊緣喚出", + "description": "完全隱藏側邊欄功能提示" + }, "folderSpacing": { "message": "資料夾間距", "description": "資料夾間距調節標籤" diff --git a/src/pages/content/sidebarAutoHide/index.ts b/src/pages/content/sidebarAutoHide/index.ts index c96bb383a..7288e12c4 100644 --- a/src/pages/content/sidebarAutoHide/index.ts +++ b/src/pages/content/sidebarAutoHide/index.ts @@ -1,14 +1,20 @@ /** - * Sidebar Auto-Hide Feature for Gemini + * Sidebar Auto-Hide & Full-Hide Feature for Gemini * - * When enabled, the sidebar automatically collapses when the mouse leaves, + * Auto-hide: sidebar automatically collapses when the mouse leaves, * and expands when the mouse enters. * + * Full-hide: when collapsed (by auto-hide or manually), the sidebar + * is fully hidden (zero width). A left-edge hover trigger allows + * revealing it. + * * Uses the `side-nav-menu-button` to toggle sidebar state. */ const STYLE_ID = 'gv-sidebar-auto-hide-style'; +const FULL_HIDE_STYLE_ID = 'gv-sidebar-full-hide-style'; const STORAGE_KEY = 'gvSidebarAutoHide'; +const FULL_HIDE_STORAGE_KEY = 'gvSidebarFullHide'; const EDGE_TRIGGER_ID = 'gv-sidebar-edge-trigger'; // Debounce delay to avoid rapid toggling @@ -31,37 +37,39 @@ const CUSTOM_POPUP_SELECTORS = [ '.gv-color-picker-dialog', ]; +// Auto-hide state let enabled = false; let leaveTimeoutId: number | null = null; let enterTimeoutId: number | null = null; let sidenavElement: HTMLElement | null = null; +let autoCollapsed = false; +let pausedUntil = 0; + +// Full-hide state +let fullHideEnabled = false; let edgeTriggerElement: HTMLElement | null = null; + +// Shared infrastructure let observer: MutationObserver | null = null; let resizeHandler: (() => void) | null = null; let resizeDebounceTimer: number | null = null; let sidenavCheckTimer: number | null = null; let menuClickHandler: ((e: Event) => void) | null = null; -// Track whether sidebar was collapsed by our feature (to avoid fighting with user) -let autoCollapsed = false; -// Temporarily pause auto-collapse after menu actions -let pausedUntil = 0; function isElementVisible(element: HTMLElement): boolean { const style = window.getComputedStyle(element); if (style.display === 'none' || style.visibility === 'hidden') { return false; } - const rect = element.getBoundingClientRect(); return rect.width > 0 || rect.height > 0; } -/** - * CSS to enable smooth transitions for the sidebar collapse/expand - */ +// ─── Transition CSS (shared) ─────────────────────────────────────────── + function getTransitionStyle(): string { return ` - /* Smooth transition for sidebar auto-hide */ + /* Smooth transition for sidebar auto-hide / full-hide */ bard-sidenav, bard-sidenav side-navigation-content, bard-sidenav side-navigation-content > div { @@ -70,55 +78,69 @@ function getTransitionStyle(): string { `; } -/** - * Insert transition CSS - */ function insertTransitionStyle(): void { if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = getTransitionStyle(); document.documentElement.appendChild(style); } -/** - * Remove transition CSS - */ function removeTransitionStyle(): void { const style = document.getElementById(STYLE_ID); if (style) style.remove(); } -/** - * Handle mouse leaving the edge trigger zone. - * Cancel expand if the mouse didn't move into the sidebar. - */ +// ─── Full-Hide CSS ───────────────────────────────────────────────────── + +function getFullHideStyle(): string { + return ` + /* Fully hide collapsed sidebar */ + body:not(.mat-sidenav-opened) bard-sidenav, + body:not(.mat-sidenav-opened) bard-sidenav side-navigation-content, + body:not(.mat-sidenav-opened) bard-sidenav side-navigation-content > div { + width: 0 !important; + min-width: 0 !important; + overflow: hidden !important; + padding: 0 !important; + } + `; +} + +function insertFullHideStyle(): void { + if (document.getElementById(FULL_HIDE_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = FULL_HIDE_STYLE_ID; + style.textContent = getFullHideStyle(); + document.documentElement.appendChild(style); +} + +function removeFullHideStyle(): void { + const style = document.getElementById(FULL_HIDE_STYLE_ID); + if (style) style.remove(); +} + +// ─── Edge Trigger (full-hide) ────────────────────────────────────────── + function handleEdgeTriggerLeave(e: MouseEvent): void { - if (!enabled) return; + if (!fullHideEnabled) return; const related = e.relatedTarget as HTMLElement | null; if (related) { const sidenav = getSidenavElement(); if (sidenav && (sidenav === related || sidenav.contains(related))) { - return; // Mouse entered the sidebar - let it handle expansion + return; } } - // Mouse left the edge without entering sidebar - cancel pending expand if (enterTimeoutId !== null) { window.clearTimeout(enterTimeoutId); enterTimeoutId = null; } } -/** - * Create the invisible edge trigger zone at the left side of the viewport. - * When the sidebar is collapsed, hovering this zone expands the sidebar. - */ function createEdgeTrigger(): void { if (edgeTriggerElement) return; - const el = document.createElement('div'); el.id = EDGE_TRIGGER_ID; el.style.cssText = ` @@ -137,9 +159,6 @@ function createEdgeTrigger(): void { edgeTriggerElement = el; } -/** - * Remove the edge trigger zone from the DOM - */ function removeEdgeTrigger(): void { if (edgeTriggerElement) { edgeTriggerElement.removeEventListener('mouseenter', handleMouseEnter); @@ -149,36 +168,26 @@ function removeEdgeTrigger(): void { } } -/** - * Show the edge trigger zone (when sidebar is collapsed) - */ function showEdgeTrigger(): void { if (edgeTriggerElement) { edgeTriggerElement.style.display = 'block'; } } -/** - * Hide the edge trigger zone (when sidebar is expanded) - */ function hideEdgeTrigger(): void { if (edgeTriggerElement) { edgeTriggerElement.style.display = 'none'; } } -/** - * Find the sidebar toggle button - * Uses the selector provided by user: side-nav-menu-button button - */ +// ─── Sidebar State Detection ─────────────────────────────────────────── + function findToggleButton(): HTMLButtonElement | null { - // Primary: Use data-test-id attribute const btn = document.querySelector( 'button[data-test-id="side-nav-menu-button"]', ); if (btn) return btn; - // Fallback: Find button inside side-nav-menu-button component const sideNavMenuButton = document.querySelector('side-nav-menu-button'); if (sideNavMenuButton) { return sideNavMenuButton.querySelector('button'); @@ -187,76 +196,52 @@ function findToggleButton(): HTMLButtonElement | null { return null; } -/** - * Check if sidebar is currently collapsed - */ function isSidebarCollapsed(): boolean { - // Method 1: Check mat-sidenav-opened class on body + // Check body class first (not affected by our full-hide CSS) if (document.body.classList.contains('mat-sidenav-opened')) { - return false; // opened = not collapsed + return false; } - // Method 2: Check side-navigation-content collapsed class const sideContent = document.querySelector('bard-sidenav side-navigation-content > div'); if (sideContent?.classList.contains('collapsed')) { return true; } - // Method 3: Check the actual width of sidenav const sidenav = document.querySelector('bard-sidenav'); if (sidenav) { const width = sidenav.getBoundingClientRect().width; - // Collapsed sidebar is typically < 80px if (width < 80) return true; } return false; } -/** - * Check if sidebar is visible (exists and has dimensions) - */ function isSidebarVisible(): boolean { const sidenav = document.querySelector('bard-sidenav'); if (!sidenav) return false; - const rect = sidenav.getBoundingClientRect(); - // Sidebar is visible if it has width and height return rect.width > 0 && rect.height > 0; } -/** - * Check if auto-collapse is currently paused - */ function isPaused(): boolean { return Date.now() < pausedUntil; } -/** - * Pause auto-collapse for a duration - */ function pauseAutoCollapse(durationMs: number): void { pausedUntil = Date.now() + durationMs; } -/** - * Check if there's a visible popup/dialog/menu open that should prevent sidebar collapse - * This includes delete confirmation dialogs, context menus, etc. - */ function isPopupOrDialogOpen(): boolean { - // Check for Angular Material dialogs - must be visible const matDialogs = document.querySelectorAll('.mat-mdc-dialog-container'); for (const dialog of matDialogs) { if (isElementVisible(dialog)) return true; } - // Check for Angular Material menus - must be visible const matMenus = document.querySelectorAll('.mat-mdc-menu-panel'); for (const menu of matMenus) { if (isElementVisible(menu)) return true; } - // Check for Gemini Voyager popup elements for (const selector of CUSTOM_POPUP_SELECTORS) { const customPopups = document.querySelectorAll(selector); for (const popup of customPopups) { @@ -267,17 +252,10 @@ function isPopupOrDialogOpen(): boolean { return false; } -/** - * Check if mouse is currently over the sidebar or any related popup element - */ function isMouseOverSidebarArea(): boolean { - // Check if mouse is over the edge trigger if (edgeTriggerElement?.matches(':hover')) return true; - - // Check if mouse is over the sidenav if (sidenavElement?.matches(':hover')) return true; - // Check if mouse is over visible dialogs/menus const matDialogs = document.querySelectorAll('.mat-mdc-dialog-container'); for (const dialog of matDialogs) { if (dialog.matches(':hover')) return true; @@ -298,31 +276,25 @@ function isMouseOverSidebarArea(): boolean { return false; } -/** - * Handle click on menu items - pause auto-collapse to wait for potential dialogs - */ +// ─── Menu Click Handling ─────────────────────────────────────────────── + function handleMenuClick(e: Event): void { if (!enabled) return; const target = e.target as HTMLElement; - // Check if clicked on a menu item (especially delete actions) const menuItem = target.closest('[role="menuitem"], [role="menuitemradio"], .mat-mdc-menu-item'); if (menuItem) { - // Pause auto-collapse to allow dialog to appear pauseAutoCollapse(MENU_CLICK_PAUSE_MS); return; } - // Check if clicked on any button inside the sidebar that might trigger a dialog const sidebarButton = target.closest('bard-sidenav button, bard-sidenav [role="button"]'); if (sidebarButton) { - // Pause auto-collapse briefly pauseAutoCollapse(MENU_CLICK_PAUSE_MS); return; } - // Check for three-dot menu buttons (options buttons) const optionsButton = target.closest( '[data-test-id*="options"], [aria-label*="选项"], [aria-label*="Options"], [aria-label*="More"]', ); @@ -332,122 +304,88 @@ function handleMenuClick(e: Event): void { } } -/** - * Click the toggle button to switch sidebar state - */ +// ─── Sidebar Toggle ──────────────────────────────────────────────────── + function clickToggleButton(): boolean { const btn = findToggleButton(); if (!btn) return false; - btn.click(); return true; } -/** - * Collapse the sidebar (if currently expanded and no popup is open) - */ function collapseSidebar(): void { - // Don't collapse if paused if (isPaused()) return; - - // Don't collapse if a popup/dialog is open if (isPopupOrDialogOpen()) return; - - // Don't collapse if mouse is still over sidebar area if (isMouseOverSidebarArea()) return; if (!isSidebarCollapsed()) { if (clickToggleButton()) { autoCollapsed = true; - showEdgeTrigger(); } } } -/** - * Expand the sidebar (if currently collapsed) - */ function expandSidebar(): void { if (isSidebarCollapsed()) { - if (clickToggleButton()) { - hideEdgeTrigger(); - autoCollapsed = false; - } + clickToggleButton(); + autoCollapsed = false; + // Schedule a reattach so auto-hide listeners are re-added after expansion + setTimeout(() => checkAndReattach(), 350); } } -/** - * Handle mouse enter on sidebar - */ +// ─── Mouse Event Handlers ────────────────────────────────────────────── + function handleMouseEnter(): void { - if (!enabled) return; + if (!enabled && !fullHideEnabled) return; - // Cancel any pending collapse if (leaveTimeoutId !== null) { window.clearTimeout(leaveTimeoutId); leaveTimeoutId = null; } - // Debounce the expand to avoid accidental triggers while crossing screens if (enterTimeoutId !== null) { window.clearTimeout(enterTimeoutId); } enterTimeoutId = window.setTimeout(() => { enterTimeoutId = null; - if (!enabled) return; + if (!enabled && !fullHideEnabled) return; expandSidebar(); }, ENTER_DELAY_MS); } -/** - * Handle mouse leave from sidebar - */ function handleMouseLeave(): void { if (!enabled) return; - // Cancel any pending expand if (enterTimeoutId !== null) { window.clearTimeout(enterTimeoutId); enterTimeoutId = null; } - // Debounce the collapse to avoid accidental triggers if (leaveTimeoutId !== null) { window.clearTimeout(leaveTimeoutId); } leaveTimeoutId = window.setTimeout(() => { leaveTimeoutId = null; - - // Double-check conditions before collapsing if (!enabled) return; - collapseSidebar(); }, LEAVE_DELAY_MS); } -/** - * Get the sidenav container element - */ +// ─── DOM Management ──────────────────────────────────────────────────── + function getSidenavElement(): HTMLElement | null { return document.querySelector('bard-sidenav'); } -/** - * Attach event listeners to the sidenav element - */ function attachEventListeners(): boolean { const sidenav = getSidenavElement(); if (!sidenav) return false; - - // Check if sidebar is actually visible (not hidden due to responsive design) if (!isSidebarVisible()) return false; - - // Already attached to this element if (sidenav === sidenavElement) return true; - // Remove old listeners if element changed if (sidenavElement) { sidenavElement.removeEventListener('mouseenter', handleMouseEnter); sidenavElement.removeEventListener('mouseleave', handleMouseLeave); @@ -459,9 +397,6 @@ function attachEventListeners(): boolean { return true; } -/** - * Remove event listeners from the sidenav element - */ function detachEventListeners(): void { if (sidenavElement) { sidenavElement.removeEventListener('mouseenter', handleMouseEnter); @@ -470,39 +405,28 @@ function detachEventListeners(): void { } } -/** - * Check and reattach event listeners if sidenav element changed or reappeared - */ function checkAndReattach(): void { - if (!enabled) return; + if (!enabled && !fullHideEnabled) return; const currentSidenav = getSidenavElement(); - // If we have a reference but it's no longer in DOM, clear it - if (sidenavElement && !sidenavElement.isConnected) { - detachEventListeners(); // properly remove listeners and null the reference - autoCollapsed = false; - } - - // If sidenav exists but is NOT visible (narrow window), clear the reference - // so that when it becomes visible again we treat it as a fresh element. - if (sidenavElement && !isSidebarVisible()) { - detachEventListeners(); - return; - } + // Auto-hide: manage event listeners on sidenav + if (enabled) { + if (sidenavElement && !sidenavElement.isConnected) { + detachEventListeners(); + autoCollapsed = false; + } - // If sidenav exists and is visible, try to attach - if (currentSidenav && isSidebarVisible()) { - // Reattach if element changed OR if we lost our reference (e.g. after resize) - if (currentSidenav !== sidenavElement) { + if (sidenavElement && !isSidebarVisible()) { + detachEventListeners(); + } else if (currentSidenav && isSidebarVisible() && currentSidenav !== sidenavElement) { attachEventListeners(); } } - // Sync edge trigger visibility with actual sidebar state - // (handles external toggles like user clicking the hamburger button) - if (edgeTriggerElement) { - if (isSidebarVisible() && isSidebarCollapsed()) { + // Full-hide: sync edge trigger visibility with sidebar state + if (fullHideEnabled && edgeTriggerElement) { + if (isSidebarCollapsed()) { showEdgeTrigger(); } else { hideEdgeTrigger(); @@ -510,13 +434,9 @@ function checkAndReattach(): void { } } -/** - * Handle window resize - reattach listeners if sidebar reappears (with debounce) - */ function handleResize(): void { - if (!enabled) return; + if (!enabled && !fullHideEnabled) return; - // Debounce resize handling if (resizeDebounceTimer !== null) { window.clearTimeout(resizeDebounceTimer); } @@ -525,28 +445,19 @@ function handleResize(): void { resizeDebounceTimer = null; checkAndReattach(); - // Gemini's layout transitions may take longer than our debounce. - // Schedule a secondary check to catch late DOM changes. setTimeout(() => { - if (enabled) checkAndReattach(); + if (enabled || fullHideEnabled) checkAndReattach(); }, 600); }, RESIZE_DEBOUNCE_MS); } -/** - * Start periodic check for sidenav element - */ function startSidenavCheck(): void { if (sidenavCheckTimer !== null) return; - sidenavCheckTimer = window.setInterval(() => { checkAndReattach(); }, SIDENAV_CHECK_INTERVAL_MS); } -/** - * Stop periodic check for sidenav element - */ function stopSidenavCheck(): void { if (sidenavCheckTimer !== null) { window.clearInterval(sidenavCheckTimer); @@ -554,87 +465,86 @@ function stopSidenavCheck(): void { } } -/** - * Enable the auto-hide feature - */ -function enable(): void { - if (enabled) return; - enabled = true; - autoCollapsed = false; - pausedUntil = 0; +// ─── Shared Infrastructure ───────────────────────────────────────────── - insertTransitionStyle(); - createEdgeTrigger(); - attachEventListeners(); - - // Start observing for DOM changes (in case sidenav is lazily loaded) +function setupInfrastructure(): void { if (!observer) { observer = new MutationObserver(() => { - if (enabled) { - checkAndReattach(); - } + if (enabled || fullHideEnabled) checkAndReattach(); }); - observer.observe(document.body, { childList: true, subtree: true }); } - // Listen for resize events to handle responsive changes if (!resizeHandler) { resizeHandler = handleResize; window.addEventListener('resize', resizeHandler); } - // Listen for clicks on menu items to pause auto-collapse + startSidenavCheck(); +} + +function teardownInfrastructure(): void { + if (enabled || fullHideEnabled) return; + + stopSidenavCheck(); + + if (resizeDebounceTimer !== null) { + window.clearTimeout(resizeDebounceTimer); + resizeDebounceTimer = null; + } + + if (observer) { + observer.disconnect(); + observer = null; + } + + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler); + resizeHandler = null; + } +} + +// ─── Auto-Hide Feature ──────────────────────────────────────────────── + +function enable(): void { + if (enabled) return; + enabled = true; + autoCollapsed = false; + pausedUntil = 0; + + insertTransitionStyle(); + attachEventListeners(); + if (!menuClickHandler) { menuClickHandler = handleMenuClick; document.addEventListener('click', menuClickHandler, true); } - // Start periodic check for sidenav element reappearing - startSidenavCheck(); + setupInfrastructure(); - // Initial: collapse if expanded, or show edge trigger if already collapsed + // Initial collapse if mouse is not on sidebar and no popup is open setTimeout(() => { if (!enabled) return; - if (isSidebarCollapsed()) { - // Sidebar already collapsed (e.g. persisted Gemini UI state) — show edge trigger - // Don't set autoCollapsed: we didn't collapse it, so disable() shouldn't expand it - showEdgeTrigger(); - } else if (sidenavElement && !sidenavElement.matches(':hover') && !isPopupOrDialogOpen()) { - collapseSidebar(); // collapseSidebar() calls showEdgeTrigger() on success + if (sidenavElement && !sidenavElement.matches(':hover') && !isPopupOrDialogOpen()) { + collapseSidebar(); } }, 500); } -/** - * Disable the auto-hide feature - */ function disable(): void { if (!enabled) return; enabled = false; - // Cancel any pending expand if (enterTimeoutId !== null) { window.clearTimeout(enterTimeoutId); enterTimeoutId = null; } - // Cancel any pending collapse if (leaveTimeoutId !== null) { window.clearTimeout(leaveTimeoutId); leaveTimeoutId = null; } - // Cancel any pending resize debounce - if (resizeDebounceTimer !== null) { - window.clearTimeout(resizeDebounceTimer); - resizeDebounceTimer = null; - } - - // Stop periodic check - stopSidenavCheck(); - - // If we auto-collapsed the sidebar, expand it back when disabled if (autoCollapsed && isSidebarCollapsed()) { clickToggleButton(); } @@ -642,59 +552,95 @@ function disable(): void { pausedUntil = 0; detachEventListeners(); - removeEdgeTrigger(); - removeTransitionStyle(); - if (observer) { - observer.disconnect(); - observer = null; - } - - if (resizeHandler) { - window.removeEventListener('resize', resizeHandler); - resizeHandler = null; + if (!fullHideEnabled) { + removeTransitionStyle(); } if (menuClickHandler) { document.removeEventListener('click', menuClickHandler, true); menuClickHandler = null; } + + teardownInfrastructure(); } -/** - * Initialize and start the sidebar auto-hide feature - */ +// ─── Full-Hide Feature ───────────────────────────────────────────────── + +function enableFullHide(): void { + if (fullHideEnabled) return; + fullHideEnabled = true; + + insertTransitionStyle(); + insertFullHideStyle(); + createEdgeTrigger(); + + setupInfrastructure(); + + // Show edge trigger if sidebar is already collapsed + setTimeout(() => { + if (!fullHideEnabled) return; + if (isSidebarCollapsed()) { + showEdgeTrigger(); + } + }, 300); +} + +function disableFullHide(): void { + if (!fullHideEnabled) return; + fullHideEnabled = false; + + removeEdgeTrigger(); + removeFullHideStyle(); + + if (!enabled) { + removeTransitionStyle(); + } + + teardownInfrastructure(); +} + +// ─── Entry Point ─────────────────────────────────────────────────────── + export function startSidebarAutoHide(): void { - // 1) Read initial setting + // 1) Read initial settings try { - chrome.storage?.sync?.get({ [STORAGE_KEY]: false }, (res) => { - const isEnabled = res?.[STORAGE_KEY] === true; - if (isEnabled) { - enable(); - } + chrome.storage?.sync?.get({ [STORAGE_KEY]: false, [FULL_HIDE_STORAGE_KEY]: false }, (res) => { + if (res?.[STORAGE_KEY] === true) enable(); + if (res?.[FULL_HIDE_STORAGE_KEY] === true) enableFullHide(); }); } catch (e) { - console.error('[Gemini Voyager] Failed to get sidebar auto-hide setting:', e); + console.error('[Gemini Voyager] Failed to get sidebar settings:', e); } // 2) Respond to storage changes try { chrome.storage?.onChanged?.addListener((changes, area) => { - if (area === 'sync' && changes[STORAGE_KEY]) { - const isEnabled = changes[STORAGE_KEY].newValue === true; - if (isEnabled) { + if (area !== 'sync') return; + + if (changes[STORAGE_KEY]) { + if (changes[STORAGE_KEY].newValue === true) { enable(); } else { disable(); } } + + if (changes[FULL_HIDE_STORAGE_KEY]) { + if (changes[FULL_HIDE_STORAGE_KEY].newValue === true) { + enableFullHide(); + } else { + disableFullHide(); + } + } }); } catch (e) { - console.error('[Gemini Voyager] Failed to add storage listener for sidebar auto-hide:', e); + console.error('[Gemini Voyager] Failed to add storage listeners for sidebar features:', e); } // 3) Cleanup on page unload window.addEventListener('beforeunload', () => { disable(); + disableFullHide(); }); } diff --git a/src/pages/popup/Popup.tsx b/src/pages/popup/Popup.tsx index 438b7cc78..24a82136c 100644 --- a/src/pages/popup/Popup.tsx +++ b/src/pages/popup/Popup.tsx @@ -295,6 +295,7 @@ interface SettingsUpdate { quoteReplyEnabled?: boolean; ctrlEnterSendEnabled?: boolean; sidebarAutoHideEnabled?: boolean; + sidebarFullHideEnabled?: boolean; visualEffect?: 'off' | 'snow' | 'sakura' | 'rain'; preventAutoScrollEnabled?: boolean; forkEnabled?: boolean; @@ -330,6 +331,7 @@ export default function Popup() { const [quoteReplyEnabled, setQuoteReplyEnabled] = useState(true); const [ctrlEnterSendEnabled, setCtrlEnterSendEnabled] = useState(false); const [sidebarAutoHideEnabled, setSidebarAutoHideEnabled] = useState(false); + const [sidebarFullHideEnabled, setSidebarFullHideEnabled] = useState(false); const [visualEffect, setVisualEffect] = useState<'off' | 'snow' | 'sakura' | 'rain'>('off'); const [preventAutoScrollEnabled, setPreventAutoScrollEnabled] = useState(false); const [forkEnabled, setForkEnabled] = useState(false); @@ -420,6 +422,8 @@ export default function Popup() { payload.gvCtrlEnterSend = settings.ctrlEnterSendEnabled; if (typeof settings.sidebarAutoHideEnabled === 'boolean') payload.gvSidebarAutoHide = settings.sidebarAutoHideEnabled; + if (typeof settings.sidebarFullHideEnabled === 'boolean') + payload.gvSidebarFullHide = settings.sidebarFullHideEnabled; if (settings.visualEffect) { payload.gvVisualEffect = settings.visualEffect; // Clear legacy key @@ -770,6 +774,7 @@ export default function Popup() { setQuoteReplyEnabled(res?.gvQuoteReplyEnabled !== false); setCtrlEnterSendEnabled(res?.gvCtrlEnterSend === true); setSidebarAutoHideEnabled(res?.gvSidebarAutoHide === true); + setSidebarFullHideEnabled(res?.gvSidebarFullHide === true); // Resolve visual effect: new key takes precedence over legacy boolean const storedVisualEffect = res?.gvVisualEffect; if ( @@ -1499,10 +1504,10 @@ export default function Popup() { }} /> - {/* Sidebar Auto-Hide - Gemini only */} + {/* Sidebar Auto-Hide & Full-Hide - Gemini only */} {!isAIStudio && ( - +
+
+
+ +

{t('sidebarFullHideHint')}

+
+ { + setSidebarFullHideEnabled(e.target.checked); + apply({ sidebarFullHideEnabled: e.target.checked }); + }} + /> +
)}