feat(desktop): add focus debug toggle (#37465)
This commit is contained in:
@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
type Mem = Performance & {
|
type Mem = Performance & {
|
||||||
memory?: {
|
memory?: {
|
||||||
@@ -107,8 +108,45 @@ function Cell(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
||||||
|
const content = () => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Force focus styles on all interactive elements"
|
||||||
|
aria-pressed={props.active}
|
||||||
|
classList={{
|
||||||
|
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||||
|
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
|
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
||||||
|
"bg-surface-raised-base text-text-strong": props.active,
|
||||||
|
}}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
||||||
|
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
|
||||||
|
{props.active ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (props.inline) {
|
||||||
|
return (
|
||||||
|
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</TooltipV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const routing = useIsRouting()
|
const routing = useIsRouting()
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
delay: undefined as number | undefined,
|
delay: undefined as number | undefined,
|
||||||
fps: undefined as number | undefined,
|
fps: undefined as number | undefined,
|
||||||
gap: undefined as number | undefined,
|
gap: undefined as number | undefined,
|
||||||
|
focus: false,
|
||||||
heap: {
|
heap: {
|
||||||
limit: undefined as number | undefined,
|
limit: undefined as number | undefined,
|
||||||
used: undefined as number | undefined,
|
used: undefined as number | undefined,
|
||||||
@@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||||
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||||
|
const toggleFocus = async () => {
|
||||||
|
if (!platform.setForceFocus) return
|
||||||
|
const enabled = !state.focus
|
||||||
|
await platform.setForceFocus(enabled)
|
||||||
|
setState("focus", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let start = 0
|
let start = 0
|
||||||
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
bad={bad(heap(), 0.8)}
|
bad={bad(heap(), 0.8)}
|
||||||
dim={state.heap.used === undefined}
|
dim={state.heap.used === undefined}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
wide
|
wide={!platform.setForceFocus}
|
||||||
/>
|
/>
|
||||||
|
{platform.setForceFocus && (
|
||||||
|
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ type PlatformBase = {
|
|||||||
/** Export collected diagnostic logs (desktop only) */
|
/** Export collected diagnostic logs (desktop only) */
|
||||||
exportDebugLogs?(): Promise<string>
|
exportDebugLogs?(): Promise<string>
|
||||||
|
|
||||||
|
/** Force focus styles on interactive elements through desktop devtools (desktop only) */
|
||||||
|
setForceFocus?(enabled: boolean): Promise<void>
|
||||||
|
|
||||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
|||||||
|
|
||||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||||
|
import { setForceFocus } from "./debug"
|
||||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||||
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
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("updater-install", () => deps.updater.install())
|
||||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||||
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
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) =>
|
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
||||||
deps.recordFatalRendererError(error),
|
deps.recordFatalRendererError(error),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ const api: ElectronAPI = {
|
|||||||
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
||||||
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||||
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
|
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
|
||||||
|
setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled),
|
||||||
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
|
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,5 +104,6 @@ export type ElectronAPI = {
|
|||||||
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
||||||
setBackgroundColor: (color: string) => Promise<void>
|
setBackgroundColor: (color: string) => Promise<void>
|
||||||
exportDebugLogs: () => Promise<string>
|
exportDebugLogs: () => Promise<string>
|
||||||
|
setForceFocus: (enabled: boolean) => Promise<void>
|
||||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
|
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
|
|||||||
|
|
||||||
exportDebugLogs: () => window.api.exportDebugLogs(),
|
exportDebugLogs: () => window.api.exportDebugLogs(),
|
||||||
|
|
||||||
|
setForceFocus: (enabled) => window.api.setForceFocus(enabled),
|
||||||
|
|
||||||
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
|
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
|
||||||
|
|
||||||
restart: async () => {
|
restart: async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user