diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9fd707fd..47eb8329 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -61,7 +61,8 @@ "mcp__serena__think_about_task_adherence", "mcp__serena__think_about_whether_you_are_done", "mcp__serena__write_memory", - "WebFetch(domain:ujiro99.github.io)" + "WebFetch(domain:ujiro99.github.io)", + "mcp__serena__list_memories" ], "deny": [] } diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 2cb260fe..468a18f2 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (sofort schließen)" }, + "Option_sidePanelAutoHide": { + "message": "Seitenbereich automatisch ausblenden" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Blendet den Seitenbereich automatisch aus, wenn in den Hauptbereich geklickt wird, während der Seitenbereich über einen Befehl geöffnet ist." + }, + "Option_sidePanelAutoHide_link": { + "message": "Seitenbereich automatisch ausblenden" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Blendet den Seitenbereich automatisch aus, wenn in den Hauptbereich geklickt wird, während die Linkvorschau angezeigt wird." + }, "Option_inherit": { "message": "Erben" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Fenster" }, + "Option_openMode_previewSidePanel": { + "message": "Seitenbereich" + }, "Option_openModeSecondary": { "message": " ┗ Strg + Klick" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 2a6a37fd..98085c63 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (close immediately)" }, + "Option_sidePanelAutoHide": { + "message": "Side Panel Auto-Hide" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Automatically hide the side panel when the main panel is clicked while the side panel is open via a command." + }, + "Option_sidePanelAutoHide_link": { + "message": "Side Panel Auto-Hide" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Automatically hide the side panel when the main panel is clicked while displaying the link preview." + }, "Option_inherit": { "message": "Inherit" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Window" }, + "Option_openMode_previewSidePanel": { + "message": "Side Panel" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + Click" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 7ddb0d23..dbe48076 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (cerrar inmediatamente)" }, + "Option_sidePanelAutoHide": { + "message": "Ocultar panel lateral automáticamente" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Oculta automáticamente el panel lateral cuando se hace clic en el panel principal mientras el panel lateral está abierto a través de un comando." + }, + "Option_sidePanelAutoHide_link": { + "message": "Ocultar panel lateral automáticamente" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Oculta automáticamente el panel lateral cuando se hace clic en el panel principal mientras se muestra la vista previa del enlace." + }, "Option_inherit": { "message": "Heredar" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Ventana" }, + "Option_openMode_previewSidePanel": { + "message": "Panel lateral" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + Clic" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index f2f29ed6..118e2cd8 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (fermer immédiatement)" }, + "Option_sidePanelAutoHide": { + "message": "Masquage automatique du panneau latéral" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Masque automatiquement le panneau latéral lorsque le panneau principal est cliqué pendant que le panneau latéral est ouvert via une commande." + }, + "Option_sidePanelAutoHide_link": { + "message": "Masquage automatique du panneau latéral" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Masque automatiquement le panneau latéral lorsque le panneau principal est cliqué pendant l'affichage de l'aperçu du lien." + }, "Option_inherit": { "message": "Hériter" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Fenêtre" }, + "Option_openMode_previewSidePanel": { + "message": "Panneau latéral" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + Clic" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 700f4f31..d87aed3f 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (तुरंत बंद करें)" }, + "Option_sidePanelAutoHide": { + "message": "साइड पैनल स्वतः छिपाएं" + }, + "Option_sidePanelAutoHide_desc": { + "message": "कमांड के माध्यम से साइड पैनल खुले होने पर मुख्य पैनल पर क्लिक करने पर साइड पैनल को स्वतः छिपा देता है।" + }, + "Option_sidePanelAutoHide_link": { + "message": "साइड पैनल स्वतः छिपाएं" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "लिंक पूर्वावलोकन प्रदर्शित करते समय मुख्य पैनल पर क्लिक करने पर साइड पैनल को स्वतः छिपा देता है।" + }, "Option_inherit": { "message": "प्राप्त करें" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "विंडो" }, + "Option_openMode_previewSidePanel": { + "message": "साइड पैनल" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + क्लिक" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index b1d44abc..c9dfae34 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (tutup segera)" }, + "Option_sidePanelAutoHide": { + "message": "Sembunyikan Panel Samping Otomatis" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Secara otomatis menyembunyikan panel samping saat panel utama diklik saat panel samping terbuka melalui perintah." + }, + "Option_sidePanelAutoHide_link": { + "message": "Sembunyikan Panel Samping Otomatis" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Secara otomatis menyembunyikan panel samping saat panel utama diklik saat menampilkan pratinjau tautan." + }, "Option_inherit": { "message": "Warisi" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Jendela" }, + "Option_openMode_previewSidePanel": { + "message": "Panel Samping" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + Klik" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 7381941f..6465bcea 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (chiudi immediatamente)" }, + "Option_sidePanelAutoHide": { + "message": "Nascondi automaticamente il pannello laterale" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Nasconde automaticamente il pannello laterale quando si fa clic sul pannello principale mentre il pannello laterale è aperto tramite un comando." + }, + "Option_sidePanelAutoHide_link": { + "message": "Nascondi automaticamente il pannello laterale" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Nasconde automaticamente il pannello laterale quando si fa clic sul pannello principale durante la visualizzazione dell'anteprima del collegamento." + }, "Option_inherit": { "message": "Eredita" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Finestra" }, + "Option_openMode_previewSidePanel": { + "message": "Pannello laterale" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + clic" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 4d8fcad8..d2cfb9a2 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (即座に閉じる)" }, + "Option_sidePanelAutoHide": { + "message": "サイドパネル自動非表示" + }, + "Option_sidePanelAutoHide_desc": { + "message": "コマンドによりサイドパネルを表示している時、メインのパネルをクリックしたときに、サイドパネルを自動的に非表示にします。" + }, + "Option_sidePanelAutoHide_link": { + "message": "サイドパネル自動非表示" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "リンクプレビューの表示中、メインのパネルをクリックしたときに、サイドパネルを自動的に非表示にします。" + }, "Option_inherit": { "message": "継承" }, @@ -360,10 +372,13 @@ "message": "実験的" }, "Option_openMode_previewPopup": { - "message": "Popup" + "message": "ポップアップ" }, "Option_openMode_previewWindow": { - "message": "Window" + "message": "ウィンドウ" + }, + "Option_openMode_previewSidePanel": { + "message": "サイドパネル" }, "Option_openModeSecondary": { "message": " ┗ Ctrl + クリック" diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index e2cfa418..860308f0 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (즉시 닫기)" }, + "Option_sidePanelAutoHide": { + "message": "사이드 패널 자동 숨기기" + }, + "Option_sidePanelAutoHide_desc": { + "message": "명령을 통해 사이드 패널이 열려 있는 동안 메인 패널을 클릭하면 사이드 패널이 자동으로 숨겨집니다." + }, + "Option_sidePanelAutoHide_link": { + "message": "사이드 패널 자동 숨기기" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "링크 미리보기 표시 중 메인 패널을 클릭하면 사이드 패널이 자동으로 숨겨집니다." + }, "Option_inherit": { "message": "상속" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "창" }, + "Option_openMode_previewSidePanel": { + "message": "사이드 패널" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + 클릭" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 7bbbbbb9..d96eb7d1 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (tutup segera)" }, + "Option_sidePanelAutoHide": { + "message": "Sembunyikan Panel Sisi Secara Automatik" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Menyembunyikan panel sisi secara automatik apabila panel utama diklik semasa panel sisi dibuka melalui perintah." + }, + "Option_sidePanelAutoHide_link": { + "message": "Sembunyikan Panel Sisi Secara Automatik" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Menyembunyikan panel sisi secara automatik apabila panel utama diklik semasa memaparkan pratonton pautan." + }, "Option_inherit": { "message": "Warisi" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Tetingkap" }, + "Option_openMode_previewSidePanel": { + "message": "Panel Sisi" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + Klik" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index f1682e77..62f53f54 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (fechar imediatamente)" }, + "Option_sidePanelAutoHide": { + "message": "Ocultar painel lateral automaticamente" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado enquanto o painel lateral está aberto via um comando." + }, + "Option_sidePanelAutoHide_link": { + "message": "Ocultar painel lateral automaticamente" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado durante a exibição da pré-visualização do link." + }, "Option_inherit": { "message": "Herdar" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Janela" }, + "Option_openMode_previewSidePanel": { + "message": "Painel lateral" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + Clique" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index f4f49e35..9e2042dd 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (fechar imediatamente)" }, + "Option_sidePanelAutoHide": { + "message": "Ocultar painel lateral automaticamente" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado enquanto o painel lateral está aberto via um comando." + }, + "Option_sidePanelAutoHide_link": { + "message": "Ocultar painel lateral automaticamente" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Oculta automaticamente o painel lateral quando o painel principal é clicado durante a visualização da pré-visualização do link." + }, "Option_inherit": { "message": "Herdar" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Janela" }, + "Option_openMode_previewSidePanel": { + "message": "Painel lateral" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + Clique" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 039ec78e..d4c5587c 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0 (закрыть немедленно)" }, + "Option_sidePanelAutoHide": { + "message": "Автоматически скрывать боковую панель" + }, + "Option_sidePanelAutoHide_desc": { + "message": "Автоматически скрывает боковую панель при клике на основную панель, пока боковая панель открыта через команду." + }, + "Option_sidePanelAutoHide_link": { + "message": "Автоматически скрывать боковую панель" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "Автоматически скрывает боковую панель при клике на основную панель во время отображения предварительного просмотра ссылки." + }, "Option_inherit": { "message": "Наследовать" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "Окно" }, + "Option_openMode_previewSidePanel": { + "message": "Боковая панель" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + клик" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 9a43837c..f87db698 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -212,6 +212,18 @@ "Option_popupAutoCloseDelay_placeholder": { "message": "0(立即关闭)" }, + "Option_sidePanelAutoHide": { + "message": "侧边栏自动隐藏" + }, + "Option_sidePanelAutoHide_desc": { + "message": "通过命令打开侧边栏时,点击主面板自动隐藏侧边栏。" + }, + "Option_sidePanelAutoHide_link": { + "message": "侧边栏自动隐藏" + }, + "Option_sidePanelAutoHide_link_desc": { + "message": "显示链接预览时,点击主面板自动隐藏侧边栏。" + }, "Option_inherit": { "message": "获取" }, @@ -365,6 +377,9 @@ "Option_openMode_previewWindow": { "message": "窗口" }, + "Option_openMode_previewSidePanel": { + "message": "侧边栏" + }, "Option_openModeSecondary": { "message": " ┗ Ctrl + 点击" }, diff --git a/packages/extension/src/action/helper.test.ts b/packages/extension/src/action/helper.test.ts index c045166a..475a770f 100644 --- a/packages/extension/src/action/helper.test.ts +++ b/packages/extension/src/action/helper.test.ts @@ -1,7 +1,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { navigateSidePanel } from "./helper" +import { + navigateSidePanel, + openSidePanel, + closeSidePanel, + sidePanelClosed, +} from "./helper" import { BgData } from "@/services/backgroundData" -import { updateSidePanelUrl } from "@/services/chrome" +import { + openSidePanel as _openSidePanel, + closeSidePanel as _closeSidePanel, + updateSidePanelUrl, +} from "@/services/chrome" +import { enhancedSettings } from "@/services/settings/enhancedSettings" +import { incrementCommandExecutionCount } from "@/services/commandMetrics" // Mock dependencies vi.mock("@/services/backgroundData", () => ({ @@ -12,9 +23,21 @@ vi.mock("@/services/backgroundData", () => ({ })) vi.mock("@/services/chrome", () => ({ + openSidePanel: vi.fn(), + closeSidePanel: vi.fn(), updateSidePanelUrl: vi.fn(), })) +vi.mock("@/services/settings/enhancedSettings", () => ({ + enhancedSettings: { + get: vi.fn(), + }, +})) + +vi.mock("@/services/commandMetrics", () => ({ + incrementCommandExecutionCount: vi.fn().mockResolvedValue(undefined), +})) + describe("helper", () => { beforeEach(() => { vi.clearAllMocks() @@ -35,7 +58,7 @@ describe("helper", () => { const sender = {} as any vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [123], + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], } as any) const result = navigateSidePanel(param, sender) @@ -48,7 +71,7 @@ describe("helper", () => { const sender = {} as any vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [123], + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], } as any) const result = navigateSidePanel(param, sender) @@ -61,7 +84,7 @@ describe("helper", () => { const sender = {} as any vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [123], + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], } as any) const result = navigateSidePanel(param, sender) @@ -74,7 +97,10 @@ describe("helper", () => { const sender = {} as any vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [456, 789], // Different tab IDs + sidePanelTabs: [ + { tabId: 456, isLinkCommand: false }, + { tabId: 789, isLinkCommand: false }, + ], // Different tab IDs } as any) const result = navigateSidePanel(param, sender) @@ -89,7 +115,7 @@ describe("helper", () => { const sender = {} as any vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [tabId], + sidePanelTabs: [{ tabId, isLinkCommand: false }], sidePanelUrls: {}, } as any) @@ -113,7 +139,7 @@ describe("helper", () => { const sender = {} as any vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [tabId], + sidePanelTabs: [{ tabId, isLinkCommand: false }], sidePanelUrls: {}, } as any) @@ -147,7 +173,7 @@ describe("helper", () => { const sender = {} as any const mockData = { - sidePanelTabs: [tabId], + sidePanelTabs: [{ tabId, isLinkCommand: false }], sidePanelUrls: {}, } as any @@ -170,4 +196,355 @@ describe("helper", () => { expect(BgData.update).toHaveBeenCalledWith(expect.any(Function)) }) }) + + describe("openSidePanel", () => { + it("OSP-01: Should use sender.tab.id when available", async () => { + const tabId = 123 + const param = { url: "https://example.com", isLinkCommand: false } + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + const mockBgData = { sidePanelTabs: [] } as any + + vi.mocked(_openSidePanel).mockResolvedValue({ tabId } as any) + vi.mocked(incrementCommandExecutionCount).mockResolvedValue(undefined) + vi.mocked(BgData.update).mockImplementation((updater) => { + if (typeof updater === "function") { + updater(mockBgData) + } + return Promise.resolve(true) + }) + + const result = openSidePanel(param, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(_openSidePanel).toHaveBeenCalledWith({ ...param, tabId }) + }) + + it("OSP-02: Should fall back to bgData.activeTabId when sender.tab.id is null", async () => { + const activeTabId = 456 + const param = { url: "https://example.com", isLinkCommand: false } + const sender = {} as any // No tab.id + const response = vi.fn() + const mockBgData = { activeTabId, sidePanelTabs: [] } as any + + vi.mocked(BgData.get).mockReturnValue(mockBgData) + vi.mocked(_openSidePanel).mockResolvedValue({ tabId: activeTabId } as any) + vi.mocked(incrementCommandExecutionCount).mockResolvedValue(undefined) + vi.mocked(BgData.update).mockImplementation((updater) => { + if (typeof updater === "function") { + updater(mockBgData) + } + return Promise.resolve(true) + }) + + const result = openSidePanel(param, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(_openSidePanel).toHaveBeenCalledWith({ + ...param, + tabId: activeTabId, + }) + expect(response).toHaveBeenCalledWith(true) + }) + + it("OSP-03: Should return false when both sender.tab.id and bgData.activeTabId are null", () => { + const param = { url: "https://example.com", isLinkCommand: false } + const sender = {} as any + const response = vi.fn() + + vi.mocked(BgData.get).mockReturnValue({ + activeTabId: null, + sidePanelTabs: [], + } as any) + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}) + + const result = openSidePanel(param, sender, response) + + expect(result).toBe(false) + expect(response).toHaveBeenCalledWith(false) + expect(_openSidePanel).not.toHaveBeenCalled() + + consoleWarnSpy.mockRestore() + }) + + it("OSP-04: Should register tab in BgData.sidePanelTabs with correct isLinkCommand after openSidePanel succeeds", async () => { + const tabId = 123 + const param = { url: "https://example.com", isLinkCommand: true } + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + const mockBgData = { sidePanelTabs: [] } as any + + vi.mocked(_openSidePanel).mockResolvedValue({ tabId } as any) + vi.mocked(incrementCommandExecutionCount).mockResolvedValue(undefined) + vi.mocked(BgData.update).mockImplementation((updater) => { + if (typeof updater === "function") { + const result = updater(mockBgData) + expect(result.sidePanelTabs).toContainEqual({ + tabId, + isLinkCommand: true, + }) + } + return Promise.resolve(true) + }) + + openSidePanel(param, sender, response) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(response).toHaveBeenCalledWith(true) + expect(BgData.update).toHaveBeenCalledWith(expect.any(Function)) + }) + + it("OSP-05: Should call response(false) when _openSidePanel rejects", async () => { + const tabId = 123 + const param = { url: "https://example.com", isLinkCommand: false } + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + + vi.mocked(_openSidePanel).mockRejectedValue(new Error("Panel error")) + + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}) + + openSidePanel(param, sender, response) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(response).toHaveBeenCalledWith(false) + + consoleErrorSpy.mockRestore() + }) + }) + + describe("closeSidePanel", () => { + it("CSP-01: Should return false when sender.tab.id is null", () => { + const sender = {} as any + const response = vi.fn() + + const result = closeSidePanel(undefined, sender, response) + + expect(result).toBe(false) + expect(response).not.toHaveBeenCalled() + }) + + it("CSP-02: Should close panel when isLinkCommand=true and linkCommand.sidePanelAutoHide=true", async () => { + const tabId = 123 + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + + vi.mocked(enhancedSettings.get).mockResolvedValue({ + linkCommand: { sidePanelAutoHide: true }, + windowOption: { sidePanelAutoHide: false }, + } as any) + + vi.mocked(BgData.get).mockReturnValue({ + sidePanelTabs: [{ tabId, isLinkCommand: true }], + } as any) + + vi.mocked(_closeSidePanel).mockResolvedValue(undefined) + + const result = closeSidePanel(undefined, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(_closeSidePanel).toHaveBeenCalledWith(tabId) + expect(response).toHaveBeenCalledWith(true) + }) + + it("CSP-03: Should NOT close panel when isLinkCommand=true and linkCommand.sidePanelAutoHide=false", async () => { + const tabId = 123 + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + + vi.mocked(enhancedSettings.get).mockResolvedValue({ + linkCommand: { sidePanelAutoHide: false }, + windowOption: { sidePanelAutoHide: true }, + } as any) + + vi.mocked(BgData.get).mockReturnValue({ + sidePanelTabs: [{ tabId, isLinkCommand: true }], + } as any) + + const result = closeSidePanel(undefined, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(_closeSidePanel).not.toHaveBeenCalled() + expect(response).toHaveBeenCalledWith(true) + }) + + it("CSP-04: Should close panel when isLinkCommand=false and windowOption.sidePanelAutoHide=true", async () => { + const tabId = 123 + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + + vi.mocked(enhancedSettings.get).mockResolvedValue({ + linkCommand: { sidePanelAutoHide: false }, + windowOption: { sidePanelAutoHide: true }, + } as any) + + vi.mocked(BgData.get).mockReturnValue({ + sidePanelTabs: [{ tabId, isLinkCommand: false }], + } as any) + + vi.mocked(_closeSidePanel).mockResolvedValue(undefined) + + const result = closeSidePanel(undefined, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(_closeSidePanel).toHaveBeenCalledWith(tabId) + expect(response).toHaveBeenCalledWith(true) + }) + + it("CSP-05: Should NOT close panel when isLinkCommand=false and windowOption.sidePanelAutoHide=false", async () => { + const tabId = 123 + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + + vi.mocked(enhancedSettings.get).mockResolvedValue({ + linkCommand: { sidePanelAutoHide: true }, + windowOption: { sidePanelAutoHide: false }, + } as any) + + vi.mocked(BgData.get).mockReturnValue({ + sidePanelTabs: [{ tabId, isLinkCommand: false }], + } as any) + + const result = closeSidePanel(undefined, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(_closeSidePanel).not.toHaveBeenCalled() + expect(response).toHaveBeenCalledWith(true) + }) + + it("CSP-06: Should call response(true) without closing panel when tab is not in sidePanelTabs", async () => { + const tabId = 123 + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + + vi.mocked(enhancedSettings.get).mockResolvedValue({ + linkCommand: { sidePanelAutoHide: true }, + windowOption: { sidePanelAutoHide: true }, + } as any) + + vi.mocked(BgData.get).mockReturnValue({ + sidePanelTabs: [], // Tab not found + } as any) + + const result = closeSidePanel(undefined, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(_closeSidePanel).not.toHaveBeenCalled() + expect(response).toHaveBeenCalledWith(true) + }) + + it("CSP-07: Should call response(false) when enhancedSettings.get() rejects", async () => { + const tabId = 123 + const sender = { tab: { id: tabId } } as any + const response = vi.fn() + + vi.mocked(enhancedSettings.get).mockRejectedValue( + new Error("Settings error"), + ) + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}) + + const result = closeSidePanel(undefined, sender, response) + + expect(result).toBe(true) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(response).toHaveBeenCalledWith(false) + + consoleWarnSpy.mockRestore() + }) + }) + + describe("sidePanelClosed", () => { + it("SPC-01: Should return early without calling BgData.update when tabId is undefined", async () => { + await sidePanelClosed(undefined) + + expect(BgData.update).not.toHaveBeenCalled() + }) + + it("SPC-02: Should remove tab from sidePanelTabs and sidePanelUrls when tabId is provided", async () => { + const tabId = 123 + const mockData = { + sidePanelTabs: [ + { tabId, isLinkCommand: false }, + { tabId: 456, isLinkCommand: false }, + ], + sidePanelUrls: { + [tabId]: "https://example.com", + 456: "https://other.com", + }, + } as any + + vi.mocked(BgData.update).mockImplementation((updater) => { + if (typeof updater === "function") { + const result = updater(mockData) + expect(result.sidePanelTabs).not.toContainEqual({ + tabId, + isLinkCommand: false, + }) + expect(result.sidePanelTabs).toContainEqual({ + tabId: 456, + isLinkCommand: false, + }) + expect(result.sidePanelUrls![tabId]).toBeUndefined() + expect(result.sidePanelUrls![456]).toBe("https://other.com") + } + return Promise.resolve(true) + }) + + await sidePanelClosed(tabId) + + expect(BgData.update).toHaveBeenCalledWith(expect.any(Function)) + }) + + it("SPC-03: Should catch and log warning when BgData.update throws", async () => { + const tabId = 123 + + vi.mocked(BgData.update).mockRejectedValue(new Error("Update failed")) + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}) + + await sidePanelClosed(tabId) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Failed to cleanup side panel:", + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + }) }) diff --git a/packages/extension/src/action/helper.ts b/packages/extension/src/action/helper.ts index 7044af4f..cc623903 100644 --- a/packages/extension/src/action/helper.ts +++ b/packages/extension/src/action/helper.ts @@ -109,7 +109,16 @@ export const openSidePanel = ( sender: Sender, response: (res: unknown) => void, ): boolean => { - const tabId = sender.tab?.id + let tabId = sender.tab?.id + if (tabId == null) { + const bgData = BgData.get() + if (bgData.activeTabId == null) { + console.warn("No active tab ID available for opening side panel") + response(false) + return false + } + tabId = bgData.activeTabId + } // Since it needs to be tied to a user action, avoid asynchronous processing // and open the side panel immediately. @@ -123,10 +132,11 @@ export const openSidePanel = ( .then(() => { // Register the tab ID for tracking if (tabId) { + const newEntry = { tabId, isLinkCommand: param.isLinkCommand ?? false } return BgData.update((data) => ({ - sidePanelTabs: data.sidePanelTabs.includes(tabId) - ? data.sidePanelTabs - : [...data.sidePanelTabs, tabId], + sidePanelTabs: data.sidePanelTabs.some((t) => t.tabId === tabId) + ? data.sidePanelTabs.map((t) => (t.tabId === tabId ? newEntry : t)) + : [...data.sidePanelTabs, newEntry], })) } }) @@ -150,21 +160,50 @@ export const closeSidePanel = ( if (tabId == null) { return false } - - enhancedSettings.get().then(async (settings) => { - const sidePanelAutoHide = settings.windowOption.sidePanelAutoHide - if (sidePanelAutoHide) { + enhancedSettings + .get() + .then(async (settings) => { const bgData = BgData.get() - if (bgData.sidePanelTabs.includes(tabId)) { - await _closeSidePanel(tabId) + const tab = bgData.sidePanelTabs.find((t) => t.tabId === tabId) + if (tab) { + const autoHideEnabled = tab.isLinkCommand + ? settings.linkCommand.sidePanelAutoHide + : settings.windowOption.sidePanelAutoHide + if (autoHideEnabled) { + await _closeSidePanel(tabId) + } } - } - response(true) - }) + response(true) + }) + .catch((err) => { + console.warn("Failed to handle panel click:", err) + response(false) + }) return true } +/** + * Handle side panel closed event for a tab + * @param {number} tabId - The ID of the tab whose side panel was closed + * @return {Promise} A promise that resolves when the side panel closed event is handled + * This function is called when a side panel is closed, either by user action or programmatically. + */ +export const sidePanelClosed = async (tabId?: number): Promise => { + if (tabId == null) return + try { + await BgData.update((data) => { + const { [tabId]: _, ...rest } = data.sidePanelUrls + return { + sidePanelTabs: data.sidePanelTabs.filter((t) => t.tabId !== tabId), + sidePanelUrls: rest, + } + }) + } catch (e) { + console.warn("Failed to cleanup side panel:", e) + } +} + export const navigateSidePanel = ( param: NavigateSidePanelProps, _sender: Sender, @@ -191,7 +230,7 @@ export const navigateSidePanel = ( // Check if tab is in sidePanelTabs const bgData = BgData.get() - if (!bgData.sidePanelTabs.includes(tabId)) { + if (!bgData.sidePanelTabs.some((t) => t.tabId === tabId)) { console.warn("[navigateSidePanel] Tab is not in sidePanelTabs:", tabId) return false } diff --git a/packages/extension/src/action/linkPreview.test.ts b/packages/extension/src/action/linkPreview.test.ts new file mode 100644 index 00000000..f16e3ca8 --- /dev/null +++ b/packages/extension/src/action/linkPreview.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { LinkPreview } from "./linkPreview" +import { Ipc, BgCommand } from "@/services/ipc" +import { findAnchorElementFromParent } from "@/services/dom" +import { getScreenSize } from "@/services/screen" +import { DRAG_OPEN_MODE } from "@/const" + +// Mock dependencies +vi.mock("@/services/ipc", () => ({ + Ipc: { + send: vi.fn(), + }, + BgCommand: { + openSidePanel: "openSidePanel", + openPopups: "openPopups", + openPopupAndClick: "openPopupAndClick", + }, +})) + +vi.mock("@/services/dom", () => ({ + findAnchorElementFromParent: vi.fn(), +})) + +vi.mock("@/services/screen", () => ({ + getScreenSize: vi.fn(), +})) + +describe("LinkPreview", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("execute", () => { + const createCommand = (openMode: string) => + ({ + id: "test-command", + openMode, + popupOption: { height: 600, width: 800 }, + }) as any + + it("LP-01: Should send openSidePanel when openMode is PREVIEW_SIDE_PANEL and href is available", async () => { + const href = "https://example.com" + vi.mocked(findAnchorElementFromParent).mockReturnValue({ + href, + } as HTMLAnchorElement) + + const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL) + const position = { x: 100, y: 200 } + const target = document.createElement("div") + + await LinkPreview.execute({ + command, + position, + target, + selectionText: "", + }) + + expect(Ipc.send).toHaveBeenCalledWith(BgCommand.openSidePanel, { + url: href, + isLinkCommand: true, + }) + }) + + it("LP-02: Should NOT send openSidePanel when openMode is PREVIEW_SIDE_PANEL and href is empty", async () => { + vi.mocked(findAnchorElementFromParent).mockReturnValue({ + href: "", + } as HTMLAnchorElement) + + const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL) + const position = { x: 100, y: 200 } + const target = document.createElement("div") + + await LinkPreview.execute({ + command, + position, + target, + selectionText: "", + }) + + expect(Ipc.send).not.toHaveBeenCalled() + }) + + it("LP-03: Should return early after PREVIEW_SIDE_PANEL handling without calling openPopups", async () => { + const href = "https://example.com" + vi.mocked(findAnchorElementFromParent).mockReturnValue({ + href, + } as HTMLAnchorElement) + vi.mocked(getScreenSize).mockResolvedValue({} as any) + + const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL) + const position = { x: 100, y: 200 } + const target = document.createElement("div") + + await LinkPreview.execute({ + command, + position, + target, + selectionText: "", + }) + + expect(Ipc.send).toHaveBeenCalledTimes(1) + expect(Ipc.send).not.toHaveBeenCalledWith( + BgCommand.openPopups, + expect.any(Object), + ) + }) + + it("LP-04: Should not execute when position or target is null", async () => { + const command = createCommand(DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL) + + await LinkPreview.execute({ + command, + position: null, + target: null, + selectionText: "", + }) + + expect(Ipc.send).not.toHaveBeenCalled() + expect(findAnchorElementFromParent).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/extension/src/action/linkPreview.ts b/packages/extension/src/action/linkPreview.ts index f55f24d1..709dec44 100644 --- a/packages/extension/src/action/linkPreview.ts +++ b/packages/extension/src/action/linkPreview.ts @@ -8,6 +8,7 @@ import { getScreenSize } from "@/services/screen" import { DRAG_OPEN_MODE, POPUP_TYPE } from "@/const" import { isEmpty } from "@/lib/utils" import type { ExecuteCommandParams } from "@/types" +import type { OpenSidePanelProps } from "@/services/chrome" export const LinkPreview = { async execute({ command, position, target }: ExecuteCommandParams) { @@ -15,6 +16,16 @@ export const LinkPreview = { const elm = findAnchorElementFromParent(target) as HTMLAnchorElement const href = elm?.href + if (command.openMode === DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL) { + if (!isEmpty(href)) { + Ipc.send(BgCommand.openSidePanel, { + url: href, + isLinkCommand: true, + }) + } + return + } + const type = command.openMode === DRAG_OPEN_MODE.PREVIEW_POPUP ? POPUP_TYPE.POPUP diff --git a/packages/extension/src/background_script.test.ts b/packages/extension/src/background_script.test.ts index 93c9aa3e..adfcd07e 100644 --- a/packages/extension/src/background_script.test.ts +++ b/packages/extension/src/background_script.test.ts @@ -392,11 +392,11 @@ describe("Popup Auto-Close Delay", () => { it("PAC-01: should close popups immediately when delay is not set", async () => { // Mock WindowStackManager - const mockGetWindowsToClose = vi.fn().mockResolvedValue([ - { id: 100, commandId: "test", srcWindowId: 1 }, - ]) + const mockGetWindowsToClose = vi + .fn() + .mockResolvedValue([{ id: 100, commandId: "test", srcWindowId: 1 }]) const mockRemoveWindow = vi.fn().mockResolvedValue(undefined) - + vi.doMock("@/services/windowStackManager", () => ({ WindowStackManager: { getWindowsToClose: mockGetWindowsToClose, @@ -409,6 +409,7 @@ describe("Popup Auto-Close Delay", () => { vi.doMock("@/services/chrome", () => ({ closeWindow: mockCloseWindow, windowExists: vi.fn(), + getCurrentTab: vi.fn().mockResolvedValue({ id: 1 }), })) // Mock enhancedSettings to return no delay @@ -446,7 +447,7 @@ describe("Popup Auto-Close Delay", () => { // Trigger focus change await focusChangedListener(200) - + // Wait for async operations await vi.runAllTimersAsync() @@ -459,11 +460,11 @@ describe("Popup Auto-Close Delay", () => { const delay = 1000 // 1 second // Mock WindowStackManager - const mockGetWindowsToClose = vi.fn().mockResolvedValue([ - { id: 100, commandId: "test", srcWindowId: 1 }, - ]) + const mockGetWindowsToClose = vi + .fn() + .mockResolvedValue([{ id: 100, commandId: "test", srcWindowId: 1 }]) const mockRemoveWindow = vi.fn().mockResolvedValue(undefined) - + vi.doMock("@/services/windowStackManager", () => ({ WindowStackManager: { getWindowsToClose: mockGetWindowsToClose, @@ -476,6 +477,7 @@ describe("Popup Auto-Close Delay", () => { vi.doMock("@/services/chrome", () => ({ closeWindow: mockCloseWindow, windowExists: vi.fn(), + getCurrentTab: vi.fn().mockResolvedValue({ id: 1 }), })) // Mock enhancedSettings to return delay @@ -533,7 +535,7 @@ describe("Popup Auto-Close Delay", () => { .mockResolvedValueOnce([{ id: 100, commandId: "test", srcWindowId: 1 }]) .mockResolvedValueOnce([]) // No windows to close when focus returns const mockRemoveWindow = vi.fn().mockResolvedValue(undefined) - + vi.doMock("@/services/windowStackManager", () => ({ WindowStackManager: { getWindowsToClose: mockGetWindowsToClose, @@ -546,6 +548,7 @@ describe("Popup Auto-Close Delay", () => { vi.doMock("@/services/chrome", () => ({ closeWindow: mockCloseWindow, windowExists: vi.fn(), + getCurrentTab: vi.fn().mockResolvedValue({ id: 1 }), })) // Mock enhancedSettings to return delay diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index d6eb43eb..8ee96287 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -15,7 +15,7 @@ import { PopupOption, PopupPlacement } from "@/services/option/defaultSettings" import * as PageActionBackground from "@/services/pageAction/background" import { BgData } from "@/services/backgroundData" import { ContextMenu } from "@/services/contextMenus" -import { closeWindow, windowExists } from "@/services/chrome" +import { closeWindow, windowExists, getCurrentTab } from "@/services/chrome" import { WindowStackManager } from "@/services/windowStackManager" import { PopupAutoClose } from "@/services/popupAutoClose" import { isSearchCommand, isPageActionCommand } from "@/lib/utils" @@ -56,13 +56,11 @@ const getActiveTabId = ( _sender: Sender, response: (res: unknown) => void, ) => { - chrome.tabs - .query({ active: true, lastFocusedWindow: true }) - .then(([tab]) => response(tab?.id)) + getCurrentTab().then((tab) => response(tab?.id)) return true } -const onConnect = async function(port: chrome.runtime.Port) { +const onConnect = async function (port: chrome.runtime.Port) { if (port.name !== CONNECTION_APP) return port.onDisconnect.addListener(() => onDisconnect(port)) const tabId = port.sender?.tab?.id @@ -72,7 +70,7 @@ const onConnect = async function(port: chrome.runtime.Port) { })) } } -const onDisconnect = async function(port: chrome.runtime.Port) { +const onDisconnect = async function (port: chrome.runtime.Port) { if (port.name !== CONNECTION_APP) return if (chrome.runtime.lastError) { if ( @@ -152,24 +150,24 @@ const commandFuncs = { const cmd = isSearch ? { - id: params.id, - title: params.title, - searchUrl: params.searchUrl, - iconUrl: params.iconUrl, - openMode: params.openMode, - openModeSecondary: params.openModeSecondary, - spaceEncoding: params.spaceEncoding, - popupOption: PopupOption, - } - : isPageAction - ? { id: params.id, title: params.title, + searchUrl: params.searchUrl, iconUrl: params.iconUrl, openMode: params.openMode, - pageActionOption: params.pageActionOption, + openModeSecondary: params.openModeSecondary, + spaceEncoding: params.spaceEncoding, popupOption: PopupOption, } + : isPageAction + ? { + id: params.id, + title: params.title, + iconUrl: params.iconUrl, + openMode: params.openMode, + pageActionOption: params.pageActionOption, + popupOption: PopupOption, + } : null if (!cmd) { @@ -362,6 +360,16 @@ const updateWindowSize = async ( } } +const updateActiveTabId = async (activeTabId?: number) => { + if (activeTabId == null) { + const activeTab = await getCurrentTab() + activeTabId = activeTab?.id + } + if (activeTabId != null) { + await BgData.update({ activeTabId }) + } +} + chrome.action.onClicked.addListener(() => { chrome.tabs.create({ url: OPTION_PAGE_PATH, @@ -379,6 +387,9 @@ chrome.windows.onFocusChanged.addListener(async (windowId: number) => { // Update active screen ID await updateActiveScreenId(windowId) + // Update active tab ID + await updateActiveTabId() + // Get windows to close based on focus change const windowsToClose = await WindowStackManager.getWindowsToClose(windowId) @@ -429,6 +440,9 @@ chrome.tabs.onActivated.addListener(async (activeInfo) => { if (tab.windowId) { await updateActiveScreenId(tab.windowId) } + + // Update active tab ID + await updateActiveTabId(tab.id) } catch (error) { console.error("Failed to get active screen ID:", error) } @@ -513,17 +527,17 @@ const checkAndPerformLegacyBackup = async () => { } } - // Initialize commandIdObj and register listener at top-level - // to ensure they are available when service worker restarts - ; (async () => { - try { - await ContextMenu.syncCommandIdObj() - chrome.contextMenus.onClicked.addListener(ContextMenu.onClicked) - } catch (error) { - // Ignore errors during initialization (e.g., in test environment) - console.debug("Failed to initialize context menu listener:", error) - } - })() +// Initialize commandIdObj and register listener at top-level +// to ensure they are available when service worker restarts +;(async () => { + try { + await ContextMenu.syncCommandIdObj() + chrome.contextMenus.onClicked.addListener(ContextMenu.onClicked) + } catch (error) { + // Ignore errors during initialization (e.g., in test environment) + console.debug("Failed to initialize context menu listener:", error) + } +})() Settings.addChangedListener(() => ContextMenu.init()) @@ -619,14 +633,12 @@ chrome.commands.onCommand.addListener(async (commandName) => { // SidePanel auto-hide functionality // Track tabs with active side panels chrome.tabs.onRemoved.addListener((tabId) => { - BgData.update((data) => { - const { [tabId]: _, ...rest } = data.sidePanelUrls - return { - sidePanelTabs: data.sidePanelTabs.filter((id) => id !== tabId), - sidePanelUrls: rest, - } - }) + ActionHelper.sidePanelClosed(tabId) + updateActiveTabId() }) +chrome.sidePanel.onClosed.addListener(({ tabId }) => + ActionHelper.sidePanelClosed(tabId), +) // Export functions for testing export const testExports = { diff --git a/packages/extension/src/components/Popup.tsx b/packages/extension/src/components/Popup.tsx index 4926b9c4..67f61e95 100644 --- a/packages/extension/src/components/Popup.tsx +++ b/packages/extension/src/components/Popup.tsx @@ -5,6 +5,7 @@ import { useUserSettings } from "@/hooks/useSettings" import { useDetectStartup } from "@/hooks/useDetectStartup" import { useTabCommandReceiver } from "@/hooks/useTabCommandReceiver" import { useSidePanelNavigation } from "@/hooks/useSidePanelNavigation" +import { useSidePanelAutoClose } from "@/hooks/useSidePanelAutoClose" import { hexToHsl, isMac, onHover, cn } from "@/lib/utils" import { t } from "@/services/i18n" import { STYLE_VARIABLE, EXIT_DURATION, SIDE, ALIGN } from "@/const" @@ -29,6 +30,7 @@ export const Popup = forwardRef( (props: PopupProps, ref) => { useTabCommandReceiver() useSidePanelNavigation() + useSidePanelAutoClose() const { userSettings } = useUserSettings() const [inTransition, setInTransition] = useState(false) diff --git a/packages/extension/src/components/option/SettingForm.tsx b/packages/extension/src/components/option/SettingForm.tsx index 20b1ae0c..72b4517e 100644 --- a/packages/extension/src/components/option/SettingForm.tsx +++ b/packages/extension/src/components/option/SettingForm.tsx @@ -100,6 +100,7 @@ const formSchema = z .refine((v) => v !== LINK_COMMAND_ENABLED.INHERIT), openMode: z.nativeEnum(DRAG_OPEN_MODE), showIndicator: z.boolean(), + sidePanelAutoHide: z.boolean(), startupMethod: z .object({ method: z.nativeEnum(LINK_COMMAND_STARTUP_METHOD), @@ -164,6 +165,12 @@ export function SettingForm({ className }: { className?: string }) { defaultValue: LINK_COMMAND_STARTUP_METHOD.KEYBOARD, }) + const linkCommandOpenMode = useWatch({ + control: form.control, + name: "linkCommand.openMode", + defaultValue: DRAG_OPEN_MODE.PREVIEW_POPUP, + }) + // Common function to load and transform settings data const loadSettingsData = async () => { const settings = await enhancedSettings.get({ excludeOptions: true }) @@ -586,6 +593,14 @@ export function SettingForm({ className }: { className?: string }) { formLabel={t("showIndicator")} description={t("showIndicator_desc")} /> + {linkCommandOpenMode === DRAG_OPEN_MODE.PREVIEW_SIDE_PANEL && ( + + )}
@@ -595,6 +610,7 @@ export function SettingForm({ className }: { className?: string }) { {t("windowSettings")}

{t("windowSettings_desc")}

+ + +
diff --git a/packages/extension/src/components/option/field/SwitchField.tsx b/packages/extension/src/components/option/field/SwitchField.tsx index e906d12f..720fbff3 100644 --- a/packages/extension/src/components/option/field/SwitchField.tsx +++ b/packages/extension/src/components/option/field/SwitchField.tsx @@ -1,3 +1,4 @@ +import { useState } from "react" import { Switch } from "@/components/ui/switch" import { @@ -8,12 +9,16 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" +import { Info } from "lucide-react" +import { Tooltip } from "@/components/Tooltip" +import { cn } from "@/lib/utils" type SwitchFieldType = { control: any name: string formLabel: string description?: string + tooltip?: string } export const SwitchField = ({ @@ -21,7 +26,10 @@ export const SwitchField = ({ name, formLabel, description, + tooltip, }: SwitchFieldType) => { + const [spanEl, setSpanEl] = useState(null) + return ( (
- {formLabel} + + {formLabel} + {tooltip && ( + + + + )} + {description && {description}} + {tooltip && ( + + )}
diff --git a/packages/extension/src/const.ts b/packages/extension/src/const.ts index 847d8546..5964c642 100644 --- a/packages/extension/src/const.ts +++ b/packages/extension/src/const.ts @@ -139,6 +139,7 @@ export enum ExecState { export enum DRAG_OPEN_MODE { PREVIEW_POPUP = "previewPopup", PREVIEW_WINDOW = "previewWindow", + PREVIEW_SIDE_PANEL = "previewSidePanel", } export enum SIDE { diff --git a/packages/extension/src/hooks/useSettings.test.tsx b/packages/extension/src/hooks/useSettings.test.tsx index c5a0330c..74b297be 100644 --- a/packages/extension/src/hooks/useSettings.test.tsx +++ b/packages/extension/src/hooks/useSettings.test.tsx @@ -63,6 +63,7 @@ const createMockUserSettings = ( enabled: LINK_COMMAND_ENABLED.ENABLE, openMode: DRAG_OPEN_MODE.PREVIEW_POPUP, showIndicator: true, + sidePanelAutoHide: false, startupMethod: { method: LINK_COMMAND_STARTUP_METHOD.KEYBOARD, keyboardParam: KEYBOARD.SHIFT, diff --git a/packages/extension/src/hooks/useSidePanelAutoClose.test.ts b/packages/extension/src/hooks/useSidePanelAutoClose.test.ts new file mode 100644 index 00000000..02e95e9e --- /dev/null +++ b/packages/extension/src/hooks/useSidePanelAutoClose.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { renderHook, waitFor, act } from "@testing-library/react" +import { useSidePanelAutoClose } from "./useSidePanelAutoClose" +import { Ipc, BgCommand } from "@/services/ipc" +import { BgData } from "@/services/backgroundData" + +// Mock dependencies +vi.mock("@/services/ipc", () => ({ + Ipc: { + send: vi.fn(), + }, + BgCommand: { + closeSidePanel: "closeSidePanel", + }, +})) + +vi.mock("@/services/backgroundData", () => ({ + BgData: { + get: vi.fn(), + watch: vi.fn(), + }, +})) + +vi.mock("@/hooks/useTabContext", () => ({ + useTabContext: () => ({ tabId: 123 }), +})) + +vi.mock("@/hooks/useSettings", () => ({ + useUserSettings: vi.fn(), +})) + +import { useUserSettings } from "@/hooks/useSettings" + +const mockBgDataGet = vi.mocked(BgData.get) +const mockBgDataWatch = vi.mocked(BgData.watch) +const mockUseUserSettings = vi.mocked(useUserSettings) + +const makeSettings = ( + windowAutoHide = false, + linkCommandAutoHide = false, +) => ({ + userSettings: { + windowOption: { sidePanelAutoHide: windowAutoHide }, + linkCommand: { sidePanelAutoHide: linkCommandAutoHide }, + }, +}) + +describe("useSidePanelAutoClose", () => { + let watchCallback: ((data: BgData) => void) | null = null + + beforeEach(() => { + vi.clearAllMocks() + watchCallback = null + + // Default: side panel not visible + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [], + } as unknown as BgData) + + // Capture the watch callback so tests can trigger BgData updates + mockBgDataWatch.mockImplementation((cb) => { + watchCallback = cb as (data: BgData) => void + return () => {} + }) + + mockUseUserSettings.mockReturnValue(makeSettings() as any) + }) + + it("SPAC-01: Should not register click listener when side panel is not visible", async () => { + const addSpy = vi.spyOn(window, "addEventListener") + renderHook(() => useSidePanelAutoClose()) + await waitFor(() => { + expect(mockBgDataGet).toHaveBeenCalled() + }) + expect(addSpy).not.toHaveBeenCalledWith("click", expect.any(Function)) + }) + + it("SPAC-02: Should not register click listener when sidePanelAutoHide is false (windowOption)", async () => { + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], + } as unknown as BgData) + mockUseUserSettings.mockReturnValue(makeSettings(false, false) as any) + + const addSpy = vi.spyOn(window, "addEventListener") + renderHook(() => useSidePanelAutoClose()) + await waitFor(() => expect(mockBgDataGet).toHaveBeenCalled()) + expect(addSpy).not.toHaveBeenCalledWith("click", expect.any(Function)) + }) + + it("SPAC-03: Should register click listener when sidePanelAutoHide is true (windowOption, non-link-command)", async () => { + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], + } as unknown as BgData) + mockUseUserSettings.mockReturnValue(makeSettings(true, false) as any) + + const addSpy = vi.spyOn(window, "addEventListener") + renderHook(() => useSidePanelAutoClose()) + await waitFor(() => + expect(addSpy).toHaveBeenCalledWith("click", expect.any(Function)), + ) + }) + + it("SPAC-04: Should use linkCommand.sidePanelAutoHide when isLinkCommand is true", async () => { + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [{ tabId: 123, isLinkCommand: true }], + } as unknown as BgData) + // windowOption.sidePanelAutoHide is false, linkCommand.sidePanelAutoHide is true + mockUseUserSettings.mockReturnValue(makeSettings(false, true) as any) + + const addSpy = vi.spyOn(window, "addEventListener") + renderHook(() => useSidePanelAutoClose()) + await waitFor(() => + expect(addSpy).toHaveBeenCalledWith("click", expect.any(Function)), + ) + }) + + it("SPAC-05: Should not register click listener when isLinkCommand but linkCommand.sidePanelAutoHide is false", async () => { + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [{ tabId: 123, isLinkCommand: true }], + } as unknown as BgData) + // windowOption.sidePanelAutoHide is true, but linkCommand.sidePanelAutoHide is false + mockUseUserSettings.mockReturnValue(makeSettings(true, false) as any) + + const addSpy = vi.spyOn(window, "addEventListener") + renderHook(() => useSidePanelAutoClose()) + await waitFor(() => expect(mockBgDataGet).toHaveBeenCalled()) + expect(addSpy).not.toHaveBeenCalledWith("click", expect.any(Function)) + }) + + it("SPAC-06: Should send closeSidePanel when click occurs and auto-close is enabled", async () => { + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], + } as unknown as BgData) + mockUseUserSettings.mockReturnValue(makeSettings(true, false) as any) + + renderHook(() => useSidePanelAutoClose()) + await waitFor(() => + expect(window.addEventListener).toHaveBeenCalledWith( + "click", + expect.any(Function), + ), + ) + + window.dispatchEvent(new MouseEvent("click")) + expect(Ipc.send).toHaveBeenCalledWith(BgCommand.closeSidePanel) + }) + + it("SPAC-07: Should update listener when BgData.watch fires with new data", async () => { + // Initially side panel is not visible + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [], + } as unknown as BgData) + mockUseUserSettings.mockReturnValue(makeSettings(true, false) as any) + + const addSpy = vi.spyOn(window, "addEventListener") + renderHook(() => useSidePanelAutoClose()) + await waitFor(() => expect(mockBgDataWatch).toHaveBeenCalled()) + + // Listener should NOT be added yet + expect(addSpy).not.toHaveBeenCalledWith("click", expect.any(Function)) + + // Now simulate BgData update making the side panel visible + await act(async () => { + watchCallback?.({ + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], + } as unknown as BgData) + }) + + await waitFor(() => + expect(addSpy).toHaveBeenCalledWith("click", expect.any(Function)), + ) + }) + + it("SPAC-08: Should remove click listener on cleanup", async () => { + mockBgDataGet.mockReturnValue({ + sidePanelTabs: [{ tabId: 123, isLinkCommand: false }], + } as unknown as BgData) + mockUseUserSettings.mockReturnValue(makeSettings(true, false) as any) + + const removeSpy = vi.spyOn(window, "removeEventListener") + const { unmount } = renderHook(() => useSidePanelAutoClose()) + await waitFor(() => + expect(window.addEventListener).toHaveBeenCalledWith( + "click", + expect.any(Function), + ), + ) + + unmount() + expect(removeSpy).toHaveBeenCalledWith("click", expect.any(Function)) + }) +}) diff --git a/packages/extension/src/hooks/useSidePanelAutoClose.ts b/packages/extension/src/hooks/useSidePanelAutoClose.ts new file mode 100644 index 00000000..47813443 --- /dev/null +++ b/packages/extension/src/hooks/useSidePanelAutoClose.ts @@ -0,0 +1,42 @@ +import { useEffect } from "react" +import { Ipc, BgCommand } from "@/services/ipc" +import { BgData } from "@/services/backgroundData" +import { useTabContext } from "./useTabContext" +import { useUserSettings } from "./useSettings" + +export function useSidePanelAutoClose() { + const { tabId } = useTabContext() + const { userSettings } = useUserSettings() + + useEffect(() => { + if (tabId == null) return + + let cleanupClickListener: (() => void) | undefined + + const setup = (data: BgData) => { + cleanupClickListener?.() + cleanupClickListener = undefined + + const tab = data.sidePanelTabs.find((t) => t.tabId === tabId) + if (!tab) return + + const autoHideEnabled = tab.isLinkCommand + ? userSettings?.linkCommand?.sidePanelAutoHide + : userSettings?.windowOption?.sidePanelAutoHide + + if (!autoHideEnabled) return + + const close = () => Ipc.send(BgCommand.closeSidePanel) + window.addEventListener("click", close) + cleanupClickListener = () => window.removeEventListener("click", close) + } + + setup(BgData.get()) + const unwatch = BgData.watch((newVal) => setup(newVal)) + + return () => { + cleanupClickListener?.() + unwatch() + } + }, [tabId, userSettings]) +} diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index bb736c98..5fdfbe3d 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -9,6 +9,11 @@ const API_SECRET = import.meta.env.VITE_API_SECRET const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100 const SESSION_EXPIRATION_IN_MIN = 30 +// Disable analytics in CI environments +const IS_CI = + typeof process !== "undefined" && process.env && process.env.CI === "true" +const DISABLE_ANALYTICS = IS_CI + export const ANALYTICS_EVENTS = { SELECTION_COMMAND: "selection_command", LINK_COMMAND: "link_command", @@ -28,13 +33,18 @@ export const ANALYTICS_EVENTS = { export type AnalyticsEventName = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS] -// https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4?t +// https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4 export async function sendEvent( name: AnalyticsEventName, params: any, screen = SCREEN.CONTENT_SCRIPT, ) { + // Do not send analytics data if running in CI. + if (DISABLE_ANALYTICS || !MEASUREMENT_ID || !API_SECRET) { + return + } + const endpoint = isDebug ? GA_DEBUG_ENDPOINT : GA_ENDPOINT try { const res = await fetch( diff --git a/packages/extension/src/services/backgroundData.ts b/packages/extension/src/services/backgroundData.ts index d614480e..8630f779 100644 --- a/packages/extension/src/services/backgroundData.ts +++ b/packages/extension/src/services/backgroundData.ts @@ -6,6 +6,11 @@ type updaterPartial = (val: BgData) => Partial type watchCallback = (newVal: BgData, oldVal: BgData) => void +export type SidePanelTab = { + tabId: number + isLinkCommand: boolean +} + export class BgData { private static instance: BgData @@ -13,8 +18,9 @@ export class BgData { public normalWindows: WindowLayer public pageActionStop: boolean public activeScreenId: string | null + public activeTabId: number | null public connectedTabs: number[] - public sidePanelTabs: number[] + public sidePanelTabs: SidePanelTab[] public sidePanelUrls: Record private constructor(val: BgData | undefined) { @@ -22,8 +28,12 @@ export class BgData { this.normalWindows = val?.normalWindows ?? [] this.pageActionStop = val?.pageActionStop ?? false this.activeScreenId = val?.activeScreenId ?? null + this.activeTabId = val?.activeTabId ?? null this.connectedTabs = val?.connectedTabs ?? [] - this.sidePanelTabs = val?.sidePanelTabs ?? [] + // Normalize sidePanelTabs: convert legacy number[] entries to SidePanelTab objects + this.sidePanelTabs = (val?.sidePanelTabs ?? []).map((t) => + typeof t === "number" ? { tabId: t, isLinkCommand: false } : t, + ) this.sidePanelUrls = val?.sidePanelUrls ?? {} } diff --git a/packages/extension/src/services/chrome.ts b/packages/extension/src/services/chrome.ts index 38f94409..1136c37c 100644 --- a/packages/extension/src/services/chrome.ts +++ b/packages/extension/src/services/chrome.ts @@ -332,11 +332,11 @@ const readClipboardContent = async ( ): Promise => { try { const result = await new Promise((resolve) => { - chrome.runtime.onConnect.addListener(function (port) { + chrome.runtime.onConnect.addListener(function(port) { if (port.sender?.tab?.id !== tabId) { return } - port.onMessage.addListener(function (msg) { + port.onMessage.addListener(function(msg) { if (msg.command === BgCommand.setClipboard) { resolve(msg.data) } @@ -621,6 +621,7 @@ export async function closeWindow( export type OpenSidePanelProps = { url: string | UrlParam tabId?: number + isLinkCommand?: boolean } export type UpdateSidePanelUrlProps = { @@ -640,7 +641,7 @@ export const openSidePanel = async ( const targetTabId = tabId if (!targetTabId) { - console.error("No valid tab ID for side panel") + console.warn("No valid tab ID for side panel") return { tabId: undefined, } @@ -662,6 +663,8 @@ export const openSidePanel = async ( } } +const SIDE_PANEL_CLOSE_ANIMATION = 1000 + /** * Close the side panel for the specified tab * @param {number} tabId - The ID of the tab to close the side panel for @@ -673,22 +676,15 @@ export const closeSidePanel = async (tabId: number): Promise => { } catch (e) { console.warn("Failed to close side panel:", e) } - - // Cleanup regardless of whether close succeeded + // Wait for the side panel close animation to finish before disabling it to prevent visual glitches. + await sleep(SIDE_PANEL_CLOSE_ANIMATION) try { - await BgData.update((data) => { - const { [tabId]: _, ...rest } = data.sidePanelUrls - return { - sidePanelTabs: data.sidePanelTabs.filter((id) => id !== tabId), - sidePanelUrls: rest, - } - }) await chrome.sidePanel.setOptions({ tabId: tabId, enabled: false, }) } catch (e) { - console.warn("Failed to cleanup side panel:", e) + console.warn("Failed to disable side panel:", e) } } diff --git a/packages/extension/src/services/option/defaultSettings.ts b/packages/extension/src/services/option/defaultSettings.ts index 1f8533c1..a0e3f5b6 100644 --- a/packages/extension/src/services/option/defaultSettings.ts +++ b/packages/extension/src/services/option/defaultSettings.ts @@ -40,6 +40,7 @@ export const emptySettings: SettingsType = { enabled: LINK_COMMAND_ENABLED.ENABLE, openMode: DRAG_OPEN_MODE.PREVIEW_POPUP, showIndicator: true, + sidePanelAutoHide: false, startupMethod: { method: LINK_COMMAND_STARTUP_METHOD.KEYBOARD, keyboardParam: KEYBOARD.SHIFT, @@ -71,6 +72,7 @@ export default { enabled: LINK_COMMAND_ENABLED.ENABLE, openMode: DRAG_OPEN_MODE.PREVIEW_POPUP, showIndicator: true, + sidePanelAutoHide: false, startupMethod: { method: LINK_COMMAND_STARTUP_METHOD.KEYBOARD, keyboardParam: KEYBOARD.SHIFT, diff --git a/packages/extension/src/services/settings/settings.ts b/packages/extension/src/services/settings/settings.ts index fc2e130a..0b4dd342 100644 --- a/packages/extension/src/services/settings/settings.ts +++ b/packages/extension/src/services/settings/settings.ts @@ -222,28 +222,31 @@ const removeOptionSettings = (data: SettingsType): void => { } export const migrate = async (data: SettingsType): Promise => { - if (versionDiff(data.settingVersion, "0.10.0") === VersionDiff.Old) { + const currentVersion = data.settingVersion + + if (versionDiff(currentVersion, "0.10.0") === VersionDiff.Old) { data = await migrate0_10_0(data) } - if (versionDiff(data.settingVersion, "0.10.3") === VersionDiff.Old) { + if (versionDiff(currentVersion, "0.10.3") === VersionDiff.Old) { data = migrate0_10_3(data) } - if (versionDiff(data.settingVersion, "0.11.3") === VersionDiff.Old) { + if (versionDiff(currentVersion, "0.11.3") === VersionDiff.Old) { data = migrate0_11_3(data) } - if (versionDiff(data.settingVersion, "0.11.5") === VersionDiff.Old) { - data.settingVersion = VERSION as Version + if (versionDiff(currentVersion, "0.11.5") === VersionDiff.Old) { data = migrate0_11_5(data) } - if (versionDiff(data.settingVersion, "0.11.9") === VersionDiff.Old) { - data.settingVersion = VERSION as Version + if (versionDiff(currentVersion, "0.11.9") === VersionDiff.Old) { data = migrate0_11_9(data) } - if (versionDiff(data.settingVersion, "0.14.3") === VersionDiff.Old) { - data.settingVersion = VERSION as Version + if (versionDiff(currentVersion, "0.14.3") === VersionDiff.Old) { data = migrate0_14_3(data) } + if (versionDiff(currentVersion, "0.15.1") === VersionDiff.Old) { + data = migrate0_15_1(data) + } + data.settingVersion = VERSION as Version return data } @@ -352,3 +355,13 @@ const migrate0_14_3 = (data: SettingsType): SettingsType => { } return data } + +const migrate0_15_1 = (data: SettingsType): SettingsType => { + // Add linkCommand.sidePanelAutoHide if not exists + if (data.linkCommand != null && data.linkCommand.sidePanelAutoHide == null) { + data.linkCommand.sidePanelAutoHide = + DefaultSettings.linkCommand.sidePanelAutoHide + console.debug("migrate 0.15.1: added linkCommand.sidePanelAutoHide") + } + return data +} diff --git a/packages/extension/src/services/sidePanelDetector.test.ts b/packages/extension/src/services/sidePanelDetector.test.ts index a337a947..c3301ba1 100644 --- a/packages/extension/src/services/sidePanelDetector.test.ts +++ b/packages/extension/src/services/sidePanelDetector.test.ts @@ -25,7 +25,7 @@ describe("sidePanelDetector", () => { it("SPD-01-a: Should continue checking when tabId is undefined (treated same as null)", () => { vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [789], // activeTabId not in list + sidePanelTabs: [{ tabId: 789, isLinkCommand: false }], } as any) const result = isSidePanel(undefined, 789) @@ -41,7 +41,7 @@ describe("sidePanelDetector", () => { it("SPD-03: Should return false when activeTabId is not in sidePanelTabs", () => { vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [789], // activeTabId not in list + sidePanelTabs: [{ tabId: 789, isLinkCommand: false }], // activeTabId not in list } as any) const result = isSidePanel(null, 456) @@ -52,7 +52,7 @@ describe("sidePanelDetector", () => { it("SPD-04: Should return true when all conditions are met", () => { vi.mocked(BgData.get).mockReturnValue({ - sidePanelTabs: [456], + sidePanelTabs: [{ tabId: 456, isLinkCommand: false }], } as any) const result = isSidePanel(null, 456) diff --git a/packages/extension/src/services/sidePanelDetector.ts b/packages/extension/src/services/sidePanelDetector.ts index 4b021229..606a4b06 100644 --- a/packages/extension/src/services/sidePanelDetector.ts +++ b/packages/extension/src/services/sidePanelDetector.ts @@ -17,7 +17,7 @@ export const isSidePanel = ( // Check if tab is in sidePanelTabs const bgData = BgData.get() - if (!bgData.sidePanelTabs.includes(activeTabId)) return false + if (!bgData.sidePanelTabs.some((t) => t.tabId === activeTabId)) return false return true } diff --git a/packages/extension/src/services/windowStackManager.test.ts b/packages/extension/src/services/windowStackManager.test.ts index 8e86413b..2ae18d2b 100644 --- a/packages/extension/src/services/windowStackManager.test.ts +++ b/packages/extension/src/services/windowStackManager.test.ts @@ -33,6 +33,7 @@ describe("WindowStackManager", () => { normalWindows: [], pageActionStop: false, activeScreenId: null, + activeTabId: null, connectedTabs: [], sidePanelTabs: [], sidePanelUrls: {}, diff --git a/packages/extension/src/test/setup.ts b/packages/extension/src/test/setup.ts index 2a49bb42..ed6089d7 100644 --- a/packages/extension/src/test/setup.ts +++ b/packages/extension/src/test/setup.ts @@ -341,6 +341,12 @@ global.chrome = { addListener: vi.fn(), }, }, + sidePanel: { + onClosed: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + }, } as any // Mock window.matchMedia diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index aa2bcf69..ada419d3 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -96,6 +96,7 @@ type LinkCommandSettings = { openMode: DRAG_OPEN_MODE showIndicator: boolean startupMethod: LinkCommandStartupMethod + sidePanelAutoHide: boolean } export type CommandFolder = {