From 7e4dcffbdcee6b35be9730c8b477533b7e98f247 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:04:19 +0800 Subject: [PATCH 1/5] feat(desktop,ui): multi-tab Browser section mirroring the terminal tab model (CODE-266) --- .../src/__tests__/shell-state.test.ts | 132 +++++++++++++++--- .../shell/browser/browser-webview-pane.tsx | 34 +++-- .../src/renderer/src/shell/desktop-shell.tsx | 26 +++- .../src/shell/layout/right-panel-region.tsx | 9 ++ .../src/renderer/src/shell/store/model.ts | 97 ++++++++++--- .../src/renderer/src/shell/store/store.ts | 81 +++++++++-- packages/presentation/i18n/src/locales/en.ts | 1 + .../presentation/i18n/src/locales/zh-cn.ts | 1 + .../presentation/ui/src/shell/panels/index.ts | 1 + .../src/shell/panels/section-browser-tabs.tsx | 58 ++++++++ .../ui/src/shell/panels/section-panel.tsx | 22 ++- .../ui/src/shell/panels/vocabulary.tsx | 5 + 12 files changed, 402 insertions(+), 65 deletions(-) create mode 100644 packages/presentation/ui/src/shell/panels/section-browser-tabs.tsx diff --git a/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts b/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts index 1637480b6..dd4a52d94 100644 --- a/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts +++ b/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts @@ -6,9 +6,11 @@ import { createDefaultDesktopShellState, createDefaultRightPanelState, createPanelState, + createRightBrowserTab, createRightFileTab, createRightTerminalTab, DEFAULT_LAYOUT, + openBrowserUrlState, openFileTabState, parsePersistedDesktopShellState, RIGHT_PANEL_MAX_SIZE, @@ -18,7 +20,9 @@ import { SIDEBAR_MIN_SIZE, seedTerminalSection, serializeDesktopShellState, + updateBrowserTabState, } from '@renderer/shell/store/model'; +import { createFixedArray } from 'foxts/create-fixed-array'; import { describe, expect, it } from 'vitest'; describe('desktop shell state persistence', () => { @@ -56,9 +60,31 @@ describe('desktop shell state persistence', () => { expect(panelTypes(state.bottomPanel)).toEqual(['terminal']); }); - it('clamps latest layout values', () => { + it('falls back to defaults for a stale v2 payload instead of migrating it', () => { const state = parsePersistedDesktopShellState({ version: 2, + sidebarOpen: false, + layout: DEFAULT_LAYOUT, + expansionStack: [], + rightPanel: { + open: true, + activeSection: 'browser', + terminalTabCount: 1, + activeTerminalTabIndex: 0, + fileTabPaths: [], + activeFileTabIndex: 0, + browserUrl: 'https://example.com', + }, + bottomPanel: { open: true, tabs: ['terminal'], activeTabIndex: 0 }, + }); + + expect(state.sidebarOpen).toBe(true); + expect(state.rightPanel).toEqual(createDefaultRightPanelState()); + }); + + it('clamps latest layout values', () => { + const state = parsePersistedDesktopShellState({ + version: 3, sidebarOpen: true, layout: { sidebarW: 10, rightW: 10000, bottomH: 1 }, expansionStack: [], @@ -80,7 +106,7 @@ describe('desktop shell state persistence', () => { it('rejects invalid bottom tabs and falls back when none remain', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: { sidebarW: SIDEBAR_MAX_SIZE, @@ -103,7 +129,7 @@ describe('desktop shell state persistence', () => { it('restores the right panel section and terminal tab count, clamping the active index', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -175,7 +201,7 @@ describe('desktop shell state persistence', () => { it('rejects an invalid right panel section and falls back to diff', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -193,7 +219,7 @@ describe('desktop shell state persistence', () => { it('caps a corrupted terminal tab count', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -211,7 +237,7 @@ describe('desktop shell state persistence', () => { it('filters expansion stack to open panels', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: ['right', 'bottom', 'bottom', 'invalid'], @@ -229,12 +255,16 @@ describe('desktop shell state persistence', () => { it('round trips the latest serialized shape', () => { const fileTab = createRightFileTab('/w/PLAN.md'); + const browserTab = createRightBrowserTab('http://web--app-1a2b3c.localhost:19523'); const rightPanel: RightPanelState = { open: true, activeSection: 'browser', terminal: { tabs: [createRightTerminalTab(), createRightTerminalTab()], activeTabId: null }, files: { tabs: [fileTab, createRightFileTab('/w/report.pdf')], activeTabId: fileTab.id }, - browser: { url: 'http://web--app-1a2b3c.localhost:19523' }, + browser: { + tabs: [createRightBrowserTab('https://example.com'), browserTab], + activeTabId: browserTab.id, + }, }; const source: DesktopShellState = { sidebarOpen: false, @@ -259,25 +289,95 @@ describe('desktop shell state persistence', () => { '/w/report.pdf', ]); expect(parsed.rightPanel.files.activeTabId).toBe(parsed.rightPanel.files.tabs[0].id); - expect(parsed.rightPanel.browser.url).toBe('http://web--app-1a2b3c.localhost:19523'); + expect(parsed.rightPanel.browser.tabs.map((tab) => tab.url)).toEqual([ + 'https://example.com', + 'http://web--app-1a2b3c.localhost:19523', + ]); + expect(parsed.rightPanel.browser.activeTabId).toBe(parsed.rightPanel.browser.tabs[1].id); expect(panelTypes(parsed.bottomPanel)).toEqual(['files']); }); - it('drops renderer-scoped blob URLs from persisted browser state', () => { + it('keeps blob-URL tabs but drops their renderer-scoped URLs', () => { const source = createDefaultDesktopShellState(); - source.rightPanel.browser.url = 'blob:http://localhost:5173/expired-preview'; + source.rightPanel.browser = { + tabs: [ + createRightBrowserTab('blob:http://localhost:5173/expired-preview'), + createRightBrowserTab('https://example.com'), + ], + activeTabId: null, + }; const serialized = serializeDesktopShellState(source); - expect(serialized.rightPanel.browserUrl).toBeNull(); + expect(serialized.rightPanel.browserTabUrls).toEqual([null, 'https://example.com']); - const parsed = parsePersistedDesktopShellState({ - ...serialized, + const parsed = parsePersistedDesktopShellState(serialized); + expect(parsed.rightPanel.browser.tabs.map((tab) => tab.url)).toEqual([ + null, + 'https://example.com', + ]); + }); + + it('caps restored browser tabs and clamps the active index', () => { + const state = parsePersistedDesktopShellState({ + version: 3, + sidebarOpen: true, + layout: DEFAULT_LAYOUT, + expansionStack: [], rightPanel: { - ...serialized.rightPanel, - browserUrl: 'blob:http://localhost:5173/expired-preview', + open: true, + activeSection: 'browser', + terminalTabCount: 0, + activeTerminalTabIndex: 0, + fileTabPaths: [], + activeFileTabIndex: 0, + browserTabUrls: createFixedArray(30).map((i) => `https://example.com/${i}`), + activeBrowserTabIndex: 99, }, + bottomPanel: { open: false, tabs: ['terminal'], activeTabIndex: 0 }, }); - expect(parsed.rightPanel.browser.url).toBeNull(); + + expect(state.rightPanel.browser.tabs).toHaveLength(20); + expect(state.rightPanel.browser.activeTabId).toBe(state.rightPanel.browser.tabs[19].id); + }); +}); + +describe('openBrowserUrlState', () => { + it('navigates the active tab and resets its stale title', () => { + const tab = { ...createRightBrowserTab('https://old.example'), title: 'Old page' }; + const browser = { tabs: [tab], activeTabId: tab.id }; + + const next = openBrowserUrlState(browser, 'https://new.example'); + + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]).toMatchObject({ id: tab.id, url: 'https://new.example', title: null }); + }); + + it('seeds a first tab when none is active', () => { + const next = openBrowserUrlState({ tabs: [], activeTabId: null }, 'https://example.com'); + + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0].url).toBe('https://example.com'); + expect(next.activeTabId).toBe(next.tabs[0].id); + }); +}); + +describe('updateBrowserTabState', () => { + it('patches only the targeted tab', () => { + const a = createRightBrowserTab('https://a.example'); + const b = createRightBrowserTab('https://b.example'); + const browser = { tabs: [a, b], activeTabId: a.id }; + + const next = updateBrowserTabState(browser, b.id, { title: 'B' }); + + expect(next.tabs[0].title).toBeNull(); + expect(next.tabs[1].title).toBe('B'); + }); + + it('is a no-op for an unknown tab id', () => { + const a = createRightBrowserTab(null); + const browser = { tabs: [a], activeTabId: a.id }; + + expect(updateBrowserTabState(browser, 'missing', { title: 'X' })).toBe(browser); }); }); diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index 8e5f44297..7a3111c5c 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -24,17 +24,26 @@ const IDLE_NAV: WebviewNavState = { }; /** - * The Browser section's Electron ``, mounted once inside the shell's resident + * One browser tab's Electron ``, mounted once inside the shell's resident * panel-content stack (moving a webview in the DOM reloads it) and shown/hidden via visibility. */ -export function BrowserWebviewPane(): React.ReactNode { +export function BrowserWebviewPane({ + tabId, + url, +}: { + tabId: string; + url: string | null; +}): React.ReactNode { const t = useTranslations('workbench.preview.browser'); - const url = useDesktopShellStore((state) => state.rightPanel.browser.url); - const setBrowserUrl = useDesktopShellStore((state) => state.setBrowserUrl); - // The webview is a permanent resident (unmounting/DOM-moving it reloads), so collapsing the - // panel or switching section only hides it — media would keep playing audio in the background. + const setBrowserTabUrl = useDesktopShellStore((state) => state.setBrowserTabUrl); + const setBrowserTabTitle = useDesktopShellStore((state) => state.setBrowserTabTitle); + // Every tab is a permanent resident (unmounting/DOM-moving it reloads), so inactive tabs and + // a hidden browser section must pause media that would otherwise keep playing out of sight. const visible = useDesktopShellStore( - (state) => state.rightPanel.open && state.rightPanel.activeSection === 'browser', + (state) => + state.rightPanel.open && + state.rightPanel.activeSection === 'browser' && + state.rightPanel.browser.activeTabId === tabId, ); const [webview, setWebview] = useState(null); const [nav, setNav] = useState(IDLE_NAV); @@ -60,10 +69,13 @@ export function BrowserWebviewPane(): React.ReactNode { })); }; const onNavigate = (event: Electron.DidNavigateEvent): void => { - if (!signal.aborted) setBrowserUrl(event.url); + if (!signal.aborted) setBrowserTabUrl(tabId, event.url); setNav((prev) => ({ ...prev, failure: null })); sync(); }; + const onTitleUpdated = (event: Electron.PageTitleUpdatedEvent): void => { + if (!signal.aborted) setBrowserTabTitle(tabId, event.title); + }; const onFail = (event: Electron.DidFailLoadEvent): void => { // -3 = ERR_ABORTED: fired for cancelled loads (e.g. quick re-navigation), not real failures. if (event.errorCode === -3 || !event.isMainFrame || signal.aborted) return; @@ -76,16 +88,18 @@ export function BrowserWebviewPane(): React.ReactNode { webview.addEventListener('did-stop-loading', sync); webview.addEventListener('did-navigate', onNavigate); webview.addEventListener('did-navigate-in-page', onNavigate); + webview.addEventListener('page-title-updated', onTitleUpdated); webview.addEventListener('did-fail-load', onFail); return () => { webview.removeEventListener('did-start-loading', sync); webview.removeEventListener('did-stop-loading', sync); webview.removeEventListener('did-navigate', onNavigate); webview.removeEventListener('did-navigate-in-page', onNavigate); + webview.removeEventListener('page-title-updated', onTitleUpdated); webview.removeEventListener('did-fail-load', onFail); }; }, - [webview, setBrowserUrl, t], + [webview, tabId, setBrowserTabUrl, setBrowserTabTitle, t], ); // Pause any playing media when the pane is hidden (panel collapsed or another section shown), @@ -106,7 +120,7 @@ export function BrowserWebviewPane(): React.ReactNode { canGoBack={nav.canGoBack} canGoForward={nav.canGoForward} failure={nav.failure} - onNavigate={(next) => setBrowserUrl(next)} + onNavigate={(next) => setBrowserTabUrl(tabId, next)} onBack={() => webview?.goBack()} onForward={() => webview?.goForward()} onReload={() => webview?.reload()} diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 9ef26075d..871725109 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -139,6 +139,9 @@ export function DesktopShell({ closeRightFileTab: state.closeRightFileTab, setActiveRightFileTab: state.setActiveRightFileTab, openBrowserUrl: state.openBrowserUrl, + addRightBrowserTab: state.addRightBrowserTab, + closeRightBrowserTab: state.closeRightBrowserTab, + setActiveRightBrowserTab: state.setActiveRightBrowserTab, openRightTerminalAttachTab: state.openRightTerminalAttachTab, resetSidebarSize: state.resetSidebarSize, resetRightPanelSize: state.resetRightPanelSize, @@ -258,6 +261,9 @@ export function DesktopShell({ closeRightFileTab, setActiveRightFileTab, openBrowserUrl, + addRightBrowserTab, + closeRightBrowserTab, + setActiveRightBrowserTab, openRightTerminalAttachTab, resetSidebarSize, resetRightPanelSize, @@ -419,6 +425,9 @@ export function DesktopShell({ onSelectFileTab={setActiveRightFileTab} onCloseFileTab={closeRightFileTab} onOpenFileTab={openRightFileTab} + onSelectBrowserTab={setActiveRightBrowserTab} + onCloseBrowserTab={closeRightBrowserTab} + onAddBrowserTab={addRightBrowserTab} onToggleMax={() => toggleMaxPanel('right')} /> ); @@ -469,13 +478,16 @@ export function DesktopShell({ /> ), })); - // The browser webview lives here permanently: unmounting or DOM-moving a webview - // reloads it, so section switches only toggle its visibility. - items.push({ - id: 'browser-resident', - active: rightPanel.activeSection === 'browser', - node: , - }); + // Browser webviews live here permanently: unmounting or DOM-moving a webview + // reloads it, so section and tab switches only toggle visibility. + const activeIsBrowser = rightPanel.activeSection === 'browser'; + for (const tab of rightPanel.browser.tabs) { + items.push({ + id: tab.id, + active: activeIsBrowser && tab.id === rightPanel.browser.activeTabId, + node: , + }); + } return createPortal(, host); } diff --git a/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx b/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx index 140ad9fad..0ebc9a1f8 100644 --- a/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx +++ b/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx @@ -28,6 +28,9 @@ export function DesktopRightPanelRegion({ onSelectFileTab, onCloseFileTab, onOpenFileTab, + onSelectBrowserTab, + onCloseBrowserTab, + onAddBrowserTab, onToggleMax, }: { panel: RightPanelState; @@ -45,6 +48,9 @@ export function DesktopRightPanelRegion({ onSelectFileTab: (id: string) => void; onCloseFileTab: (id: string) => void; onOpenFileTab: (path: string) => void; + onSelectBrowserTab: (id: string) => void; + onCloseBrowserTab: (id: string) => void; + onAddBrowserTab: () => void; onToggleMax: () => void; }): React.ReactNode { return ( @@ -77,6 +83,9 @@ export function DesktopRightPanelRegion({ onSelectTerminalTab={onSelectTerminalTab} onCloseTerminalTab={onCloseTerminalTab} onAddTerminalTab={onAddTerminalTab} + onSelectBrowserTab={onSelectBrowserTab} + onCloseBrowserTab={onCloseBrowserTab} + onAddBrowserTab={onAddBrowserTab} onToggleMax={onToggleMax} /> ); diff --git a/apps/desktop/src/renderer/src/shell/store/model.ts b/apps/desktop/src/renderer/src/shell/store/model.ts index e28e74b9c..6e116b4c2 100644 --- a/apps/desktop/src/renderer/src/shell/store/model.ts +++ b/apps/desktop/src/renderer/src/shell/store/model.ts @@ -36,13 +36,21 @@ export interface RightPanelFilesState { activeTabId: string | null; } -/** The right panel's single-instance in-app browser (Electron webview). */ -export interface RightPanelBrowserState { +/** One open page in the right panel's browser section (its own Electron webview). */ +export interface BrowserSectionTab extends PanelSectionTab { url: string | null; + /** Last title reported by the page; null falls back to an index-derived label. */ + title: string | null; +} + +/** The right panel's in-app browser: webview sub-tabs, mirroring the terminal section. */ +export interface RightPanelBrowserState { + tabs: BrowserSectionTab[]; + activeTabId: string | null; } /** The right panel: fixed Diff/Terminal/Browser/Files sections, with per-instance - * sub-tabs for Terminal (PTYs) and Files (viewers). */ + * sub-tabs for Terminal (PTYs), Browser (webviews), and Files (viewers). */ export interface RightPanelState { open: boolean; activeSection: PanelSection; @@ -66,7 +74,7 @@ export interface DesktopShellState { } export interface PersistedDesktopShellState { - version: 2; + version: 3; sidebarOpen: boolean; layout: LayoutState; expansionStack: PanelSide[]; @@ -81,7 +89,9 @@ export interface PersistedRightPanelState { activeTerminalTabIndex: number; fileTabPaths: string[]; activeFileTabIndex: number; - browserUrl: string | null; + /** Null entries are empty tabs, kept so activeBrowserTabIndex stays aligned. */ + browserTabUrls: Array; + activeBrowserTabIndex: number; } export interface PersistedPanelState { @@ -90,7 +100,7 @@ export interface PersistedPanelState { activeTabIndex: number; } -export const DESKTOP_SHELL_STORAGE_KEY = 'linkcode.desktop.shell-state:v2'; +export const DESKTOP_SHELL_STORAGE_KEY = 'linkcode.desktop.shell-state:v3'; export const SIDEBAR_MIN_SIZE = 240; export const SIDEBAR_MAX_SIZE = 520; @@ -122,6 +132,9 @@ const MAX_PERSISTED_RIGHT_TERMINAL_TABS = 20; /** Defensive cap on the file tab count restored from persisted state. */ const MAX_PERSISTED_RIGHT_FILE_TABS = 20; +/** Defensive cap on the browser tab count restored from persisted state. */ +const MAX_PERSISTED_RIGHT_BROWSER_TABS = 20; + let tabSequence = 0; const PanelSideSchema = z.enum(['right', 'bottom']); @@ -164,7 +177,7 @@ export function createDefaultRightPanelState(): RightPanelState { activeSection: 'diff', terminal: { tabs: [], activeTabId: null }, files: { tabs: [], activeTabId: null }, - browser: { url: null }, + browser: { tabs: [], activeTabId: null }, }; } @@ -195,18 +208,19 @@ export function seedTerminalSection(terminal: RightPanelTerminalState): RightPan return { tabs: [tab], activeTabId: tab.id }; } -/** Brings `section` forward, seeding the terminal section's first tab when it becomes visible. */ +/** Brings `section` forward, seeding sections that must never become visible without a tab. */ export function revealSectionState( panel: RightPanelState, section: PanelSection, open: boolean, ): RightPanelState { - return { + const revealed = { ...panel, open, activeSection: section, terminal: open && section === 'terminal' ? seedTerminalSection(panel.terminal) : panel.terminal, }; + return open ? seedBrowserSection(revealed) : revealed; } export function createRightFileTab(path: string): FileSectionTab { @@ -214,6 +228,11 @@ export function createRightFileTab(path: string): FileSectionTab { return { id: `right-file-${tabSequence}`, path }; } +export function createRightBrowserTab(url: string | null = null): BrowserSectionTab { + tabSequence += 1; + return { id: `right-browser-${tabSequence}`, url, title: null }; +} + /** Removes a section sub-tab, falling back the active tab to a neighbor if it was the one closed. */ export function closeSectionTabState( section: { tabs: Tab[]; activeTabId: string | null }, @@ -240,6 +259,36 @@ export function openFileTabState(files: RightPanelFilesState, path: string): Rig return { tabs: [...files.tabs, tab], activeTabId: tab.id }; } +/** Navigates the active browser tab (title resets until the page reports one), or seeds a first tab. */ +export function openBrowserUrlState( + browser: RightPanelBrowserState, + url: string, +): RightPanelBrowserState { + const active = browser.tabs.find((tab) => tab.id === browser.activeTabId); + if (active) return updateBrowserTabState(browser, active.id, { url, title: null }); + const tab = createRightBrowserTab(url); + return { tabs: [...browser.tabs, tab], activeTabId: tab.id }; +} + +/** The browser section always activates with at least one (possibly empty) tab. */ +export function seedBrowserSection(panel: RightPanelState): RightPanelState { + if (panel.activeSection !== 'browser' || panel.browser.tabs.length > 0) return panel; + const tab = createRightBrowserTab(); + return { ...panel, browser: { tabs: [tab], activeTabId: tab.id } }; +} + +export function updateBrowserTabState( + browser: RightPanelBrowserState, + tabId: string, + patch: Partial>, +): RightPanelBrowserState { + if (!browser.tabs.some((tab) => tab.id === tabId)) return browser; + return { + ...browser, + tabs: browser.tabs.map((tab) => (tab.id === tabId ? { ...tab, ...patch } : tab)), + }; +} + export function pushExpandedPanel(stack: PanelSide[], side: PanelSide): PanelSide[] { return [...removeExpandedPanel(stack, side), side]; } @@ -301,7 +350,7 @@ export function parsePersistedDesktopShellState(value: unknown): DesktopShellSta export function serializeDesktopShellState(state: DesktopShellState): PersistedDesktopShellState { return { - version: 2, + version: 3, sidebarOpen: state.sidebarOpen, layout: normalizeLayout(state.layout), expansionStack: normalizeExpansionStack( @@ -325,7 +374,7 @@ function createPersistedShellStateSchema(): z.ZodType { return z .object({ - version: z.literal(2), + version: z.literal(3), sidebarOpen: z.boolean().catch(fallback.sidebarOpen), layout: PersistedLayoutSchema, expansionStack: PersistedExpansionStackSchema, @@ -393,7 +442,8 @@ function createPersistedRightPanelSchema(): z.ZodType { // Absent in pre-files persisted payloads; the catches make the section start empty. fileTabPaths: z.array(z.string().min(1)).catch([]), activeFileTabIndex: FiniteNumberSchema.int().catch(0), - browserUrl: z.string().min(1).nullable().catch(null), + browserTabUrls: z.array(z.string().min(1).nullable()).catch([]), + activeBrowserTabIndex: FiniteNumberSchema.int().catch(0), }) .catch({ open: fallback.open, @@ -402,7 +452,8 @@ function createPersistedRightPanelSchema(): z.ZodType { activeTerminalTabIndex: 0, fileTabPaths: [], activeFileTabIndex: 0, - browserUrl: null, + browserTabUrls: [], + activeBrowserTabIndex: 0, }) .transform( ({ @@ -412,7 +463,8 @@ function createPersistedRightPanelSchema(): z.ZodType { activeTerminalTabIndex, fileTabPaths, activeFileTabIndex, - browserUrl, + browserTabUrls, + activeBrowserTabIndex, }) => { const tabCount = clamp(terminalTabCount, 0, MAX_PERSISTED_RIGHT_TERMINAL_TABS); const tabs = createFixedArray(tabCount).map(() => createRightTerminalTab()); @@ -426,6 +478,11 @@ function createPersistedRightPanelSchema(): z.ZodType { tabs, activeTabId: tabs.length > 0 ? tabs[activeIndex].id : null, }; + const browserTabs = browserTabUrls + .slice(0, MAX_PERSISTED_RIGHT_BROWSER_TABS) + .map((url) => createRightBrowserTab(durableBrowserUrl(url))); + const activeBrowserIndex = + browserTabs.length > 0 ? clamp(activeBrowserTabIndex, 0, browserTabs.length - 1) : 0; return { open, @@ -435,7 +492,10 @@ function createPersistedRightPanelSchema(): z.ZodType { tabs: fileTabs, activeTabId: fileTabs.length > 0 ? fileTabs[activeFileIndex].id : null, }, - browser: { url: durableBrowserUrl(browserUrl) }, + browser: { + tabs: browserTabs, + activeTabId: browserTabs.length > 0 ? browserTabs[activeBrowserIndex].id : null, + }, }; }, ); @@ -457,7 +517,12 @@ function serializeRightPanel(panel: RightPanelState): PersistedRightPanelState { 0, Math.max(0, panel.files.tabs.length - 1), ), - browserUrl: durableBrowserUrl(panel.browser.url), + browserTabUrls: panel.browser.tabs.map((tab) => durableBrowserUrl(tab.url)), + activeBrowserTabIndex: clamp( + panel.browser.tabs.findIndex((tab) => tab.id === panel.browser.activeTabId), + 0, + Math.max(0, panel.browser.tabs.length - 1), + ), }; } diff --git a/apps/desktop/src/renderer/src/shell/store/store.ts b/apps/desktop/src/renderer/src/shell/store/store.ts index c619c62a6..6422d134d 100644 --- a/apps/desktop/src/renderer/src/shell/store/store.ts +++ b/apps/desktop/src/renderer/src/shell/store/store.ts @@ -12,19 +12,21 @@ import type { import { closeSectionTabState, createDefaultDesktopShellState, + createRightBrowserTab, createRightTerminalTab, createTab, DEFAULT_LAYOUT, DESKTOP_SHELL_STORAGE_KEY, getExpandedPanel, normalizeLayout, + openBrowserUrlState, openFileTabState, PersistedDesktopShellStateSchema, pushExpandedPanel, removeExpandedPanel, revealSectionState, - seedTerminalSection, serializeDesktopShellState, + updateBrowserTabState, } from './model'; /** The bottom panel's window type when it needs to seed a first tab. */ @@ -50,10 +52,15 @@ interface DesktopShellActions { openRightFileTab: (path: string) => void; closeRightFileTab: (id: string) => void; setActiveRightFileTab: (id: string) => void; - /** Navigate the in-app browser and bring the browser section forward. */ + /** Navigate the active browser tab (or open a first one) and bring the browser section forward. */ openBrowserUrl: (url: string) => void; - /** Track a navigation that happened inside the webview (keeps the address bar honest). */ - setBrowserUrl: (url: string | null) => void; + /** Open a new browser tab (empty unless a URL is given) and make it active. */ + addRightBrowserTab: (url?: string) => void; + closeRightBrowserTab: (id: string) => void; + setActiveRightBrowserTab: (id: string) => void; + /** Track a navigation that happened inside a tab's webview (keeps the address bar honest). */ + setBrowserTabUrl: (tabId: string, url: string | null) => void; + setBrowserTabTitle: (tabId: string, title: string | null) => void; /** Attach a viewer tab for a terminal that already exists on the daemon (script logs). */ openRightTerminalAttachTab: (terminalId: string) => void; resetSidebarSize: () => void; @@ -104,14 +111,11 @@ export const useDesktopShellStore = create()( expansionStack: open ? current.expansionStack : removeExpandedPanel(current.expansionStack, side), - rightPanel: { - ...current.rightPanel, + rightPanel: revealSectionState( + current.rightPanel, + current.rightPanel.activeSection, open, - terminal: - open && current.rightPanel.activeSection === 'terminal' - ? seedTerminalSection(current.rightPanel.terminal) - : current.rightPanel.terminal, - }, + ), }; } @@ -294,15 +298,62 @@ export const useDesktopShellStore = create()( ...current.rightPanel, open: true, activeSection: 'browser', - browser: { url }, + browser: openBrowserUrlState(current.rightPanel.browser, url), }, })); }, - setBrowserUrl(url) { + addRightBrowserTab(url) { + const tab = createRightBrowserTab(url ?? null); updateShellState((current) => ({ ...current, - rightPanel: { ...current.rightPanel, browser: { url } }, + rightPanel: { + ...current.rightPanel, + browser: { + tabs: [...current.rightPanel.browser.tabs, tab], + activeTabId: tab.id, + }, + }, + })); + }, + + closeRightBrowserTab(id) { + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: closeSectionTabState(current.rightPanel.browser, id), + }, + })); + }, + + setActiveRightBrowserTab(id) { + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: { ...current.rightPanel.browser, activeTabId: id }, + }, + })); + }, + + setBrowserTabUrl(tabId, url) { + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: updateBrowserTabState(current.rightPanel.browser, tabId, { url }), + }, + })); + }, + + setBrowserTabTitle(tabId, title) { + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: updateBrowserTabState(current.rightPanel.browser, tabId, { title }), + }, })); }, @@ -340,7 +391,7 @@ export const useDesktopShellStore = create()( }, { name: DESKTOP_SHELL_STORAGE_KEY, - version: 2, + version: 3, schema: PersistedDesktopShellStateSchema, partialize: serializeDesktopShellState, }, diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index ab01c0985..308cab6df 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -338,6 +338,7 @@ export const en = { }, openWindow: 'Open window', newTerminalTab: 'New terminal', + newBrowserTab: 'New tab', closeTab: 'Close {label}', fullscreen: 'Fullscreen', restore: 'Restore', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 65037e34d..1db353a50 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -328,6 +328,7 @@ export const zhCN = { }, openWindow: '打开窗口', newTerminalTab: '新建终端', + newBrowserTab: '新建标签页', closeTab: '关闭 {label}', fullscreen: '全屏', restore: '还原', diff --git a/packages/presentation/ui/src/shell/panels/index.ts b/packages/presentation/ui/src/shell/panels/index.ts index 62f6af0e9..ae679a46e 100644 --- a/packages/presentation/ui/src/shell/panels/index.ts +++ b/packages/presentation/ui/src/shell/panels/index.ts @@ -13,6 +13,7 @@ export { PanelRegion } from './panel-region'; export type { SectionPanelRegionProps, SectionPanelState } from './section-panel'; export { SectionPanelRegion } from './section-panel'; export type { + BrowserPanelSectionTab, PanelControl, PanelSection, PanelSectionTab, diff --git a/packages/presentation/ui/src/shell/panels/section-browser-tabs.tsx b/packages/presentation/ui/src/shell/panels/section-browser-tabs.tsx new file mode 100644 index 000000000..0b71f40cb --- /dev/null +++ b/packages/presentation/ui/src/shell/panels/section-browser-tabs.tsx @@ -0,0 +1,58 @@ +import { PlusIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { cn } from '../../lib/cn'; +import { ShellIconButton } from '../shell-control'; +import { SectionTabButton } from './section-tab-button'; +import type { BrowserPanelSectionTab } from './vocabulary'; +import { PANEL_WINDOW_ICONS } from './vocabulary'; + +/** The browser section's own sub-tab strip, one tab per webview instance. */ +export function SectionBrowserTabStrip({ + tabs, + activeTabId, + onSelectTab, + onCloseTab, + onAddTab, + className, +}: { + tabs: BrowserPanelSectionTab[]; + activeTabId: string | null; + onSelectTab: (id: string) => void; + onCloseTab: (id: string) => void; + onAddTab: () => void; + className?: string; +}): React.ReactNode { + const t = useTranslations('workbench.panel'); + const browserLabel = useTranslations('workbench.panel.window')('browser'); + + return ( +
+
+ {tabs.map((tab, index) => { + const fallback = `${browserLabel} ${index + 1}`; + const label = tab.title !== null && tab.title.length > 0 ? tab.title : fallback; + return ( + onSelectTab(tab.id)} + onClose={() => onCloseTab(tab.id)} + /> + ); + })} + + + +
+
+ ); +} diff --git a/packages/presentation/ui/src/shell/panels/section-panel.tsx b/packages/presentation/ui/src/shell/panels/section-panel.tsx index 19356a9a5..f42964ca1 100644 --- a/packages/presentation/ui/src/shell/panels/section-panel.tsx +++ b/packages/presentation/ui/src/shell/panels/section-panel.tsx @@ -3,8 +3,9 @@ import { cn } from '../../lib/cn'; import type { ChromeSurface, PanelChromePortalProps } from './chrome-portal'; import { getPanelChromePlacement, PanelContextualChromePortal } from './chrome-portal'; import { PanelContextualControls } from './panel-controls'; +import { SectionBrowserTabStrip } from './section-browser-tabs'; import { SectionTerminalTabStrip } from './section-terminal-tabs'; -import type { PanelSection, PanelSectionTab } from './vocabulary'; +import type { BrowserPanelSectionTab, PanelSection, PanelSectionTab } from './vocabulary'; import { PANEL_SECTIONS, PANEL_TAB_ACTIVE_CLASSNAME, @@ -19,6 +20,10 @@ export interface SectionPanelState { tabs: PanelSectionTab[]; activeTabId: string | null; }; + browser: { + tabs: BrowserPanelSectionTab[]; + activeTabId: string | null; + }; } export interface SectionPanelRegionProps { @@ -41,6 +46,9 @@ export interface SectionPanelRegionProps { onSelectTerminalTab: (id: string) => void; onCloseTerminalTab: (id: string) => void; onAddTerminalTab: () => void; + onSelectBrowserTab: (id: string) => void; + onCloseBrowserTab: (id: string) => void; + onAddBrowserTab: () => void; onToggleMax: () => void; } @@ -62,6 +70,9 @@ export function SectionPanelRegion({ onSelectTerminalTab, onCloseTerminalTab, onAddTerminalTab, + onSelectBrowserTab, + onCloseBrowserTab, + onAddBrowserTab, onToggleMax, }: SectionPanelRegionProps): React.ReactNode { const chromePlacement = getPanelChromePlacement('right', chromeSurface); @@ -78,6 +89,15 @@ export function SectionPanelRegion({ onAddTab={onAddTerminalTab} /> )} + {panel.activeSection === 'browser' && ( + + )}
{/* Sections without inline content (e.g. Browser, whose webview lives in the shell's diff --git a/packages/presentation/ui/src/shell/panels/vocabulary.tsx b/packages/presentation/ui/src/shell/panels/vocabulary.tsx index d20c79a9e..86f20619c 100644 --- a/packages/presentation/ui/src/shell/panels/vocabulary.tsx +++ b/packages/presentation/ui/src/shell/panels/vocabulary.tsx @@ -21,6 +21,11 @@ export interface PanelSectionTab { id: string; } +/** One browser sub-tab; a null title falls back to an index-derived label. */ +export interface BrowserPanelSectionTab extends PanelSectionTab { + title: string | null; +} + export interface PanelControl { id: string; label: string; From 068bf13b53778f2c706f7581fa4e54f01b509235 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:09:43 +0800 Subject: [PATCH 2/5] feat(desktop,ipc): route Browser-pane guest popups into a new in-app tab (CODE-266) --- apps/desktop/src/main/window.ts | 24 ++++++++++++------- .../src/renderer/src/shell/desktop-shell.tsx | 6 +++++ .../src/renderer/src/shell/store/store.ts | 18 ++++++++++++++ packages/system-plane/ipc/src/bridge.ts | 5 ++++ .../system-plane/ipc/src/electron-renderer.ts | 10 ++++++++ packages/system-plane/ipc/src/events.ts | 2 ++ 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 447601527..6e3962ca4 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -1,7 +1,11 @@ import { release } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { DAEMON_RUNTIME_CHANGED_CHANNEL, UPDATER_STATUS_CHANNEL } from '@linkcode/ipc'; +import { + BROWSER_OPEN_TAB_CHANNEL, + DAEMON_RUNTIME_CHANGED_CHANNEL, + UPDATER_STATUS_CHANNEL, +} from '@linkcode/ipc'; import { bindElectronSystemIpc } from '@linkcode/ipc/electron-main'; import { BrowserWindow, ipcMain, nativeTheme, shell } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; @@ -74,15 +78,7 @@ function createWindow(): BrowserWindow { }, }); - // window.open / target=_blank means "the system browser" everywhere in the app - // (chat links, preview open-external); nothing may spawn a child Electron window. - win.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://') || url.startsWith('https://')) void shell.openExternal(url); - return { action: 'deny' }; - }); - persistWindowStateOnClose(win); - const updateBackgroundColor = (): void => { win.setBackgroundColor(desktopBackgroundColor()); }; @@ -118,6 +114,16 @@ function createWindow(): BrowserWindow { if (isHttpUrl(url)) void shell.openExternal(url); }); + // Browser-pane guests run with `allowpopups`, and popups would otherwise spawn unmanaged + // BrowserWindows: keep untrusted content window-less by rerouting http(s) popups into a new + // in-app browser tab (renderer push) and denying everything else. + win.webContents.on('did-attach-webview', (_event, guest) => { + guest.setWindowOpenHandler(({ url }) => { + if (isHttpUrl(url) && !win.isDestroyed()) win.webContents.send(BROWSER_OPEN_TAB_CHANNEL, url); + return { action: 'deny' }; + }); + }); + void loadRenderer(win); return win; } diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 871725109..f72b78ead 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -238,6 +238,12 @@ export function DesktopShell({ [systemBridge], ); + const openBrowserTab = useDesktopShellStore((state) => state.openBrowserTab); + useAbortableEffect( + () => systemBridge.browser.onOpenTab(openBrowserTab), + [systemBridge, openBrowserTab], + ); + const active = activeSession; const titledSession = active?.title === undefined ? null : active; const hideMainTitle = draft !== null || (active === null ? false : titledSession === null); diff --git a/apps/desktop/src/renderer/src/shell/store/store.ts b/apps/desktop/src/renderer/src/shell/store/store.ts index 6422d134d..fb79d5d8f 100644 --- a/apps/desktop/src/renderer/src/shell/store/store.ts +++ b/apps/desktop/src/renderer/src/shell/store/store.ts @@ -54,6 +54,8 @@ interface DesktopShellActions { setActiveRightFileTab: (id: string) => void; /** Navigate the active browser tab (or open a first one) and bring the browser section forward. */ openBrowserUrl: (url: string) => void; + /** Open `url` in a NEW active browser tab and bring the section forward (guest popups). */ + openBrowserTab: (url: string) => void; /** Open a new browser tab (empty unless a URL is given) and make it active. */ addRightBrowserTab: (url?: string) => void; closeRightBrowserTab: (id: string) => void; @@ -303,6 +305,22 @@ export const useDesktopShellStore = create()( })); }, + openBrowserTab(url) { + const tab = createRightBrowserTab(url); + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + open: true, + activeSection: 'browser', + browser: { + tabs: [...current.rightPanel.browser.tabs, tab], + activeTabId: tab.id, + }, + }, + })); + }, + addRightBrowserTab(url) { const tab = createRightBrowserTab(url ?? null); updateShellState((current) => ({ diff --git a/packages/system-plane/ipc/src/bridge.ts b/packages/system-plane/ipc/src/bridge.ts index d6f8f1bcc..684d66ae3 100644 --- a/packages/system-plane/ipc/src/bridge.ts +++ b/packages/system-plane/ipc/src/bridge.ts @@ -60,4 +60,9 @@ export interface SystemBridge { /** Subscribe to notification clicks; main focuses the window, then pushes the `clickToken`. */ onClick(cb: (clickToken: string) => void): () => void; }; + browser: { + /** Subscribe to Browser-pane guest popups (window.open / target=_blank) redirected by main; + * the renderer opens the URL in a new in-app browser tab. */ + onOpenTab(cb: (url: string) => void): () => void; + }; } diff --git a/packages/system-plane/ipc/src/electron-renderer.ts b/packages/system-plane/ipc/src/electron-renderer.ts index e32e22c20..4e20ba70e 100644 --- a/packages/system-plane/ipc/src/electron-renderer.ts +++ b/packages/system-plane/ipc/src/electron-renderer.ts @@ -5,6 +5,7 @@ import type { IpcRenderer } from 'electron'; import type { SystemBridge } from './bridge'; import type { DesktopSettings, UpdaterStatus } from './context'; import { + BROWSER_OPEN_TAB_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, DAEMON_URL_SNAPSHOT_CHANNEL, NOTIFICATION_CLICKED_CHANNEL, @@ -98,6 +99,15 @@ export function createElectronSystemBridge( return () => ipcRenderer.removeListener(NOTIFICATION_CLICKED_CHANNEL, handler); }, }, + browser: { + onOpenTab(cb) { + const handler: IpcRendererListener = (_event, value: unknown) => { + if (typeof value === 'string') cb(value); + }; + ipcRenderer.on(BROWSER_OPEN_TAB_CHANNEL, handler); + return () => ipcRenderer.removeListener(BROWSER_OPEN_TAB_CHANNEL, handler); + }, + }, }; } diff --git a/packages/system-plane/ipc/src/events.ts b/packages/system-plane/ipc/src/events.ts index c688e9b8c..f9771e621 100644 --- a/packages/system-plane/ipc/src/events.ts +++ b/packages/system-plane/ipc/src/events.ts @@ -19,6 +19,8 @@ export const UPDATER_STATUS_CHANNEL = 'linkcode.system.app.updaterStatus'; export const DAEMON_RUNTIME_CHANGED_CHANNEL = 'linkcode.system.daemon.runtimeChanged'; /** Main → renderer push: an OS notification was clicked; payload is its `clickToken`. */ export const NOTIFICATION_CLICKED_CHANNEL = 'linkcode.system.notifications.clicked'; +/** Main → renderer push: a Browser-pane guest page opened a popup; payload is its http(s) URL. */ +export const BROWSER_OPEN_TAB_CHANNEL = 'linkcode.system.browser.openTab'; export const systemIpcEvents = { windowMinimize: defineInvokeEventa('linkcode.system.window.minimize'), From 38faddb2b8afc61c856bde9d45e0fd680e346869 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:21:36 +0800 Subject: [PATCH 3/5] feat(desktop,ui,ipc): browser find-in-page, zoom, devtools menu, download toast (CODE-266) --- apps/desktop/src/main/window.ts | 20 ++- .../shell/browser/browser-webview-pane.tsx | 144 +++++++++++++++--- .../src/renderer/src/shell/desktop-shell.tsx | 15 ++ packages/presentation/i18n/src/locales/en.ts | 13 ++ .../presentation/i18n/src/locales/zh-cn.ts | 13 ++ .../ui/src/shell/browser/browser-pane.tsx | 111 +++++++++++++- .../ui/src/shell/browser/index.ts | 2 +- packages/system-plane/ipc/src/bridge.ts | 3 + packages/system-plane/ipc/src/context.ts | 6 + .../system-plane/ipc/src/electron-renderer.ts | 18 ++- packages/system-plane/ipc/src/events.ts | 2 + 11 files changed, 322 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 6e3962ca4..e7c2ea700 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -1,13 +1,15 @@ import { release } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import type { BrowserDownloadDone } from '@linkcode/ipc'; import { + BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, UPDATER_STATUS_CHANNEL, } from '@linkcode/ipc'; import { bindElectronSystemIpc } from '@linkcode/ipc/electron-main'; -import { BrowserWindow, ipcMain, nativeTheme, shell } from 'electron'; +import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; // electron-vite resolves `?asset` to a runtime file path. Used as the Win/Linux window icon in dev // (packaged builds get the real icon from the bundle). macOS uses a separate Dock image set at bootstrap. @@ -25,6 +27,9 @@ import { readWindowState, } from './window-state'; +/** Must match the renderer's Browser-pane ``. */ +const BROWSER_PARTITION = 'persist:linkcode-browser'; + export function createDesktopWindow(): BrowserWindow { const win = createWindow(); const ctx = systemContextFor(win); @@ -45,6 +50,19 @@ export function createDesktopWindow(): BrowserWindow { }); win.once('closed', unwatchRuntime); + // Browser-pane downloads keep Electron's default save flow; the renderer only gets a + // terminal-state push for its toast. + const browserSession = session.fromPartition(BROWSER_PARTITION); + const onWillDownload = (_event: Electron.Event, item: Electron.DownloadItem): void => { + item.once('done', (_doneEvent, state) => { + if (win.isDestroyed()) return; + const result: BrowserDownloadDone = { filename: item.getFilename(), state }; + win.webContents.send(BROWSER_DOWNLOAD_DONE_CHANNEL, result); + }); + }; + browserSession.on('will-download', onWillDownload); + win.once('closed', () => browserSession.removeListener('will-download', onWillDownload)); + return win; } diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index 7a3111c5c..5498635e5 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -1,8 +1,10 @@ +import { isKeyboardShortcutLocalTarget, useKeyboardShortcut } from '@linkcode/ui'; +import type { BrowserFindState } from '@linkcode/ui/shell/browser'; import { BrowserPane } from '@linkcode/ui/shell/browser'; import type { WebviewTag } from 'electron'; import { useEffect as useAbortableEffect } from 'foxact/use-abortable-effect'; import { noop } from 'foxts/noop'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; import { useDesktopShellStore } from '../store/store'; @@ -23,6 +25,14 @@ const IDLE_NAV: WebviewNavState = { failure: null, }; +function whenNotLocal(event: KeyboardEvent): boolean { + return !isKeyboardShortcutLocalTarget(event.target); +} + +/** Chromium's supported zoom-level range (each level is a 1.2× factor step). */ +const MIN_ZOOM_LEVEL = -8; +const MAX_ZOOM_LEVEL = 9; + /** * One browser tab's Electron ``, mounted once inside the shell's resident * panel-content stack (moving a webview in the DOM reloads it) and shown/hidden via visibility. @@ -45,8 +55,10 @@ export function BrowserWebviewPane({ state.rightPanel.activeSection === 'browser' && state.rightPanel.browser.activeTabId === tabId, ); + const rootRef = useRef(null); const [webview, setWebview] = useState(null); const [nav, setNav] = useState(IDLE_NAV); + const [find, setFind] = useState(null); // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. const captureWebview = (element: HTMLWebViewElement | null): void => { @@ -84,12 +96,24 @@ export function BrowserWebviewPane({ failure: t('loadFailed', { error: event.errorDescription }), })); }; + const onFoundInPage = (event: Electron.FoundInPageEvent): void => { + if (signal.aborted) return; + setFind((prev) => + prev === null + ? prev + : { + ...prev, + matches: { active: event.result.activeMatchOrdinal, total: event.result.matches }, + }, + ); + }; webview.addEventListener('did-start-loading', sync); webview.addEventListener('did-stop-loading', sync); webview.addEventListener('did-navigate', onNavigate); webview.addEventListener('did-navigate-in-page', onNavigate); webview.addEventListener('page-title-updated', onTitleUpdated); webview.addEventListener('did-fail-load', onFail); + webview.addEventListener('found-in-page', onFoundInPage); return () => { webview.removeEventListener('did-start-loading', sync); webview.removeEventListener('did-stop-loading', sync); @@ -97,6 +121,7 @@ export function BrowserWebviewPane({ webview.removeEventListener('did-navigate-in-page', onNavigate); webview.removeEventListener('page-title-updated', onTitleUpdated); webview.removeEventListener('did-fail-load', onFail); + webview.removeEventListener('found-in-page', onFoundInPage); }; }, [webview, tabId, setBrowserTabUrl, setBrowserTabTitle, t], @@ -113,26 +138,103 @@ export function BrowserWebviewPane({ .catch(noop); }, [webview, visible]); + const openFind = (): void => { + setFind((prev) => prev ?? { query: '', matches: null }); + }; + const closeFind = (): void => { + webview?.stopFindInPage('clearSelection'); + setFind(null); + }; + const changeFindQuery = (query: string): void => { + setFind({ query, matches: null }); + if (query.length > 0) webview?.findInPage(query); + else webview?.stopFindInPage('clearSelection'); + }; + const stepFind = (forward: boolean): void => { + if (find !== null && find.query.length > 0) { + webview?.findInPage(find.query, { forward, findNext: true }); + } + }; + const zoom = (action: 'in' | 'out' | 'reset'): void => { + if (webview === null) return; + const level = webview.getZoomLevel(); + if (action === 'in') webview.setZoomLevel(Math.min(level + 1, MAX_ZOOM_LEVEL)); + else if (action === 'out') webview.setZoomLevel(Math.max(level - 1, MIN_ZOOM_LEVEL)); + else webview.setZoomLevel(0); + }; + + // Owner-scoped chords: the registry only fires these while this tab's pane is the + // visible (non-inert) item of the resident stack. + useKeyboardShortcut({ + actionId: 'browser.find', + shortcut: { code: 'KeyF', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + openFind(); + return true; + }, + }); + useKeyboardShortcut({ + actionId: 'browser.zoom-in', + shortcut: { code: 'Equal', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + zoom('in'); + return true; + }, + }); + useKeyboardShortcut({ + actionId: 'browser.zoom-out', + shortcut: { code: 'Minus', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + zoom('out'); + return true; + }, + }); + useKeyboardShortcut({ + actionId: 'browser.zoom-reset', + shortcut: { code: 'Digit0', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + zoom('reset'); + return true; + }, + }); + return ( - setBrowserTabUrl(tabId, next)} - onBack={() => webview?.goBack()} - onForward={() => webview?.goForward()} - onReload={() => webview?.reload()} - > - {url !== null && ( - - )} - +
+ setBrowserTabUrl(tabId, next)} + onBack={() => webview?.goBack()} + onForward={() => webview?.goForward()} + onReload={() => webview?.reload()} + onFindQueryChange={changeFindQuery} + onFindStep={stepFind} + onFindClose={closeFind} + onOpenFind={openFind} + onZoom={zoom} + onOpenDevTools={() => webview?.openDevTools()} + > + {url !== null && ( + + )} + +
); } diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index f72b78ead..6c01efbe4 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -25,6 +25,7 @@ import { useSelectedHostStore, WorkspaceServicesMenu, } from '@linkcode/workbench'; +import { toastManager } from 'coss-ui/components/toast'; import { useEffect as useAbortableEffect } from 'foxact/use-abortable-effect'; import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; import { useSingleton } from 'foxact/use-singleton'; @@ -244,6 +245,20 @@ export function DesktopShell({ [systemBridge, openBrowserTab], ); + const tBrowser = useTranslations('workbench.preview.browser'); + useAbortableEffect( + () => + systemBridge.browser.onDownloadDone(({ filename, state }) => { + // 'cancelled' is the user dismissing the save dialog — nothing to report. + if (state === 'completed') { + toastManager.add({ title: tBrowser('downloadCompleted', { filename }) }); + } else if (state === 'interrupted') { + toastManager.add({ title: tBrowser('downloadFailed', { filename }), type: 'error' }); + } + }), + [systemBridge, tBrowser], + ); + const active = activeSession; const titledSession = active?.title === undefined ? null : active; const hideMainTitle = draft !== null || (active === null ? false : titledSession === null); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 308cab6df..a31710428 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -533,6 +533,19 @@ export const en = { placeholder: 'Enter an address, or open a preview from Services', empty: 'Open a preview from Services, or type an address', loadFailed: 'Failed to load the page ({error})', + pageMenu: 'Page menu', + findAction: 'Find in page', + findPlaceholder: 'Find…', + findMatches: '{active}/{total}', + findPrevious: 'Previous', + findNext: 'Next', + findClose: 'Close find', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + resetZoom: 'Reset zoom', + openDevTools: 'Open DevTools', + downloadCompleted: 'Downloaded {filename}', + downloadFailed: 'Download failed: {filename}', }, }, agentKind: { diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 1db353a50..095bc4aa1 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -522,6 +522,19 @@ export const zhCN = { placeholder: '输入地址,或从服务列表打开预览', empty: '从服务列表打开预览,或在地址栏输入网址', loadFailed: '页面加载失败({error})', + pageMenu: '页面菜单', + findAction: '在页面中查找', + findPlaceholder: '查找…', + findMatches: '{active}/{total}', + findPrevious: '上一个', + findNext: '下一个', + findClose: '关闭查找', + zoomIn: '放大', + zoomOut: '缩小', + resetZoom: '重置缩放', + openDevTools: '打开开发者工具', + downloadCompleted: '已下载 {filename}', + downloadFailed: '下载失败:{filename}', }, }, agentKind: { diff --git a/packages/presentation/ui/src/shell/browser/browser-pane.tsx b/packages/presentation/ui/src/shell/browser/browser-pane.tsx index 233011b5b..dc4d675f4 100644 --- a/packages/presentation/ui/src/shell/browser/browser-pane.tsx +++ b/packages/presentation/ui/src/shell/browser/browser-pane.tsx @@ -1,9 +1,27 @@ -import { ArrowLeftIcon, ArrowRightIcon, GlobeIcon, RotateCwIcon } from 'lucide-react'; +import { Button } from 'coss-ui/components/button'; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from 'coss-ui/components/menu'; +import { + ArrowLeftIcon, + ArrowRightIcon, + ChevronDownIcon, + ChevronUpIcon, + EllipsisVerticalIcon, + GlobeIcon, + RotateCwIcon, + SearchIcon, + XIcon, +} from 'lucide-react'; import { useTranslations } from 'use-intl'; import { WebPreviewNavigationButton, WebPreviewUrl } from '../../chat/web-preview'; import { cn } from '../../lib/cn'; import { isAllowedBrowserUrl, normalizeBrowserUrl } from './normalize'; +export interface BrowserFindState { + query: string; + /** 1-based active ordinal / total matches; null before the first result arrives. */ + matches: { active: number; total: number } | null; +} + export interface BrowserPaneProps { /** Current address; null renders the empty state. */ url: string | null; @@ -16,6 +34,15 @@ export interface BrowserPaneProps { onBack: () => void; onForward: () => void; onReload: () => void; + /** Presence renders the find-in-page bar (the host owns open/close state). */ + find?: BrowserFindState | null; + onFindQueryChange?: (query: string) => void; + onFindStep?: (forward: boolean) => void; + onFindClose?: () => void; + /** Presence renders the overflow page menu (find / zoom / devtools). */ + onOpenFind?: () => void; + onZoom?: (action: 'in' | 'out' | 'reset') => void; + onOpenDevTools?: () => void; /** The host's actual browsing surface (desktop: the Electron webview). */ children?: React.ReactNode; className?: string; @@ -32,6 +59,13 @@ export function BrowserPane({ onBack, onForward, onReload, + find, + onFindQueryChange, + onFindStep, + onFindClose, + onOpenFind, + onZoom, + onOpenDevTools, children, className, }: BrowserPaneProps): React.ReactNode { @@ -72,7 +106,82 @@ export function BrowserPane({ placeholder={t('placeholder')} onCommit={commit} /> + {onZoom !== undefined && ( + + + + + } + /> + + + {t('findAction')} + + + onZoom('in')}> + {t('zoomIn')} + + onZoom('out')}> + {t('zoomOut')} + + onZoom('reset')}> + {t('resetZoom')} + + + + {t('openDevTools')} + + + + )}
+ {find != null && ( +
+ + onFindQueryChange?.(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onFindStep?.(!event.shiftKey); + else if (event.key === 'Escape') onFindClose?.(); + }} + /> + {find.matches !== null && ( + + {t('findMatches', { active: find.matches.active, total: find.matches.total })} + + )} + onFindStep?.(false)} + > + + + onFindStep?.(true)} + > + + + onFindClose?.()}> + + +
+ )}
{children} {url === null && ( diff --git a/packages/presentation/ui/src/shell/browser/index.ts b/packages/presentation/ui/src/shell/browser/index.ts index 18f8f7a1c..21d98d00d 100644 --- a/packages/presentation/ui/src/shell/browser/index.ts +++ b/packages/presentation/ui/src/shell/browser/index.ts @@ -1,3 +1,3 @@ -export type { BrowserPaneProps } from './browser-pane'; +export type { BrowserFindState, BrowserPaneProps } from './browser-pane'; export { BrowserPane } from './browser-pane'; export { isAllowedBrowserUrl, normalizeBrowserUrl } from './normalize'; diff --git a/packages/system-plane/ipc/src/bridge.ts b/packages/system-plane/ipc/src/bridge.ts index 684d66ae3..7282f5e1e 100644 --- a/packages/system-plane/ipc/src/bridge.ts +++ b/packages/system-plane/ipc/src/bridge.ts @@ -1,4 +1,5 @@ import type { + BrowserDownloadDone, DesktopSettings, DesktopSettingsPatch, PickFileOptions, @@ -64,5 +65,7 @@ export interface SystemBridge { /** Subscribe to Browser-pane guest popups (window.open / target=_blank) redirected by main; * the renderer opens the URL in a new in-app browser tab. */ onOpenTab(cb: (url: string) => void): () => void; + /** Subscribe to finished Browser-pane downloads (main default download flow). */ + onDownloadDone(cb: (result: BrowserDownloadDone) => void): () => void; }; } diff --git a/packages/system-plane/ipc/src/context.ts b/packages/system-plane/ipc/src/context.ts index 8e8983de4..040bb81de 100644 --- a/packages/system-plane/ipc/src/context.ts +++ b/packages/system-plane/ipc/src/context.ts @@ -104,3 +104,9 @@ export const UpdaterStatusSchema = z.enum([ 'error', ]); export type UpdaterStatus = z.infer; + +/** Terminal state of a Browser-pane download, pushed main → renderer for a toast. */ +export interface BrowserDownloadDone { + filename: string; + state: 'completed' | 'cancelled' | 'interrupted'; +} diff --git a/packages/system-plane/ipc/src/electron-renderer.ts b/packages/system-plane/ipc/src/electron-renderer.ts index 4e20ba70e..c5a922386 100644 --- a/packages/system-plane/ipc/src/electron-renderer.ts +++ b/packages/system-plane/ipc/src/electron-renderer.ts @@ -3,8 +3,9 @@ import { defineInvokes } from '@moeru/eventa'; import { createContext as createRendererContext } from '@moeru/eventa/adapters/electron/renderer'; import type { IpcRenderer } from 'electron'; import type { SystemBridge } from './bridge'; -import type { DesktopSettings, UpdaterStatus } from './context'; +import type { BrowserDownloadDone, DesktopSettings, UpdaterStatus } from './context'; import { + BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, DAEMON_URL_SNAPSHOT_CHANNEL, @@ -107,6 +108,21 @@ export function createElectronSystemBridge( ipcRenderer.on(BROWSER_OPEN_TAB_CHANNEL, handler); return () => ipcRenderer.removeListener(BROWSER_OPEN_TAB_CHANNEL, handler); }, + onDownloadDone(cb) { + const handler: IpcRendererListener = (_event, value: unknown) => { + // Main constructs the payload; a shape check is enough in the zod-free preload. + if ( + typeof value === 'object' && + value !== null && + 'filename' in value && + 'state' in value + ) { + cb(value as BrowserDownloadDone); + } + }; + ipcRenderer.on(BROWSER_DOWNLOAD_DONE_CHANNEL, handler); + return () => ipcRenderer.removeListener(BROWSER_DOWNLOAD_DONE_CHANNEL, handler); + }, }, }; } diff --git a/packages/system-plane/ipc/src/events.ts b/packages/system-plane/ipc/src/events.ts index f9771e621..0f025566b 100644 --- a/packages/system-plane/ipc/src/events.ts +++ b/packages/system-plane/ipc/src/events.ts @@ -21,6 +21,8 @@ export const DAEMON_RUNTIME_CHANGED_CHANNEL = 'linkcode.system.daemon.runtimeCha export const NOTIFICATION_CLICKED_CHANNEL = 'linkcode.system.notifications.clicked'; /** Main → renderer push: a Browser-pane guest page opened a popup; payload is its http(s) URL. */ export const BROWSER_OPEN_TAB_CHANNEL = 'linkcode.system.browser.openTab'; +/** Main → renderer push: a Browser-pane download finished (any terminal state). */ +export const BROWSER_DOWNLOAD_DONE_CHANNEL = 'linkcode.system.browser.downloadDone'; export const systemIpcEvents = { windowMinimize: defineInvokeEventa('linkcode.system.window.minimize'), From 22cc72cd7467168776bdfe8ac0278b8b357388f1 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:45:00 +0800 Subject: [PATCH 4/5] fix(desktop): attach-time allowpopups, event-safe add-tab action, browser-tabs E2E (CODE-266) --- apps/desktop/e2e/browser-tabs.e2e.mts | 204 ++++++++++++++++++ apps/desktop/package.json | 1 + .../shell/browser/browser-webview-pane.tsx | 9 +- .../src/renderer/src/shell/store/store.ts | 8 +- 4 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/e2e/browser-tabs.e2e.mts diff --git a/apps/desktop/e2e/browser-tabs.e2e.mts b/apps/desktop/e2e/browser-tabs.e2e.mts new file mode 100644 index 000000000..a44cade5b --- /dev/null +++ b/apps/desktop/e2e/browser-tabs.e2e.mts @@ -0,0 +1,204 @@ +/** + * Multi-tab Browser section E2E (CODE-266): boots an isolated daemon + the built desktop app, + * opens the right panel's Browser section, and asserts the tab strip seeds an empty tab, + * address-bar navigation retitles the tab, "New tab" adds an instance, a guest `target="_blank"` + * popup lands in a new in-app tab, closing works, and tabs survive an app restart. + * Run `pnpm -F @linkcode/desktop e2e:browser-tabs` after building daemon and desktop. + */ + +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { homedir, tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { falseFn, noop } from 'foxts/noop'; +import { wait } from 'foxts/wait'; +import type { ElectronApplication, Page } from 'playwright-core'; +import { _electron } from 'playwright-core'; + +const require = createRequire(import.meta.url); +const desktopDir = resolve(import.meta.dirname, '..'); +const daemonDir = resolve(desktopDir, '../daemon'); +const electronBinary = require('electron') as unknown as string; + +const PORT = 44000 + (process.pid % 1000); + +/** Isolates userData + single-instance lock from any real install: `--user-data-dir` is + * overridden by the app's identity resolution, so a dedicated profile is the only lever. */ +const PROFILE = `e2e-browser-${process.pid}`; + +function fail(message: string): never { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +async function waitForDaemon(): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + await fetch(`http://127.0.0.1:${PORT}/socket.io/?EIO=4&transport=polling`); + return; + } catch { + await wait(250); + } + } + fail(`daemon did not come up on port ${PORT}`); +} + +function startPageServer(): Promise<{ server: Server; port: number }> { + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/html'); + if (req.url === '/popup') { + res.end('E2E Popup Pagepopup'); + return; + } + res.end( + 'E2E Page One' + + 'open popup', + ); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') fail('page server has no port'); + resolve({ server, port: address.port }); + }); + }); +} + +async function waitForTab(win: Page, name: string): Promise { + const button = win.getByRole('button', { name, exact: true }); + await button.waitFor({ state: 'visible', timeout: 15000 }); +} + +async function run(win: Page, app: ElectronApplication, pagePort: number): Promise { + const composer = win.locator('textarea').first(); + await composer.waitFor({ state: 'visible', timeout: 30000 }); + await win.waitForTimeout(1000); + + // Fresh profile: the right panel starts closed — open it, then switch to the Browser section. + await win.getByRole('button', { name: 'Toggle side panel', exact: true }).click(); + await win.getByRole('button', { name: 'Browser', exact: true }).click(); + await win.waitForTimeout(500); + + // Entering the section seeds one empty tab with the index-derived label. + await waitForTab(win, 'Browser 1'); + console.log('browser section seeded one empty tab'); + + // Address-bar navigation: the loaded page's title becomes the tab label. + const address = win.getByPlaceholder('Enter an address, or open a preview from Services'); + await address.fill(`http://127.0.0.1:${pagePort}/`); + await address.press('Enter'); + await waitForTab(win, 'E2E Page One'); + console.log('navigation retitled the tab from the page title'); + + // New tab: a second, empty instance appears and becomes active. + await win.getByRole('button', { name: 'New tab', exact: true }).click(); + await waitForTab(win, 'Browser 2'); + console.log('added a second (empty) tab'); + + // Popup: click the target=_blank link inside the FIRST tab's webview guest page. The guest + // is a separate Playwright page; main must reroute the popup into a new in-app tab. + await win.getByRole('button', { name: 'E2E Page One', exact: true }).click(); + const guest = app.windows().find((page) => page.url().startsWith(`http://127.0.0.1:${pagePort}`)); + if (guest === undefined) fail('did not find the webview guest page'); + await guest.click('#pop'); + await waitForTab(win, 'E2E Popup Page'); + console.log('guest popup landed in a new in-app tab'); + + // Close the popup tab; the neighbor becomes active and the button disappears. + await win.getByRole('button', { name: 'Close E2E Popup Page', exact: true }).click(); + await win.waitForTimeout(500); + const closed = await win + .getByRole('button', { name: 'E2E Popup Page', exact: true }) + .isVisible() + .catch(falseFn); + if (closed) fail('closing the popup tab did not remove it'); + console.log('closed the popup tab'); +} + +async function assertRestored(win: Page): Promise { + const composer = win.locator('textarea').first(); + await composer.waitFor({ state: 'visible', timeout: 30000 }); + // activeSection persisted as browser; the navigated tab reloads and re-reports its title. + await waitForTab(win, 'E2E Page One'); + await waitForTab(win, 'Browser 2'); + console.log('tabs restored after restart'); +} + +async function main(): Promise { + if (!existsSync(join(daemonDir, 'dist/index.js'))) { + fail('apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first'); + } + if (!existsSync(join(desktopDir, 'out/main/index.js'))) { + fail('apps/desktop/out is missing — run `pnpm -F @linkcode/desktop build` first'); + } + + const home = mkdtempSync(join(tmpdir(), 'linkcode-e2e-home-')); + const userData = mkdtempSync(join(tmpdir(), 'linkcode-e2e-userdata-')); + const { server, port } = await startPageServer(); + console.log(`page server on :${port}`); + + let daemon: ChildProcess | null = null; + let app: ElectronApplication | null = null; + let passed = false; + try { + daemon = spawn(process.execPath, ['dist/index.js'], { + cwd: daemonDir, + env: { ...process.env, HOME: home, LINKCODE_PORT: String(PORT), LINKCODE_PROFILE: PROFILE }, + stdio: 'ignore', + }); + await waitForDaemon(); + console.log(`daemon up on :${PORT} (HOME=${home}, profile=${PROFILE})`); + + const launch = (): Promise => + _electron.launch({ + executablePath: electronBinary, + // --lang pins navigator.languages (the renderer's locale source) to English so the + // role-name locators are deterministic on non-English machines. + args: [desktopDir, '--use-mock-keychain', '--lang=en-US'], + env: { ...process.env, HOME: home, LINKCODE_PROFILE: PROFILE }, + }); + + app = await launch(); + let win = await app.firstWindow(); + try { + await run(win, app, port); + await app.close(); + app = await launch(); + win = await app.firstWindow(); + await assertRestored(win); + } catch (error) { + const shot = join(tmpdir(), `linkcode-e2e-browser-tabs-${process.pid}.png`); + await win.screenshot({ path: shot }).catch(noop); + console.error(`screenshot: ${shot}`); + throw error; + } + passed = true; + console.log('PASS'); + } finally { + await app?.close().catch(noop); + daemon?.kill('SIGTERM'); + server.close(); + // Identity resolution ignores $HOME for appData, so the profile's userData lands in the + // real Application Support; always remove that profile universe. + const profileUserData = join( + homedir(), + 'Library/Application Support', + `LinkCode Development (${PROFILE})`, + ); + rmSync(profileUserData, { recursive: true, force: true }); + if (passed) { + rmSync(home, { recursive: true, force: true }); + rmSync(userData, { recursive: true, force: true }); + } else { + console.error(`kept for debugging: HOME=${home} userData=${userData}`); + process.exitCode = 1; + } + } +} + +void main(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b147fe45a..35473516a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "e2e:notifications": "node e2e/notifications.e2e.mts", "e2e:file-tree": "node e2e/file-tree.e2e.mts", "e2e:window-bounds": "node e2e/window-bounds.e2e.mts", + "e2e:browser-tabs": "node e2e/browser-tabs.e2e.mts", "lint": "pnpm --dir ../.. exec eslint --format=sukka apps/desktop" }, "author": "ArcBox Labs ", diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index 5498635e5..f79f28482 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -62,9 +62,6 @@ export function BrowserWebviewPane({ // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. const captureWebview = (element: HTMLWebViewElement | null): void => { - // React 19 rejects a boolean value for this custom content attribute. Electron only - // cares about its presence, so set it directly before the guest navigation begins. - element?.toggleAttribute('allowpopups', true); setWebview(element as WebviewTag | null); }; @@ -231,6 +228,12 @@ export function BrowserWebviewPane({ ref={captureWebview} src={url} partition={BROWSER_PARTITION} + // Must be present BEFORE the element attaches — Electron snapshots webview params at + // attach time, and a post-mount toggle leaves popups silently blocked (the guest + // window-open handler is then never consulted; verified via main-process probe). + // @ts-expect-error -- React types this boolean, but React 19 only forwards the + // string form; the empty string is the boolean-attribute-present form. + allowpopups="" className="h-full w-full" /> )} diff --git a/apps/desktop/src/renderer/src/shell/store/store.ts b/apps/desktop/src/renderer/src/shell/store/store.ts index fb79d5d8f..af6fbef96 100644 --- a/apps/desktop/src/renderer/src/shell/store/store.ts +++ b/apps/desktop/src/renderer/src/shell/store/store.ts @@ -56,8 +56,8 @@ interface DesktopShellActions { openBrowserUrl: (url: string) => void; /** Open `url` in a NEW active browser tab and bring the section forward (guest popups). */ openBrowserTab: (url: string) => void; - /** Open a new browser tab (empty unless a URL is given) and make it active. */ - addRightBrowserTab: (url?: string) => void; + /** Open a new empty browser tab and make it active. */ + addRightBrowserTab: () => void; closeRightBrowserTab: (id: string) => void; setActiveRightBrowserTab: (id: string) => void; /** Track a navigation that happened inside a tab's webview (keeps the address bar honest). */ @@ -321,8 +321,8 @@ export const useDesktopShellStore = create()( })); }, - addRightBrowserTab(url) { - const tab = createRightBrowserTab(url ?? null); + addRightBrowserTab() { + const tab = createRightBrowserTab(); updateShellState((current) => ({ ...current, rightPanel: { From c94d084346913323c7d78fb605755a64d1be2d23 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 21 Jul 2026 12:02:18 +0000 Subject: [PATCH 5/5] fix(desktop): route focused browser shortcuts (CODE-266) Amp-Thread-ID: https://ampcode.com/threads/T-019f843c-f6b9-73de-b6d2-e99d1861632b --- apps/desktop/e2e/browser-tabs.e2e.mts | 110 ++++++++-- apps/desktop/src/main/window.ts | 42 +++- .../src/__tests__/shell-state.test.ts | 6 +- .../shell/browser/browser-webview-pane.tsx | 205 +++++++++++------- .../src/renderer/src/shell/desktop-shell.tsx | 2 +- packages/system-plane/ipc/src/bridge.ts | 3 + packages/system-plane/ipc/src/context.ts | 3 + .../system-plane/ipc/src/electron-renderer.ts | 15 ++ packages/system-plane/ipc/src/events.ts | 2 + 9 files changed, 287 insertions(+), 101 deletions(-) diff --git a/apps/desktop/e2e/browser-tabs.e2e.mts b/apps/desktop/e2e/browser-tabs.e2e.mts index a44cade5b..25bb0ead1 100644 --- a/apps/desktop/e2e/browser-tabs.e2e.mts +++ b/apps/desktop/e2e/browser-tabs.e2e.mts @@ -12,7 +12,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import type { Server } from 'node:http'; import { createServer } from 'node:http'; import { createRequire } from 'node:module'; -import { homedir, tmpdir } from 'node:os'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { falseFn, noop } from 'foxts/noop'; import { wait } from 'foxts/wait'; @@ -74,8 +74,53 @@ async function waitForTab(win: Page, name: string): Promise { await button.waitFor({ state: 'visible', timeout: 15000 }); } +async function waitForMainWindow(app: ElectronApplication): Promise { + await app.firstWindow(); + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + const main = app.windows().find((page) => page.url().endsWith('/out/renderer/index.html')); + if (main !== undefined) return main; + await wait(50); + } + return fail('desktop main window did not appear'); +} + +function logPageErrors(page: Page): void { + page.on('pageerror', (error) => console.error('renderer page error:', error)); + page.on('crash', () => console.error('renderer page crashed:', page.url())); +} + +function watchAppErrors(app: ElectronApplication): void { + for (const page of app.windows()) logPageErrors(page); + app.on('window', logPageErrors); +} + +async function sendGuestShortcut( + app: ElectronApplication, + urlPrefix: string, + keyCode: string, +): Promise { + const sent = await app.evaluate( + ({ webContents }, { prefix, key }) => { + const guest = webContents + .getAllWebContents() + .find((contents) => contents.getURL().startsWith(prefix)); + if (guest === undefined) return false; + const modifiers: Electron.InputEvent['modifiers'] = [ + process.platform === 'darwin' ? 'meta' : 'control', + ]; + guest.focus(); + guest.sendInputEvent({ type: 'keyDown', keyCode: key, modifiers }); + guest.sendInputEvent({ type: 'keyUp', keyCode: key, modifiers }); + return true; + }, + { prefix: urlPrefix, key: keyCode }, + ); + if (!sent) fail(`no guest WebContents for ${urlPrefix}`); +} + async function run(win: Page, app: ElectronApplication, pagePort: number): Promise { - const composer = win.locator('textarea').first(); + const composer = win.locator('[data-slot="composer-editor"][contenteditable="true"]'); await composer.waitFor({ state: 'visible', timeout: 30000 }); await win.waitForTimeout(1000); @@ -95,6 +140,28 @@ async function run(win: Page, app: ElectronApplication, pagePort: number): Promi await waitForTab(win, 'E2E Page One'); console.log('navigation retitled the tab from the page title'); + // App-owned shortcuts must still work while the guest WebContents owns focus; guest key events + // do not bubble to the renderer's keyboard registry. + const guest = app.windows().find((page) => page.url().startsWith(`http://127.0.0.1:${pagePort}`)); + if (guest === undefined) fail('did not find the webview guest page'); + await guest.locator('body').click(); + await sendGuestShortcut(app, `http://127.0.0.1:${pagePort}`, 'f'); + await win.getByPlaceholder('Find…').waitFor({ state: 'visible' }); + await win.keyboard.press('Escape'); + await guest.locator('body').click(); + await sendGuestShortcut(app, `http://127.0.0.1:${pagePort}`, '='); + await win.waitForTimeout(100); + const zoomLevel = await app.evaluate( + ({ webContents }, prefix) => + webContents + .getAllWebContents() + .find((contents) => contents.getURL().startsWith(prefix)) + ?.getZoomLevel(), + `http://127.0.0.1:${pagePort}`, + ); + if (zoomLevel === undefined || zoomLevel <= 0) fail('focused guest zoom shortcut did not run'); + console.log('focused guest find and zoom shortcuts reached the active browser tab'); + // New tab: a second, empty instance appears and becomes active. await win.getByRole('button', { name: 'New tab', exact: true }).click(); await waitForTab(win, 'Browser 2'); @@ -103,8 +170,6 @@ async function run(win: Page, app: ElectronApplication, pagePort: number): Promi // Popup: click the target=_blank link inside the FIRST tab's webview guest page. The guest // is a separate Playwright page; main must reroute the popup into a new in-app tab. await win.getByRole('button', { name: 'E2E Page One', exact: true }).click(); - const guest = app.windows().find((page) => page.url().startsWith(`http://127.0.0.1:${pagePort}`)); - if (guest === undefined) fail('did not find the webview guest page'); await guest.click('#pop'); await waitForTab(win, 'E2E Popup Page'); console.log('guest popup landed in a new in-app tab'); @@ -120,12 +185,20 @@ async function run(win: Page, app: ElectronApplication, pagePort: number): Promi console.log('closed the popup tab'); } -async function assertRestored(win: Page): Promise { - const composer = win.locator('textarea').first(); - await composer.waitFor({ state: 'visible', timeout: 30000 }); +async function assertRestored( + win: Page, + app: ElectronApplication, + pagePort: number, +): Promise { // activeSection persisted as browser; the navigated tab reloads and re-reports its title. await waitForTab(win, 'E2E Page One'); await waitForTab(win, 'Browser 2'); + await win.getByRole('button', { name: 'E2E Page One', exact: true }).click(); + const guest = app.windows().find((page) => page.url().startsWith(`http://127.0.0.1:${pagePort}`)); + if (guest === undefined) fail('did not restore the webview guest page'); + await guest.locator('body').click(); + await sendGuestShortcut(app, `http://127.0.0.1:${pagePort}`, 'f'); + await win.getByPlaceholder('Find…').waitFor({ state: 'visible' }); console.log('tabs restored after restart'); } @@ -138,12 +211,12 @@ async function main(): Promise { } const home = mkdtempSync(join(tmpdir(), 'linkcode-e2e-home-')); - const userData = mkdtempSync(join(tmpdir(), 'linkcode-e2e-userdata-')); const { server, port } = await startPageServer(); console.log(`page server on :${port}`); let daemon: ChildProcess | null = null; let app: ElectronApplication | null = null; + let userData: string | null = null; let passed = false; try { daemon = spawn(process.execPath, ['dist/index.js'], { @@ -164,13 +237,16 @@ async function main(): Promise { }); app = await launch(); - let win = await app.firstWindow(); + watchAppErrors(app); + userData = await app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')); + let win = await waitForMainWindow(app); try { await run(win, app, port); await app.close(); app = await launch(); - win = await app.firstWindow(); - await assertRestored(win); + watchAppErrors(app); + win = await waitForMainWindow(app); + await assertRestored(win, app, port); } catch (error) { const shot = join(tmpdir(), `linkcode-e2e-browser-tabs-${process.pid}.png`); await win.screenshot({ path: shot }).catch(noop); @@ -183,19 +259,11 @@ async function main(): Promise { await app?.close().catch(noop); daemon?.kill('SIGTERM'); server.close(); - // Identity resolution ignores $HOME for appData, so the profile's userData lands in the - // real Application Support; always remove that profile universe. - const profileUserData = join( - homedir(), - 'Library/Application Support', - `LinkCode Development (${PROFILE})`, - ); - rmSync(profileUserData, { recursive: true, force: true }); + if (userData !== null) rmSync(userData, { recursive: true, force: true }); if (passed) { rmSync(home, { recursive: true, force: true }); - rmSync(userData, { recursive: true, force: true }); } else { - console.error(`kept for debugging: HOME=${home} userData=${userData}`); + console.error(`kept for debugging: HOME=${home}`); process.exitCode = 1; } } diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index e7c2ea700..1526ffaae 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -1,10 +1,11 @@ import { release } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { BrowserDownloadDone } from '@linkcode/ipc'; +import type { BrowserDownloadDone, BrowserShortcutAction } from '@linkcode/ipc'; import { BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, + BROWSER_SHORTCUT_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, UPDATER_STATUS_CHANNEL, } from '@linkcode/ipc'; @@ -140,6 +141,12 @@ function createWindow(): BrowserWindow { if (isHttpUrl(url) && !win.isDestroyed()) win.webContents.send(BROWSER_OPEN_TAB_CHANNEL, url); return { action: 'deny' }; }); + guest.on('before-input-event', (event, input) => { + const action = browserShortcutAction(input); + if (action === null || win.isDestroyed()) return; + event.preventDefault(); + win.webContents.send(BROWSER_SHORTCUT_CHANNEL, action); + }); }); void loadRenderer(win); @@ -155,6 +162,39 @@ function isHttpUrl(url: string): boolean { } } +function browserShortcutAction(input: Electron.Input): BrowserShortcutAction | null { + const primary = + process.platform === 'darwin' ? input.meta && !input.control : input.control && !input.meta; + if ( + input.type !== 'keyDown' || + input.isAutoRepeat || + input.isComposing || + !primary || + input.alt + ) { + return null; + } + if ((input.code === 'KeyF' || input.key.toLowerCase() === 'f') && !input.shift) return 'find'; + if ( + input.code === 'Equal' || + input.code === 'NumpadAdd' || + input.key === '=' || + input.key === '+' + ) { + return 'zoom-in'; + } + if ( + (input.code === 'Minus' || input.code === 'NumpadSubtract' || input.key === '-') && + !input.shift + ) { + return 'zoom-out'; + } + if ((input.code === 'Digit0' || input.code === 'Numpad0' || input.key === '0') && !input.shift) { + return 'zoom-reset'; + } + return null; +} + /** Mirrors {@link loadRenderer}'s allowed targets: the dev server origin, or the packaged entry file. */ function isSelfNavigation(url: string): boolean { let target: URL; diff --git a/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts b/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts index dd4a52d94..d395e924b 100644 --- a/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts +++ b/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts @@ -150,7 +150,7 @@ describe('desktop shell state persistence', () => { it('seeds a first terminal tab when restoring an open panel showing an empty terminal section', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -169,7 +169,7 @@ describe('desktop shell state persistence', () => { it('does not seed a terminal tab when the restored panel is closed or on another section', () => { const closed = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -184,7 +184,7 @@ describe('desktop shell state persistence', () => { expect(closed.rightPanel.terminal.tabs).toEqual([]); const otherSection = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index f79f28482..e0b36f2dd 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -1,10 +1,12 @@ +import type { SystemBridge } from '@linkcode/ipc'; import { isKeyboardShortcutLocalTarget, useKeyboardShortcut } from '@linkcode/ui'; import type { BrowserFindState } from '@linkcode/ui/shell/browser'; import { BrowserPane } from '@linkcode/ui/shell/browser'; import type { WebviewTag } from 'electron'; import { useEffect as useAbortableEffect } from 'foxact/use-abortable-effect'; +import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; import { noop } from 'foxts/noop'; -import { useRef, useState } from 'react'; +import { useEffectEvent, useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; import { useDesktopShellStore } from '../store/store'; @@ -33,14 +35,28 @@ function whenNotLocal(event: KeyboardEvent): boolean { const MIN_ZOOM_LEVEL = -8; const MAX_ZOOM_LEVEL = 9; +function applyZoom( + webview: WebviewTag | null, + ready: boolean, + action: 'in' | 'out' | 'reset', +): void { + if (webview === null || !ready) return; + const level = webview.getZoomLevel(); + if (action === 'in') webview.setZoomLevel(Math.min(level + 1, MAX_ZOOM_LEVEL)); + else if (action === 'out') webview.setZoomLevel(Math.max(level - 1, MIN_ZOOM_LEVEL)); + else webview.setZoomLevel(0); +} + /** * One browser tab's Electron ``, mounted once inside the shell's resident * panel-content stack (moving a webview in the DOM reloads it) and shown/hidden via visibility. */ export function BrowserWebviewPane({ + systemBridge, tabId, url, }: { + systemBridge: SystemBridge; tabId: string; url: string | null; }): React.ReactNode { @@ -57,107 +73,120 @@ export function BrowserWebviewPane({ ); const rootRef = useRef(null); const [webview, setWebview] = useState(null); + const [webviewReady, setWebviewReady] = useState(false); const [nav, setNav] = useState(IDLE_NAV); const [find, setFind] = useState(null); // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. const captureWebview = (element: HTMLWebViewElement | null): void => { + setWebviewReady(false); setWebview(element as WebviewTag | null); }; - useAbortableEffect( - (signal) => { - if (webview === null) return; - const sync = (): void => { - if (signal.aborted) return; - setNav((prev) => ({ - ...prev, - isLoading: webview.isLoading(), - canGoBack: webview.canGoBack(), - canGoForward: webview.canGoForward(), - })); - }; - const onNavigate = (event: Electron.DidNavigateEvent): void => { - if (!signal.aborted) setBrowserTabUrl(tabId, event.url); - setNav((prev) => ({ ...prev, failure: null })); - sync(); - }; - const onTitleUpdated = (event: Electron.PageTitleUpdatedEvent): void => { - if (!signal.aborted) setBrowserTabTitle(tabId, event.title); - }; - const onFail = (event: Electron.DidFailLoadEvent): void => { - // -3 = ERR_ABORTED: fired for cancelled loads (e.g. quick re-navigation), not real failures. - if (event.errorCode === -3 || !event.isMainFrame || signal.aborted) return; - setNav((prev) => ({ - ...prev, - failure: t('loadFailed', { error: event.errorDescription }), - })); - }; - const onFoundInPage = (event: Electron.FoundInPageEvent): void => { - if (signal.aborted) return; - setFind((prev) => - prev === null - ? prev - : { - ...prev, - matches: { active: event.result.activeMatchOrdinal, total: event.result.matches }, - }, - ); - }; - webview.addEventListener('did-start-loading', sync); - webview.addEventListener('did-stop-loading', sync); - webview.addEventListener('did-navigate', onNavigate); - webview.addEventListener('did-navigate-in-page', onNavigate); - webview.addEventListener('page-title-updated', onTitleUpdated); - webview.addEventListener('did-fail-load', onFail); - webview.addEventListener('found-in-page', onFoundInPage); - return () => { - webview.removeEventListener('did-start-loading', sync); - webview.removeEventListener('did-stop-loading', sync); - webview.removeEventListener('did-navigate', onNavigate); - webview.removeEventListener('did-navigate-in-page', onNavigate); - webview.removeEventListener('page-title-updated', onTitleUpdated); - webview.removeEventListener('did-fail-load', onFail); - webview.removeEventListener('found-in-page', onFoundInPage); - }; - }, - [webview, tabId, setBrowserTabUrl, setBrowserTabTitle, t], - ); + const syncDocumentState = useEffectEvent((currentUrl: string, currentTitle: string) => { + if (currentUrl.length > 0) setBrowserTabUrl(tabId, currentUrl); + if (currentTitle.length > 0) setBrowserTabTitle(tabId, currentTitle); + }); + const reportLoadFailure = useEffectEvent((description: string) => { + setNav((prev) => ({ ...prev, failure: t('loadFailed', { error: description }) })); + }); + + useLayoutEffect(() => { + if (webview === null) return; + let ready = false; + const sync = (): void => { + if (!ready) return; + setNav((prev) => ({ + ...prev, + isLoading: webview.isLoading(), + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + })); + }; + const syncDocument = (): void => { + ready = true; + setWebviewReady(true); + sync(); + syncDocumentState(webview.getURL(), webview.getTitle()); + }; + const onNavigate = (event: Electron.DidNavigateEvent): void => { + syncDocumentState(event.url, ''); + setNav((prev) => ({ ...prev, failure: null })); + sync(); + }; + const onTitleUpdated = (event: Electron.PageTitleUpdatedEvent): void => { + syncDocumentState('', event.title); + }; + const onFail = (event: Electron.DidFailLoadEvent): void => { + // -3 = ERR_ABORTED: fired for cancelled loads (e.g. quick re-navigation), not real failures. + if (event.errorCode === -3 || !event.isMainFrame) return; + reportLoadFailure(event.errorDescription); + }; + const onFoundInPage = (event: Electron.FoundInPageEvent): void => { + setFind((prev) => + prev === null + ? prev + : { + ...prev, + matches: { active: event.result.activeMatchOrdinal, total: event.result.matches }, + }, + ); + }; + webview.addEventListener('did-start-loading', sync); + webview.addEventListener('did-stop-loading', sync); + webview.addEventListener('dom-ready', syncDocument); + webview.addEventListener('did-navigate', onNavigate); + webview.addEventListener('did-navigate-in-page', onNavigate); + webview.addEventListener('page-title-updated', onTitleUpdated); + webview.addEventListener('did-fail-load', onFail); + webview.addEventListener('found-in-page', onFoundInPage); + return () => { + webview.removeEventListener('did-start-loading', sync); + webview.removeEventListener('did-stop-loading', sync); + webview.removeEventListener('dom-ready', syncDocument); + webview.removeEventListener('did-navigate', onNavigate); + webview.removeEventListener('did-navigate-in-page', onNavigate); + webview.removeEventListener('page-title-updated', onTitleUpdated); + webview.removeEventListener('did-fail-load', onFail); + webview.removeEventListener('found-in-page', onFoundInPage); + }; + }, [webview]); // Pause any playing media when the pane is hidden (panel collapsed or another section shown), // so a preview stops instead of playing audio out of sight. Paused, not resumed — the user // restarts it on their next visit. useAbortableEffect(() => { - if (webview === null || visible) return; - // Guest may be mid-navigation or detached, in which case there is nothing to pause. - webview - .executeJavaScript('document.querySelectorAll("video,audio").forEach((m) => m.pause())') + if (webview === null || !webviewReady || visible) return; + // Guest may detach after the readiness check, in which case there is nothing to pause. + void Promise.resolve() + .then(() => + webview.executeJavaScript( + 'document.querySelectorAll("video,audio").forEach((m) => m.pause())', + ), + ) .catch(noop); - }, [webview, visible]); + }, [webview, webviewReady, visible]); const openFind = (): void => { setFind((prev) => prev ?? { query: '', matches: null }); }; const closeFind = (): void => { - webview?.stopFindInPage('clearSelection'); + if (webviewReady) webview?.stopFindInPage('clearSelection'); setFind(null); }; const changeFindQuery = (query: string): void => { setFind({ query, matches: null }); + if (!webviewReady) return; if (query.length > 0) webview?.findInPage(query); else webview?.stopFindInPage('clearSelection'); }; const stepFind = (forward: boolean): void => { - if (find !== null && find.query.length > 0) { + if (webviewReady && find !== null && find.query.length > 0) { webview?.findInPage(find.query, { forward, findNext: true }); } }; const zoom = (action: 'in' | 'out' | 'reset'): void => { - if (webview === null) return; - const level = webview.getZoomLevel(); - if (action === 'in') webview.setZoomLevel(Math.min(level + 1, MAX_ZOOM_LEVEL)); - else if (action === 'out') webview.setZoomLevel(Math.max(level - 1, MIN_ZOOM_LEVEL)); - else webview.setZoomLevel(0); + applyZoom(webview, webviewReady, action); }; // Owner-scoped chords: the registry only fires these while this tab's pane is the @@ -203,6 +232,32 @@ export function BrowserWebviewPane({ }, }); + useLayoutEffect(() => { + if (!visible) return; + return systemBridge.browser.onShortcut((action) => { + const panel = useDesktopShellStore.getState().rightPanel; + if (!panel.open || panel.activeSection !== 'browser' || panel.browser.activeTabId !== tabId) { + return; + } + switch (action) { + case 'find': + setFind((prev) => prev ?? { query: '', matches: null }); + break; + case 'zoom-in': + applyZoom(webview, webviewReady, 'in'); + break; + case 'zoom-out': + applyZoom(webview, webviewReady, 'out'); + break; + case 'zoom-reset': + applyZoom(webview, webviewReady, 'reset'); + break; + default: + break; + } + }); + }, [systemBridge, tabId, visible, webview, webviewReady]); + return (
setBrowserTabUrl(tabId, next)} - onBack={() => webview?.goBack()} - onForward={() => webview?.goForward()} - onReload={() => webview?.reload()} + onBack={() => webviewReady && webview?.goBack()} + onForward={() => webviewReady && webview?.goForward()} + onReload={() => webviewReady && webview?.reload()} onFindQueryChange={changeFindQuery} onFindStep={stepFind} onFindClose={closeFind} onOpenFind={openFind} onZoom={zoom} - onOpenDevTools={() => webview?.openDevTools()} + onOpenDevTools={() => webviewReady && webview?.openDevTools()} > {url !== null && ( , + node: , }); } return createPortal(, host); diff --git a/packages/system-plane/ipc/src/bridge.ts b/packages/system-plane/ipc/src/bridge.ts index 7282f5e1e..600192c49 100644 --- a/packages/system-plane/ipc/src/bridge.ts +++ b/packages/system-plane/ipc/src/bridge.ts @@ -1,5 +1,6 @@ import type { BrowserDownloadDone, + BrowserShortcutAction, DesktopSettings, DesktopSettingsPatch, PickFileOptions, @@ -67,5 +68,7 @@ export interface SystemBridge { onOpenTab(cb: (url: string) => void): () => void; /** Subscribe to finished Browser-pane downloads (main default download flow). */ onDownloadDone(cb: (result: BrowserDownloadDone) => void): () => void; + /** Subscribe to app-owned shortcuts captured while a guest webview owns keyboard focus. */ + onShortcut(cb: (action: BrowserShortcutAction) => void): () => void; }; } diff --git a/packages/system-plane/ipc/src/context.ts b/packages/system-plane/ipc/src/context.ts index 040bb81de..3f478f56d 100644 --- a/packages/system-plane/ipc/src/context.ts +++ b/packages/system-plane/ipc/src/context.ts @@ -110,3 +110,6 @@ export interface BrowserDownloadDone { filename: string; state: 'completed' | 'cancelled' | 'interrupted'; } + +/** Browser command captured from a focused guest WebContents before page dispatch. */ +export type BrowserShortcutAction = 'find' | 'zoom-in' | 'zoom-out' | 'zoom-reset'; diff --git a/packages/system-plane/ipc/src/electron-renderer.ts b/packages/system-plane/ipc/src/electron-renderer.ts index c5a922386..a91e46209 100644 --- a/packages/system-plane/ipc/src/electron-renderer.ts +++ b/packages/system-plane/ipc/src/electron-renderer.ts @@ -7,6 +7,7 @@ import type { BrowserDownloadDone, DesktopSettings, UpdaterStatus } from './cont import { BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, + BROWSER_SHORTCUT_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, DAEMON_URL_SNAPSHOT_CHANNEL, NOTIFICATION_CLICKED_CHANNEL, @@ -123,6 +124,20 @@ export function createElectronSystemBridge( ipcRenderer.on(BROWSER_DOWNLOAD_DONE_CHANNEL, handler); return () => ipcRenderer.removeListener(BROWSER_DOWNLOAD_DONE_CHANNEL, handler); }, + onShortcut(cb) { + const handler: IpcRendererListener = (_event, value: unknown) => { + if ( + value === 'find' || + value === 'zoom-in' || + value === 'zoom-out' || + value === 'zoom-reset' + ) { + cb(value); + } + }; + ipcRenderer.on(BROWSER_SHORTCUT_CHANNEL, handler); + return () => ipcRenderer.removeListener(BROWSER_SHORTCUT_CHANNEL, handler); + }, }, }; } diff --git a/packages/system-plane/ipc/src/events.ts b/packages/system-plane/ipc/src/events.ts index 0f025566b..5d3c3675a 100644 --- a/packages/system-plane/ipc/src/events.ts +++ b/packages/system-plane/ipc/src/events.ts @@ -23,6 +23,8 @@ export const NOTIFICATION_CLICKED_CHANNEL = 'linkcode.system.notifications.click export const BROWSER_OPEN_TAB_CHANNEL = 'linkcode.system.browser.openTab'; /** Main → renderer push: a Browser-pane download finished (any terminal state). */ export const BROWSER_DOWNLOAD_DONE_CHANNEL = 'linkcode.system.browser.downloadDone'; +/** Main → renderer push: a browser shortcut was pressed while its guest page owned focus. */ +export const BROWSER_SHORTCUT_CHANNEL = 'linkcode.system.browser.shortcut'; export const systemIpcEvents = { windowMinimize: defineInvokeEventa('linkcode.system.window.minimize'),