Skip to content

Commit 436ffd6

Browse files
committed
add right click to browser and cleanup terminal right click options
1 parent 2381ef7 commit 436ffd6

10 files changed

Lines changed: 609 additions & 61 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import type { ContextMenuParams, MenuItemConstructorOptions } from 'electron'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
vi.mock('electron', () => import('@/test/electron-mock'))
5+
6+
import { Menu, WebContentsView } from 'electron'
7+
import {
8+
attachAgentContextMenu,
9+
buildAgentContextMenuTemplate,
10+
steppedZoomFactor,
11+
} from '@/main/browser-agent/context-menu'
12+
13+
const EDIT_FLAGS: ContextMenuParams['editFlags'] = {
14+
canUndo: false,
15+
canRedo: false,
16+
canCut: false,
17+
canCopy: false,
18+
canPaste: false,
19+
canDelete: false,
20+
canSelectAll: false,
21+
canEditRichly: false,
22+
}
23+
24+
type Params = Parameters<typeof buildAgentContextMenuTemplate>[0]
25+
type Page = Parameters<typeof buildAgentContextMenuTemplate>[1]
26+
type Handlers = Parameters<typeof buildAgentContextMenuTemplate>[2]
27+
28+
function params(overrides: Partial<Params> = {}): Params {
29+
return { selectionText: '', linkURL: '', isEditable: false, editFlags: EDIT_FLAGS, ...overrides }
30+
}
31+
32+
function page(overrides: Partial<Page> = {}): Page {
33+
return { canGoBack: true, canGoForward: true, zoomFactor: 1, ...overrides }
34+
}
35+
36+
function handlers(): Handlers {
37+
return {
38+
copy: vi.fn(),
39+
paste: vi.fn(),
40+
back: vi.fn(),
41+
forward: vi.fn(),
42+
reload: vi.fn(),
43+
openTab: vi.fn(),
44+
copyLink: vi.fn(),
45+
setZoomFactor: vi.fn(),
46+
}
47+
}
48+
49+
const labels = (template: MenuItemConstructorOptions[]) =>
50+
template.filter((item) => item.type !== 'separator').map((item) => item.label)
51+
52+
const item = (template: MenuItemConstructorOptions[], label: string) =>
53+
template.find((entry) => entry.label === label)
54+
55+
describe('buildAgentContextMenuTemplate', () => {
56+
it('always offers navigation and zoom, whatever was clicked', () => {
57+
const template = buildAgentContextMenuTemplate(params(), page(), handlers())
58+
59+
// Unlike the main window's menu, an empty template is not an option here:
60+
// the page has no menu of its own to fall back to.
61+
expect(labels(template)).toEqual([
62+
'Back',
63+
'Forward',
64+
'Reload',
65+
'Zoom In',
66+
'Zoom Out',
67+
'Actual Size (100%)',
68+
])
69+
})
70+
71+
it('offers clipboard items only where the click can use them', () => {
72+
expect(labels(buildAgentContextMenuTemplate(params(), page(), handlers()))).not.toContain(
73+
'Copy'
74+
)
75+
76+
const withSelection = buildAgentContextMenuTemplate(
77+
params({ selectionText: ' hello ' }),
78+
page(),
79+
handlers()
80+
)
81+
expect(labels(withSelection)).toContain('Copy')
82+
83+
const inField = buildAgentContextMenuTemplate(
84+
params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }),
85+
page(),
86+
handlers()
87+
)
88+
expect(labels(inField)).toContain('Paste')
89+
90+
// A read-only field can report canPaste; both signals have to agree.
91+
const readOnly = buildAgentContextMenuTemplate(
92+
params({ isEditable: false, editFlags: { ...EDIT_FLAGS, canPaste: true } }),
93+
page(),
94+
handlers()
95+
)
96+
expect(labels(readOnly)).not.toContain('Paste')
97+
})
98+
99+
it('offers link items for http(s) targets only', () => {
100+
const handled = handlers()
101+
const template = buildAgentContextMenuTemplate(
102+
params({ linkURL: 'https://example.com/docs' }),
103+
page(),
104+
handled
105+
)
106+
expect(labels(template)).toContain('Open Link in New Tab')
107+
108+
item(template, 'Open Link in New Tab')?.click?.({} as never, undefined as never, {} as never)
109+
expect(handled.openTab).toHaveBeenCalledWith('https://example.com/docs')
110+
111+
// The actions open a tab or copy an address; neither means anything for a
112+
// script or mail target, so the menu must not offer them.
113+
for (const linkURL of ['javascript:alert(1)', 'mailto:a@b.com', 'file:///etc/passwd']) {
114+
const other = buildAgentContextMenuTemplate(params({ linkURL }), page(), handlers())
115+
expect(labels(other)).not.toContain('Open Link in New Tab')
116+
expect(labels(other)).not.toContain('Copy Link Address')
117+
}
118+
})
119+
120+
it('disables navigation the page cannot do', () => {
121+
const template = buildAgentContextMenuTemplate(
122+
params(),
123+
page({ canGoBack: false, canGoForward: false }),
124+
handlers()
125+
)
126+
127+
expect(item(template, 'Back')?.enabled).toBe(false)
128+
expect(item(template, 'Forward')?.enabled).toBe(false)
129+
expect(item(template, 'Reload')?.enabled).toBeUndefined()
130+
})
131+
132+
it('reports the current zoom and disables the ends of the ladder', () => {
133+
const stepped = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 1.21 }), handlers())
134+
expect(item(stepped, 'Actual Size (121%)')?.enabled).toBe(true)
135+
136+
const atMax = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 3 }), handlers())
137+
expect(item(atMax, 'Zoom In')?.enabled).toBe(false)
138+
expect(item(atMax, 'Zoom Out')?.enabled).toBe(true)
139+
140+
const atMin = buildAgentContextMenuTemplate(params(), page({ zoomFactor: 0.5 }), handlers())
141+
expect(item(atMin, 'Zoom Out')?.enabled).toBe(false)
142+
143+
// Nothing to reset to at 100%.
144+
expect(
145+
item(buildAgentContextMenuTemplate(params(), page(), handlers()), 'Actual Size (100%)')
146+
?.enabled
147+
).toBe(false)
148+
})
149+
150+
it('resets to exactly 100%, undoing accumulated drift', () => {
151+
const handled = handlers()
152+
const template = buildAgentContextMenuTemplate(
153+
params(),
154+
page({ zoomFactor: 1.3310000000000004 }),
155+
handled
156+
)
157+
158+
item(template, 'Actual Size (133%)')?.click?.({} as never, undefined as never, {} as never)
159+
160+
expect(handled.setZoomFactor).toHaveBeenCalledWith(1)
161+
})
162+
163+
it('never leaves a separator with nothing above it', () => {
164+
for (const p of [
165+
params(),
166+
params({ selectionText: 'hi' }),
167+
params({ linkURL: 'https://example.com' }),
168+
params({ isEditable: true, editFlags: { ...EDIT_FLAGS, canPaste: true } }),
169+
]) {
170+
const template = buildAgentContextMenuTemplate(p, page(), handlers())
171+
expect(template[0].type).not.toBe('separator')
172+
expect(template[template.length - 1].type).not.toBe('separator')
173+
expect(
174+
template.some(
175+
(entry, index) => entry.type === 'separator' && template[index - 1]?.type === 'separator'
176+
)
177+
).toBe(false)
178+
}
179+
})
180+
})
181+
182+
describe('attachAgentContextMenu', () => {
183+
type ContextMenuListener = (event: unknown, params: Params) => void
184+
185+
it('pops a menu built from the page that was right-clicked', () => {
186+
const contents = new WebContentsView().webContents
187+
vi.mocked(contents.navigationHistory.canGoBack).mockReturnValue(true)
188+
attachAgentContextMenu(contents, { openTab: vi.fn() })
189+
190+
const listeners = vi.mocked(contents.on).mock.calls as unknown as [
191+
string,
192+
ContextMenuListener,
193+
][]
194+
const onContextMenu = listeners.find(([event]) => event === 'context-menu')?.[1]
195+
expect(onContextMenu).toBeDefined()
196+
onContextMenu?.({}, params())
197+
198+
const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] as
199+
| MenuItemConstructorOptions[]
200+
| undefined
201+
// The template is read off the live page, not a snapshot of it.
202+
expect(item(template ?? [], 'Back')?.enabled).toBe(true)
203+
expect(item(template ?? [], 'Forward')?.enabled).toBe(false)
204+
})
205+
})
206+
207+
describe('steppedZoomFactor', () => {
208+
it('steps up and down from the current factor', () => {
209+
expect(steppedZoomFactor(1, 1)).toBe(1.1)
210+
expect(steppedZoomFactor(1, -1)).toBe(1 / 1.1)
211+
})
212+
213+
it('clamps at both ends so a step never runs away', () => {
214+
expect(steppedZoomFactor(3, 1)).toBe(3)
215+
expect(steppedZoomFactor(0.5, -1)).toBe(0.5)
216+
})
217+
218+
it('treats a nonsense factor as 100%', () => {
219+
expect(steppedZoomFactor(Number.NaN, 1)).toBe(1.1)
220+
expect(steppedZoomFactor(0, 1)).toBe(1.1)
221+
})
222+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Right-click menu for the embedded browser page.
3+
*
4+
* Native, deliberately. The page is a native view composited OVER the renderer,
5+
* so a menu drawn from the app's own dropdown can only appear by freezing the
6+
* page to a captured frame and hiding the view beneath it. That frame has to
7+
* pixel-match a live compositor surface, and scrollbars, device scale, and
8+
* resize timing all conspire against it — a mismatch shows up as the page
9+
* flickering or shifting under the menu. A native menu draws above the view
10+
* while the page keeps rendering, so none of that applies.
11+
*
12+
* It is also what a browser's page menu is everywhere else, and unlike the
13+
* terminal's hidden textarea, the roles here act on a real page: `copy` and
14+
* `paste` go to the frame that was clicked.
15+
*/
16+
import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from 'electron'
17+
import { clipboard, Menu } from 'electron'
18+
19+
/**
20+
* Page-zoom ladder for the embedded browser, in factors (1 = 100%) because that
21+
* is what the menu displays.
22+
*/
23+
const ZOOM_STEP_RATIO = 1.1
24+
const MIN_ZOOM_FACTOR = 0.5
25+
const MAX_ZOOM_FACTOR = 3
26+
27+
/**
28+
* One step along the zoom ladder, clamped to its ends. Returning the current
29+
* factor unchanged is how the menu knows an end is reached, so it can disable
30+
* the item rather than offer a step that does nothing.
31+
*/
32+
export function steppedZoomFactor(current: number, direction: 1 | -1): number {
33+
const base = Number.isFinite(current) && current > 0 ? current : 1
34+
const next = direction === 1 ? base * ZOOM_STEP_RATIO : base / ZOOM_STEP_RATIO
35+
return Math.min(MAX_ZOOM_FACTOR, Math.max(MIN_ZOOM_FACTOR, next))
36+
}
37+
38+
/** The parts of a right-click the menu acts on. */
39+
type AgentContextMenuParams = Pick<
40+
ContextMenuParams,
41+
'selectionText' | 'linkURL' | 'isEditable' | 'editFlags'
42+
>
43+
44+
/** What the page can currently do, read at the moment of the click. */
45+
interface AgentPageContext {
46+
canGoBack: boolean
47+
canGoForward: boolean
48+
zoomFactor: number
49+
}
50+
51+
interface AgentContextMenuHandlers {
52+
copy(): void
53+
paste(): void
54+
back(): void
55+
forward(): void
56+
reload(): void
57+
openTab(url: string): void
58+
copyLink(url: string): void
59+
setZoomFactor(factor: number): void
60+
}
61+
62+
export interface AgentContextMenuHost {
63+
/** Opens a link from the page in another tab of the same browser. */
64+
openTab(url: string): void
65+
}
66+
67+
/**
68+
* Builds the page menu. Unlike the main window's menu this is never empty —
69+
* navigation and zoom always apply, and only the clipboard and link items
70+
* depend on what was clicked.
71+
*
72+
* Only http(s) links get link items: the actions open a browser tab or copy an
73+
* address, and neither means anything for a `javascript:` or `mailto:` target.
74+
*/
75+
export function buildAgentContextMenuTemplate(
76+
params: AgentContextMenuParams,
77+
page: AgentPageContext,
78+
handlers: AgentContextMenuHandlers
79+
): MenuItemConstructorOptions[] {
80+
const template: MenuItemConstructorOptions[] = []
81+
const linkUrl = /^https?:\/\//i.test(params.linkURL) ? params.linkURL : ''
82+
83+
if (linkUrl) {
84+
template.push(
85+
{ label: 'Open Link in New Tab', click: () => handlers.openTab(linkUrl) },
86+
{ label: 'Copy Link Address', click: () => handlers.copyLink(linkUrl) },
87+
{ type: 'separator' }
88+
)
89+
}
90+
91+
if (params.selectionText.trim()) {
92+
template.push({ label: 'Copy', click: () => handlers.copy() })
93+
}
94+
if (params.isEditable && params.editFlags.canPaste) {
95+
template.push({ label: 'Paste', click: () => handlers.paste() })
96+
}
97+
if (template.length > 0 && template[template.length - 1].type !== 'separator') {
98+
template.push({ type: 'separator' })
99+
}
100+
101+
template.push(
102+
{ label: 'Back', enabled: page.canGoBack, click: () => handlers.back() },
103+
{ label: 'Forward', enabled: page.canGoForward, click: () => handlers.forward() },
104+
{ label: 'Reload', click: () => handlers.reload() },
105+
{ type: 'separator' }
106+
)
107+
108+
const zoomIn = steppedZoomFactor(page.zoomFactor, 1)
109+
const zoomOut = steppedZoomFactor(page.zoomFactor, -1)
110+
const zoomPercent = Math.round(page.zoomFactor * 100)
111+
template.push(
112+
{
113+
label: 'Zoom In',
114+
enabled: zoomIn !== page.zoomFactor,
115+
click: () => handlers.setZoomFactor(zoomIn),
116+
},
117+
{
118+
label: 'Zoom Out',
119+
enabled: zoomOut !== page.zoomFactor,
120+
click: () => handlers.setZoomFactor(zoomOut),
121+
},
122+
{
123+
label: `Actual Size (${zoomPercent}%)`,
124+
enabled: zoomPercent !== 100,
125+
click: () => handlers.setZoomFactor(1),
126+
}
127+
)
128+
129+
return template
130+
}
131+
132+
/** Gives one agent tab its page menu. */
133+
export function attachAgentContextMenu(contents: WebContents, host: AgentContextMenuHost): void {
134+
contents.on('context-menu', (_event, params) => {
135+
const template = buildAgentContextMenuTemplate(
136+
params,
137+
{
138+
canGoBack: contents.navigationHistory.canGoBack(),
139+
canGoForward: contents.navigationHistory.canGoForward(),
140+
zoomFactor: contents.getZoomFactor(),
141+
},
142+
{
143+
copy: () => contents.copy(),
144+
paste: () => contents.paste(),
145+
back: () => contents.navigationHistory.goBack(),
146+
forward: () => contents.navigationHistory.goForward(),
147+
reload: () => contents.reload(),
148+
openTab: (url) => host.openTab(url),
149+
copyLink: (url) => clipboard.writeText(url),
150+
setZoomFactor: (factor) => contents.setZoomFactor(factor),
151+
}
152+
)
153+
Menu.buildFromTemplate(template).popup()
154+
})
155+
}

0 commit comments

Comments
 (0)