mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
fix: retire native edit menus when their renderer document ends (#35383)
* fix: retire editable context menus with renderer documents * fix: retire edit menus opened during a pending reload at commit A right-click while a cross-document reload is pending opens a menu after the navigation fence fired. Close it at main-frame commit and drop clicks from retired menus, since macOS closePopup is posted asynchronously. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -36,7 +36,9 @@ function openMainWindowForContextMenu() {
|
||||
openDevTools: vi.fn(),
|
||||
closeDevTools: vi.fn(),
|
||||
replaceMisspelling: vi.fn(),
|
||||
session: { addWordToSpellCheckerDictionary: vi.fn() }
|
||||
session: { addWordToSpellCheckerDictionary: vi.fn() },
|
||||
getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'),
|
||||
isLoadingMainFrame: vi.fn(() => true)
|
||||
}
|
||||
const browserWindowInstance = {
|
||||
webContents,
|
||||
@@ -531,7 +533,12 @@ describe('createMainWindow', () => {
|
||||
expect.objectContaining({ label: 'Table' })
|
||||
])
|
||||
)
|
||||
expect(menuPopupMock).toHaveBeenCalledWith({ window: browserWindowInstance, x: 42, y: 84 })
|
||||
expect(menuPopupMock).toHaveBeenCalledWith({
|
||||
window: browserWindowInstance,
|
||||
x: 42,
|
||||
y: 84,
|
||||
callback: expect.any(Function)
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the plain edit menu for the one right-click the renderer marks as plain text', () => {
|
||||
@@ -567,4 +574,56 @@ describe('createMainWindow', () => {
|
||||
expect.arrayContaining([expect.objectContaining({ label: 'Format' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('retires the edit menu with its document but not on same-document navigation', () => {
|
||||
const { webContents } = openMainWindowForContextMenu()
|
||||
const emit = (event: string, ...args: unknown[]): void => {
|
||||
for (const [name, handler] of webContents.on.mock.calls) {
|
||||
if (name === event) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
const openMenu = () => {
|
||||
const closePopup = vi.fn()
|
||||
buildFromTemplateMock.mockImplementationOnce(() => ({
|
||||
popup: menuPopupMock,
|
||||
closePopup
|
||||
}))
|
||||
emit(
|
||||
'context-menu',
|
||||
{},
|
||||
{
|
||||
x: 1,
|
||||
y: 2,
|
||||
isEditable: true,
|
||||
formControlType: 'input',
|
||||
dictionarySuggestions: [],
|
||||
misspelledWord: ''
|
||||
}
|
||||
)
|
||||
const template = buildFromTemplateMock.mock.calls.at(-1)?.[0]
|
||||
const paste = Array.isArray(template)
|
||||
? template.find((item) => item.label === 'Paste')
|
||||
: undefined
|
||||
expect(paste?.click).toBeTypeOf('function')
|
||||
return { closePopup, paste }
|
||||
}
|
||||
const nextDocument = 'file:///opt/orca/renderer/index.html?reload'
|
||||
|
||||
const first = openMenu()
|
||||
emit('did-start-navigation', {}, `${nextDocument}#hash`, true, true)
|
||||
expect(first.closePopup).not.toHaveBeenCalled()
|
||||
emit('did-start-navigation', {}, nextDocument, false, true)
|
||||
expect(first.closePopup).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The old document still takes right-clicks until the pending reload commits.
|
||||
const pending = openMenu()
|
||||
emit('did-frame-navigate', {}, nextDocument, 200, 'OK', false)
|
||||
expect(pending.closePopup).not.toHaveBeenCalled()
|
||||
emit('did-frame-navigate', {}, nextDocument, 200, 'OK', true)
|
||||
expect(pending.closePopup).toHaveBeenCalledTimes(1)
|
||||
pending.paste.click()
|
||||
expect(webContents.send).not.toHaveBeenCalledWith('ui:editableContextPaste', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,9 +16,9 @@ export const attachRouteGuestMock: MainWindowSpy = vi.fn(() => false)
|
||||
export const retireRouteRendererMock: MainWindowSpy = vi.fn()
|
||||
export const attachClientPageRendererMock: MainWindowSpy = vi.fn()
|
||||
export const retireClientPageRendererMock: MainWindowSpy = vi.fn()
|
||||
export const buildFromTemplateMock: Mock<(...args: unknown[]) => { popup: MainWindowSpy }> = vi.fn(
|
||||
() => ({ popup: menuPopupMock })
|
||||
)
|
||||
export const buildFromTemplateMock: Mock<
|
||||
(...args: unknown[]) => { popup: MainWindowSpy; closePopup: MainWindowSpy }
|
||||
> = vi.fn(() => ({ popup: menuPopupMock, closePopup: vi.fn() }))
|
||||
export const notificationMock: Mock<(...args: unknown[]) => { show: MainWindowSpy }> = vi.fn(
|
||||
function () {
|
||||
return { show: notificationShowMock }
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Menu,
|
||||
type BrowserWindow,
|
||||
type MenuItemConstructorOptions,
|
||||
type WebContents
|
||||
} from 'electron'
|
||||
|
||||
type EditableContextMenuPopup = {
|
||||
open: (template: MenuItemConstructorOptions[], point: { x: number; y: number }) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
type Retirement = { retired: boolean }
|
||||
type OpenMenu = { menu: Menu; retirement: Retirement }
|
||||
|
||||
// macOS closePopup is posted asynchronously, so a click can still land after retirement.
|
||||
function retirableTemplate(
|
||||
template: MenuItemConstructorOptions[],
|
||||
retirement: Retirement
|
||||
): MenuItemConstructorOptions[] {
|
||||
return template.map((item) => {
|
||||
const { click, submenu } = item
|
||||
return {
|
||||
...item,
|
||||
...(click
|
||||
? {
|
||||
click: (...args: Parameters<typeof click>) => {
|
||||
if (!retirement.retired) {
|
||||
click(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(Array.isArray(submenu) ? { submenu: retirableTemplate(submenu, retirement) } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createEditableContextMenuPopup(
|
||||
window: BrowserWindow,
|
||||
webContents: Pick<WebContents, 'on'>
|
||||
): EditableContextMenuPopup {
|
||||
let current: OpenMenu | null = null
|
||||
const close = (): void => {
|
||||
const opened = current
|
||||
current = null
|
||||
if (opened) {
|
||||
opened.retirement.retired = true
|
||||
opened.menu.closePopup()
|
||||
}
|
||||
}
|
||||
// A menu opened while a reload is pending belongs to the old document; retire it at commit.
|
||||
webContents.on('did-frame-navigate', (_event, _url, _code, _status, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
close()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
open: (template, point) => {
|
||||
close()
|
||||
const retirement: Retirement = { retired: false }
|
||||
const menu = Menu.buildFromTemplate(retirableTemplate(template, retirement))
|
||||
const opened: OpenMenu = { menu, retirement }
|
||||
current = opened
|
||||
menu.popup({
|
||||
window,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
callback: () => {
|
||||
if (current === opened) {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
close
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcMain, Menu, type BrowserWindow } from 'electron'
|
||||
import { ipcMain, type BrowserWindow } from 'electron'
|
||||
import { isCrashReportReason } from '../../shared/crash-reporting'
|
||||
import {
|
||||
plainTextContextMenuTargetChannel,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
matchingRichMarkdownContextMenuTableTarget,
|
||||
parseRichMarkdownContextMenuTableTarget
|
||||
} from './editable-context-menu'
|
||||
import { createEditableContextMenuPopup } from './editable-context-menu-popup'
|
||||
import type { CreateMainWindowOptions, MainWindowLoadObserver } from './main-window-contracts'
|
||||
import { browserRouteWebContentsRegistry } from '../browser/browser-route-session-runtime'
|
||||
import {
|
||||
@@ -114,6 +115,7 @@ export function installMainWindowFocusLifecycle(args: {
|
||||
}
|
||||
}
|
||||
ipcMain.on(plainTextContextMenuTargetChannel, onPlainTextContextMenuTarget)
|
||||
const editableContextMenuPopup = createEditableContextMenuPopup(mainWindow, rendererWebContents)
|
||||
const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
|
||||
const tableTarget = matchingRichMarkdownContextMenuTableTarget(
|
||||
params,
|
||||
@@ -130,12 +132,13 @@ export function installMainWindowFocusLifecycle(args: {
|
||||
return
|
||||
}
|
||||
// Context menu may precede focus mirroring; trust Electron's editable params.
|
||||
Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y })
|
||||
editableContextMenuPopup.open(template, params)
|
||||
}
|
||||
rendererWebContents.on('context-menu', onMainContextMenu)
|
||||
|
||||
// Dead renderers cannot clear focus mirrors; reset carve-outs so later lifecycles retain app shortcuts.
|
||||
const resetMarkdownEditorFocus = (): void => {
|
||||
editableContextMenuPopup.close()
|
||||
markdownEditorFocused = false
|
||||
pendingRichMarkdownContextMenuTableTarget = null
|
||||
pendingPlainTextContextMenuTarget = false
|
||||
|
||||
Reference in New Issue
Block a user