feat: Support find in desktop client (#10723)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-05 14:44:22 +07:00
committed by GitHub
parent 85c991ffcc
commit d78d9daba5
19 changed files with 1093 additions and 13 deletions
+3 -1
View File
@@ -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', () => ({
@@ -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<string, IpcHandlerFn>()
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')
})
})
+45
View File
@@ -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')
)
})
})
+1
View File
@@ -18,6 +18,7 @@ import { OptionValues, program } from 'commander'
program
.name('Huly')
.allowUnknownOption()
.allowExcessArguments(true)
.option('-s, --server <url>', 'Remote server URL (front). E.g. https://huly.app')
let opts: OptionValues | null = null
+15 -5
View File
@@ -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())
}
+5 -1
View File
@@ -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
+207
View File
@@ -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<number, WebContents>()
/** Page webContents id → overlay that should receive `found-in-page` IPC. */
const findResultRecipientByPageId = new Map<number, WebContents>()
const pagesWithFoundInPageListener = new WeakSet<WebContents>()
const overlayViewsByWindowId = new Map<number, BrowserView>()
const overlayVisibleByWindowId = new Map<number, boolean>()
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()
})
}
+110
View File
@@ -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<void> {
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)
}
}
+6 -2
View File
@@ -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)
}
+13 -1
View File
@@ -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' }
+14
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title></title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
</style>
</head>
<body>
</body>
</html>
+427
View File
@@ -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<typeof setTimeout> | 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<void> {
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;
}
`
+25
View File
@@ -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)
}
})
+6 -1
View File
@@ -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
+27 -1
View File
@@ -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)
+1
View File
@@ -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
+25
View File
@@ -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<boolean>
onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => void
onOpenFindBar: (callback: () => void) => void
findInPage: (text: string, options?: DesktopFindInPageOptions) => Promise<number>
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => Promise<void>
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
}
+10 -1
View File
@@ -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'