From d78d9daba5601cc1df5f943290b9fc3a099aad1f Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sun, 5 Apr 2026 14:44:22 +0700 Subject: [PATCH] feat: Support find in desktop client (#10723) Signed-off-by: Artem Savchenko --- desktop/src/__test__/main/config.test.ts | 4 +- desktop/src/__test__/main/findInPage.test.ts | 134 ++++++ desktop/src/__test__/main/path.test.ts | 45 ++ desktop/src/main/args.ts | 1 + desktop/src/main/config.ts | 20 +- desktop/src/main/customMenu.ts | 6 +- desktop/src/main/findInPage.ts | 207 +++++++++ desktop/src/main/findInPageOverlayHost.ts | 110 +++++ desktop/src/main/path.ts | 8 +- desktop/src/main/standardMenu.ts | 14 +- desktop/src/main/start.ts | 14 + desktop/src/ui/find-in-page-overlay.ejs | 19 + desktop/src/ui/findInPageBar.ts | 427 +++++++++++++++++++ desktop/src/ui/findInPageOverlay.ts | 25 ++ desktop/src/ui/ipcMessages.ts | 7 +- desktop/src/ui/preload.ts | 28 +- desktop/src/ui/titleBarMenu.ts | 1 + desktop/src/ui/types.ts | 25 ++ desktop/webpack.config.js | 11 +- 19 files changed, 1093 insertions(+), 13 deletions(-) create mode 100644 desktop/src/__test__/main/findInPage.test.ts create mode 100644 desktop/src/__test__/main/path.test.ts create mode 100644 desktop/src/main/findInPage.ts create mode 100644 desktop/src/main/findInPageOverlayHost.ts create mode 100644 desktop/src/ui/find-in-page-overlay.ejs create mode 100644 desktop/src/ui/findInPageBar.ts create mode 100644 desktop/src/ui/findInPageOverlay.ts diff --git a/desktop/src/__test__/main/config.test.ts b/desktop/src/__test__/main/config.test.ts index 9e1e3c3c09..7b66d1c9f1 100644 --- a/desktop/src/__test__/main/config.test.ts +++ b/desktop/src/__test__/main/config.test.ts @@ -21,7 +21,9 @@ const mockApp = { } return `/mock/${name}` }), - getName: jest.fn(() => 'TestApp') + getName: jest.fn(() => 'TestApp'), + isPackaged: true, + getAppPath: jest.fn(() => '/mock/appPath') } jest.mock('electron', () => ({ diff --git a/desktop/src/__test__/main/findInPage.test.ts b/desktop/src/__test__/main/findInPage.test.ts new file mode 100644 index 0000000000..738141f41e --- /dev/null +++ b/desktop/src/__test__/main/findInPage.test.ts @@ -0,0 +1,134 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { WebContents } from 'electron' +import { IpcMessage } from '../../ui/ipcMessages' + +type IpcHandlerFn = (...args: any[]) => any + +/** Captured ipcMain.handle / ipcMain.on callbacks for assertions. */ +const ipcHandlers = new Map() + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: (...args: any[]) => any) => { + ipcHandlers.set(channel, handler) + }), + on: jest.fn((channel: string, handler: (...args: any[]) => any) => { + ipcHandlers.set(`on:${channel}`, handler) + }) + }, + BrowserWindow: { + fromWebContents: jest.fn() + }, + BrowserView: jest.fn() +})) + +function makeWebContents (partial: { + id: number + destroyed?: boolean + findInPage?: jest.Mock + stopFindInPage?: jest.Mock + send?: jest.Mock +}): WebContents { + return { + id: partial.id, + isDestroyed: () => partial.destroyed ?? false, + findInPage: partial.findInPage ?? jest.fn(), + stopFindInPage: partial.stopFindInPage ?? jest.fn(), + send: partial.send ?? jest.fn() + } as unknown as WebContents +} + +describe('findInPage main IPC', () => { + let originalConsoleError: typeof console.error + let registerFindInPageIpcHandlers: () => void + let registerFindInPageTarget: (overlayWc: WebContents, pageWc: WebContents) => void + + beforeEach(async () => { + ipcHandlers.clear() + jest.resetModules() + originalConsoleError = console.error + console.error = jest.fn() + const m = await import('../../main/findInPage') + registerFindInPageIpcHandlers = m.registerFindInPageIpcHandlers + registerFindInPageTarget = m.registerFindInPageTarget + registerFindInPageIpcHandlers() + }) + + afterEach(() => { + console.error = originalConsoleError + }) + + test('FindInPage clears selection and returns -1 for empty text', async () => { + const stopFindInPage = jest.fn() + const findInPage = jest.fn() + const sender = makeWebContents({ id: 10, stopFindInPage, findInPage }) + const handler = ipcHandlers.get(IpcMessage.FindInPage) + expect(handler).toBeDefined() + const result = await handler?.({ sender }, '', {}) + expect(result).toBe(-1) + expect(stopFindInPage).toHaveBeenCalledWith('clearSelection') + expect(findInPage).not.toHaveBeenCalled() + }) + + test('FindInPage runs on page webContents when overlay is registered as invoker', async () => { + const pageFindInPage = jest.fn().mockResolvedValue(7) + const pageStop = jest.fn() + const pageWc = makeWebContents({ id: 1, findInPage: pageFindInPage, stopFindInPage: pageStop }) + const overlayWc = makeWebContents({ id: 2 }) + registerFindInPageTarget(overlayWc, pageWc) + + const handler = ipcHandlers.get(IpcMessage.FindInPage) + const result = await handler?.({ sender: overlayWc }, 'needle', { forward: true, findNext: false }) + expect(result).toBe(7) + expect(pageFindInPage).toHaveBeenCalledWith('needle', { forward: true, findNext: false }) + }) + + test('FindInPage returns -1 when target webContents is destroyed', async () => { + const findInPage = jest.fn() + const sender = makeWebContents({ id: 20, destroyed: true, findInPage }) + const handler = ipcHandlers.get(IpcMessage.FindInPage) + const result = await handler?.({ sender }, 'x', {}) + expect(result).toBe(-1) + expect(findInPage).not.toHaveBeenCalled() + }) + + test('FindInPage returns -1 when findInPage throws', async () => { + const findInPage = jest.fn().mockImplementation(() => { + throw new Error('find failed') + }) + const sender = makeWebContents({ id: 30, findInPage }) + const handler = ipcHandlers.get(IpcMessage.FindInPage) + const result = await handler?.({ sender }, 'x', {}) + expect(result).toBe(-1) + }) + + test('StopFindInPage no-ops when webContents is destroyed', async () => { + const stopFindInPage = jest.fn() + const sender = makeWebContents({ id: 40, destroyed: true, stopFindInPage }) + const handler = ipcHandlers.get(IpcMessage.StopFindInPage) + await handler?.({ sender }, 'clearSelection') + expect(stopFindInPage).not.toHaveBeenCalled() + }) + + test('StopFindInPage forwards to resolveFindTarget', async () => { + const stopFindInPage = jest.fn() + const sender = makeWebContents({ id: 50, stopFindInPage }) + const handler = ipcHandlers.get(IpcMessage.StopFindInPage) + await handler?.({ sender }, 'keepSelection') + expect(stopFindInPage).toHaveBeenCalledWith('keepSelection') + }) +}) diff --git a/desktop/src/__test__/main/path.test.ts b/desktop/src/__test__/main/path.test.ts new file mode 100644 index 0000000000..6aa1008247 --- /dev/null +++ b/desktop/src/__test__/main/path.test.ts @@ -0,0 +1,45 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import * as nodePath from 'path' +import { getBundledUiDistPath, getFileInPublicBundledFolder } from '../../main/path' + +const mockGetAppPath = jest.fn(() => '/mock/appPath') + +jest.mock('electron', () => ({ + app: { + getAppPath: (): string => mockGetAppPath() + } +})) + +describe('path (bundled UI)', () => { + beforeEach(() => { + mockGetAppPath.mockReturnValue('/mock/appPath') + }) + + test('getBundledUiDistPath joins getAppPath with dist/ui', () => { + mockGetAppPath.mockReturnValue('/Applications/Huly.app/Contents/Resources/app.asar') + expect(getBundledUiDistPath()).toBe( + nodePath.join('/Applications/Huly.app/Contents/Resources/app.asar', 'dist', 'ui') + ) + }) + + test('getFileInPublicBundledFolder nests under public', () => { + mockGetAppPath.mockReturnValue('/repo/desktop') + expect(getFileInPublicBundledFolder('AppIcon.png')).toBe( + nodePath.join('/repo/desktop', 'dist', 'ui', 'public', 'AppIcon.png') + ) + }) +}) diff --git a/desktop/src/main/args.ts b/desktop/src/main/args.ts index b118841bac..67225c656b 100644 --- a/desktop/src/main/args.ts +++ b/desktop/src/main/args.ts @@ -18,6 +18,7 @@ import { OptionValues, program } from 'commander' program .name('Huly') .allowUnknownOption() + .allowExcessArguments(true) .option('-s, --server ', 'Remote server URL (front). E.g. https://huly.app') let opts: OptionValues | null = null diff --git a/desktop/src/main/config.ts b/desktop/src/main/config.ts index f371ee73e0..1f0de4353f 100644 --- a/desktop/src/main/config.ts +++ b/desktop/src/main/config.ts @@ -28,12 +28,23 @@ export interface PackedConfig { function readConfigFile (filePath: string): PackedConfig | undefined { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackedConfig - } catch (err) { - console.error(`Failed to read config from ${filePath}:`, err) + } catch (err: unknown) { + const code = err != null && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined + if (code !== 'ENOENT') { + console.error(`Failed to read config from ${filePath}:`, err) + } return undefined } } +/** Packaged app: extraResources `config/config.json`. Dev: webpack `public/` → `dist/ui/public/`. */ +function getBundledResourcesConfigPath (): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'config', 'config.json') + } + return path.join(app.getAppPath(), 'dist', 'ui', 'public', 'config', 'config.json') +} + /** * Writes a JSON config file, logging errors. */ @@ -55,7 +66,7 @@ function writeConfigFile (filePath: string, config: PackedConfig): boolean { function migrateConfigIfNeeded (): void { try { const userDataConfigPath = path.join(app.getPath('userData'), 'config.json') - const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json') + const resourcesConfigPath = getBundledResourcesConfigPath() const userDataDir = app.getPath('userData') if (!fs.existsSync(userDataDir)) { @@ -109,6 +120,5 @@ export function readPackedConfig (): PackedConfig | undefined { } // Fallback to bundled config if userData config doesn't exist - const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json') - return readConfigFile(resourcesConfigPath) + return readConfigFile(getBundledResourcesConfigPath()) } diff --git a/desktop/src/main/customMenu.ts b/desktop/src/main/customMenu.ts index ef28ed9b35..7bae4d2ef4 100644 --- a/desktop/src/main/customMenu.ts +++ b/desktop/src/main/customMenu.ts @@ -15,8 +15,9 @@ import { BrowserWindow } from 'electron' import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types' -import { OsIntegration } from './osIntegration' import { IpcMessage } from '../ui/ipcMessages' +import { OsIntegration } from './osIntegration' +import { openFindInPageBar } from './findInPage' export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction, os: OsIntegration | undefined): void { if (mainWindow == null) { @@ -67,6 +68,9 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac case 'select-all': mainWindow.webContents.selectAll() break + case 'find': + openFindInPageBar(mainWindow) + break case 'reload': mainWindow?.reload() break diff --git a/desktop/src/main/findInPage.ts b/desktop/src/main/findInPage.ts new file mode 100644 index 0000000000..eb9cb844d0 --- /dev/null +++ b/desktop/src/main/findInPage.ts @@ -0,0 +1,207 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { BrowserView, BrowserWindow, ipcMain, WebContents } from 'electron' +import { IpcMessage } from '../ui/ipcMessages' + +let ipcHandlersRegistered = false + +/** Overlay invoker webContents id → page webContents to run `findInPage` on. */ +const findPageTargetByInvokerId = new Map() +/** Page webContents id → overlay that should receive `found-in-page` IPC. */ +const findResultRecipientByPageId = new Map() +const pagesWithFoundInPageListener = new WeakSet() + +const overlayViewsByWindowId = new Map() +const overlayVisibleByWindowId = new Map() + +const OVERLAY_WIDTH = 392 +const OVERLAY_HEIGHT = 64 + +function resolveFindTarget (sender: WebContents): WebContents { + return findPageTargetByInvokerId.get(sender.id) ?? sender +} + +function webContentsIfAlive (wc: WebContents): WebContents | null { + return wc.isDestroyed() ? null : wc +} + +export function registerFindInPageTarget (overlayWc: WebContents, pageWc: WebContents): void { + findPageTargetByInvokerId.set(overlayWc.id, pageWc) +} + +export function unregisterFindInPageTarget (overlayWc: WebContents): void { + findPageTargetByInvokerId.delete(overlayWc.id) +} + +export function unregisterFindInPageForwarding (pageWc: WebContents): void { + findResultRecipientByPageId.delete(pageWc.id) +} + +export function attachFindInPageResultForwarding (pageWc: WebContents, overlayWc: WebContents): void { + findResultRecipientByPageId.set(pageWc.id, overlayWc) + if (pagesWithFoundInPageListener.has(pageWc)) { + return + } + pagesWithFoundInPageListener.add(pageWc) + pageWc.on('found-in-page', (_event, result) => { + const recipient = findResultRecipientByPageId.get(pageWc.id) + if (recipient == null || recipient.isDestroyed()) { + return + } + try { + recipient.send(IpcMessage.FindInPageResult, result) + } catch (err) { + console.error('[find] forward found-in-page failed:', err) + } + }) +} + +function layoutFindOverlayBounds (win: BrowserWindow, view: BrowserView, visible: boolean): void { + overlayVisibleByWindowId.set(win.id, visible) + if (view.webContents.isDestroyed()) { + return + } + if (!visible || win.isDestroyed()) { + view.setBounds({ x: 0, y: 0, width: 0, height: 0 }) + return + } + const b = win.getContentBounds() + view.setBounds({ + x: Math.max(0, b.width - OVERLAY_WIDTH - 12), + y: 12, + width: OVERLAY_WIDTH, + height: OVERLAY_HEIGHT + }) +} + +export function registerFindOverlayView (win: BrowserWindow, view: BrowserView): void { + overlayViewsByWindowId.set(win.id, view) +} + +export function unregisterFindOverlayView (win: BrowserWindow): void { + overlayViewsByWindowId.delete(win.id) + overlayVisibleByWindowId.delete(win.id) +} + +export function openFindInPageBar (win: BrowserWindow | undefined): void { + if (win == null || win.isDestroyed()) { + return + } + const view = overlayViewsByWindowId.get(win.id) + if (view == null || view.webContents.isDestroyed()) { + try { + if (!win.webContents.isDestroyed()) { + win.webContents.send(IpcMessage.OpenFindBar) + } + } catch (err) { + console.error('[find] openFindBar fallback send failed:', err) + } + return + } + try { + layoutFindOverlayBounds(win, view, true) + view.webContents.send(IpcMessage.OpenFindBar) + } catch (err) { + console.error('[find] openFindBar overlay send failed:', err) + } +} + +export function attachResizeRelayoutFindOverlay (win: BrowserWindow): void { + const onResize = (): void => { + if (win.isDestroyed()) { + return + } + if (overlayVisibleByWindowId.get(win.id) !== true) { + return + } + const view = overlayViewsByWindowId.get(win.id) + if (view == null || view.webContents.isDestroyed()) { + return + } + layoutFindOverlayBounds(win, view, true) + } + win.on('resize', onResize) +} + +export function registerFindInPageIpcHandlers (): void { + if (ipcHandlersRegistered) { + return + } + ipcHandlersRegistered = true + + ipcMain.handle(IpcMessage.FindInPage, async (event, text: string, options?: { forward?: boolean, findNext?: boolean, matchCase?: boolean, wordStart?: boolean, medialCapitalAsWordStart?: boolean }) => { + try { + const wc = webContentsIfAlive(resolveFindTarget(event.sender)) + if (wc == null) { + return -1 + } + if (text === '') { + wc.stopFindInPage('clearSelection') + return -1 + } + return await Promise.resolve(wc.findInPage(text, options ?? {})) + } catch (err) { + console.error('[find] findInPage handler failed:', err) + return -1 + } + }) + + ipcMain.handle(IpcMessage.StopFindInPage, async (event, action: 'clearSelection' | 'keepSelection' | 'activateSelection') => { + try { + const wc = webContentsIfAlive(resolveFindTarget(event.sender)) + if (wc == null) { + return + } + wc.stopFindInPage(action) + } catch (err) { + console.error('[find] stopFindInPage handler failed:', err) + } + }) + + ipcMain.on(IpcMessage.FindOverlayLayout, (event, visible: boolean) => { + try { + const win = BrowserWindow.fromWebContents(event.sender) + if (win == null || win.isDestroyed()) { + return + } + const view = overlayViewsByWindowId.get(win.id) + if (view == null || view.webContents.isDestroyed()) { + return + } + layoutFindOverlayBounds(win, view, visible) + } catch (err) { + console.error('[find] FindOverlayLayout handler failed:', err) + } + }) +} + +export function attachFindShortcutToWebContents (wc: WebContents, openFindBar: () => void): void { + wc.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') { + return + } + const mod = input.control || input.meta + if (!mod || input.alt) { + return + } + const key = input.key.toLowerCase() + if (key !== 'f' || input.shift) { + return + } + event.preventDefault() + openFindBar() + }) +} diff --git a/desktop/src/main/findInPageOverlayHost.ts b/desktop/src/main/findInPageOverlayHost.ts new file mode 100644 index 0000000000..38d60ea6ae --- /dev/null +++ b/desktop/src/main/findInPageOverlayHost.ts @@ -0,0 +1,110 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { BrowserView, BrowserWindow } from 'electron' +import path from 'path' +import { + attachFindInPageResultForwarding, + attachFindShortcutToWebContents, + attachResizeRelayoutFindOverlay, + openFindInPageBar, + registerFindInPageTarget, + registerFindOverlayView, + unregisterFindInPageForwarding, + unregisterFindInPageTarget, + unregisterFindOverlayView +} from './findInPage' +import { getBundledUiDistPath } from './path' + +function destroyBrowserViewSafe (view: BrowserView): void { + try { + if (view.webContents.isDestroyed()) { + return + } + const destroy = (view.webContents as { destroy?: () => void }).destroy + destroy?.() + } catch { + /* ignore */ + } +} + +/** + * Hosts the find UI in a separate BrowserView so `webContents.findInPage` on the + * main page does not match the query typed in the find field. + * + * If the overlay cannot be loaded, the window still works: Cmd/Ctrl+F becomes a + * no-op for the overlay (main page may still receive `OpenFindBar` if anything listens). + */ +export async function setupFindInPageOverlayForWindow (win: BrowserWindow, sessionPartition: string, preloadScriptPath: string): Promise { + const pageWc = win.webContents + const overlayHtmlPath = path.join(getBundledUiDistPath(), 'find-in-page-overlay.html') + + const view = new BrowserView({ + webPreferences: { + devTools: true, + sandbox: false, + nodeIntegration: true, + partition: sessionPartition, + preload: preloadScriptPath + } + }) + + const openFind = (): void => { + openFindInPageBar(win) + } + + try { + await view.webContents.loadFile(overlayHtmlPath) + } catch (err) { + console.error('[find] Overlay failed to load; find bar disabled for this window:', overlayHtmlPath, err) + destroyBrowserViewSafe(view) + attachFindShortcutToWebContents(pageWc, openFind) + return + } + + if (win.isDestroyed() || pageWc.isDestroyed()) { + destroyBrowserViewSafe(view) + return + } + + try { + win.addBrowserView(view) + view.setBounds({ x: 0, y: 0, width: 0, height: 0 }) + registerFindOverlayView(win, view) + attachResizeRelayoutFindOverlay(win) + registerFindInPageTarget(view.webContents, pageWc) + attachFindInPageResultForwarding(pageWc, view.webContents) + attachFindShortcutToWebContents(pageWc, openFind) + attachFindShortcutToWebContents(view.webContents, openFind) + + win.on('close', () => { + unregisterFindOverlayView(win) + if (!view.webContents.isDestroyed()) { + unregisterFindInPageTarget(view.webContents) + } + unregisterFindInPageForwarding(pageWc) + }) + } catch (err) { + console.error('[find] Overlay registration failed; find bar disabled for this window:', err) + try { + win.removeBrowserView(view) + } catch { + /* ignore */ + } + unregisterFindOverlayView(win) + destroyBrowserViewSafe(view) + attachFindShortcutToWebContents(pageWc, openFind) + } +} diff --git a/desktop/src/main/path.ts b/desktop/src/main/path.ts index 5de505a45f..4912c98626 100644 --- a/desktop/src/main/path.ts +++ b/desktop/src/main/path.ts @@ -14,8 +14,12 @@ // import { app } from 'electron' -import path from 'path' +import * as nodePath from 'path' + +export function getBundledUiDistPath (): string { + return nodePath.join(app.getAppPath(), 'dist', 'ui') +} export function getFileInPublicBundledFolder (fileName: string): string { - return path.join(app.getAppPath(), 'dist', 'ui', 'public', fileName) + return nodePath.join(getBundledUiDistPath(), 'public', fileName) } diff --git a/desktop/src/main/standardMenu.ts b/desktop/src/main/standardMenu.ts index 2cbc0b7d10..c8f794224e 100644 --- a/desktop/src/main/standardMenu.ts +++ b/desktop/src/main/standardMenu.ts @@ -13,8 +13,9 @@ // limitations under the License. // -import { Menu, MenuItemConstructorOptions } from 'electron' +import { BrowserWindow, Menu, MenuItemConstructorOptions } from 'electron' import { Command, CommandOpenSettings, CommandSelectWorkspace, CommandLogout } from '../ui/types' +import { openFindInPageBar } from './findInPage' const isMac = process.platform === 'darwin' const isLinux = process.platform === 'linux' @@ -40,6 +41,17 @@ export const addMenus = (sendCommand: (cmd: Command, ...args: any[]) => void): v { role: isMac ? 'close' : 'quit' } ] }, + { + label: 'Search', + submenu: [ + { + label: 'Find…', + click: (_item, browserWindow) => { + openFindInPageBar(browserWindow as BrowserWindow | undefined) + } + } + ] + }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' } diff --git a/desktop/src/main/start.ts b/desktop/src/main/start.ts index ffe16d40ff..44198c3aac 100644 --- a/desktop/src/main/start.ts +++ b/desktop/src/main/start.ts @@ -26,6 +26,8 @@ import { Config, MenuBarAction, NotificationParams, JumpListSpares, CommandClose import { getOptions } from './args' import { addMenus } from './standardMenu' import { dispatchMenuBarAction } from './customMenu' +import { registerFindInPageIpcHandlers } from './findInPage' +import { setupFindInPageOverlayForWindow } from './findInPageOverlayHost' import { addPermissionHandlers } from './permissions' import autoUpdater from './updater' import { generateId } from '@hcengineering/core' @@ -163,6 +165,11 @@ function runTheApp (): void { } setupWindowTitleBar(windowOptions) const childWindow = new BrowserWindow(windowOptions) + try { + await setupFindInPageOverlayForWindow(childWindow, sessionPartition, preloadScriptPath) + } catch (err) { + log.error('Find overlay setup failed (child window)', err) + } await childWindow.loadFile(containerPagePath) hookOpenWindow(childWindow) })() @@ -263,6 +270,11 @@ function runTheApp (): void { } setupWindowTitleBar(windowOptions) mainWindow = new BrowserWindow(windowOptions) + try { + await setupFindInPageOverlayForWindow(mainWindow, sessionPartition, preloadScriptPath) + } catch (err) { + log.error('Find overlay setup failed (main window)', err) + } app.dock?.setIcon(nativeImage.createFromPath(iconKey)) if (isDev) { mainWindow.webContents.openDevTools() @@ -382,6 +394,8 @@ function runTheApp (): void { showSelectAll: false }) + registerFindInPageIpcHandlers() + ipcMain.on(IpcMessage.SetBadge, (_event: any, badge: number) => { app.dock?.setBadge(badge > 0 ? `${badge}` : '') app.badgeCount = badge diff --git a/desktop/src/ui/find-in-page-overlay.ejs b/desktop/src/ui/find-in-page-overlay.ejs new file mode 100644 index 0000000000..f7b5a230d5 --- /dev/null +++ b/desktop/src/ui/find-in-page-overlay.ejs @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/desktop/src/ui/findInPageBar.ts b/desktop/src/ui/findInPageBar.ts new file mode 100644 index 0000000000..f5eb706e7f --- /dev/null +++ b/desktop/src/ui/findInPageBar.ts @@ -0,0 +1,427 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { IPCMainExposed } from './types' + +const DEBOUNCE_MS = 300 + +/** + * Find UI is loaded in a separate BrowserView (see `findInPageOverlayHost.ts`) so + * `findInPage` on the page webContents does not search the query in this input. + * Shadow DOM keeps chrome text minimal. Match counts are not shown. + * + * Chromium moves focus to the matched text in the **page** webContents after + * `findInPage`, so the overlay stops receiving keystrokes unless we put focus back + * on the field after each search (caret is restored from before the call; we do not + * steal focus on arbitrary blur — only after our own `findInPage`). + */ +export function setupDesktopFindInPageBar (electronApi: IPCMainExposed): void { + const host = document.createElement('div') + host.id = 'desktop-find-bar-host' + host.style.display = 'none' + + const shadow = host.attachShadow({ mode: 'open' }) + + const style = document.createElement('style') + style.textContent = shadowStyles + + const root = document.createElement('div') + root.className = 'bar' + root.setAttribute('role', 'search') + + const input = document.createElement('input') + input.className = 'field' + input.type = 'text' + input.placeholder = '' + input.autocomplete = 'off' + input.spellcheck = false + input.setAttribute('aria-label', 'Find in page') + + const nav = document.createElement('div') + nav.className = 'nav' + nav.setAttribute('role', 'group') + nav.setAttribute('aria-label', 'Find matches') + + const prevBtn = document.createElement('button') + prevBtn.type = 'button' + prevBtn.className = 'icon-btn prev' + prevBtn.setAttribute('aria-label', 'Previous match') + prevBtn.title = 'Previous (Shift+Enter)' + + const nextBtn = document.createElement('button') + nextBtn.type = 'button' + nextBtn.className = 'icon-btn next' + nextBtn.setAttribute('aria-label', 'Next match') + nextBtn.title = 'Next (Enter)' + + nav.appendChild(prevBtn) + nav.appendChild(nextBtn) + + const closeBtn = document.createElement('button') + closeBtn.type = 'button' + closeBtn.className = 'icon-btn close-x' + closeBtn.setAttribute('aria-label', 'Close') + closeBtn.title = 'Close' + + root.appendChild(input) + root.appendChild(nav) + root.appendChild(closeBtn) + shadow.appendChild(style) + shadow.appendChild(root) + document.body.appendChild(host) + + let visible = false + let debounceTimer: ReturnType | undefined + + function getQuery (): string { + return input.value.trim() + } + + function updateNavState (): void { + const q = getQuery() + prevBtn.disabled = q === '' + nextBtn.disabled = q === '' + } + + function show (): void { + const openingFromHidden = !visible + visible = true + host.style.display = 'block' + try { + electronApi.notifyFindOverlayLayout(true) + } catch { + /* ignore — overlay hit area may be wrong but avoid breaking the window */ + } + input.focus() + if (openingFromHidden) { + input.select() + } + updateNavState() + } + + function hide (): void { + visible = false + host.style.display = 'none' + prevBtn.disabled = true + nextBtn.disabled = true + try { + electronApi.notifyFindOverlayLayout(false) + } catch { + /* ignore */ + } + void electronApi.stopFindInPage('clearSelection').catch(() => {}) + } + + async function find (text: string, options?: { findNext?: boolean, forward?: boolean }): Promise { + const selStart = input.selectionStart ?? input.value.length + const selEnd = input.selectionEnd ?? input.value.length + try { + await electronApi.findInPage(text, { + forward: options?.forward ?? true, + findNext: options?.findNext ?? false + }) + } catch { + return + } + if (!visible) { + return + } + // Page view steals focus when a match is highlighted; without this, further typing + // never reaches the input and scheduleFind appears to "stop working". + input.focus({ preventScroll: true }) + const max = input.value.length + try { + input.setSelectionRange(Math.min(selStart, max), Math.min(selEnd, max)) + } catch { + /* ignored */ + } + } + + function runFindNext (forward: boolean): void { + const q = getQuery() + if (q === '') { + return + } + void find(q, { findNext: true, forward }) + } + + function scheduleFind (): void { + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer) + } + debounceTimer = setTimeout(() => { + debounceTimer = undefined + const q = getQuery() + if (q === '') { + void electronApi.stopFindInPage('clearSelection').catch(() => {}) + updateNavState() + return + } + void find(q, { findNext: false, forward: true }) + }, DEBOUNCE_MS) + } + + electronApi.onOpenFindBar(() => { + show() + if (getQuery() !== '') { + scheduleFind() + } + }) + + input.addEventListener('input', () => { + scheduleFind() + updateNavState() + }) + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + if (getQuery() === '') { + return + } + runFindNext(!e.shiftKey) + return + } + if (e.key === 'Escape') { + e.preventDefault() + hide() + } + }) + + prevBtn.addEventListener('click', () => { + runFindNext(false) + }) + + nextBtn.addEventListener('click', () => { + runFindNext(true) + }) + + closeBtn.addEventListener('click', () => { + hide() + }) + + document.addEventListener( + 'keydown', + (e) => { + if (!visible || e.key !== 'Escape') { + return + } + const t = e.target as Node + if (!root.contains(t)) { + return + } + e.stopPropagation() + }, + true + ) +} + +/** Left-pointing chevron; `.next::after` mirrors with `scaleX(-1)` for identical vertical alignment. */ +const chevronMaskUrl = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M15.41 16.59 10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z'/%3E%3C/svg%3E\")" + +/** Close (×); same mask + `currentColor` treatment as `.prev` / `.next`. */ +const closeIconMaskUrl = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E\")" + +const shadowStyles = ` +:host { + all: initial; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.bar { + position: fixed; + top: 12px; + right: 12px; + z-index: 2147483647; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); + color: #1a1a1a; + font-size: 13px; +} + +@media (prefers-color-scheme: dark) { + .bar { + background: rgba(42, 42, 46, 0.96); + box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08); + color: #e8e8e8; + } +} + +:host-context([data-theme='theme-dark']) .bar { + background: rgba(42, 42, 46, 0.96); + box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08); + color: #e8e8e8; +} + +.field { + width: 200px; + padding: 7px 10px; + border: 1px solid rgba(0, 0, 0, 0.14); + border-radius: 8px; + background: transparent; + color: inherit; + font-size: 13px; + line-height: 1.35; + outline: none; + box-sizing: border-box; +} + +@media (prefers-color-scheme: dark) { + .field { + border-color: rgba(255, 255, 255, 0.16); + } +} + +:host-context([data-theme='theme-dark']) .field { + border-color: rgba(255, 255, 255, 0.16); +} + +.field:focus { + border-color: color-mix(in srgb, var(--accent-color, #0b74da) 42%, rgba(0, 0, 0, 0.2)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-color, #0b74da) 35%, transparent); +} + +@media (prefers-color-scheme: dark) { + .field:focus { + border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22)); + } +} + +:host-context([data-theme='theme-dark']) .field:focus { + border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22)); +} + +.field::placeholder { + color: transparent; +} + +.nav { + display: flex; + align-items: center; + gap: 2px; + padding-left: 4px; + margin-left: 2px; + border-left: 1px solid rgba(0, 0, 0, 0.08); +} + +@media (prefers-color-scheme: dark) { + .nav { + border-left-color: rgba(255, 255, 255, 0.12); + } +} + +:host-context([data-theme='theme-dark']) .nav { + border-left-color: rgba(255, 255, 255, 0.12); +} + +.icon-btn { + position: relative; + box-sizing: border-box; + width: 30px; + height: 30px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: inherit; + opacity: 0.72; + cursor: pointer; + flex-shrink: 0; +} + +.icon-btn.prev, +.icon-btn.next, +.icon-btn.close-x { + display: flex; + align-items: center; + justify-content: center; +} + +.icon-btn:hover:not(:disabled) { + opacity: 1; + background: rgba(0, 0, 0, 0.05); +} + +@media (prefers-color-scheme: dark) { + .icon-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + } +} + +:host-context([data-theme='theme-dark']) .icon-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.icon-btn:disabled { + opacity: 0.28; + cursor: default; +} + +.prev::after, +.next::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 22px; + height: 22px; + margin: 0; + padding: 0; + transform: translate(-50%, -50%); + background-color: currentColor; + -webkit-mask-image: ${chevronMaskUrl}; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-image: ${chevronMaskUrl}; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + pointer-events: none; +} + +.next::after { + transform: translate(-50%, -50%) scaleX(-1); +} + +.close-x::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 18px; + height: 18px; + margin: 0; + padding: 0; + transform: translate(-50%, -50%); + background-color: currentColor; + -webkit-mask-image: ${closeIconMaskUrl}; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-image: ${closeIconMaskUrl}; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + pointer-events: none; +} +` diff --git a/desktop/src/ui/findInPageOverlay.ts b/desktop/src/ui/findInPageOverlay.ts new file mode 100644 index 0000000000..9105696b4c --- /dev/null +++ b/desktop/src/ui/findInPageOverlay.ts @@ -0,0 +1,25 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { setupDesktopFindInPageBar } from './findInPageBar' +import { ipcMainExposed } from './typesUtils' + +window.addEventListener('DOMContentLoaded', () => { + try { + setupDesktopFindInPageBar(ipcMainExposed()) + } catch (err) { + console.error('[find] Overlay UI failed to initialize:', err) + } +}) diff --git a/desktop/src/ui/ipcMessages.ts b/desktop/src/ui/ipcMessages.ts index 759c593d35..40b15a0b4d 100644 --- a/desktop/src/ui/ipcMessages.ts +++ b/desktop/src/ui/ipcMessages.ts @@ -43,5 +43,10 @@ export const IpcMessage = { OnDeepLinkHandler: 'on-deep-link-handler', HandleNotificationNavigation: 'handle-notification-navigation', HandleUpdateDownloadProgress: 'handle-update-download-progress', - HandleAuth: 'handle-auth' + HandleAuth: 'handle-auth', + OpenFindBar: 'open-find-bar', + FindInPage: 'find-in-page', + StopFindInPage: 'stop-find-in-page', + FindInPageResult: 'find-in-page-result', + FindOverlayLayout: 'find-overlay-layout' } as const diff --git a/desktop/src/ui/preload.ts b/desktop/src/ui/preload.ts index 57fe324203..2367e56af9 100644 --- a/desktop/src/ui/preload.ts +++ b/desktop/src/ui/preload.ts @@ -14,7 +14,7 @@ // import { contextBridge, ipcRenderer } from 'electron' -import { BrandingMap, Config, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types' +import { BrandingMap, Config, DesktopFoundInPageResult, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types' import { IpcMessage } from './ipcMessages' export function concatLink (host: string, path: string): string { @@ -201,6 +201,32 @@ const expose: IPCMainExposed = { }, onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => { ipcRenderer.on(IpcMessage.AutoLaunchSettingChanged, (_event, enabled: boolean) => { callback(enabled) }) + }, + + onOpenFindBar: (callback: () => void) => { + ipcRenderer.removeAllListeners(IpcMessage.OpenFindBar) + ipcRenderer.on(IpcMessage.OpenFindBar, () => { + callback() + }) + }, + + findInPage: async (text, options) => { + return await ipcRenderer.invoke(IpcMessage.FindInPage, text, options ?? {}) + }, + + stopFindInPage: async (action) => { + await ipcRenderer.invoke(IpcMessage.StopFindInPage, action) + }, + + onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => { + ipcRenderer.removeAllListeners(IpcMessage.FindInPageResult) + ipcRenderer.on(IpcMessage.FindInPageResult, (_event, result: DesktopFoundInPageResult) => { + callback(result) + }) + }, + + notifyFindOverlayLayout: (visible: boolean) => { + ipcRenderer.send(IpcMessage.FindOverlayLayout, visible) } } contextBridge.exposeInMainWorld('electron', expose) diff --git a/desktop/src/ui/titleBarMenu.ts b/desktop/src/ui/titleBarMenu.ts index 601bbd2148..b7cc7d7d3c 100644 --- a/desktop/src/ui/titleBarMenu.ts +++ b/desktop/src/ui/titleBarMenu.ts @@ -111,6 +111,7 @@ export function buildHulyApplicationMenu (minimizeToTrayEnabled: boolean, autoLa .addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p') .addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd') .addSeparator(MenuEditIndex) + .addMenuItem(MenuEditIndex, 'Find', 'find', 'Ctrl+F', 'f') .addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a') const MenuViewIndex = 2 diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 7141dbc2da..d2c018d31a 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -140,6 +140,7 @@ export const MenuBarActions = [ 'copy', 'paste', 'delete', + 'find', 'select-all', 'reload', 'force-reload', @@ -200,7 +201,31 @@ export interface IPCMainExposed { onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => void isAutoLaunchEnabled: () => Promise onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => void + + onOpenFindBar: (callback: () => void) => void + findInPage: (text: string, options?: DesktopFindInPageOptions) => Promise + stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => Promise + onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => void + /** Resize the find BrowserView hit target (main process); overlay document only. */ + notifyFindOverlayLayout: (visible: boolean) => void } export type SendCommandDelegate = (cmd: Command, ...args: any[]) => void export type WindowAction = () => void + +/** Options passed to `webContents.findInPage` from the renderer. */ +export interface DesktopFindInPageOptions { + forward?: boolean + findNext?: boolean + matchCase?: boolean + wordStart?: boolean + medialCapitalAsWordStart?: boolean +} + +/** Payload mirrored from Electron `found-in-page` (subset used by the find bar UI). */ +export interface DesktopFoundInPageResult { + requestId: number + activeMatchOrdinal: number + matches: number + finalUpdate: boolean +} diff --git a/desktop/webpack.config.js b/desktop/webpack.config.js index 1ac4436d49..d955e51256 100644 --- a/desktop/webpack.config.js +++ b/desktop/webpack.config.js @@ -126,7 +126,8 @@ module.exports = [ { entry: { bundle: ['@hcengineering/theme/styles/global.scss', ...['./src/ui/index.ts']], - 'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts' + 'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts', + findInPageOverlay: './src/ui/findInPageOverlay.ts' }, ignoreWarnings: [ { @@ -340,6 +341,14 @@ module.exports = [ isWindows: true } }), + new HtmlWebpackPlugin({ + template: './src/ui/find-in-page-overlay.ejs', + filename: 'find-in-page-overlay.html', + chunks: ['findInPageOverlay'], + inject: 'body', + publicPath: '', + scriptLoading: 'blocking' + }), ...(!dev ? [new CompressionPlugin()] : []), // new MiniCssExtractPlugin({ // filename: '[name].[id][contenthash].css'