feat(desktop): add focus debug toggle (#37465)

This commit is contained in:
Brendan Allan 2026-07-17 18:09:30 +08:00 committed by GitHub
commit ba6cf38607
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 159 additions and 1 deletions

View file

@ -0,0 +1,95 @@
import type { WebContents } from "electron"
const focusDebuggerOwners = new WeakSet<WebContents>()
const forcedFocusNodes = new WeakMap<WebContents, number[]>()
const focusableSelector = `
a[href],
button:not([disabled]),
input:not([disabled]),
select:not([disabled]),
textarea:not([disabled]),
summary,
[contenteditable="true"],
[tabindex]:not([tabindex="-1"])
`
export async function setForceFocus(contents: WebContents, enabled: boolean) {
const debuggerApi = contents.debugger
if (!debuggerApi.isAttached()) {
if (!enabled) {
focusDebuggerOwners.delete(contents)
forcedFocusNodes.delete(contents)
return
}
debuggerApi.attach("1.3")
focusDebuggerOwners.add(contents)
debuggerApi.once("detach", () => {
focusDebuggerOwners.delete(contents)
forcedFocusNodes.delete(contents)
})
}
if (!enabled) {
await Promise.allSettled(
(forcedFocusNodes.get(contents) ?? []).map((nodeId) =>
debuggerApi.sendCommand("CSS.forcePseudoState", {
nodeId,
forcedPseudoClasses: [],
}),
),
)
forcedFocusNodes.delete(contents)
if (!focusDebuggerOwners.delete(contents)) return
debuggerApi.detach()
return
}
await debuggerApi.sendCommand("DOM.enable")
await debuggerApi.sendCommand("CSS.enable")
const document: unknown = await debuggerApi.sendCommand("DOM.getDocument", {
depth: -1,
pierce: true,
})
const nodes: unknown = await debuggerApi.sendCommand("DOM.querySelectorAll", {
nodeId: readDocumentNodeId(document),
selector: focusableSelector,
})
const nodeIds = readNodeIds(nodes)
forcedFocusNodes.set(contents, [...new Set([...(forcedFocusNodes.get(contents) ?? []), ...nodeIds])])
await Promise.allSettled(
nodeIds.map((nodeId) =>
debuggerApi.sendCommand("CSS.forcePseudoState", {
nodeId,
forcedPseudoClasses: ["focus", "focus-visible"],
}),
),
)
}
function readDocumentNodeId(value: unknown) {
if (
!value ||
typeof value !== "object" ||
!("root" in value) ||
!value.root ||
typeof value.root !== "object" ||
!("nodeId" in value.root) ||
typeof value.root.nodeId !== "number"
) {
throw new Error("Invalid DOM.getDocument response")
}
return value.root.nodeId
}
function readNodeIds(value: unknown) {
if (
!value ||
typeof value !== "object" ||
!("nodeIds" in value) ||
!Array.isArray(value.nodeIds) ||
!value.nodeIds.every((nodeId) => typeof nodeId === "number")
) {
throw new Error("Invalid DOM.querySelectorAll response")
}
return value.nodeIds
}

View file

@ -7,6 +7,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
@ -81,6 +82,9 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("updater-install", () => deps.updater.install())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) =>
setForceFocus(event.sender, enabled),
)
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)

View file

@ -121,6 +121,7 @@ const api: ElectronAPI = {
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
}

View file

@ -104,5 +104,6 @@ export type ElectronAPI = {
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
setBackgroundColor: (color: string) => Promise<void>
exportDebugLogs: () => Promise<string>
setForceFocus: (enabled: boolean) => Promise<void>
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
}

View file

@ -240,6 +240,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
exportDebugLogs: () => window.api.exportDebugLogs(),
setForceFocus: (enabled) => window.api.setForceFocus(enabled),
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
restart: async () => {