5-way navigation for React and SolidJS
0

Configure Feed

Select the types of activity you want to include in your feed.

inspector: start sending HandlerDescription to inspector

authored by

Pavel Hrách and committed by
Pavel Hrách
(Apr 29, 2026, 5:03 PM +0200) c7bcc7e8 b94ba752

+112 -57
+1 -1
packages/fiveway-extension/src/background.ts
··· 1 1 import * as v from "valibot"; 2 2 import browser from "webextension-polyfill"; 3 3 4 - import { InitMessage, InspectorCommand } from "./messages.ts"; 4 + import { InitMessage, InspectorCommand } from "./protocol.ts"; 5 5 6 6 const contentScriptPorts: Map<number, browser.Runtime.Port> = new Map(); 7 7 const devtoolsPorts: Map<number, browser.Runtime.Port> = new Map();
+6 -2
packages/fiveway-extension/src/devtools/panel.tsx
··· 5 5 import * as v from "valibot"; 6 6 import browser from "webextension-polyfill"; 7 7 8 - import { type InspectorCommand as InspectorCommandMessage } from "../messages.ts"; 9 - import { type InitMessage, InspectorMessage, ReloadMessage } from "../messages.ts"; 8 + import { 9 + type InitMessage, 10 + type InspectorCommand as InspectorCommandMessage, 11 + InspectorMessage, 12 + ReloadMessage, 13 + } from "../protocol.ts"; 10 14 11 15 const port = browser.runtime.connect({ name: "devtools" }); 12 16
+1 -1
packages/fiveway-extension/src/fivewayDevtools.ts
··· 1 1 import * as v from "valibot"; 2 2 import browser from "webextension-polyfill"; 3 3 4 - import { InspectorMessage } from "./messages.ts"; 4 + import { InspectorMessage } from "./protocol.ts"; 5 5 6 6 let port = browser.runtime.connect({ name: "content-script" }); 7 7
+8
packages/fiveway-extension/src/messages.ts packages/fiveway-extension/src/protocol.ts
··· 24 24 parent: v.nullable(v.string()), 25 25 order: v.nullable(v.number()), 26 26 children: v.array(v.string()), 27 + handler: v.optional(v.any()), 27 28 }), 28 29 ), 29 30 ), ··· 41 42 tree: v.string(), 42 43 node: v.optional(v.string()), 43 44 action: v.unknown(), 45 + }), 46 + 47 + // inspect handler of a specific node 48 + v.object({ 49 + kind: v.literal("inspectHandler"), 50 + tree: v.string(), 51 + node: v.string(), 44 52 }), 45 53 ]); 46 54
+1
packages/fiveway-inspector/src/inspector/context.ts
··· 115 115 return; 116 116 } 117 117 118 + handle.sendCommand({ kind: "inspectHandler", tree: tree.label, node: nodeId }); 118 119 setState("trees", tree.label, "inspected", nodeId); 119 120 }; 120 121
+13
packages/fiveway-inspector/src/inspector/ui/InspectedNode.tsx
··· 1 + import { type InspectorNode } from "@fiveway/core"; 2 + import { For } from "solid-js"; 3 + 4 + export function InspectedNode(props: { node: InspectorNode }) { 5 + return ( 6 + <div> 7 + <div>{props.node.id}</div> 8 + <For each={props.node.handler}> 9 + {(handler) => <div>{JSON.stringify(handler, null, 2)}</div>} 10 + </For> 11 + </div> 12 + ); 13 + }
+17 -8
packages/fiveway-inspector/src/inspector/ui/NavigationPad.tsx
··· 1 1 import { type NavigationAction } from "@fiveway/core"; 2 2 import { clsx } from "clsx"; 3 3 4 + import { useDevtoolsContext } from "../context.ts"; 4 5 import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Check, Undo2 } from "./icons.ts"; 5 6 6 7 import styles from "./NavigationPad.module.css"; 7 8 8 - export function NavigationPad(props: { onAction: (action: NavigationAction) => void }) { 9 - const { onAction } = props; 9 + export function NavigationPad(props: { tree: string }) { 10 + const devtools = useDevtoolsContext(); 11 + 12 + const sendAction = (action: NavigationAction) => { 13 + devtools.sendCommand({ 14 + kind: "handleAction", 15 + tree: props.tree, 16 + action, 17 + }); 18 + }; 10 19 11 20 return ( 12 21 <div class={styles.navPad} id="fiveway-nav-pad" aria-label="Simulate navigation"> ··· 15 24 class={clsx(styles.navButton, styles.navPadBack)} 16 25 title="Back (Backspace)" 17 26 aria-label="Back" 18 - onClick={() => onAction({ kind: "move", direction: "back" })} 27 + onClick={() => sendAction({ kind: "move", direction: "back" })} 19 28 > 20 29 <Undo2 size={16} /> 21 30 </button> ··· 24 33 class={clsx(styles.navButton, styles.navPadUp)} 25 34 title="Move up" 26 35 aria-label="Move up" 27 - onClick={() => onAction({ kind: "move", direction: "up" })} 36 + onClick={() => sendAction({ kind: "move", direction: "up" })} 28 37 > 29 38 <ArrowUp size={16} /> 30 39 </button> ··· 33 42 class={clsx(styles.navButton, styles.navPadSelect)} 34 43 title="Select (Enter)" 35 44 aria-label="Select" 36 - onClick={() => onAction({ kind: "select" })} 45 + onClick={() => sendAction({ kind: "select" })} 37 46 > 38 47 <Check size={16} /> 39 48 </button> ··· 42 51 class={clsx(styles.navButton, styles.navPadLeft)} 43 52 title="Move left" 44 53 aria-label="Move left" 45 - onClick={() => onAction({ kind: "move", direction: "left" })} 54 + onClick={() => sendAction({ kind: "move", direction: "left" })} 46 55 > 47 56 <ArrowLeft size={16} /> 48 57 </button> ··· 51 60 class={clsx(styles.navButton, styles.navPadRight)} 52 61 title="Move right" 53 62 aria-label="Move right" 54 - onClick={() => onAction({ kind: "move", direction: "right" })} 63 + onClick={() => sendAction({ kind: "move", direction: "right" })} 55 64 > 56 65 <ArrowRight size={16} /> 57 66 </button> ··· 60 69 class={clsx(styles.navButton, styles.navPadDown)} 61 70 title="Move down" 62 71 aria-label="Move down" 63 - onClick={() => onAction({ kind: "move", direction: "down" })} 72 + onClick={() => sendAction({ kind: "move", direction: "down" })} 64 73 > 65 74 <ArrowDown size={16} /> 66 75 </button>
+10 -10
packages/fiveway-inspector/src/inspector/ui/TreeInspector.tsx
··· 1 - import { type NavigationAction } from "@fiveway/core"; 2 - import { createSignal, For, Show } from "solid-js"; 1 + import { createMemo, createSignal, For, Show } from "solid-js"; 3 2 4 3 import { type InspectedTree, useDevtoolsContext } from "../context.ts"; 5 4 import { Gamepad2 } from "./icons.ts"; 5 + import { InspectedNode } from "./InspectedNode.tsx"; 6 6 import { NavigationPad } from "./NavigationPad.tsx"; 7 7 import { TreeNode } from "./TreeNode.tsx"; 8 8 ··· 13 13 14 14 const [navOpen, setNavOpen] = createSignal(false); 15 15 16 - const sendNav = (action: NavigationAction) => { 17 - devtools.sendCommand({ 18 - kind: "handleAction", 19 - tree: props.tree.label, 20 - action, 21 - }); 22 - }; 16 + const inspectedNode = createMemo( 17 + () => devtools.trees[props.tree.label]?.nodes[props.tree.inspected], 18 + ); 23 19 24 20 return ( 25 21 <div class={styles.inspector}> ··· 84 80 </div> 85 81 86 82 <Show when={navOpen()}> 87 - <NavigationPad onAction={sendNav} /> 83 + <NavigationPad tree={props.tree.label} /> 88 84 </Show> 89 85 90 86 <div class={styles.tree}> 91 87 <TreeNode tree={props.tree} node="#" /> 88 + </div> 89 + 90 + <div> 91 + <Show when={inspectedNode()}>{(node) => <InspectedNode node={node()} />}</Show> 92 92 </div> 93 93 </div> 94 94 </div>
+1 -1
packages/fiveway-inspector/src/routes/sse/notify.ts
··· 4 4 import { subscribeNotifications } from "../../server/bridge.ts"; 5 5 6 6 export const GET = defineHandler((event) => { 7 - // @ts-expect-error - @solid/start is using its own event and exposes H3 event as nativeEvent 7 + // @ts-expect-error - @solid/start is using its own event and exposes H3 event as nativeEvent 8 8 const stream = createEventStream(event.nativeEvent); 9 9 10 10 const unsubscribe = subscribeNotifications(async () => {
+4 -4
packages/fiveway-inspector/vite.config.ts
··· 8 8 server: { 9 9 port: 3003, 10 10 }, 11 - plugins: process.env.VITEST 12 - ? [] 13 - : [ 11 + plugins: !process.env.VITEST 12 + ? [ 14 13 solidStart(), 15 14 nitro({ 16 15 features: { ··· 18 17 }, 19 18 }), 20 19 tailwindcss(), 21 - ], 20 + ] 21 + : [], 22 22 resolve: { 23 23 dedupe: ["@solidjs/start"], 24 24 },
+2 -2
packages/fiveway/src/handler/chained.ts
··· 1 1 import { type NavigationAction } from "../action.ts"; 2 - import { describeHandler, type HandlerInfo } from "../inspector.ts"; 2 + import { describeHandler, type HandlerDescription } from "../inspector.ts"; 3 3 import { type NodeId } from "../tree/id.ts"; 4 4 import { type NavtreeNode } from "../tree/node.ts"; 5 5 import { type NavigationHandler } from "./handler.ts"; ··· 120 120 return; 121 121 } 122 122 123 - const value: Array<HandlerInfo> = []; 123 + const value: Array<HandlerDescription> = []; 124 124 handler(node, { kind: "query", key: "core:handler-info", value }, () => null); 125 125 if (value.length === 0) { 126 126 describeHandler(action, {
+3 -3
packages/fiveway/src/handler/handler.test.ts
··· 5 5 createNavigationTree, 6 6 createNode, 7 7 type NavigationHandler, 8 - queryHandlerInfo, 8 + inspectHandler, 9 9 containerHandler, 10 10 } from "../index.ts"; 11 11 ··· 50 50 }); 51 51 insertNode(tree, item); 52 52 53 - expect(queryHandlerInfo(tree, container.id)).toEqual([ 53 + expect(inspectHandler(tree, container.id)).toEqual([ 54 54 { name: "core:focus", skipEmpty: true, direction: "default" }, 55 55 { name: "core:parent" }, 56 56 ]); 57 57 58 - expect(queryHandlerInfo(tree, item.id)).toEqual([ 58 + expect(inspectHandler(tree, item.id)).toEqual([ 59 59 { name: "core:focus", skipEmpty: false, direction: "default" }, 60 60 { name: "core:parent" }, 61 61 ]);
+2 -2
packages/fiveway/src/index.ts
··· 78 78 export { type MetaHandler, metaHandler } from "./handler/metadata.ts"; 79 79 80 80 export { 81 - type HandlerInfo, 81 + type HandlerDescription, 82 82 type InspectorMessage, 83 83 type InspectorCommand, 84 84 type InspectorNode, 85 85 describeHandler, 86 - queryHandlerInfo, 86 + inspectHandler, 87 87 } from "./inspector.ts";
+3 -3
packages/fiveway/src/inspector.test.ts
··· 2 2 3 3 import { 4 4 describeHandler, 5 - queryHandlerInfo, 5 + inspectHandler, 6 6 createNavigationTree, 7 7 insertNode, 8 8 createNode, ··· 25 25 }), 26 26 ); 27 27 28 - expect(queryHandlerInfo(tree, "#/test")).toEqual([{ name: "test" }]); 28 + expect(inspectHandler(tree, "#/test")).toEqual([{ name: "test" }]); 29 29 }); 30 30 31 31 test("chain handler adds fallback info to link handlers", () => { ··· 44 44 }), 45 45 ); 46 46 47 - expect(queryHandlerInfo(tree, "#/test")).toEqual( 47 + expect(inspectHandler(tree, "#/test")).toEqual( 48 48 expect.arrayContaining([{ name: "handlerWithoutInfo" }]), 49 49 ); 50 50 });
+18 -14
packages/fiveway/src/inspector.ts
··· 8 8 parent: string | null; 9 9 order: number | null; 10 10 children: string[]; 11 + handler?: HandlerDescription[]; 11 12 }; 12 13 13 14 export type InspectorMessage = { ··· 36 37 37 38 export type InspectorCommand = 38 39 | { kind: "handleAction"; tree: string; action: NavigationAction; node?: NodeId } 39 - | { kind: "requestCompleteSnapshot"; tree: string }; 40 + | { kind: "requestCompleteSnapshot"; tree: string } 41 + | { kind: "inspectHandler"; tree: string; node: NodeId }; 40 42 41 43 export function subscribeToInspectorCommands( 42 44 tree: NavigationTree, ··· 56 58 }); 57 59 } 58 60 59 - export type HandlerInfo = Record<string, string | { toString(): string }>; 60 - 61 - export function describeHandler(action: NavigationAction, info: HandlerInfo): void { 62 - if (action.kind === "query" && action.key === "core:handler-info") { 63 - if (!Array.isArray(action.value)) { 64 - action.value = []; 65 - } 61 + export type HandlerDescription = Record<string, string | { toString(): string }>; 66 62 67 - (action.value as HandlerInfo[]).push(info); 68 - } 69 - } 63 + export const INSPECT_HANLDER = "inspect_handler"; 70 64 71 - export function queryHandlerInfo(tree: NavigationTree, id: NodeId): HandlerInfo[] { 72 - const value = [] as HandlerInfo[]; 65 + export function inspectHandler(tree: NavigationTree, id: NodeId): HandlerDescription[] { 66 + const value = [] as HandlerDescription[]; 73 67 runHandler(tree, id, { 74 68 kind: "query", 75 - key: "core:handler-info", 69 + key: INSPECT_HANLDER, 76 70 value, 77 71 }); 78 72 79 73 return value; 74 + } 75 + 76 + export function describeHandler(action: NavigationAction, info: HandlerDescription): void { 77 + if (action.kind === "query" && action.key === INSPECT_HANLDER) { 78 + if (!Array.isArray(action.value)) { 79 + action.value = []; 80 + } 81 + 82 + (action.value as HandlerDescription[]).push(info); 83 + } 80 84 } 81 85 82 86 const queue: InspectorMessage[] = [];
+5 -2
packages/fiveway/src/tree/node.ts
··· 1 1 import { type NavigationHandler } from "../handler/handler.ts"; 2 2 import { defaultHandler } from "../handler/handler.ts"; 3 - import { type InspectorNode } from "../inspector.ts"; 3 + import { inspectHandler, type InspectorNode } from "../inspector.ts"; 4 4 import { binarySearch } from "../lib/array.ts"; 5 5 import { joinId, type NodeId } from "./id.ts"; 6 6 import { type NavigationTree } from "./tree.ts"; ··· 81 81 parentNode.children.splice(newIndex, 0, ...removed); 82 82 } 83 83 84 - export function toInspectorNode(node: NavtreeNode): InspectorNode { 84 + export function inspectNode(node: NavtreeNode, handler = false): InspectorNode { 85 85 const children: string[] = []; 86 + 86 87 for (const child of node.children) { 87 88 if (child.active) { 88 89 children.push(child.id); 89 90 } 90 91 } 92 + 91 93 return { 92 94 id: node.id, 93 95 parent: node.parent, 94 96 order: node.order, 95 97 children, 98 + handler: handler ? inspectHandler(node.tree, node.id) : undefined, 96 99 }; 97 100 }
+17 -4
packages/fiveway/src/tree/tree.ts
··· 9 9 import { binarySearch } from "../lib/array.ts"; 10 10 import { notifyListeners, type NavtreeListener } from "./events.ts"; 11 11 import { type NodeId, convergingPaths, idsToRoot, isParent } from "./id.ts"; 12 - import { toInspectorNode, type CreatedNavtreeNode, type NavtreeNode } from "./node.ts"; 12 + import { inspectNode, type CreatedNavtreeNode, type NavtreeNode } from "./node.ts"; 13 13 14 14 export type NavigationTree = { 15 15 label: string; ··· 95 95 emitInspectorMessage({ 96 96 type: "fiveway:treeState", 97 97 tree: tree.label, 98 - nodes: [toInspectorNode(node), toInspectorNode(parentNode)], 98 + nodes: [inspectNode(node), inspectNode(parentNode)], 99 99 }); 100 100 } 101 101 } ··· 129 129 emitInspectorMessage({ 130 130 type: "fiveway:treeState", 131 131 tree: tree.label, 132 - nodes: parentNode != null ? [toInspectorNode(parentNode)] : undefined, 132 + nodes: parentNode != null ? [inspectNode(parentNode)] : undefined, 133 133 removedNodes: [id], 134 134 }); 135 135 } ··· 353 353 handleAction(tree, command.action, command.node); 354 354 } 355 355 356 + if (command.kind === "inspectHandler") { 357 + const node = tree.nodes.get(command.node); 358 + if (node == null || !node.connected) { 359 + return; 360 + } 361 + 362 + emitInspectorMessage({ 363 + type: "fiveway:treeState", 364 + tree: tree.label, 365 + nodes: [inspectNode(node, true)], 366 + }); 367 + } 368 + 356 369 if (command.kind === "requestCompleteSnapshot") { 357 - const nodes = Array.from(tree.nodes.values(), toInspectorNode); 370 + const nodes = Array.from(tree.nodes.values(), (node) => inspectNode(node, false)); 358 371 359 372 emitInspectorMessage({ 360 373 type: "fiveway:treeState",