Просмотр исходного кода

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

Brendan Allan 1 месяц назад
Родитель
Сommit
ba6cf38607

+ 53 - 1
packages/app/src/components/debug-bar.tsx

@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
 
 type Mem = Performance & {
   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 } = {}) {
   const language = useLanguage()
+  const platform = usePlatform()
   const location = useLocation()
   const routing = useIsRouting()
   const [state, setState] = createStore({
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
     delay: undefined as number | undefined,
     fps: undefined as number | undefined,
     gap: undefined as number | undefined,
+    focus: false,
     heap: {
       limit: 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 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 start = 0
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
           bad={bad(heap(), 0.8)}
           dim={state.heap.used === undefined}
           inline={props.inline}
-          wide
+          wide={!platform.setForceFocus}
         />
+        {platform.setForceFocus && (
+          <FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
+        )}
       </div>
     </aside>
   )

+ 3 - 0
packages/app/src/context/platform.tsx

@@ -115,6 +115,9 @@ type PlatformBase = {
   /** Export collected diagnostic logs (desktop only) */
   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) */
   recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
 }

+ 95 - 0
packages/desktop/src/main/debug.ts

@@ -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
+}

+ 4 - 0
packages/desktop/src/main/ipc.ts

@@ -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),
   )

+ 1 - 0
packages/desktop/src/preload/index.ts

@@ -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),
 }
 

+ 1 - 0
packages/desktop/src/preload/types.ts

@@ -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>
 }

+ 2 - 0
packages/desktop/src/renderer/index.tsx

@@ -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 () => {