From 10aec4281288172aab1fc38f0419055fcedb088b Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 21 Mar 2025 13:03:12 -0700 Subject: [PATCH 01/15] feat: state machine component --- jumble/src/components/View.tsx | 106 +++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 jumble/src/components/View.tsx diff --git a/jumble/src/components/View.tsx b/jumble/src/components/View.tsx new file mode 100644 index 000000000..0374b6eab --- /dev/null +++ b/jumble/src/components/View.tsx @@ -0,0 +1,106 @@ +import { JSX, useCallback, useEffect, useRef, useState } from "react"; + +export interface Behavior { + init(): { state: Model; fx?: Effect[] }; + update( + model: Model, + command: Command, + ): { state: Model; fx?: Effect[] }; + + view(model: Model, controller: Controller): JSX.Element; +} + +export interface Effect { + (): Promise; +} + +export interface Controller { + perform(effect: Effect): void; + + dispatch(command: Command): () => void; +} + +class Process implements Controller { + state: Model; + view: JSX.Element; + + constructor( + public behavior: Behavior, + public advance: (self: [Process]) => void, + ) { + const { state, fx } = behavior.init(); + this.state = state; + this.view = behavior.view(state, this); + + this.enqueue(fx ?? []); + } + + async perform(effect: Effect) { + const command = await effect(); + const { state, fx } = this.behavior.update(this.state, command); + this.state = state; + this.view = this.behavior.view(this.state, this); + this.enqueue(fx ?? []); + + this.advance([this]); + } + + enqueue(effects: Effect[]) { + for (const effect of effects) { + this.perform(effect); + } + } + + dispatch(command: Command) { + return () => this.perform(async () => command); + } + + terminate() { + } +} + +/** + * Define react component as state machine. + * + * @example + * ```js + * const Counter = View({ + * init() { + * return { state: { count: 0 } }; + * }, + * update({ count }: { count: number }, command: "inc" | "dec") { + * switch (command) { + * case "inc": + * return { state: { count: count + 1 } }; + * case "dec": + * return { state: { count: count + 1 } }; + * default: + * return { state: { count } }; + * } + * }, + * view(state, controller) { + * return ; + * }, + * }); + * ``` + * + * Then you can use it as react component as + * + * ```js + * + * ``` + */ +export default (behavior: Behavior) => { + return function View() { + const [process, advance] = useState<[Process]>(); + + useEffect(() => { + const process = new Process(behavior, advance); + advance([process]); + + return () => process.terminate(); + }, []); + + return process?.[0]?.view ?? null; + }; +}; From 15ae47e6ebcf50dcb69d8b54882fd2bb8fba30f8 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 21 Mar 2025 16:33:27 -0700 Subject: [PATCH 02/15] Implement mock inspector component --- jumble/src/components/Inspector.tsx | 457 ++++++++++++++++++++++ jumble/src/hooks/use-resizeable-drawer.ts | 126 ++++++ jumble/src/views/CharmDetailView.tsx | 221 ++++++----- 3 files changed, 696 insertions(+), 108 deletions(-) create mode 100644 jumble/src/components/Inspector.tsx create mode 100644 jumble/src/hooks/use-resizeable-drawer.ts diff --git a/jumble/src/components/Inspector.tsx b/jumble/src/components/Inspector.tsx new file mode 100644 index 000000000..a5d97f467 --- /dev/null +++ b/jumble/src/components/Inspector.tsx @@ -0,0 +1,457 @@ +import * as Inspector from "@commontools/runner/storage/inspector"; +import React, { useCallback, useState } from "react"; +import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts"; +import JsonView from "@uiw/react-json-view"; +import { githubDarkTheme } from "@uiw/react-json-view/githubDark"; + +// Mock data for testing the ModelInspector component +const createMockModel = () => { + const now = Date.now(); + + return { + connection: { + ready: { ok: { attempt: 3 } }, + time: now, + }, + push: { + "job:tx1": { + ok: { + invocation: { cmd: "/memory/transact", args: { data: "example1" } }, + authorization: { access: { "tx1": {} } }, + }, + }, + "job:tx2": { + error: Object.assign(new Error("Transaction failed"), { + reason: "conflict", + time: now - 5000, + }), + }, + }, + pull: { + "job:query1": { + ok: { + invocation: { cmd: "/memory/query", args: { filter: "all" } }, + authorization: { access: { "query1": {} } }, + }, + }, + "job:query2": { + error: Object.assign(new Error("Query timed out"), { + reason: "timeout", + time: now - 2000, + }), + }, + }, + subscriptions: { + "job:sub1": { + source: { cmd: "/memory/query/subscribe", args: { topic: "updates" } }, + opened: now - 10000, + updated: now - 1000, + value: { id: "update-123", status: "active", timestamp: now - 1000 }, + }, + "job:sub2": { + source: { + cmd: "/memory/query/subscribe", + args: { topic: "notifications" }, + }, + opened: now - 20000, + updated: now - 500, + value: [ + { id: "notif-1", message: "New message received", read: false }, + { id: "notif-2", message: "Task completed", read: true }, + ], + }, + }, + } as Inspector.Model; +}; + +// Example usage with dummy data +export const DummyModelInspector: React.FC = () => { + const mockModel = createMockModel(); + return ; +}; +const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => { + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState<"actions" | "subscriptions">( + "actions", + ); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [filterText, setFilterText] = useState(""); + + const formatTime = (time: number) => { + const date = new Date(time); + return `${date.toLocaleTimeString()}.${ + date.getMilliseconds().toString().padStart(3, "0") + }`; + }; + + const toggleRowExpand = (id: string) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + prev.has(id) ? newSet.delete(id) : newSet.add(id); + return newSet; + }); + }; + + const connectionStatus = () => { + if (model.connection.pending) { + return model.connection.pending.ok + ? ( + + Conn: A{model.connection.pending.ok.attempt} + + ) + : ( + + Err: {model.connection.pending.error?.message} + + ); + } + return ( + + Conn: A{model.connection.ready?.ok?.attempt} + + ); + }; + + // Helper function to extract changes fields from transaction + const extractTransactionDetails = (result: any) => { + try { + if ( + result.ok?.invocation?.cmd === "/memory/transact" && + result.ok?.invocation?.args?.changes + ) { + const changes = result.ok.invocation.args.changes; + // Extract of, the, cause + const ofs = Object.keys(changes); + if (ofs.length > 0) { + const of = ofs[0]; + const thes = Object.keys(changes[of]); + if (thes.length > 0) { + const the = thes[0]; + const causes = Object.keys(changes[of][the]); + if (causes.length > 0) { + const cause = causes[0]; + return { of, the, cause }; + } + } + } + } + return null; + } catch (e) { + return null; + } + }; + + const getActionItems = useCallback(() => { + const pushItems = Object.entries(model.push).map(([id, result]) => ({ + id, + type: "push", + result, + time: result.error?.time || model.connection.time, + details: extractTransactionDetails(result), + hasError: !!result.error, + })); + + const pullItems = Object.entries(model.pull).map(([id, result]) => ({ + id, + type: "pull", + result, + time: result.error?.time || model.connection.time, + details: null, // pulls don't have of/the/cause + hasError: !!result.error, + })); + + const items = [...pushItems, ...pullItems].sort((a, b) => b.time - a.time); + + if (!filterText) return items; + + try { + const regex = new RegExp(filterText, "i"); + return items.filter((item) => { + return regex.test(item.id) || + regex.test(item.type) || + regex.test(JSON.stringify(item.result)); + }); + } catch (e) { + // If regex is invalid, fallback to simple string search + return items.filter((item) => { + const itemStr = JSON.stringify(item); + return itemStr.toLowerCase().includes(filterText.toLowerCase()); + }); + } + }, [model, filterText]); + + const getFilteredSubscriptions = useCallback(() => { + if (!filterText) return Object.entries(model.subscriptions); + + try { + const regex = new RegExp(filterText, "i"); + return Object.entries(model.subscriptions).filter(([id, sub]) => { + return regex.test(id) || + regex.test(sub.source.cmd) || + regex.test(JSON.stringify(sub)); + }); + } catch (e) { + // Fallback to simple string search + return Object.entries(model.subscriptions).filter(([id, sub]) => { + const subStr = JSON.stringify({ id, ...sub }); + return subStr.toLowerCase().includes(filterText.toLowerCase()); + }); + } + }, [model.subscriptions, filterText]); + + const { + drawerHeight, + isResizing, + handleResizeStart, + handleTouchResizeStart, + } = useResizableDrawer({ + initialHeight: 240, + resizeDirection: "down", // Resize from bottom up (for bottom drawer) + }); + + // Icons for action types + const getActionIcon = (type: string, hasError: boolean) => { + if (hasError) return "⁈"; + return type === "push" ? "↑" : "↓"; + }; + + return ( +
+ + + {isOpen && ( +
+ {/* Resize Handle */} +
+
+
+ + {/* Tab Navigation with Filter Box */} +
+
+ + +
+
+ setFilterText(e.target.value)} + className="w-32 px-2 py-0.5 text-xs bg-gray-800 border border-gray-700 rounded text-white" + /> +
{connectionStatus()}
+
+
+ + {/* Content Area - This is the scrollable container */} +
+ {activeTab === "actions" && ( +
+ {getActionItems().length > 0 + ? ( + getActionItems().map(( + { id, type, result, time, details, hasError }, + ) => ( +
+
toggleRowExpand(id)} + > +
+ + {getActionIcon(type, hasError)} + + + {id} + + {details && ( + + + {details.of} + + . + {details.the} + + . + {details.cause} + + + )} +
+
+ + {formatTime(time)} + + + {expandedRows.has(id) ? "▼" : "▶"} + +
+
+ + {result.error + ? ( +
+ {result.error.message} + {(result.error as any).reason && + ( + + ({(result.error as any).reason}) + + )} +
+ ) + : ( +
+ {result.ok?.invocation.cmd} +
+ )} + + {expandedRows.has(id) && ( +
+ +
+ )} +
+ )) + ) + : ( +
+ No actions {filterText && "matching filter"} +
+ )} +
+ )} + + {activeTab === "subscriptions" && ( +
+ {getFilteredSubscriptions().length > 0 + ? ( + + + + + + + + + + + {getFilteredSubscriptions().map(( + [id, sub], + ) => ( + + + + + + + + {expandedRows.has(id) && ( + + + + )} + + ))} + +
IDCommandAge
+ {id} + + {sub.source.cmd} + + {Math.floor((Date.now() - sub.opened) / 1000)}s + {sub.updated && ( + + (+{Math.floor( + (Date.now() - sub.updated) / 1000, + )}s) + + )} + + +
+
+ +
+
+ ) + : ( +
+ No subscriptions {filterText && "matching filter"} +
+ )} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/jumble/src/hooks/use-resizeable-drawer.ts b/jumble/src/hooks/use-resizeable-drawer.ts new file mode 100644 index 000000000..aeef1522c --- /dev/null +++ b/jumble/src/hooks/use-resizeable-drawer.ts @@ -0,0 +1,126 @@ +import { useCallback, useRef, useState } from "react"; + +// A shared hook for resizable drawers that could be imported in both components +export function useResizableDrawer({ + initialHeight = 240, + minHeight = 150, + maxHeightFactor = 0.8, + resizeDirection = "up", // 'up' for Inspector (resize from bottom to top), 'down' for CharmDetailView (resize from top to bottom) +} = {}) { + const [drawerHeight, setDrawerHeight] = useState(initialHeight); + const [isResizing, setIsResizing] = useState(false); + const resizeStartY = useRef(null); + const startHeight = useRef(null); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + resizeStartY.current = e.clientY; + startHeight.current = drawerHeight; + setIsResizing(true); + + // Add a layer over the entire document to capture events + const overlay = document.createElement("div"); + overlay.id = "resize-overlay"; + overlay.style.position = "fixed"; + overlay.style.top = "0"; + overlay.style.left = "0"; + overlay.style.width = "100%"; + overlay.style.height = "100%"; + overlay.style.zIndex = "9999"; + overlay.style.cursor = "ns-resize"; + document.body.appendChild(overlay); + + const handleResizeMove = (e: MouseEvent) => { + if (resizeStartY.current !== null && startHeight.current !== null) { + // Calculate the difference based on resize direction + const diff = resizeDirection === "up" + ? e.clientY - resizeStartY.current // Inspector - moving up increases height + : resizeStartY.current - e.clientY; // CharmDetailView - moving down increases height + + const newHeight = Math.max( + minHeight, + Math.min( + globalThis.innerHeight * maxHeightFactor, + startHeight.current + diff, + ), + ); + setDrawerHeight(newHeight); + } + }; + + const handleResizeEnd = () => { + resizeStartY.current = null; + startHeight.current = null; + setIsResizing(false); + + // Remove overlay + const overlay = document.getElementById("resize-overlay"); + if (overlay) { + document.body.removeChild(overlay); + } + + document.removeEventListener("mousemove", handleResizeMove); + document.removeEventListener("mouseup", handleResizeEnd); + }; + + document.addEventListener("mousemove", handleResizeMove); + document.addEventListener("mouseup", handleResizeEnd); + }, + [drawerHeight, resizeDirection, minHeight, maxHeightFactor], + ); + + const handleTouchResizeStart = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + if (e.touches.length === 1) { + resizeStartY.current = e.touches[0].clientY; + startHeight.current = drawerHeight; + setIsResizing(true); + } + + const handleTouchMove = (e: TouchEvent) => { + if ( + resizeStartY.current !== null && + startHeight.current !== null && + e.touches.length === 1 + ) { + // Calculate the difference based on resize direction + const diff = resizeDirection === "up" + ? e.touches[0].clientY - resizeStartY.current // Inspector + : resizeStartY.current - e.touches[0].clientY; // CharmDetailView + + const newHeight = Math.max( + minHeight, + Math.min( + globalThis.innerHeight * maxHeightFactor, + startHeight.current + diff, + ), + ); + setDrawerHeight(newHeight); + } + }; + + const handleTouchEnd = () => { + resizeStartY.current = null; + startHeight.current = null; + setIsResizing(false); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + }; + + document.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + document.addEventListener("touchend", handleTouchEnd); + }, + [drawerHeight, resizeDirection, minHeight, maxHeightFactor], + ); + + return { + drawerHeight, + isResizing, + handleResizeStart, + handleTouchResizeStart, + }; +} diff --git a/jumble/src/views/CharmDetailView.tsx b/jumble/src/views/CharmDetailView.tsx index 7c4103b46..473f488cf 100644 --- a/jumble/src/views/CharmDetailView.tsx +++ b/jumble/src/views/CharmDetailView.tsx @@ -40,6 +40,7 @@ import { Composer, ComposerSubmitBar } from "@/components/Composer.tsx"; import { useCharmMentions } from "@/components/CommandCenter.tsx"; import { formatPromptWithMentions } from "@/utils/format.ts"; import { CharmLink } from "@/components/CharmLink.tsx"; +import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts"; type Tab = "iterate" | "code" | "data"; type OperationType = "iterate" | "extend"; @@ -95,111 +96,111 @@ const useCharmOperationContext = () => { // =================== Custom Hooks =================== -// Hook for managing bottom sheet functionality -function useBottomSheet(initialHeight = 585) { - const [sheetHeight, setSheetHeight] = useState(initialHeight); - const [isResizing, setIsResizing] = useState(false); - const resizeStartY = useRef(null); - const startHeight = useRef(null); - - const handleResizeStart = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - resizeStartY.current = e.clientY; - startHeight.current = sheetHeight; - setIsResizing(true); - - // Add a layer over the entire document to capture events - const overlay = document.createElement("div"); - overlay.id = "resize-overlay"; - overlay.style.position = "fixed"; - overlay.style.top = "0"; - overlay.style.left = "0"; - overlay.style.width = "100%"; - overlay.style.height = "100%"; - overlay.style.zIndex = "9999"; - overlay.style.cursor = "ns-resize"; - document.body.appendChild(overlay); - - const handleResizeMove = (e: MouseEvent) => { - if (resizeStartY.current !== null && startHeight.current !== null) { - const diff = resizeStartY.current - e.clientY; - const newHeight = Math.max( - 150, - Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff), - ); - setSheetHeight(newHeight); - } - }; - - const handleResizeEnd = () => { - resizeStartY.current = null; - startHeight.current = null; - setIsResizing(false); - - // Remove overlay - const overlay = document.getElementById("resize-overlay"); - if (overlay) { - document.body.removeChild(overlay); - } - - document.removeEventListener("mousemove", handleResizeMove); - document.removeEventListener("mouseup", handleResizeEnd); - }; - - document.addEventListener("mousemove", handleResizeMove); - document.addEventListener("mouseup", handleResizeEnd); - }, - [sheetHeight], - ); - - const handleTouchResizeStart = useCallback( - (e: React.TouchEvent) => { - e.preventDefault(); - if (e.touches.length === 1) { - resizeStartY.current = e.touches[0].clientY; - startHeight.current = sheetHeight; - setIsResizing(true); - } - - const handleTouchMove = (e: TouchEvent) => { - if ( - resizeStartY.current !== null && - startHeight.current !== null && - e.touches.length === 1 - ) { - const diff = resizeStartY.current - e.touches[0].clientY; - const newHeight = Math.max( - 150, - Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff), - ); - setSheetHeight(newHeight); - } - }; - - const handleTouchEnd = () => { - resizeStartY.current = null; - startHeight.current = null; - setIsResizing(false); - document.removeEventListener("touchmove", handleTouchMove); - document.removeEventListener("touchend", handleTouchEnd); - }; - - document.addEventListener("touchmove", handleTouchMove, { - passive: false, - }); - document.addEventListener("touchend", handleTouchEnd); - }, - [sheetHeight], - ); - - return { - sheetHeight, - isResizing, - handleResizeStart, - handleTouchResizeStart, - }; -} +// // Hook for managing bottom sheet functionality +// function useBottomSheet(initialHeight = 585) { +// const [sheetHeight, setSheetHeight] = useState(initialHeight); +// const [isResizing, setIsResizing] = useState(false); +// const resizeStartY = useRef(null); +// const startHeight = useRef(null); + +// const handleResizeStart = useCallback( +// (e: React.MouseEvent) => { +// e.preventDefault(); +// resizeStartY.current = e.clientY; +// startHeight.current = sheetHeight; +// setIsResizing(true); + +// // Add a layer over the entire document to capture events +// const overlay = document.createElement("div"); +// overlay.id = "resize-overlay"; +// overlay.style.position = "fixed"; +// overlay.style.top = "0"; +// overlay.style.left = "0"; +// overlay.style.width = "100%"; +// overlay.style.height = "100%"; +// overlay.style.zIndex = "9999"; +// overlay.style.cursor = "ns-resize"; +// document.body.appendChild(overlay); + +// const handleResizeMove = (e: MouseEvent) => { +// if (resizeStartY.current !== null && startHeight.current !== null) { +// const diff = resizeStartY.current - e.clientY; +// const newHeight = Math.max( +// 150, +// Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff), +// ); +// setSheetHeight(newHeight); +// } +// }; + +// const handleResizeEnd = () => { +// resizeStartY.current = null; +// startHeight.current = null; +// setIsResizing(false); + +// // Remove overlay +// const overlay = document.getElementById("resize-overlay"); +// if (overlay) { +// document.body.removeChild(overlay); +// } + +// document.removeEventListener("mousemove", handleResizeMove); +// document.removeEventListener("mouseup", handleResizeEnd); +// }; + +// document.addEventListener("mousemove", handleResizeMove); +// document.addEventListener("mouseup", handleResizeEnd); +// }, +// [sheetHeight], +// ); + +// const handleTouchResizeStart = useCallback( +// (e: React.TouchEvent) => { +// e.preventDefault(); +// if (e.touches.length === 1) { +// resizeStartY.current = e.touches[0].clientY; +// startHeight.current = sheetHeight; +// setIsResizing(true); +// } + +// const handleTouchMove = (e: TouchEvent) => { +// if ( +// resizeStartY.current !== null && +// startHeight.current !== null && +// e.touches.length === 1 +// ) { +// const diff = resizeStartY.current - e.touches[0].clientY; +// const newHeight = Math.max( +// 150, +// Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff), +// ); +// setSheetHeight(newHeight); +// } +// }; + +// const handleTouchEnd = () => { +// resizeStartY.current = null; +// startHeight.current = null; +// setIsResizing(false); +// document.removeEventListener("touchmove", handleTouchMove); +// document.removeEventListener("touchend", handleTouchEnd); +// }; + +// document.addEventListener("touchmove", handleTouchMove, { +// passive: false, +// }); +// document.addEventListener("touchend", handleTouchEnd); +// }, +// [sheetHeight], +// ); + +// return { +// sheetHeight, +// isResizing, +// handleResizeStart, +// handleTouchResizeStart, +// }; +// } // Hook for tab management function useTabNavigation() { @@ -1257,14 +1258,18 @@ const BottomSheet = ({ }: { children: (activeTab: Tab, isResizing: boolean) => React.ReactNode; }) => { - const { sheetHeight, isResizing, handleResizeStart, handleTouchResizeStart } = - useBottomSheet(); + const { + drawerHeight, + isResizing, + handleResizeStart, + handleTouchResizeStart, + } = useResizableDrawer({ initialHeight: 585, resizeDirection: "down" }); const { activeTab, handleTabChange } = useTabNavigation(); return (
{/* Resize Handle */}
<5009316+bfollington@users.noreply.github.com> Date: Fri, 21 Mar 2025 16:52:24 -0700 Subject: [PATCH 03/15] Export Model interface --- runner/src/storage/inspector.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/runner/src/storage/inspector.ts b/runner/src/storage/inspector.ts index 163c3f1fb..2614ebe80 100644 --- a/runner/src/storage/inspector.ts +++ b/runner/src/storage/inspector.ts @@ -11,7 +11,7 @@ import { Transaction, TransactionError, UCAN, - Variant + Variant, } from "@commontools/memory/interface"; import { JSONValue } from "../../../builder/src/index.ts"; @@ -51,8 +51,7 @@ export interface ConnectionError extends Error { reason: "timeout" | "error" | "close"; time: Time; } - -class Model { +export interface Model { /** * Status of the connection to the upstream. If pending it will contain * result holding potentially an error which occurred causing a reconnection @@ -80,11 +79,28 @@ class Model { updated?: Time; value: JSONValue | undefined; }>; +} + +class Model { + connection: Status>; + push: PushState; + pull: PullState; + subscriptions: Record; constructor( - connection: typeof this.connection, - push: typeof this.push, - pull: typeof this.pull, - subscriptions: typeof this.subscriptions, + connection: Status>, + push: PushState, + pull: PullState, + subscriptions: Record, ) { this.connection = connection; this.push = push; From ca52c76a1d9d0bc6f29362179543c6ec4362ac3a Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Fri, 21 Mar 2025 18:44:42 -0700 Subject: [PATCH 04/15] feat: implement tasks --- deno.jsonc | 30 +-- jumble/src/components/View.tsx | 330 ++++++++++++++++++++++++++------- jumble/src/views/Shell.tsx | 47 +++++ 3 files changed, 314 insertions(+), 93 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index c415c99ef..120572606 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -25,16 +25,8 @@ }, "compilerOptions": { "jsx": "react-jsxdev", - "lib": [ - "deno.ns", - "dom", - "dom.iterable", - "dom.asynciterable", - "esnext" - ], - "types": [ - "./jumble/src/global.d.ts" - ] + "lib": ["deno.ns", "dom", "dom.iterable", "dom.asynciterable", "esnext"], + "types": ["./jumble/src/global.d.ts"] }, "exclude": [ "jumble/.vite/deps/", @@ -46,21 +38,11 @@ "**/coverage/" ], "lint": { - "exclude": [ - "deno-vite-plugin" - ], + "exclude": ["deno-vite-plugin"], "rules": { - "tags": [ - "recommended" - ], - "include": [ - "ban-untagged-todo", - "no-external-import" - ], - "exclude": [ - "no-unused-vars", - "no-explicit-any" - ] + "tags": ["recommended"], + "include": ["ban-untagged-todo", "no-external-import"], + "exclude": ["no-unused-vars", "no-explicit-any", "require-yield"] } }, "fmt": { diff --git a/jumble/src/components/View.tsx b/jumble/src/components/View.tsx index 0374b6eab..de4972853 100644 --- a/jumble/src/components/View.tsx +++ b/jumble/src/components/View.tsx @@ -1,13 +1,26 @@ +// deno-lint-ignore-file require-yield import { JSX, useCallback, useEffect, useRef, useState } from "react"; +class Perform { + constructor(public execute: () => Promise) { + } +} + +class Spawn { + constructor( + public work: () => Task, + ) { + } +} + +export type FX = + | Send + | Spawn | Perform | Wait>; + export interface Behavior { - init(): { state: Model; fx?: Effect[] }; - update( - model: Model, - command: Command, - ): { state: Model; fx?: Effect[] }; + init(): Task, Model>; - view(model: Model, controller: Controller): JSX.Element; + update: (model: Model, command: Command) => Task, Model>; } export interface Effect { @@ -15,92 +28,271 @@ export interface Effect { } export interface Controller { - perform(effect: Effect): void; - dispatch(command: Command): () => void; } -class Process implements Controller { - state: Model; - view: JSX.Element; +export interface Subscriber { + (self: Service): View; +} + +function* test() { + yield Promise.resolve(); +} + +export interface Task> { + [Symbol.iterator](): Job; +} + +export interface Job { + throw(error: InferError): Step; + return(ok: Ok): Step; + next(value: void): Step; + [Symbol.iterator](): Job; +} + +export type InferError = Command extends Throw ? Error + : never; + +export type InferReturn = Command extends Return ? Ok + : never; +export type Step< + Ok extends unknown, + Command extends unknown, +> = IteratorResult; + +class Throw { + constructor(public error: Failure) {} +} + +class Wait { + constructor(public promise: Promise) { + } +} + +type T = Generator; + +export const wait = function* ( + promise: Promise, +): Task, T> { + const result = yield new Wait(promise); + return result as T; +}; + +export const sleep = function* (ms: number = 0): Task, void> { + yield* wait(new Promise((wake) => setTimeout(wake, ms))); +}; + +export const spawn = function* ( + work: () => Task, +): Task, Spawn> { + const spawn = new Spawn(work); + yield spawn; + return spawn; +}; + +export const fork = (task: Task) => spawn(() => task); + +export const send = function* < + Command extends string | number | boolean | null | object, +>( + command: Command, +): Task, void> { + yield new Send(command); +}; + +class Return { + constructor(public ok: Ok) {} +} + +class Send { + constructor(public command: Command) {} +} + +export interface Execution< + Ok extends unknown, + Command extends unknown, +> { + next(): IterableIterator; + [Symbol.iterator](): Execution; +} + +export const service = (behavior: Behavior) => + new Service(behavior); + +export type InferSend = Effect extends Send ? Command + : never; + +class Service { + state!: Model; + + subscribers: Set> | undefined; + inbox: Command[] = []; + queue: FX[] = []; + + work: Job | Perform | Wait, void>[] = []; + idle: boolean = true; constructor( public behavior: Behavior, - public advance: (self: [Process]) => void, ) { - const { state, fx } = behavior.init(); - this.state = state; - this.view = behavior.view(state, this); + } - this.enqueue(fx ?? []); + execute(command: Command) { + this.inbox.push(command); + this.wake(); } - async perform(effect: Effect) { - const command = await effect(); - const { state, fx } = this.behavior.update(this.state, command); - this.state = state; - this.view = this.behavior.view(this.state, this); - this.enqueue(fx ?? []); + wake() { + if (this.idle) { + this.idle = false; + while (this.inbox.length > 0 || this.queue.length > 0) { + for (const command of this.inbox.splice(0)) { + this.state = this.advance(this.behavior.update(this.state, command)); + } + + for (const effect of this.queue.splice(0)) { + if (effect instanceof Send) { + this.inbox.push(effect.command); + } else if (effect instanceof Spawn) { + this.spawn(effect); + } + } + } - this.advance([this]); + this.idle = true; + this.notify(); + } + } + + async spawn(effect: Spawn | Perform>) { + const work = effect.work()[Symbol.iterator](); + let state = undefined; + while (true) { + const step = work.next(state as void); + if (step.done) { + return step.value; + } else { + const { value } = step; + if (value instanceof Send) { + this.execute(value.command); + } else if (value instanceof Wait) { + state = await value.promise; + } else { + state = await value.execute(); + } + } + } } - enqueue(effects: Effect[]) { - for (const effect of effects) { - this.perform(effect); + advance(task: Task, Model>) { + const work = task[Symbol.iterator](); + while (true) { + const step = work.next(); + if (step.done) { + this.state = step.value; + return this.state; + } else { + this.queue.push(step.value); + } + } + } + + notify() { + for (const subscriber of this.subscribers ?? []) { + subscriber(this); } } dispatch(command: Command) { - return () => this.perform(async () => command); + return () => this.execute(command); } terminate() { } -} -/** - * Define react component as state machine. - * - * @example - * ```js - * const Counter = View({ - * init() { - * return { state: { count: 0 } }; - * }, - * update({ count }: { count: number }, command: "inc" | "dec") { - * switch (command) { - * case "inc": - * return { state: { count: count + 1 } }; - * case "dec": - * return { state: { count: count + 1 } }; - * default: - * return { state: { count } }; - * } - * }, - * view(state, controller) { - * return ; - * }, - * }); - * ``` - * - * Then you can use it as react component as - * - * ```js - * - * ``` - */ -export default (behavior: Behavior) => { - return function View() { - const [process, advance] = useState<[Process]>(); + subscribe(subscriber: Subscriber) { + if (!this.subscribers) { + this.subscribers = new Set([subscriber]); + this.advance(this.behavior.init()); + this.wake(); + } else { + this.subscribers.add(subscriber); + } + } + render(view: (state: Model, controller: Controller) => View) { + const [process, advance] = useState<[Service]>(); + const [ui, setUI] = useState(null); + + useEffect( + () => + this.subscribe((process) => { + console.log("advance"); + advance([process]); + }), + [], + ); useEffect(() => { - const process = new Process(behavior, advance); - advance([process]); + console.log("render"); + const [task] = process ?? []; + if (task) { + setUI(view(task.state, task)); + } + }, [process]); + + return ui; + } - return () => process.terminate(); - }, []); + View(view: (state: Model, controller: Controller) => View) { + return () => this.render(view); + } +} - return process?.[0]?.view ?? null; - }; -}; +// /** +// * Define react component as state machine. +// * +// * @example +// * ```js +// * const Counter = View({ +// * init() { +// * return { state: { count: 0 } }; +// * }, +// * update({ count }: { count: number }, command: "inc" | "dec") { +// * switch (command) { +// * case "inc": +// * return { state: { count: count + 1 } }; +// * case "dec": +// * return { state: { count: count + 1 } }; +// * default: +// * return { state: { count } }; +// * } +// * }, +// * view(state, controller) { +// * return ; +// * }, +// * }); +// * ``` +// * +// * Then you can use it as react component as +// * +// * ```js +// * +// * ``` +// */ +// export default (behavior: Behavior) => { +// return function View() { +// const [process, advance] = useState<[Process]>(); + +// useEffect(() => { +// const process = new Process(behavior, advance); +// advance([process]); + +// return () => process.terminate(); +// }, []); + +// return process?.[0]?.view ?? null; +// }; +// }; + +export { Service as Process }; +export default Service; diff --git a/jumble/src/views/Shell.tsx b/jumble/src/views/Shell.tsx index cf3edef62..cb36ca843 100644 --- a/jumble/src/views/Shell.tsx +++ b/jumble/src/views/Shell.tsx @@ -10,6 +10,51 @@ import { ActionBar } from "@/components/ActionBar.tsx"; import { CharmPublisher } from "@/components/Publish.tsx"; import { useGlobalActions } from "@/hooks/use-global-actions.tsx"; import { SyncStatusProvider } from "@/contexts/SyncStatusContext.tsx"; +import * as Process from "@/components/View.tsx"; + +function* subscribe() { + const test = yield* Process.wait(Promise.resolve(1)); + + yield* Process.send("inc"); +} + +function* test() { + yield* Process.send("inc"); + + return { count: 0 }; +} + +const Counter = Process.service({ + *init() { + yield* Process.spawn(function* () { + // while (true) { + yield* Process.sleep(1000); + yield* Process.send("inc"); + // } + }); + + return { count: 0 }; + }, + + *update({ count }, command: "inc" | "dec") { + switch (command) { + case "inc": + return { count: count + 1 }; + case "dec": + return { count: count + 1 }; + default: + return { count }; + } + }, +}); + +const CounterView = Counter.View((state, controller) => ( + +)); + +const CounterView2 = Counter.View((state, controller) => ( +

{state.count}

+)); export default function Shell() { const { charmId } = useParams(); @@ -33,6 +78,8 @@ export default function Shell() { + +
From b7f4f8d94a4d77c3bcda75979781a1dceca45e7f Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:06:48 -0700 Subject: [PATCH 05/15] Inspector is alive! --- .../{Inspector.tsx => NetworkInspector.tsx} | 69 ++++++++++++++++++- jumble/src/components/User.tsx | 27 ++------ jumble/src/views/Shell.tsx | 4 +- 3 files changed, 72 insertions(+), 28 deletions(-) rename jumble/src/components/{Inspector.tsx => NetworkInspector.tsx} (89%) diff --git a/jumble/src/components/Inspector.tsx b/jumble/src/components/NetworkInspector.tsx similarity index 89% rename from jumble/src/components/Inspector.tsx rename to jumble/src/components/NetworkInspector.tsx index a5d97f467..5addfe3d6 100644 --- a/jumble/src/components/Inspector.tsx +++ b/jumble/src/components/NetworkInspector.tsx @@ -1,8 +1,35 @@ import * as Inspector from "@commontools/runner/storage/inspector"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts"; import JsonView from "@uiw/react-json-view"; import { githubDarkTheme } from "@uiw/react-json-view/githubDark"; +import { getDoc } from "@commontools/runner"; + +import { useCell } from "@/hooks/use-cell.ts"; +const model = getDoc(Inspector.create(), "inspector", "").asCell(); + +// Custom hooks +export function useStorageBroadcast(callback: (data: any) => void) { + useEffect(() => { + const messages = new BroadcastChannel("storage/remote"); + messages.onmessage = ({ data }) => callback(data); + return () => messages.close(); + }, [callback]); +} + +export function useStatusMonitor() { + const status = useRef(Inspector.create()); + + const updateStatus = useCallback((command: Inspector.Command) => { + if (!status.current) { + throw new Error("Status is not initialized"); + } + const state = Inspector.update(status.current, command); + status.current = state; + }, []); + + return { status, updateStatus }; +} // Mock data for testing the ModelInspector component const createMockModel = () => { @@ -66,8 +93,11 @@ const createMockModel = () => { // Example usage with dummy data export const DummyModelInspector: React.FC = () => { - const mockModel = createMockModel(); - return ; + const { status, updateStatus } = useStatusMonitor(); + useStorageBroadcast(updateStatus); + if (!status.current) return null; + + return ; }; const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => { const [isOpen, setIsOpen] = useState(false); @@ -76,6 +106,39 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => { ); const [expandedRows, setExpandedRows] = useState>(new Set()); const [filterText, setFilterText] = useState(""); + const [, setRenderTrigger] = useState(0); + const rafRef = useRef(null); + const lastRenderTimeRef = useRef(0); + + // Set up render loop with requestAnimationFrame when inspector is open + useEffect(() => { + if (!isOpen) { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + const renderLoop = () => { + const now = performance.now(); + // Limit to ~10 FPS when not actively receiving updates + if (now - lastRenderTimeRef.current > 100) { + setRenderTrigger(prev => (prev + 1) % 1000); // Force re-render + lastRenderTimeRef.current = now; + } + rafRef.current = requestAnimationFrame(renderLoop); + }; + + rafRef.current = requestAnimationFrame(renderLoop); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [isOpen]); const formatTime = (time: number) => { const date = new Date(time); diff --git a/jumble/src/components/User.tsx b/jumble/src/components/User.tsx index 62b67ed95..1a7bedbc0 100644 --- a/jumble/src/components/User.tsx +++ b/jumble/src/components/User.tsx @@ -9,6 +9,10 @@ import * as Inspector from "@commontools/runner/storage/inspector"; import { FaArrowDown, FaArrowUp, FaExclamationTriangle } from "react-icons/fa"; import { useCallback, useEffect, useRef, useState } from "react"; +import { + useStatusMonitor, + useStorageBroadcast, +} from "@/components/NetworkInspector.tsx"; // Constants const COLORS = { @@ -80,15 +84,6 @@ const AVATAR_SHAPES = [ `, ]; -// Custom hooks -export function useStorageBroadcast(callback: (data: any) => void) { - useEffect(() => { - const messages = new BroadcastChannel("storage/remote"); - messages.onmessage = ({ data }) => callback(data); - return () => messages.close(); - }, [callback]); -} - function useAvatarGenerator(did: string | undefined) { const [avatarColor, setAvatarColor] = useState(""); const [avatarShape, setAvatarShape] = useState(""); @@ -117,20 +112,6 @@ function useAvatarGenerator(did: string | undefined) { return { avatarColor, avatarShape }; } -function useStatusMonitor() { - const status = useRef(Inspector.create()); - - const updateStatus = useCallback((command: Inspector.Command) => { - if (!status.current) { - throw new Error("Status is not initialized"); - } - const state = Inspector.update(status.current, command); - status.current = state; - }, []); - - return { status, updateStatus }; -} - // Helper functions const ease = (current: number, target: number, factor: number = 0.1) => { return current + (target - current) * factor; diff --git a/jumble/src/views/Shell.tsx b/jumble/src/views/Shell.tsx index cb36ca843..710651e65 100644 --- a/jumble/src/views/Shell.tsx +++ b/jumble/src/views/Shell.tsx @@ -11,6 +11,7 @@ import { CharmPublisher } from "@/components/Publish.tsx"; import { useGlobalActions } from "@/hooks/use-global-actions.tsx"; import { SyncStatusProvider } from "@/contexts/SyncStatusContext.tsx"; import * as Process from "@/components/View.tsx"; +import { DummyModelInspector } from "@/components/NetworkInspector.tsx"; function* subscribe() { const test = yield* Process.wait(Promise.resolve(1)); @@ -78,8 +79,7 @@ export default function Shell() { - - +
From 86aa09bbe645b7bcfef3597f4b8f5c871874da18 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 14:22:40 -0700 Subject: [PATCH 06/15] Extract animation and smoothing logic into hook --- jumble/src/components/NetworkInspector.tsx | 56 +++++++++- jumble/src/components/User.tsx | 90 +++++----------- jumble/src/hooks/use-animation-smoothing.ts | 111 ++++++++++++++++++++ 3 files changed, 190 insertions(+), 67 deletions(-) create mode 100644 jumble/src/hooks/use-animation-smoothing.ts diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx index 5addfe3d6..64e5d6bb5 100644 --- a/jumble/src/components/NetworkInspector.tsx +++ b/jumble/src/components/NetworkInspector.tsx @@ -4,6 +4,7 @@ import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts"; import JsonView from "@uiw/react-json-view"; import { githubDarkTheme } from "@uiw/react-json-view/githubDark"; import { getDoc } from "@commontools/runner"; +import { useAnimationSmoothing } from "@/hooks/use-animation-smoothing.ts"; import { useCell } from "@/hooks/use-cell.ts"; const model = getDoc(Inspector.create(), "inspector", "").asCell(); @@ -107,8 +108,11 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => { const [expandedRows, setExpandedRows] = useState>(new Set()); const [filterText, setFilterText] = useState(""); const [, setRenderTrigger] = useState(0); - const rafRef = useRef(null); const lastRenderTimeRef = useRef(0); + const buttonTextRef = useRef(null); + + // Use our animation smoothing hook + const { updateValue, getValue, rafRef } = useAnimationSmoothing(); // Set up render loop with requestAnimationFrame when inspector is open useEffect(() => { @@ -138,7 +142,53 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => { rafRef.current = null; } }; - }, [isOpen]); + }, [isOpen, rafRef]); + + // Update button text with counts using requestAnimationFrame + useEffect(() => { + const updateCounts = () => { + if (!buttonTextRef.current) { + rafRef.current = requestAnimationFrame(updateCounts); + return; + } + + // Count calculations + const actualPushCount = Object.values(model.push).filter(v => v.ok).length; + const actualPullCount = Object.values(model.pull).filter(v => v.ok).length; + const pushErrorCount = Object.values(model.push).filter(v => v.error).length; + const pullErrorCount = Object.values(model.pull).filter(v => v.error).length; + const actualErrorCount = pushErrorCount + pullErrorCount; + + // Update values with easing + const pushResult = updateValue("push", actualPushCount); + const pullResult = updateValue("pull", actualPullCount); + const errorResult = updateValue("error", actualErrorCount); + + // Create status text + const statusParts = []; + if (pushResult.value > 0) statusParts.push(`↑${pushResult.value}`); + if (pullResult.value > 0) statusParts.push(`↓${pullResult.value}`); + if (errorResult.value > 0) statusParts.push(`!${errorResult.value}`); + + const statusText = statusParts.length > 0 + ? `Inspector ${statusParts.join(" ")} ${isOpen ? "▼" : "▲"}` + : `Inspector ${isOpen ? "▼" : "▲"}`; + + buttonTextRef.current.textContent = statusText; + + // Continue animation + rafRef.current = requestAnimationFrame(updateCounts); + }; + + // Start updating counts + rafRef.current = requestAnimationFrame(updateCounts); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [model, isOpen, updateValue, rafRef]); const formatTime = (time: number) => { const date = new Date(time); @@ -286,7 +336,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => { onClick={() => setIsOpen(!isOpen)} className="absolute top-0 left-4 transform -translate-y-full bg-gray-800 text-white px-2 py-0.5 rounded-t-md text-xs" > - Inspector {isOpen ? "▼" : "▲"} + Inspector {isOpen ? "▼" : "▲"} {isOpen && ( diff --git a/jumble/src/components/User.tsx b/jumble/src/components/User.tsx index 1a7bedbc0..71303ead0 100644 --- a/jumble/src/components/User.tsx +++ b/jumble/src/components/User.tsx @@ -13,6 +13,7 @@ import { useStatusMonitor, useStorageBroadcast, } from "@/components/NetworkInspector.tsx"; +import { useAnimationSmoothing } from "@/hooks/use-animation-smoothing.ts"; // Constants const COLORS = { @@ -164,16 +165,12 @@ export function User() { const pullErrorRef = useRef(null); // Animation state refs - const animationRef = useRef(null); const rotationRef = useRef(0); const opacityRef = useRef(0); const bounceRef = useRef(0); - const easedPushCountRef = useRef(0); - const easedPullCountRef = useRef(0); - const easedErrorCountRef = useRef(0); - const lastPushTimestampRef = useRef(0); - const lastPullTimestampRef = useRef(0); - const lastErrorTimestampRef = useRef(0); + + // Use our shared animation smoothing hook + const { updateValue, getValue, rafRef } = useAnimationSmoothing(); // Get DID from session useEffect(() => { @@ -190,13 +187,13 @@ export function User() { useEffect(() => { const animate = () => { if (!svgRef.current || !avatarRef.current || !tooltipRef.current) { - animationRef.current = requestAnimationFrame(animate); + rafRef.current = requestAnimationFrame(animate); return; } const circle = svgRef.current.querySelector("circle"); if (!circle) { - animationRef.current = requestAnimationFrame(animate); + rafRef.current = requestAnimationFrame(animate); return; } @@ -215,56 +212,20 @@ export function User() { Object.values(currentStatus.pull).filter((v) => v.error).length; const actualErrorCount = pushErrorCount + pullErrorCount; - // Update timestamps for animation duration - if (actualPushCount > Math.round(easedPushCountRef.current)) { - lastPushTimestampRef.current = now; - } - if (actualPullCount > Math.round(easedPullCountRef.current)) { - lastPullTimestampRef.current = now; - } - if (actualErrorCount > Math.round(easedErrorCountRef.current)) { - lastErrorTimestampRef.current = now; - } - - // Activity states - const pushActive = actualPushCount > 0 || - (now - lastPushTimestampRef.current < MIN_ANIMATION_DURATION); - const pullActive = actualPullCount > 0 || - (now - lastPullTimestampRef.current < MIN_ANIMATION_DURATION); - const errorActive = actualErrorCount > 0 || - (now - lastErrorTimestampRef.current < MIN_ANIMATION_DURATION); - - // Ease count values - const easingFactor = 0.06; - easedPushCountRef.current = ease( - easedPushCountRef.current, - pushActive ? Math.max(actualPushCount, 0.01) : 0, - easingFactor, - ); - easedPullCountRef.current = ease( - easedPullCountRef.current, - pullActive ? Math.max(actualPullCount, 0.01) : 0, - easingFactor, - ); - easedErrorCountRef.current = ease( - easedErrorCountRef.current, - errorActive ? Math.max(actualErrorCount, 0.01) : 0, - easingFactor, - ); - + // Use our shared hook to update values with easing + const pushResult = updateValue("push", actualPushCount); + const pullResult = updateValue("pull", actualPullCount); + const errorResult = updateValue("error", actualErrorCount); + + // Activity states based on the hook's results + const pushActive = pushResult.isActive; + const pullActive = pullResult.isActive; + const errorActive = errorResult.isActive; + // Display counts - even if count is 0 but animation is active, show at least 1 - const displayPushCount = - pushActive && Math.round(easedPushCountRef.current) === 0 - ? 1 - : Math.round(easedPushCountRef.current); - const displayPullCount = - pullActive && Math.round(easedPullCountRef.current) === 0 - ? 1 - : Math.round(easedPullCountRef.current); - const displayErrorCount = - errorActive && Math.round(easedErrorCountRef.current) === 0 - ? 1 - : Math.round(easedErrorCountRef.current); + const displayPushCount = pushActive && pushResult.value === 0 ? 1 : pushResult.value; + const displayPullCount = pullActive && pullResult.value === 0 ? 1 : pullResult.value; + const displayErrorCount = errorActive && errorResult.value === 0 ? 1 : errorResult.value; // Default status message let statusMessage = "Click to log out"; @@ -463,19 +424,20 @@ export function User() { ); // Continue animation - animationRef.current = requestAnimationFrame(animate); + rafRef.current = requestAnimationFrame(animate); }; - // Start animation - animationRef.current = requestAnimationFrame(animate); + // Start animation using our shared RAF reference + rafRef.current = requestAnimationFrame(animate); // Cleanup return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; } }; - }, []); + }, [updateValue, rafRef]); return (
diff --git a/jumble/src/hooks/use-animation-smoothing.ts b/jumble/src/hooks/use-animation-smoothing.ts new file mode 100644 index 000000000..3e411d15c --- /dev/null +++ b/jumble/src/hooks/use-animation-smoothing.ts @@ -0,0 +1,111 @@ +import { useRef, useEffect } from "react"; + +// Minimum time animation stays active after last update +const MIN_ANIMATION_DURATION = 800; + +/** + * Hook for smoothing animation values with easing + * and minimum duration for network operation indicators + */ +export function useAnimationSmoothing( + initialValues: Record = {} +) { + // Animation refs + const rafRef = useRef(null); + const easedValuesRef = useRef>({}); + const lastUpdatesRef = useRef>({}); + + // Initialize refs with provided initial values + useEffect(() => { + Object.entries(initialValues).forEach(([key, value]) => { + easedValuesRef.current[key] = value; + lastUpdatesRef.current[key] = 0; + }); + }, []); + + // Helper function for easing values + const ease = (current: number, target: number, factor: number = 0.1) => { + return current + (target - current) * factor; + }; + + /** + * Update a value with easing effect + * @param key Unique identifier for the value + * @param actualValue The current actual value to ease toward + * @param isActive Whether the animation is active (reset timestamp) + * @param easingFactor Optional easing factor (0-1) + */ + const updateValue = ( + key: string, + actualValue: number, + isActive: boolean = true, + easingFactor: number = 0.06 + ) => { + const now = Date.now(); + + // Initialize if doesn't exist + if (easedValuesRef.current[key] === undefined) { + easedValuesRef.current[key] = 0; + lastUpdatesRef.current[key] = 0; + } + + // Update timestamp if value increased or is manually set as active + if (isActive || actualValue > Math.round(easedValuesRef.current[key])) { + lastUpdatesRef.current[key] = now; + } + + // Determine if animation should be active (min duration logic) + const isAnimationActive = actualValue > 0 || + (now - lastUpdatesRef.current[key] < MIN_ANIMATION_DURATION); + + // Update eased value + easedValuesRef.current[key] = ease( + easedValuesRef.current[key], + isAnimationActive ? Math.max(actualValue, 0.01) : 0, + easingFactor + ); + + // Return displayable rounded value + return { + value: Math.round(easedValuesRef.current[key]), + isActive: isAnimationActive + }; + }; + + /** + * Get the current eased value + * @param key Unique identifier for the value + */ + const getValue = (key: string) => { + const now = Date.now(); + + if (easedValuesRef.current[key] === undefined) { + return { value: 0, isActive: false }; + } + + const isAnimationActive = + Math.round(easedValuesRef.current[key]) > 0 || + (now - lastUpdatesRef.current[key] < MIN_ANIMATION_DURATION); + + return { + value: Math.round(easedValuesRef.current[key]), + isActive: isAnimationActive + }; + }; + + // Cleanup animation frame on unmount + useEffect(() => { + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, []); + + return { + updateValue, + getValue, + rafRef + }; +} \ No newline at end of file From b7d7d75401cec8045f6c557001309ad06ef33044 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 16:19:54 -0700 Subject: [PATCH 07/15] Toggleable via command and local storage --- jumble/src/components/NetworkInspector.tsx | 13 +++++- jumble/src/components/commands.ts | 26 +++++++++++ .../src/contexts/NetworkInspectorContext.tsx | 31 +++++++++++++ jumble/src/hooks/use-network-inspector.ts | 46 +++++++++++++++++++ jumble/src/views/Shell.tsx | 26 ++++++----- 5 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 jumble/src/contexts/NetworkInspectorContext.tsx create mode 100644 jumble/src/hooks/use-network-inspector.ts diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx index 64e5d6bb5..c06d80426 100644 --- a/jumble/src/components/NetworkInspector.tsx +++ b/jumble/src/components/NetworkInspector.tsx @@ -100,8 +100,17 @@ export const DummyModelInspector: React.FC = () => { return ; }; -const ModelInspector: React.FC<{ model: Inspector.Model }> = ({ model }) => { - const [isOpen, setIsOpen] = useState(false); + +export const ToggleableNetworkInspector: React.FC<{ visible: boolean }> = ({ visible }) => { + const { status, updateStatus } = useStatusMonitor(); + useStorageBroadcast(updateStatus); + + if (!visible || !status.current) return null; + + return ; +}; +const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }> = ({ model, initiallyOpen = false }) => { + const [isOpen, setIsOpen] = useState(initiallyOpen); const [activeTab, setActiveTab] = useState<"actions" | "subscriptions">( "actions", ); diff --git a/jumble/src/components/commands.ts b/jumble/src/components/commands.ts index 5937d4b8d..42babbfae 100644 --- a/jumble/src/components/commands.ts +++ b/jumble/src/components/commands.ts @@ -768,6 +768,32 @@ async function handleAddRemoteRecipe( export function getCommands(deps: CommandContext): CommandItem[] { return [ + { + id: "enable-network-inspector", + type: "action", + title: "Enable Network Inspector", + group: "Tools", + predicate: localStorage.getItem("networkInspectorVisible") !== "true", + handler: () => { + localStorage.setItem("networkInspectorVisible", "true"); + deps.setOpen(false); + // Force a refresh to make sure the UI updates + window.dispatchEvent(new Event("networkInspectorUpdate")); + }, + }, + { + id: "disable-network-inspector", + type: "action", + title: "Disable Network Inspector", + group: "Tools", + predicate: localStorage.getItem("networkInspectorVisible") === "true", + handler: () => { + localStorage.setItem("networkInspectorVisible", "false"); + deps.setOpen(false); + // Force a refresh to make sure the UI updates + window.dispatchEvent(new Event("networkInspectorUpdate")); + }, + }, { id: "new-charm", type: "input", diff --git a/jumble/src/contexts/NetworkInspectorContext.tsx b/jumble/src/contexts/NetworkInspectorContext.tsx new file mode 100644 index 000000000..6ced1b19d --- /dev/null +++ b/jumble/src/contexts/NetworkInspectorContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext, ReactNode } from "react"; +import { useNetworkInspector } from "@/hooks/use-network-inspector.ts"; + +interface NetworkInspectorContextType { + visible: boolean; + toggleVisibility: (value?: boolean) => void; + show: () => void; + hide: () => void; +} + +const NetworkInspectorContext = createContext(null); + +export function NetworkInspectorProvider({ children }: { children: ReactNode }) { + const inspector = useNetworkInspector(); + + return ( + + {children} + + ); +} + +export function useNetworkInspectorContext() { + const context = useContext(NetworkInspectorContext); + if (!context) { + throw new Error( + "useNetworkInspectorContext must be used within a NetworkInspectorProvider" + ); + } + return context; +} \ No newline at end of file diff --git a/jumble/src/hooks/use-network-inspector.ts b/jumble/src/hooks/use-network-inspector.ts new file mode 100644 index 000000000..8ff39f57d --- /dev/null +++ b/jumble/src/hooks/use-network-inspector.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; + +export function useNetworkInspector() { + const [visible, setVisible] = useState(false); + + // On first render, check localStorage for saved preference + useEffect(() => { + const updateFromStorage = () => { + const savedPreference = localStorage.getItem("networkInspectorVisible"); + if (savedPreference !== null) { + setVisible(savedPreference === "true"); + } + }; + + // Initialize from localStorage + updateFromStorage(); + + // Listen for changes in localStorage (for cross-tab synchronization) + window.addEventListener("storage", updateFromStorage); + + // Also listen for our custom event for same-tab updates + window.addEventListener("networkInspectorUpdate", updateFromStorage); + + return () => { + window.removeEventListener("storage", updateFromStorage); + window.removeEventListener("networkInspectorUpdate", updateFromStorage); + }; + }, []); + + // Update localStorage when the visible state changes + const toggleVisibility = (value?: boolean) => { + const newValue = value !== undefined ? value : !visible; + setVisible(newValue); + localStorage.setItem("networkInspectorVisible", String(newValue)); + + // Dispatch an event to notify other components about the change + window.dispatchEvent(new Event("networkInspectorUpdate")); + }; + + return { + visible, + toggleVisibility, + show: () => toggleVisibility(true), + hide: () => toggleVisibility(false), + }; +} \ No newline at end of file diff --git a/jumble/src/views/Shell.tsx b/jumble/src/views/Shell.tsx index 710651e65..961f1002e 100644 --- a/jumble/src/views/Shell.tsx +++ b/jumble/src/views/Shell.tsx @@ -11,7 +11,8 @@ import { CharmPublisher } from "@/components/Publish.tsx"; import { useGlobalActions } from "@/hooks/use-global-actions.tsx"; import { SyncStatusProvider } from "@/contexts/SyncStatusContext.tsx"; import * as Process from "@/components/View.tsx"; -import { DummyModelInspector } from "@/components/NetworkInspector.tsx"; +import { ToggleableNetworkInspector } from "@/components/NetworkInspector.tsx"; +import { NetworkInspectorProvider } from "@/contexts/NetworkInspectorContext.tsx"; function* subscribe() { const test = yield* Process.wait(Promise.resolve(1)); @@ -57,6 +58,7 @@ const CounterView2 = Counter.View((state, controller) => (

{state.count}

)); + export default function Shell() { const { charmId } = useParams(); useGlobalActions(); @@ -69,18 +71,20 @@ export default function Shell() { return ( -
- + +
+ -
- -
+
+ +
- - - - -
+ + + + +
+
); From 4e91b31ac0cccfc5ea2c45429541f9aba18e7b7a Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 16:37:04 -0700 Subject: [PATCH 08/15] Fix User.tsx --- jumble/src/components/User.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/jumble/src/components/User.tsx b/jumble/src/components/User.tsx index 71303ead0..20ba0f633 100644 --- a/jumble/src/components/User.tsx +++ b/jumble/src/components/User.tsx @@ -168,6 +168,8 @@ export function User() { const rotationRef = useRef(0); const opacityRef = useRef(0); const bounceRef = useRef(0); + const easedPushCountRef = useRef(0); + const easedPullCountRef = useRef(0); // Use our shared animation smoothing hook const { updateValue, getValue, rafRef } = useAnimationSmoothing(); @@ -217,6 +219,10 @@ export function User() { const pullResult = updateValue("pull", actualPullCount); const errorResult = updateValue("error", actualErrorCount); + // Update our refs with the eased values + easedPushCountRef.current = pushResult.value; + easedPullCountRef.current = pullResult.value; + // Activity states based on the hook's results const pushActive = pushResult.isActive; const pullActive = pullResult.isActive; From 428ede6e243af30b3910a7232f8d3a15f70623f4 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 16:43:10 -0700 Subject: [PATCH 09/15] Toggle inspector via localStorage preference --- jumble/src/components/NetworkInspector.tsx | 79 +++++++++++++++++++--- jumble/src/components/commands.ts | 50 +++++++------- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx index c06d80426..2510b8ed6 100644 --- a/jumble/src/components/NetworkInspector.tsx +++ b/jumble/src/components/NetworkInspector.tsx @@ -302,6 +302,55 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }); } }, [model, filterText]); + + // Define helper functions for filtering to avoid circular dependencies + const filterItems = useCallback((items: any[], filter: string) => { + try { + const regex = new RegExp(filter, "i"); + return items.filter((item) => { + return regex.test(item.id) || + regex.test(item.type) || + regex.test(JSON.stringify(item.result)); + }); + } catch (e) { + // If regex is invalid, fallback to simple string search + return items.filter((item) => { + const itemStr = JSON.stringify(item); + return itemStr.toLowerCase().includes(filter.toLowerCase()); + }); + } + }, []); + + // Functions to count items efficiently + const getActionCount = useCallback(() => { + if (!filterText) { + // If no filter, just count the keys + return Object.keys(model.push).length + Object.keys(model.pull).length; + } else { + // If filter is applied, we need a quick filtered count + const pushItems = Object.entries(model.push).map(([id, result]) => ({ + id, type: "push", result + })); + const pullItems = Object.entries(model.pull).map(([id, result]) => ({ + id, type: "pull", result + })); + const allItems = [...pushItems, ...pullItems]; + return filterItems(allItems, filterText).length; + } + }, [model.push, model.pull, filterText, filterItems]); + + const getSubscriptionCount = useCallback(() => { + if (!filterText) { + // If no filter, just count the keys + return Object.keys(model.subscriptions).length; + } else { + // If filter is applied, we need to check filtered items + const subEntries = Object.entries(model.subscriptions).map(([id, sub]) => ({ + id, sub, type: "subscription" + })); + return filterItems(subEntries, filterText).length; + } + }, [model.subscriptions, filterText, filterItems]); const getFilteredSubscriptions = useCallback(() => { if (!filterText) return Object.entries(model.subscriptions); @@ -374,7 +423,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }`} onClick={() => setActiveTab("actions")} > - Actions + Actions ({getActionCount()})
- setFilterText(e.target.value)} - className="w-32 px-2 py-0.5 text-xs bg-gray-800 border border-gray-700 rounded text-white" - /> +
+ setFilterText(e.target.value)} + className={`w-32 px-2 py-0.5 text-xs bg-gray-800 border border-gray-700 rounded text-white ${ + filterText ? "border-blue-500" : "" + }`} + /> + {filterText && ( + + )} +
{connectionStatus()}
diff --git a/jumble/src/components/commands.ts b/jumble/src/components/commands.ts index 42babbfae..81d748936 100644 --- a/jumble/src/components/commands.ts +++ b/jumble/src/components/commands.ts @@ -768,32 +768,6 @@ async function handleAddRemoteRecipe( export function getCommands(deps: CommandContext): CommandItem[] { return [ - { - id: "enable-network-inspector", - type: "action", - title: "Enable Network Inspector", - group: "Tools", - predicate: localStorage.getItem("networkInspectorVisible") !== "true", - handler: () => { - localStorage.setItem("networkInspectorVisible", "true"); - deps.setOpen(false); - // Force a refresh to make sure the UI updates - window.dispatchEvent(new Event("networkInspectorUpdate")); - }, - }, - { - id: "disable-network-inspector", - type: "action", - title: "Disable Network Inspector", - group: "Tools", - predicate: localStorage.getItem("networkInspectorVisible") === "true", - handler: () => { - localStorage.setItem("networkInspectorVisible", "false"); - deps.setOpen(false); - // Force a refresh to make sure the UI updates - window.dispatchEvent(new Event("networkInspectorUpdate")); - }, - }, { id: "new-charm", type: "input", @@ -1084,6 +1058,30 @@ export function getCommands(deps: CommandContext): CommandItem[] { type: "menu", title: "Advanced", children: [ + { + id: "enable-network-inspector", + type: "action", + title: "Enable Network Inspector", + predicate: localStorage.getItem("networkInspectorVisible") !== "true", + handler: () => { + localStorage.setItem("networkInspectorVisible", "true"); + deps.setOpen(false); + // Refresh the page to ensure the setting takes effect + window.location.reload(); + }, + }, + { + id: "disable-network-inspector", + type: "action", + title: "Disable Network Inspector", + predicate: localStorage.getItem("networkInspectorVisible") === "true", + handler: () => { + localStorage.setItem("networkInspectorVisible", "false"); + deps.setOpen(false); + // Refresh the page to ensure the setting takes effect + window.location.reload(); + }, + }, { id: "start-counter-job", type: "action", From 2fa1d84166561e5b27e73bade2369fec6d69e8b6 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:13:53 -0700 Subject: [PATCH 10/15] Fix JsonView type error --- deno.lock | 1 + jumble/src/components/NetworkInspector.tsx | 113 ++++++--------------- 2 files changed, 32 insertions(+), 82 deletions(-) diff --git a/deno.lock b/deno.lock index f810730dd..1ad0e37b0 100644 --- a/deno.lock +++ b/deno.lock @@ -90,6 +90,7 @@ "npm:@types/three@0.173": "0.173.0", "npm:@uiw/codemirror-theme-vscode@^4.23.7": "4.23.10_@codemirror+view@6.36.4", "npm:@uiw/react-codemirror@^4.23.7": "4.23.10_@babel+runtime@7.26.10_@codemirror+state@6.5.2_@codemirror+theme-one-dark@6.1.2_@codemirror+view@6.36.4_codemirror@6.0.1_react@18.3.1_react-dom@18.3.1__react@18.3.1_@codemirror+commands@6.8.0", + "npm:@uiw/react-json-view@*": "2.0.0-alpha.30_@babel+runtime@7.26.10_react@18.3.1_react-dom@18.3.1__react@18.3.1", "npm:@uiw/react-json-view@2.0.0-alpha.30": "2.0.0-alpha.30_@babel+runtime@7.26.10_react@18.3.1_react-dom@18.3.1__react@18.3.1", "npm:@use-gesture/react@^10.3.1": "10.3.1_react@18.3.1", "npm:@vercel/otel@^1.10.1": "1.10.4_@opentelemetry+api@1.9.0_@opentelemetry+api-logs@0.57.2_@opentelemetry+instrumentation@0.57.2__@opentelemetry+api@1.9.0_@opentelemetry+resources@1.30.1__@opentelemetry+api@1.9.0_@opentelemetry+sdk-logs@0.57.2__@opentelemetry+api@1.9.0_@opentelemetry+sdk-metrics@1.30.1__@opentelemetry+api@1.9.0_@opentelemetry+sdk-trace-base@1.30.1__@opentelemetry+api@1.9.0", diff --git a/jumble/src/components/NetworkInspector.tsx b/jumble/src/components/NetworkInspector.tsx index 2510b8ed6..b0d382459 100644 --- a/jumble/src/components/NetworkInspector.tsx +++ b/jumble/src/components/NetworkInspector.tsx @@ -1,8 +1,16 @@ import * as Inspector from "@commontools/runner/storage/inspector"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { useResizableDrawer } from "@/hooks/use-resizeable-drawer.ts"; -import JsonView from "@uiw/react-json-view"; +import JsonViewImport from "@uiw/react-json-view"; import { githubDarkTheme } from "@uiw/react-json-view/githubDark"; + +// Type assertion to help TypeScript understand this is a valid React component +const JsonView: React.FC<{ + value: any; + style: any; + collapsed?: number | boolean; +}> = JsonViewImport as any; + import { getDoc } from "@commontools/runner"; import { useAnimationSmoothing } from "@/hooks/use-animation-smoothing.ts"; @@ -32,65 +40,6 @@ export function useStatusMonitor() { return { status, updateStatus }; } -// Mock data for testing the ModelInspector component -const createMockModel = () => { - const now = Date.now(); - - return { - connection: { - ready: { ok: { attempt: 3 } }, - time: now, - }, - push: { - "job:tx1": { - ok: { - invocation: { cmd: "/memory/transact", args: { data: "example1" } }, - authorization: { access: { "tx1": {} } }, - }, - }, - "job:tx2": { - error: Object.assign(new Error("Transaction failed"), { - reason: "conflict", - time: now - 5000, - }), - }, - }, - pull: { - "job:query1": { - ok: { - invocation: { cmd: "/memory/query", args: { filter: "all" } }, - authorization: { access: { "query1": {} } }, - }, - }, - "job:query2": { - error: Object.assign(new Error("Query timed out"), { - reason: "timeout", - time: now - 2000, - }), - }, - }, - subscriptions: { - "job:sub1": { - source: { cmd: "/memory/query/subscribe", args: { topic: "updates" } }, - opened: now - 10000, - updated: now - 1000, - value: { id: "update-123", status: "active", timestamp: now - 1000 }, - }, - "job:sub2": { - source: { - cmd: "/memory/query/subscribe", - args: { topic: "notifications" }, - }, - opened: now - 20000, - updated: now - 500, - value: [ - { id: "notif-1", message: "New message received", read: false }, - { id: "notif-2", message: "Task completed", read: true }, - ], - }, - }, - } as Inspector.Model; -}; // Example usage with dummy data export const DummyModelInspector: React.FC = () => { @@ -104,9 +53,9 @@ export const DummyModelInspector: React.FC = () => { export const ToggleableNetworkInspector: React.FC<{ visible: boolean }> = ({ visible }) => { const { status, updateStatus } = useStatusMonitor(); useStorageBroadcast(updateStatus); - + if (!visible || !status.current) return null; - + return ; }; const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }> = ({ model, initiallyOpen = false }) => { @@ -119,10 +68,10 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean const [, setRenderTrigger] = useState(0); const lastRenderTimeRef = useRef(0); const buttonTextRef = useRef(null); - + // Use our animation smoothing hook const { updateValue, getValue, rafRef } = useAnimationSmoothing(); - + // Set up render loop with requestAnimationFrame when inspector is open useEffect(() => { if (!isOpen) { @@ -142,9 +91,9 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean } rafRef.current = requestAnimationFrame(renderLoop); }; - + rafRef.current = requestAnimationFrame(renderLoop); - + return () => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); @@ -152,7 +101,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean } }; }, [isOpen, rafRef]); - + // Update button text with counts using requestAnimationFrame useEffect(() => { const updateCounts = () => { @@ -160,38 +109,38 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean rafRef.current = requestAnimationFrame(updateCounts); return; } - + // Count calculations const actualPushCount = Object.values(model.push).filter(v => v.ok).length; const actualPullCount = Object.values(model.pull).filter(v => v.ok).length; const pushErrorCount = Object.values(model.push).filter(v => v.error).length; const pullErrorCount = Object.values(model.pull).filter(v => v.error).length; const actualErrorCount = pushErrorCount + pullErrorCount; - + // Update values with easing const pushResult = updateValue("push", actualPushCount); const pullResult = updateValue("pull", actualPullCount); const errorResult = updateValue("error", actualErrorCount); - + // Create status text const statusParts = []; if (pushResult.value > 0) statusParts.push(`↑${pushResult.value}`); if (pullResult.value > 0) statusParts.push(`↓${pullResult.value}`); if (errorResult.value > 0) statusParts.push(`!${errorResult.value}`); - - const statusText = statusParts.length > 0 + + const statusText = statusParts.length > 0 ? `Inspector ${statusParts.join(" ")} ${isOpen ? "▼" : "▲"}` : `Inspector ${isOpen ? "▼" : "▲"}`; - + buttonTextRef.current.textContent = statusText; - + // Continue animation rafRef.current = requestAnimationFrame(updateCounts); }; - + // Start updating counts rafRef.current = requestAnimationFrame(updateCounts); - + return () => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); @@ -302,7 +251,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }); } }, [model, filterText]); - + // Define helper functions for filtering to avoid circular dependencies const filterItems = useCallback((items: any[], filter: string) => { try { @@ -320,7 +269,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }); } }, []); - + // Functions to count items efficiently const getActionCount = useCallback(() => { if (!filterText) { @@ -338,15 +287,15 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean return filterItems(allItems, filterText).length; } }, [model.push, model.pull, filterText, filterItems]); - + const getSubscriptionCount = useCallback(() => { if (!filterText) { // If no filter, just count the keys return Object.keys(model.subscriptions).length; } else { // If filter is applied, we need to check filtered items - const subEntries = Object.entries(model.subscriptions).map(([id, sub]) => ({ - id, sub, type: "subscription" + const subEntries = Object.entries(model.subscriptions).map(([id, sub]) => ({ + id, sub, type: "subscription" })); return filterItems(subEntries, filterText).length; } @@ -449,7 +398,7 @@ const ModelInspector: React.FC<{ model: Inspector.Model, initiallyOpen?: boolean }`} /> {filterText && ( - -)); - -const CounterView2 = Counter.View((state, controller) => ( -

{state.count}

-)); - export default function Shell() { const { charmId } = useParams(); From 4f9b857fe7ac0bc2dcdc13e8b867a2a6421bd833 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:38:04 -0700 Subject: [PATCH 13/15] Fix import --- jumble/src/main.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jumble/src/main.tsx b/jumble/src/main.tsx index 8c3fe6a16..fa23d166b 100644 --- a/jumble/src/main.tsx +++ b/jumble/src/main.tsx @@ -14,7 +14,7 @@ import { import * as Sentry from "@sentry/react"; import { ErrorBoundary } from "@sentry/react"; import "./styles/index.css"; -import Shell from "@/views/Shell.tsx"; +import Shell from "./views/Shell.tsx"; import { CharmsProvider } from "@/contexts/CharmsContext.tsx"; import CharmList from "@/views/CharmList.tsx"; import CharmShowView from "@/views/CharmShowView.tsx"; From 068fd7c398995d47ee4943276ff561e66adcbad8 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:42:46 -0700 Subject: [PATCH 14/15] Remove old extracted hook --- jumble/src/views/CharmDetailView.tsx | 112 +-------------------------- 1 file changed, 2 insertions(+), 110 deletions(-) diff --git a/jumble/src/views/CharmDetailView.tsx b/jumble/src/views/CharmDetailView.tsx index 473f488cf..b6b1c0ae2 100644 --- a/jumble/src/views/CharmDetailView.tsx +++ b/jumble/src/views/CharmDetailView.tsx @@ -94,114 +94,6 @@ const useCharmOperationContext = () => { return context; }; -// =================== Custom Hooks =================== - -// // Hook for managing bottom sheet functionality -// function useBottomSheet(initialHeight = 585) { -// const [sheetHeight, setSheetHeight] = useState(initialHeight); -// const [isResizing, setIsResizing] = useState(false); -// const resizeStartY = useRef(null); -// const startHeight = useRef(null); - -// const handleResizeStart = useCallback( -// (e: React.MouseEvent) => { -// e.preventDefault(); -// resizeStartY.current = e.clientY; -// startHeight.current = sheetHeight; -// setIsResizing(true); - -// // Add a layer over the entire document to capture events -// const overlay = document.createElement("div"); -// overlay.id = "resize-overlay"; -// overlay.style.position = "fixed"; -// overlay.style.top = "0"; -// overlay.style.left = "0"; -// overlay.style.width = "100%"; -// overlay.style.height = "100%"; -// overlay.style.zIndex = "9999"; -// overlay.style.cursor = "ns-resize"; -// document.body.appendChild(overlay); - -// const handleResizeMove = (e: MouseEvent) => { -// if (resizeStartY.current !== null && startHeight.current !== null) { -// const diff = resizeStartY.current - e.clientY; -// const newHeight = Math.max( -// 150, -// Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff), -// ); -// setSheetHeight(newHeight); -// } -// }; - -// const handleResizeEnd = () => { -// resizeStartY.current = null; -// startHeight.current = null; -// setIsResizing(false); - -// // Remove overlay -// const overlay = document.getElementById("resize-overlay"); -// if (overlay) { -// document.body.removeChild(overlay); -// } - -// document.removeEventListener("mousemove", handleResizeMove); -// document.removeEventListener("mouseup", handleResizeEnd); -// }; - -// document.addEventListener("mousemove", handleResizeMove); -// document.addEventListener("mouseup", handleResizeEnd); -// }, -// [sheetHeight], -// ); - -// const handleTouchResizeStart = useCallback( -// (e: React.TouchEvent) => { -// e.preventDefault(); -// if (e.touches.length === 1) { -// resizeStartY.current = e.touches[0].clientY; -// startHeight.current = sheetHeight; -// setIsResizing(true); -// } - -// const handleTouchMove = (e: TouchEvent) => { -// if ( -// resizeStartY.current !== null && -// startHeight.current !== null && -// e.touches.length === 1 -// ) { -// const diff = resizeStartY.current - e.touches[0].clientY; -// const newHeight = Math.max( -// 150, -// Math.min(globalThis.innerHeight * 0.8, startHeight.current + diff), -// ); -// setSheetHeight(newHeight); -// } -// }; - -// const handleTouchEnd = () => { -// resizeStartY.current = null; -// startHeight.current = null; -// setIsResizing(false); -// document.removeEventListener("touchmove", handleTouchMove); -// document.removeEventListener("touchend", handleTouchEnd); -// }; - -// document.addEventListener("touchmove", handleTouchMove, { -// passive: false, -// }); -// document.addEventListener("touchend", handleTouchEnd); -// }, -// [sheetHeight], -// ); - -// return { -// sheetHeight, -// isResizing, -// handleResizeStart, -// handleTouchResizeStart, -// }; -// } - // Hook for tab management function useTabNavigation() { const location = useLocation(); @@ -743,12 +635,12 @@ const Suggestions = () => { const successValue = result.value; setVariants((existingPrev) => { const newVariants = [...existingPrev, successValue]; - + // Set the first successful variant as selected if (existingPrev.length === 0) { setSelectedVariant(successValue); } - + return newVariants; }); } From 0512ddf1ffc3515012b206fe93d36c06238c72e3 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:44:25 -0700 Subject: [PATCH 15/15] Clean up unused event --- jumble/src/hooks/use-network-inspector.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/jumble/src/hooks/use-network-inspector.ts b/jumble/src/hooks/use-network-inspector.ts index 71e84a345..f46bb4e71 100644 --- a/jumble/src/hooks/use-network-inspector.ts +++ b/jumble/src/hooks/use-network-inspector.ts @@ -1,12 +1,15 @@ import { useEffect, useState } from "react"; +const STORAGE_KEY = "networkInspectorVisible"; +const STORAGE_EVENT = "storage"; + export function useNetworkInspector() { const [visible, setVisible] = useState(false); // On first render, check localStorage for saved preference useEffect(() => { const updateFromStorage = () => { - const savedPreference = localStorage.getItem("networkInspectorVisible"); + const savedPreference = localStorage.getItem(STORAGE_KEY); if (savedPreference !== null) { setVisible(savedPreference === "true"); } @@ -16,14 +19,10 @@ export function useNetworkInspector() { updateFromStorage(); // Listen for changes in localStorage (for cross-tab synchronization) - globalThis.addEventListener("storage", updateFromStorage); - - // Also listen for our custom event for same-tab updates - globalThis.addEventListener("networkInspectorUpdate", updateFromStorage); + globalThis.addEventListener(STORAGE_EVENT, updateFromStorage); return () => { - globalThis.removeEventListener("storage", updateFromStorage); - globalThis.removeEventListener("networkInspectorUpdate", updateFromStorage); + globalThis.removeEventListener(STORAGE_EVENT, updateFromStorage); }; }, []); @@ -31,10 +30,7 @@ export function useNetworkInspector() { const toggleVisibility = (value?: boolean) => { const newValue = value !== undefined ? value : !visible; setVisible(newValue); - localStorage.setItem("networkInspectorVisible", String(newValue)); - - // Dispatch an event to notify other components about the change - globalThis.dispatchEvent(new Event("networkInspectorUpdate")); + localStorage.setItem(STORAGE_KEY, String(newValue)); }; return { @@ -43,4 +39,4 @@ export function useNetworkInspector() { show: () => toggleVisibility(true), hide: () => toggleVisibility(false), }; -} \ No newline at end of file +}