From 326eaf6b4d1fd4dd493bbe774caafa399d6692d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:01:34 +0000 Subject: [PATCH 1/5] Initial plan From 1e16ac0008df695cf998bf5959a43beac3286a52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:10:16 +0000 Subject: [PATCH 2/5] Add serial console feature with core components and integration - Create useSerialConsole hook with Web Serial API support - Create SerialConsole component with filtering and replacement - Create DraggableWindow component for floating console - Create SerialConsoleContext for state management - Add DebugConsolePage with console tab integration - Integrate console into App with tab switching and fallback logic - Add Web Serial API type definitions Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com> --- src/App.tsx | 166 +++++++++++---- src/components/DraggableWindow.tsx | 160 ++++++++++++++ src/components/SerialConsole.tsx | 266 ++++++++++++++++++++++++ src/contexts/SerialConsoleContext.tsx | 42 ++++ src/contexts/SerialConsoleContextDef.ts | 31 +++ src/hooks/useSerialConsole.ts | 216 +++++++++++++++++++ src/pages/DebugConsolePage.tsx | 56 +++++ src/vite-env.d.ts | 17 ++ 8 files changed, 910 insertions(+), 44 deletions(-) create mode 100644 src/components/DraggableWindow.tsx create mode 100644 src/components/SerialConsole.tsx create mode 100644 src/contexts/SerialConsoleContext.tsx create mode 100644 src/contexts/SerialConsoleContextDef.ts create mode 100644 src/hooks/useSerialConsole.ts create mode 100644 src/pages/DebugConsolePage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8ead759..3ae0d88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useContext } from "react"; +import { useState, useContext, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { IconBattery2, @@ -7,6 +7,7 @@ import { IconKeyboard, IconPointer, IconSettings, + IconTerminal2, } from "@tabler/icons-react"; import { SplashScreen } from "./components/SplashScreen"; @@ -15,6 +16,8 @@ import { ConnectionContext, } from "./components/DeviceConnection"; import { ThemeProvider } from "./contexts/ThemeContext"; +import { SerialConsoleProvider } from "./contexts/SerialConsoleContext"; +import { useSerialConsoleContext } from "./contexts/SerialConsoleContextDef"; import { TabNavigation } from "./components/TabNavigation"; import type { TabItem } from "./components/TabNavigation"; import { AppLayout } from "./layouts/AppLayout"; @@ -24,59 +27,115 @@ import { HealthCheckPage } from "./pages/HealthCheckPage"; import { KeymapPage } from "./pages/KeymapPage"; import { TrackballPage } from "./pages/TrackballPage"; import { SettingsPage } from "./pages/SettingsPage"; - -const tabs: TabItem[] = [ - { - id: "battery", - label: "Battery", - icon: , - content: , - }, - { - id: "ble", - label: "BLE", - icon: , - content: , - }, - { - id: "health", - label: "Health", - icon: , - content: , - }, - { - id: "keymap", - label: "Keymap", - icon: , - content: , - }, - { - id: "trackball", - label: "Trackball", - icon: , - content: , - }, - { - id: "settings", - label: "Settings", - icon: , - content: , - }, -]; +import { DebugConsolePage } from "./pages/DebugConsolePage"; +import { DraggableWindow } from "./components/DraggableWindow"; +import { SerialConsole } from "./components/SerialConsole"; function App() { return ( - - - + + + + + ); } function AppContent() { const connection = useContext(ConnectionContext); + const consoleContext = useSerialConsoleContext(); const [activeTab, setActiveTab] = useState("battery"); + const [showConsoleFallback, setShowConsoleFallback] = useState(false); + + // Tabs array - conditionally include debug console tab + const tabs: TabItem[] = [ + { + id: "battery", + label: "Battery", + icon: , + content: , + }, + { + id: "ble", + label: "BLE", + icon: , + content: , + }, + { + id: "health", + label: "Health", + icon: , + content: , + }, + { + id: "keymap", + label: "Keymap", + icon: , + content: , + }, + { + id: "trackball", + label: "Trackball", + icon: , + content: , + }, + { + id: "settings", + label: "Settings", + icon: , + content: , + }, + // Add debug console tab when connected to ZMK Studio + ...(connection.isConnected + ? [ + { + id: "console", + label: "Console", + icon: , + content: , + }, + ] + : []), + ]; + + // Handle tab changes - move console to/from window + const handleTabChange = (tabId: string) => { + setActiveTab(tabId); + + // If switching away from console tab while console has active connection + if ( + activeTab === "console" && + tabId !== "console" && + consoleContext.hasActiveConnection + ) { + consoleContext.showAsWindow(); + } + + // If switching to console tab while console is in window + if (tabId === "console" && consoleContext.position === "window") { + consoleContext.showInTab(); + } + }; + + // Monitor ZMK connection errors and show console fallback + useEffect(() => { + if (!connection.isConnected && connection.error && !connection.isLoading) { + // Check if this is an unexpected error (not user cancellation) + if ( + !connection.error.includes("cancelled") && + !connection.error.includes("User") && + !connection.error.includes("selected") + ) { + // Use setTimeout to avoid setState within effect + setTimeout(() => { + setShowConsoleFallback(true); + consoleContext.showAsWindow(); + }, 0); + } + } + }, [connection.error, connection.isConnected, connection.isLoading, consoleContext]); return ( <> @@ -114,11 +173,30 @@ function AppContent() { )} + + {/* Draggable Console Window */} + {consoleContext.position === "window" && ( + { + consoleContext.hide(); + setShowConsoleFallback(false); + }} + title="Serial Console" + > + + consoleContext.setConnectionState(connected) + } + /> + + )} ); } diff --git a/src/components/DraggableWindow.tsx b/src/components/DraggableWindow.tsx new file mode 100644 index 0000000..a8e85f9 --- /dev/null +++ b/src/components/DraggableWindow.tsx @@ -0,0 +1,160 @@ +import { useState, useRef, useEffect, type ReactNode } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { IconX, IconMaximize, IconMinimize } from "@tabler/icons-react"; + +interface DraggableWindowProps { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; + defaultPosition?: { x: number; y: number }; + defaultSize?: { width: number; height: number }; +} + +export function DraggableWindow({ + open, + onClose, + title, + children, + defaultPosition = { x: 100, y: 100 }, + defaultSize = { width: 600, height: 400 }, +}: DraggableWindowProps) { + const [position, setPosition] = useState(defaultPosition); + const [size, setSize] = useState(defaultSize); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); + const dragStartPos = useRef({ x: 0, y: 0 }); + const resizeStartPos = useRef({ x: 0, y: 0 }); + const resizeStartSize = useRef({ width: 0, height: 0 }); + const windowRef = useRef(null); + + const handleMouseDown = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest(".window-header")) { + setIsDragging(true); + dragStartPos.current = { x: e.clientX - position.x, y: e.clientY - position.y }; + } + }; + + const handleResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + resizeStartPos.current = { x: e.clientX, y: e.clientY }; + resizeStartSize.current = { ...size }; + }; + + const toggleMaximize = () => { + setIsMaximized(!isMaximized); + }; + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging && !isMaximized) { + setPosition({ + x: e.clientX - dragStartPos.current.x, + y: e.clientY - dragStartPos.current.y, + }); + } else if (isResizing && !isMaximized) { + const newWidth = Math.max( + 300, + resizeStartSize.current.width + (e.clientX - resizeStartPos.current.x), + ); + const newHeight = Math.max( + 200, + resizeStartSize.current.height + (e.clientY - resizeStartPos.current.y), + ); + setSize({ width: newWidth, height: newHeight }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + if (isDragging || isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, isResizing, isMaximized]); + + return ( + !isOpen && onClose()}> + + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + {/* Window Header */} +
+ + {title} + +
+ + +
+
+ + {/* Window Content */} +
{children}
+ + {/* Resize Handle */} + {!isMaximized && ( +
+
+
+ )} + + + + ); +} diff --git a/src/components/SerialConsole.tsx b/src/components/SerialConsole.tsx new file mode 100644 index 0000000..79dea19 --- /dev/null +++ b/src/components/SerialConsole.tsx @@ -0,0 +1,266 @@ +import { useRef, useEffect, useState } from "react"; +import { + IconTerminal, + IconPlug, + IconPlugOff, + IconSend, + IconTrash, + IconSettings, + IconFilter, +} from "@tabler/icons-react"; +import { useSerialConsole } from "../hooks/useSerialConsole"; + +interface SerialConsoleProps { + /** Whether to auto-connect on mount */ + autoConnect?: boolean; + /** Callback when connection state changes */ + onConnectionChange?: (connected: boolean) => void; +} + +export function SerialConsole({ + autoConnect, + onConnectionChange, +}: SerialConsoleProps) { + const { + isConnected, + isConnecting, + error, + messages, + settings, + connect, + disconnect, + sendMessage, + clearMessages, + updateSettings, + } = useSerialConsole(); + + const [inputText, setInputText] = useState(""); + const [showSettings, setShowSettings] = useState(false); + const messagesEndRef = useRef(null); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Auto-connect on mount if requested + useEffect(() => { + if (autoConnect && !isConnected && !isConnecting) { + connect(); + } + }, [autoConnect, isConnected, isConnecting, connect]); + + // Notify parent of connection state changes + useEffect(() => { + onConnectionChange?.(isConnected); + }, [isConnected, onConnectionChange]); + + const handleSend = async () => { + if (inputText.trim()) { + await sendMessage(inputText); + setInputText(""); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Header */} +
+
+ + + Serial Console + + {isConnected && ( + + )} +
+
+ + + {isConnected ? ( + + ) : ( + + )} +
+
+ + {/* Settings Panel */} + {showSettings && ( +
+
+
+ + +
+
+ + updateSettings({ filterRegex: e.target.value })} + placeholder="e.g., ^ERROR" + className="input-field text-sm" + /> +
+
+
+
+ + + updateSettings({ replacePattern: e.target.value }) + } + placeholder="e.g., \\d{3}" + className="input-field text-sm" + /> +
+
+ + updateSettings({ replaceWith: e.target.value })} + placeholder="e.g., XXX" + className="input-field text-sm" + /> +
+
+
+ )} + + {/* Messages Area */} +
+ {!isConnected && !error && ( +
+
+ +

Not connected

+

+ Click Connect to start a serial console session +

+
+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {messages.map((msg, idx) => ( +
+ + {msg.timestamp.toLocaleTimeString()} + + + {msg.type === "sent" ? "→" : "←"} + + {msg.text} +
+ ))} +
+
+ + {/* Input Area */} + {isConnected && ( +
+
+ setInputText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type a message and press Enter..." + className="input-field text-sm flex-1" + /> + +
+
+ )} +
+ ); +} diff --git a/src/contexts/SerialConsoleContext.tsx b/src/contexts/SerialConsoleContext.tsx new file mode 100644 index 0000000..f79214a --- /dev/null +++ b/src/contexts/SerialConsoleContext.tsx @@ -0,0 +1,42 @@ +import { type ReactNode, useState, useCallback } from "react"; +import { SerialConsoleContext } from "./SerialConsoleContextDef"; + +interface SerialConsoleProviderProps { + children: ReactNode; +} + +export function SerialConsoleProvider({ children }: SerialConsoleProviderProps) { + const [position, setPosition] = useState<"tab" | "window" | "hidden">("hidden"); + const [hasActiveConnection, setHasActiveConnection] = useState(false); + + const showAsWindow = useCallback(() => { + setPosition("window"); + }, []); + + const showInTab = useCallback(() => { + setPosition("tab"); + }, []); + + const hide = useCallback(() => { + setPosition("hidden"); + }, []); + + const setConnectionState = useCallback((connected: boolean) => { + setHasActiveConnection(connected); + }, []); + + return ( + + {children} + + ); +} diff --git a/src/contexts/SerialConsoleContextDef.ts b/src/contexts/SerialConsoleContextDef.ts new file mode 100644 index 0000000..f4251de --- /dev/null +++ b/src/contexts/SerialConsoleContextDef.ts @@ -0,0 +1,31 @@ +import { createContext, useContext } from "react"; + +export type ConsolePosition = "tab" | "window" | "hidden"; + +export interface SerialConsoleContextValue { + /** Current position of the console */ + position: ConsolePosition; + /** Whether the console has an active connection */ + hasActiveConnection: boolean; + /** Show console in a draggable window */ + showAsWindow: () => void; + /** Show console in the tab */ + showInTab: () => void; + /** Hide console */ + hide: () => void; + /** Set connection state */ + setConnectionState: (connected: boolean) => void; +} + +export const SerialConsoleContext = createContext({ + position: "hidden", + hasActiveConnection: false, + showAsWindow: () => {}, + showInTab: () => {}, + hide: () => {}, + setConnectionState: () => {}, +}); + +export function useSerialConsoleContext() { + return useContext(SerialConsoleContext); +} diff --git a/src/hooks/useSerialConsole.ts b/src/hooks/useSerialConsole.ts new file mode 100644 index 0000000..b1b25c1 --- /dev/null +++ b/src/hooks/useSerialConsole.ts @@ -0,0 +1,216 @@ +import { useState, useCallback, useRef, useEffect } from "react"; + +export interface SerialConsoleMessage { + timestamp: Date; + text: string; + type: "received" | "sent"; +} + +export interface SerialConsoleSettings { + baudRate: number; + filterRegex: string; + replacePattern: string; + replaceWith: string; +} + +export interface UseSerialConsoleReturn { + isConnected: boolean; + isConnecting: boolean; + error: string | null; + messages: SerialConsoleMessage[]; + settings: SerialConsoleSettings; + connect: () => Promise; + disconnect: () => void; + sendMessage: (text: string) => Promise; + clearMessages: () => void; + updateSettings: (settings: Partial) => void; +} + +export function useSerialConsole(): UseSerialConsoleReturn { + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + const [messages, setMessages] = useState([]); + const [settings, setSettings] = useState({ + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }); + + const portRef = useRef(null); + const readerRef = useRef | null>( + null, + ); + const writerRef = useRef | null>( + null, + ); + + const disconnect = useCallback(() => { + if (readerRef.current) { + readerRef.current.cancel(); + readerRef.current = null; + } + if (writerRef.current) { + writerRef.current.close(); + writerRef.current = null; + } + if (portRef.current) { + portRef.current.close(); + portRef.current = null; + } + setIsConnected(false); + setError(null); + }, []); + + const processLine = useCallback( + (line: string): string | null => { + // Apply regex filter if set + if (settings.filterRegex) { + try { + const regex = new RegExp(settings.filterRegex); + if (!regex.test(line)) { + return null; + } + } catch { + // Invalid regex, skip filtering + } + } + + // Apply sed-style replacement if set + if (settings.replacePattern) { + try { + const regex = new RegExp(settings.replacePattern, "g"); + return line.replace(regex, settings.replaceWith); + } catch { + // Invalid regex, return original + return line; + } + } + + return line; + }, + [settings.filterRegex, settings.replacePattern, settings.replaceWith], + ); + + const connect = useCallback(async () => { + if (!("serial" in navigator)) { + setError("Web Serial API is not supported in this browser"); + return; + } + + setIsConnecting(true); + setError(null); + + try { + const port = await navigator.serial!.requestPort(); + await port.open({ baudRate: settings.baudRate }); + + portRef.current = port; + setIsConnected(true); + setIsConnecting(false); + + // Start reading from the port + const reader = port.readable?.getReader(); + if (reader) { + readerRef.current = reader; + const decoder = new TextDecoder(); + let buffer = ""; + + (async () => { + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const processedLine = processLine(line.trim()); + if (processedLine !== null && processedLine !== "") { + setMessages((prev) => [ + ...prev, + { + timestamp: new Date(), + text: processedLine, + type: "received", + }, + ]); + } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + } + } + })(); + } + + // Get writer for sending data + const writer = port.writable?.getWriter(); + if (writer) { + writerRef.current = writer; + } + } catch (err) { + setError((err as Error).message); + setIsConnecting(false); + } + }, [settings.baudRate, processLine]); + + const sendMessage = useCallback(async (text: string) => { + if (!writerRef.current) { + setError("No active connection"); + return; + } + + try { + const encoder = new TextEncoder(); + await writerRef.current.write(encoder.encode(text + "\n")); + + setMessages((prev) => [ + ...prev, + { + timestamp: new Date(), + text, + type: "sent", + }, + ]); + } catch (err) { + setError((err as Error).message); + } + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + const updateSettings = useCallback( + (newSettings: Partial) => { + setSettings((prev) => ({ ...prev, ...newSettings })); + }, + [], + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + disconnect(); + }; + }, [disconnect]); + + return { + isConnected, + isConnecting, + error, + messages, + settings, + connect, + disconnect, + sendMessage, + clearMessages, + updateSettings, + }; +} diff --git a/src/pages/DebugConsolePage.tsx b/src/pages/DebugConsolePage.tsx new file mode 100644 index 0000000..4bd7e8e --- /dev/null +++ b/src/pages/DebugConsolePage.tsx @@ -0,0 +1,56 @@ +import { IconTerminal2 } from "@tabler/icons-react"; +import { SerialConsole } from "../components/SerialConsole"; +import { useSerialConsoleContext } from "../contexts/SerialConsoleContextDef"; +import { useEffect } from "react"; + +export function DebugConsolePage() { + const consoleContext = useSerialConsoleContext(); + + // When this page is active, show console in tab + useEffect(() => { + if (consoleContext.hasActiveConnection && consoleContext.position === "window") { + consoleContext.showInTab(); + } + }, [consoleContext]); + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

+ Debug Console +

+

+ Connect to serial port for debugging and monitoring +

+
+
+ + {/* Console */} +
+ + consoleContext.setConnectionState(connected) + } + /> +
+ + {/* Info Box */} +
+

+ Tip: The console supports regex-based filtering and + sed-style word replacement. Click the settings icon to configure + these features. When you switch to another tab, the console will + automatically move to a draggable window if you have an active + connection. +

+
+
+
+ ); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e460e58..0a7b5c1 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,3 +5,20 @@ declare module "*.svg?react" { const SVGComponent: React.FC>; export default SVGComponent; } + +// Web Serial API type definitions +interface SerialPort { + readonly readable: ReadableStream | null; + readonly writable: WritableStream | null; + open(options: { baudRate: number }): Promise; + close(): Promise; +} + +interface Serial { + requestPort(): Promise; + getPorts(): Promise; +} + +interface Navigator { + serial?: Serial; +} From bf39c7ccbd0a3c69bb5ce58f972036e5673d58eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:14:59 +0000 Subject: [PATCH 3/5] Add comprehensive tests for serial console components - Add SerialConsole component tests with full coverage - Add DraggableWindow component tests - Add DebugConsolePage tests - Fix accessibility warnings in DraggableWindow - Fix scrollIntoView optional chaining for test compatibility - All tests passing (150 passed, 1 skipped) Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com> --- src/components/DraggableWindow.tsx | 3 +- src/components/SerialConsole.tsx | 2 +- .../__tests__/DraggableWindow.test.tsx | 99 +++++ .../__tests__/SerialConsole.test.tsx | 416 ++++++++++++++++++ src/pages/__tests__/DebugConsolePage.test.tsx | 83 ++++ 5 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 src/components/__tests__/DraggableWindow.test.tsx create mode 100644 src/components/__tests__/SerialConsole.test.tsx create mode 100644 src/pages/__tests__/DebugConsolePage.test.tsx diff --git a/src/components/DraggableWindow.tsx b/src/components/DraggableWindow.tsx index a8e85f9..1d2e041 100644 --- a/src/components/DraggableWindow.tsx +++ b/src/components/DraggableWindow.tsx @@ -110,6 +110,7 @@ export function DraggableWindow({ } onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} + aria-describedby="window-content" > {/* Window Header */}
{/* Window Content */} -
{children}
+
{children}
{/* Resize Handle */} {!isMaximized && ( diff --git a/src/components/SerialConsole.tsx b/src/components/SerialConsole.tsx index 79dea19..4d5cc74 100644 --- a/src/components/SerialConsole.tsx +++ b/src/components/SerialConsole.tsx @@ -40,7 +40,7 @@ export function SerialConsole({ // Auto-scroll to bottom when new messages arrive useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + messagesEndRef.current?.scrollIntoView?.({ behavior: "smooth" }); }, [messages]); // Auto-connect on mount if requested diff --git a/src/components/__tests__/DraggableWindow.test.tsx b/src/components/__tests__/DraggableWindow.test.tsx new file mode 100644 index 0000000..46d055d --- /dev/null +++ b/src/components/__tests__/DraggableWindow.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DraggableWindow } from "../DraggableWindow"; + +describe("DraggableWindow", () => { + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders when open", () => { + render( + +
Window Content
+
, + ); + + expect(screen.getByText("Test Window")).toBeInTheDocument(); + expect(screen.getByText("Window Content")).toBeInTheDocument(); + }); + + test("does not render when closed", () => { + render( + +
Window Content
+
, + ); + + expect(screen.queryByText("Test Window")).not.toBeInTheDocument(); + expect(screen.queryByText("Window Content")).not.toBeInTheDocument(); + }); + + test("calls onClose when close button is clicked", async () => { + const user = userEvent.setup(); + render( + +
Window Content
+
, + ); + + const closeButton = screen.getByRole("button", { name: /close/i }); + await user.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + test("has maximize button", () => { + render( + +
Window Content
+
, + ); + + const maximizeButton = screen.getByRole("button", { name: /maximize/i }); + expect(maximizeButton).toBeInTheDocument(); + }); + + test("toggles maximize state", async () => { + const user = userEvent.setup(); + render( + +
Window Content
+
, + ); + + const maximizeButton = screen.getByRole("button", { name: /maximize/i }); + await user.click(maximizeButton); + + // After clicking, should show "Restore" button + const restoreButton = screen.getByRole("button", { name: /restore/i }); + expect(restoreButton).toBeInTheDocument(); + }); + + test("renders children content", () => { + render( + +
Custom Content Here
+
, + ); + + expect(screen.getByTestId("test-content")).toBeInTheDocument(); + expect(screen.getByText("Custom Content Here")).toBeInTheDocument(); + }); + + test("displays title in header", () => { + render( + +
Content
+
, + ); + + expect(screen.getByText("My Custom Title")).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/SerialConsole.test.tsx b/src/components/__tests__/SerialConsole.test.tsx new file mode 100644 index 0000000..496d894 --- /dev/null +++ b/src/components/__tests__/SerialConsole.test.tsx @@ -0,0 +1,416 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SerialConsole } from "../SerialConsole"; + +// Mock the useSerialConsole hook +jest.mock("../../hooks/useSerialConsole"); + +import { useSerialConsole } from "../../hooks/useSerialConsole"; + +const mockUseSerialConsole = useSerialConsole as jest.MockedFunction< + typeof useSerialConsole +>; + +describe("SerialConsole", () => { + const mockConnect = jest.fn(); + const mockDisconnect = jest.fn(); + const mockSendMessage = jest.fn(); + const mockClearMessages = jest.fn(); + const mockUpdateSettings = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSerialConsole.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + }); + + describe("Initial State", () => { + test("renders disconnected state", () => { + render(); + + expect(screen.getByText("Serial Console")).toBeInTheDocument(); + expect(screen.getByText("Connect")).toBeInTheDocument(); + expect(screen.getByText("Not connected")).toBeInTheDocument(); + }); + + test("shows connect button when disconnected", () => { + render(); + + const connectButton = screen.getByRole("button", { name: /connect/i }); + expect(connectButton).toBeEnabled(); + }); + }); + + describe("Connection Flow", () => { + test("calls connect when connect button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const connectButton = screen.getByRole("button", { name: /connect/i }); + await user.click(connectButton); + + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + test("shows disconnect button when connected", () => { + mockUseSerialConsole.mockReturnValue({ + isConnected: true, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + expect(screen.getByText("Disconnect")).toBeInTheDocument(); + expect(screen.queryByText("Connect")).not.toBeInTheDocument(); + }); + + test("calls disconnect when disconnect button is clicked", async () => { + const user = userEvent.setup(); + mockUseSerialConsole.mockReturnValue({ + isConnected: true, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + const disconnectButton = screen.getByRole("button", { + name: /disconnect/i, + }); + await user.click(disconnectButton); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + test("shows connecting state", () => { + mockUseSerialConsole.mockReturnValue({ + isConnected: false, + isConnecting: true, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + expect(screen.getByText("Connecting...")).toBeInTheDocument(); + }); + }); + + describe("Message Display", () => { + test("displays received messages", () => { + mockUseSerialConsole.mockReturnValue({ + isConnected: true, + isConnecting: false, + error: null, + messages: [ + { + timestamp: new Date("2024-01-01T10:00:00"), + text: "Hello from device", + type: "received", + }, + ], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + expect(screen.getByText("Hello from device")).toBeInTheDocument(); + }); + + test("displays sent messages", () => { + mockUseSerialConsole.mockReturnValue({ + isConnected: true, + isConnecting: false, + error: null, + messages: [ + { + timestamp: new Date("2024-01-01T10:00:00"), + text: "Test command", + type: "sent", + }, + ], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + expect(screen.getByText("Test command")).toBeInTheDocument(); + }); + }); + + describe("Sending Messages", () => { + test("shows input area when connected", () => { + mockUseSerialConsole.mockReturnValue({ + isConnected: true, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + expect( + screen.getByPlaceholderText(/type a message/i), + ).toBeInTheDocument(); + }); + + test("calls sendMessage when send button is clicked", async () => { + const user = userEvent.setup(); + mockUseSerialConsole.mockReturnValue({ + isConnected: true, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + const input = screen.getByPlaceholderText(/type a message/i); + const sendButton = screen.getByRole("button", { name: /send message/i }); + + await user.type(input, "test message"); + await user.click(sendButton); + + expect(mockSendMessage).toHaveBeenCalledWith("test message"); + }); + + test("calls sendMessage when Enter key is pressed", async () => { + const user = userEvent.setup(); + mockUseSerialConsole.mockReturnValue({ + isConnected: true, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + const input = screen.getByPlaceholderText(/type a message/i); + + await user.type(input, "test{Enter}"); + + expect(mockSendMessage).toHaveBeenCalledWith("test"); + }); + }); + + describe("Settings", () => { + test("toggles settings panel", async () => { + const user = userEvent.setup(); + render(); + + const settingsButton = screen.getByRole("button", { name: /settings/i }); + await user.click(settingsButton); + + expect(screen.getByText("Baud Rate")).toBeInTheDocument(); + }); + + test("updates baud rate setting", async () => { + const user = userEvent.setup(); + render(); + + const settingsButton = screen.getByRole("button", { name: /settings/i }); + await user.click(settingsButton); + + const baudRateSelect = screen.getByDisplayValue("115200"); + await user.selectOptions(baudRateSelect, "9600"); + + expect(mockUpdateSettings).toHaveBeenCalledWith({ baudRate: 9600 }); + }); + + test("updates filter regex", async () => { + const user = userEvent.setup(); + render(); + + const settingsButton = screen.getByRole("button", { name: /settings/i }); + await user.click(settingsButton); + + const filterInput = screen.getByPlaceholderText("e.g., ^ERROR"); + await user.type(filterInput, "TEST"); + + // Check that updateSettings was called + expect(mockUpdateSettings).toHaveBeenCalled(); + // At least one call should have filterRegex property + expect(mockUpdateSettings.mock.calls.some( + call => call[0] && 'filterRegex' in call[0] + )).toBe(true); + }); + }); + + describe("Error Handling", () => { + test("displays error message", () => { + mockUseSerialConsole.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: "Failed to connect to serial port", + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + expect( + screen.getByText("Failed to connect to serial port"), + ).toBeInTheDocument(); + }); + }); + + describe("Clear Messages", () => { + test("calls clearMessages when clear button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const clearButton = screen.getByRole("button", { + name: /clear messages/i, + }); + await user.click(clearButton); + + expect(mockClearMessages).toHaveBeenCalledTimes(1); + }); + }); + + describe("Auto Connect", () => { + test("auto connects when autoConnect prop is true", () => { + mockUseSerialConsole.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }, + connect: mockConnect, + disconnect: mockDisconnect, + sendMessage: mockSendMessage, + clearMessages: mockClearMessages, + updateSettings: mockUpdateSettings, + }); + + render(); + + expect(mockConnect).toHaveBeenCalled(); + }); + + test("does not auto connect when autoConnect prop is false", () => { + render(); + + expect(mockConnect).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pages/__tests__/DebugConsolePage.test.tsx b/src/pages/__tests__/DebugConsolePage.test.tsx new file mode 100644 index 0000000..e08a805 --- /dev/null +++ b/src/pages/__tests__/DebugConsolePage.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import { DebugConsolePage } from "../DebugConsolePage"; +import { SerialConsoleContext } from "../../contexts/SerialConsoleContextDef"; + +// Mock the SerialConsole component +jest.mock("../../components/SerialConsole", () => ({ + SerialConsole: ({ onConnectionChange }: { onConnectionChange?: (connected: boolean) => void }) => { + return
Serial Console Component
; + }, +})); + +describe("DebugConsolePage", () => { + const mockShowAsWindow = jest.fn(); + const mockShowInTab = jest.fn(); + const mockHide = jest.fn(); + const mockSetConnectionState = jest.fn(); + + const mockContextValue = { + position: "tab" as const, + hasActiveConnection: false, + showAsWindow: mockShowAsWindow, + showInTab: mockShowInTab, + hide: mockHide, + setConnectionState: mockSetConnectionState, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders page header", () => { + render( + + + , + ); + + expect(screen.getByText("Debug Console")).toBeInTheDocument(); + expect( + screen.getByText(/Connect to serial port for debugging/i), + ).toBeInTheDocument(); + }); + + test("renders SerialConsole component", () => { + render( + + + , + ); + + expect(screen.getByTestId("serial-console")).toBeInTheDocument(); + }); + + test("renders info box with tip", () => { + render( + + + , + ); + + expect(screen.getByText(/Tip:/i)).toBeInTheDocument(); + expect( + screen.getByText(/regex-based filtering and sed-style word replacement/i), + ).toBeInTheDocument(); + }); + + test("moves console to tab when page is active with window position", () => { + const contextWithWindow = { + ...mockContextValue, + position: "window" as const, + hasActiveConnection: true, + }; + + render( + + + , + ); + + // The effect should call showInTab when position is window and has active connection + expect(mockShowInTab).toHaveBeenCalled(); + }); +}); From bb28ee4269f0c0ee019ad0b86f79de0492a90a87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:44:28 +0000 Subject: [PATCH 4/5] Redesign serial console with proper state machine - Implement 5-state machine (A-E) as requested - State A: No connection - State B: Only ZMK Studio connection - State C: Only serial console (with splash screen) - State D: ZMK + serial, console tab active - State E: ZMK + serial, other tab active - Auto-detect serial logs from failed ZMK connections - Steal RpcTransport for serial console usage - Console tab only appears when serial connected - Fix lint errors and all tests passing Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com> --- src/App.old.tsx | 204 +++++++++++++++++ src/App.tsx | 116 ++++------ .../__tests__/SerialConsole.test.tsx | 2 +- src/contexts/AppStateContext.tsx | 183 +++++++++++++++ src/contexts/AppStateContextDef.ts | 40 ++++ src/hooks/useSerialConsole.old.ts | 216 ++++++++++++++++++ src/hooks/useSerialConsole.ts | 112 +++++---- src/pages/DebugConsolePage.tsx | 83 +++++-- src/pages/__tests__/DebugConsolePage.test.tsx | 111 ++++++--- 9 files changed, 895 insertions(+), 172 deletions(-) create mode 100644 src/App.old.tsx create mode 100644 src/contexts/AppStateContext.tsx create mode 100644 src/contexts/AppStateContextDef.ts create mode 100644 src/hooks/useSerialConsole.old.ts diff --git a/src/App.old.tsx b/src/App.old.tsx new file mode 100644 index 0000000..3ae0d88 --- /dev/null +++ b/src/App.old.tsx @@ -0,0 +1,204 @@ +import { useState, useContext, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + IconBattery2, + IconBluetooth, + IconHeartRateMonitor, + IconKeyboard, + IconPointer, + IconSettings, + IconTerminal2, +} from "@tabler/icons-react"; + +import { SplashScreen } from "./components/SplashScreen"; +import { + DeviceConnectionProvider, + ConnectionContext, +} from "./components/DeviceConnection"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import { SerialConsoleProvider } from "./contexts/SerialConsoleContext"; +import { useSerialConsoleContext } from "./contexts/SerialConsoleContextDef"; +import { TabNavigation } from "./components/TabNavigation"; +import type { TabItem } from "./components/TabNavigation"; +import { AppLayout } from "./layouts/AppLayout"; +import { BatteryPage } from "./pages/BatteryPage"; +import { BLEConnectionsPage } from "./pages/BLEConnectionsPage"; +import { HealthCheckPage } from "./pages/HealthCheckPage"; +import { KeymapPage } from "./pages/KeymapPage"; +import { TrackballPage } from "./pages/TrackballPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { DebugConsolePage } from "./pages/DebugConsolePage"; +import { DraggableWindow } from "./components/DraggableWindow"; +import { SerialConsole } from "./components/SerialConsole"; + +function App() { + return ( + + + + + + + + ); +} + +function AppContent() { + const connection = useContext(ConnectionContext); + const consoleContext = useSerialConsoleContext(); + const [activeTab, setActiveTab] = useState("battery"); + const [showConsoleFallback, setShowConsoleFallback] = useState(false); + + // Tabs array - conditionally include debug console tab + const tabs: TabItem[] = [ + { + id: "battery", + label: "Battery", + icon: , + content: , + }, + { + id: "ble", + label: "BLE", + icon: , + content: , + }, + { + id: "health", + label: "Health", + icon: , + content: , + }, + { + id: "keymap", + label: "Keymap", + icon: , + content: , + }, + { + id: "trackball", + label: "Trackball", + icon: , + content: , + }, + { + id: "settings", + label: "Settings", + icon: , + content: , + }, + // Add debug console tab when connected to ZMK Studio + ...(connection.isConnected + ? [ + { + id: "console", + label: "Console", + icon: , + content: , + }, + ] + : []), + ]; + + // Handle tab changes - move console to/from window + const handleTabChange = (tabId: string) => { + setActiveTab(tabId); + + // If switching away from console tab while console has active connection + if ( + activeTab === "console" && + tabId !== "console" && + consoleContext.hasActiveConnection + ) { + consoleContext.showAsWindow(); + } + + // If switching to console tab while console is in window + if (tabId === "console" && consoleContext.position === "window") { + consoleContext.showInTab(); + } + }; + + // Monitor ZMK connection errors and show console fallback + useEffect(() => { + if (!connection.isConnected && connection.error && !connection.isLoading) { + // Check if this is an unexpected error (not user cancellation) + if ( + !connection.error.includes("cancelled") && + !connection.error.includes("User") && + !connection.error.includes("selected") + ) { + // Use setTimeout to avoid setState within effect + setTimeout(() => { + setShowConsoleFallback(true); + consoleContext.showAsWindow(); + }, 0); + } + } + }, [connection.error, connection.isConnected, connection.isLoading, consoleContext]); + + return ( + <> + + {!connection.isConnected && ( + + + + )} + + + {connection.isConnected && ( + + + + + + )} + + {/* Draggable Console Window */} + {consoleContext.position === "window" && ( + { + consoleContext.hide(); + setShowConsoleFallback(false); + }} + title="Serial Console" + > + + consoleContext.setConnectionState(connected) + } + /> + + )} + + ); +} + +export default App; diff --git a/src/App.tsx b/src/App.tsx index 3ae0d88..93bd193 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useContext, useEffect } from "react"; +import { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { IconBattery2, @@ -11,13 +11,9 @@ import { } from "@tabler/icons-react"; import { SplashScreen } from "./components/SplashScreen"; -import { - DeviceConnectionProvider, - ConnectionContext, -} from "./components/DeviceConnection"; import { ThemeProvider } from "./contexts/ThemeContext"; -import { SerialConsoleProvider } from "./contexts/SerialConsoleContext"; -import { useSerialConsoleContext } from "./contexts/SerialConsoleContextDef"; +import { AppStateProvider } from "./contexts/AppStateContext"; +import { useAppState } from "./contexts/AppStateContextDef"; import { TabNavigation } from "./components/TabNavigation"; import type { TabItem } from "./components/TabNavigation"; import { AppLayout } from "./layouts/AppLayout"; @@ -34,23 +30,19 @@ import { SerialConsole } from "./components/SerialConsole"; function App() { return ( - - - - - + + + ); } function AppContent() { - const connection = useContext(ConnectionContext); - const consoleContext = useSerialConsoleContext(); + const appState = useAppState(); const [activeTab, setActiveTab] = useState("battery"); - const [showConsoleFallback, setShowConsoleFallback] = useState(false); - // Tabs array - conditionally include debug console tab - const tabs: TabItem[] = [ + // Base tabs - always available when ZMK connected + const baseTabs: TabItem[] = [ { id: "battery", label: "Battery", @@ -87,8 +79,12 @@ function AppContent() { icon: , content: , }, - // Add debug console tab when connected to ZMK Studio - ...(connection.isConnected + ]; + + // Console tab only appears when serial console is connected + const tabs: TabItem[] = [ + ...baseTabs, + ...(appState.serialConnected ? [ { id: "console", @@ -100,47 +96,35 @@ function AppContent() { : []), ]; - // Handle tab changes - move console to/from window + // Handle tab changes const handleTabChange = (tabId: string) => { setActiveTab(tabId); - // If switching away from console tab while console has active connection - if ( - activeTab === "console" && - tabId !== "console" && - consoleContext.hasActiveConnection - ) { - consoleContext.showAsWindow(); - } - - // If switching to console tab while console is in window - if (tabId === "console" && consoleContext.position === "window") { - consoleContext.showInTab(); + if (tabId === "console") { + appState.onConsoleTabActivated(); + } else if (activeTab === "console") { + appState.onOtherTabActivated(); } }; - // Monitor ZMK connection errors and show console fallback + // Auto-switch to console tab when entering state D useEffect(() => { - if (!connection.isConnected && connection.error && !connection.isLoading) { - // Check if this is an unexpected error (not user cancellation) - if ( - !connection.error.includes("cancelled") && - !connection.error.includes("User") && - !connection.error.includes("selected") - ) { - // Use setTimeout to avoid setState within effect - setTimeout(() => { - setShowConsoleFallback(true); - consoleContext.showAsWindow(); - }, 0); - } + if (appState.state === "D" && activeTab !== "console") { + setTimeout(() => setActiveTab("console"), 0); } - }, [connection.error, connection.isConnected, connection.isLoading, consoleContext]); + }, [appState.state, activeTab]); + + // Show splash screen in states A and C + const showSplashScreen = appState.state === "A" || appState.state === "C"; + // Show main app in states B, D, E + const showMainApp = appState.state === "B" || appState.state === "D" || appState.state === "E"; + // Show console window in states C and E + const showConsoleWindow = appState.state === "C" || appState.state === "E"; return ( <> - {!connection.isConnected && ( + {showSplashScreen && ( )} - {connection.isConnected && ( + {showMainApp && ( )} - {/* Draggable Console Window */} - {consoleContext.position === "window" && ( + {/* Serial Console Window - shown in states C and E */} + {showConsoleWindow && ( { - consoleContext.hide(); - setShowConsoleFallback(false); - }} + onClose={appState.onSerialDisconnect} title="Serial Console" > - consoleContext.setConnectionState(connected) - } + onConnectionChange={(connected) => { + if (!connected) { + appState.onSerialDisconnect(); + } + }} /> )} diff --git a/src/components/__tests__/SerialConsole.test.tsx b/src/components/__tests__/SerialConsole.test.tsx index 496d894..66f9269 100644 --- a/src/components/__tests__/SerialConsole.test.tsx +++ b/src/components/__tests__/SerialConsole.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SerialConsole } from "../SerialConsole"; diff --git a/src/contexts/AppStateContext.tsx b/src/contexts/AppStateContext.tsx new file mode 100644 index 0000000..9b7e4d5 --- /dev/null +++ b/src/contexts/AppStateContext.tsx @@ -0,0 +1,183 @@ +import { type ReactNode, useState, useCallback, useRef, useEffect } from "react"; +import { useZMKApp, ZMKAppContext } from "@cormoran/zmk-studio-react-hook"; +import { connect as connectSerial } from "@zmkfirmware/zmk-studio-ts-client/transport/serial"; +import { connect as connectBLE } from "@zmkfirmware/zmk-studio-ts-client/transport/gatt"; +import { connect as connectDemo } from "../lib/transport/demo"; +import type { RpcTransport } from "@zmkfirmware/zmk-studio-ts-client/transport/index"; +import { useSerialConsole } from "../hooks/useSerialConsole"; +import { AppStateContext, type AppState, type AppStateContextValue } from "./AppStateContextDef"; + +export type ConnectionMethod = "serial" | "ble" | "demo"; + +interface AppStateProviderProps { + children: ReactNode; +} + +// Helper to detect if connection output looks like ZMK logs +function looksLikeZMKLog(text: string): boolean { + const zmkPatterns = [ + /\*\*\* Booting/, + /\[00:00:00\.\d+,\d+\]/, + /zmk/i, + /bluetooth/i, + /ble/i, + /keyboard/i, + ]; + return zmkPatterns.some((pattern) => pattern.test(text)); +} + +export function AppStateProvider({ children }: AppStateProviderProps) { + const zmkApp = useZMKApp(); + const serialConsole = useSerialConsole(); + const [appState, setAppState] = useState("A"); + const pendingTransportRef = useRef(null); + + // Calculate current state based on connections + const zmkConnected = zmkApp.isConnected; + const serialConnected = serialConsole.isConnected; + + // Update app state based on connections and tab state + useEffect(() => { + if (!zmkConnected && !serialConnected) { + setAppState("A"); + } else if (zmkConnected && !serialConnected) { + setAppState("B"); + } else if (!zmkConnected && serialConnected) { + setAppState("C"); + } + // States D and E are set explicitly by tab navigation + }, [zmkConnected, serialConnected]); + + const handleConnect = useCallback( + async (method: ConnectionMethod) => { + let connectFn; + if (method === "ble") { + connectFn = connectBLE; + } else if (method === "demo") { + connectFn = connectDemo; + } else { + connectFn = connectSerial; + } + + try { + const transport = await connectFn(); + pendingTransportRef.current = transport; + + // Try to connect as ZMK Studio + try { + await zmkApp.connect(async () => transport); + // Success - we're in state B + pendingTransportRef.current = null; + } catch (err) { + // Connection failed - try to detect if it's serial logs + // Read some data to see if it looks like logs + const reader = transport.readable.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let detectedLog = false; + + // Read for a short time to detect logs + const timeout = setTimeout(() => { + reader.cancel(); + }, 1000); + + try { + for (let i = 0; i < 10; i++) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + if (looksLikeZMKLog(buffer)) { + detectedLog = true; + break; + } + } + } catch { + // Ignore read errors + } finally { + clearTimeout(timeout); + reader.releaseLock(); + } + + if (detectedLog) { + // Use this connection for serial console - state C + serialConsole.connectWithTransport(transport); + pendingTransportRef.current = null; + } else { + // Not a log, just throw the original error + throw err; + } + } + } catch (err) { + // Connection completely failed + console.error("Connection failed:", err); + } + }, + [zmkApp, serialConsole], + ); + + const handleDisconnect = useCallback(() => { + zmkApp.disconnect(); + if (appState === "B") { + setAppState("A"); + } else if (appState === "D" || appState === "E") { + setAppState("C"); + } + }, [zmkApp, appState]); + + const handleSerialConnect = useCallback(async () => { + await serialConsole.connect(); + if (zmkConnected) { + setAppState("D"); // Console tab will be activated + } else { + setAppState("C"); + } + }, [serialConsole, zmkConnected]); + + const handleSerialDisconnect = useCallback(() => { + serialConsole.disconnect(); + if (zmkConnected) { + setAppState("B"); + } else { + setAppState("A"); + } + }, [serialConsole, zmkConnected]); + + const handleConsoleTabActivated = useCallback(() => { + if (zmkConnected && serialConnected) { + setAppState("D"); + } + }, [zmkConnected, serialConnected]); + + const handleOtherTabActivated = useCallback(() => { + if (zmkConnected && serialConnected && appState === "D") { + setAppState("E"); + } + }, [zmkConnected, serialConnected, appState]); + + const contextValue: AppStateContextValue = { + state: appState, + zmkConnected, + serialConnected, + deviceName: zmkApp.state.deviceInfo?.name, + isLoading: zmkApp.state.isLoading || serialConsole.isConnecting, + error: zmkApp.state.error || serialConsole.error, + onConnect: handleConnect, + onDisconnect: handleDisconnect, + onSerialConnect: handleSerialConnect, + onSerialDisconnect: handleSerialDisconnect, + onConsoleTabActivated: handleConsoleTabActivated, + onOtherTabActivated: handleOtherTabActivated, + serialConsole, + }; + + return ( + + + {children} + + + ); +} + +export { AppStateContext }; diff --git a/src/contexts/AppStateContextDef.ts b/src/contexts/AppStateContextDef.ts new file mode 100644 index 0000000..62b3cdd --- /dev/null +++ b/src/contexts/AppStateContextDef.ts @@ -0,0 +1,40 @@ +import { createContext, useContext } from "react"; +import type { useSerialConsole } from "../hooks/useSerialConsole"; + +export type AppState = "A" | "B" | "C" | "D" | "E"; + +export interface AppStateContextValue { + state: AppState; + zmkConnected: boolean; + serialConnected: boolean; + deviceName: string | undefined; + isLoading: boolean; + error: string | null; + onConnect: (method: "serial" | "ble" | "demo") => void; + onDisconnect: () => void; + onSerialConnect: () => void; + onSerialDisconnect: () => void; + onConsoleTabActivated: () => void; + onOtherTabActivated: () => void; + serialConsole: ReturnType; +} + +export const AppStateContext = createContext({ + state: "A", + zmkConnected: false, + serialConnected: false, + deviceName: undefined, + isLoading: false, + error: null, + onConnect: () => {}, + onDisconnect: () => {}, + onSerialConnect: () => {}, + onSerialDisconnect: () => {}, + onConsoleTabActivated: () => {}, + onOtherTabActivated: () => {}, + serialConsole: {} as ReturnType, +}); + +export function useAppState() { + return useContext(AppStateContext); +} diff --git a/src/hooks/useSerialConsole.old.ts b/src/hooks/useSerialConsole.old.ts new file mode 100644 index 0000000..b1b25c1 --- /dev/null +++ b/src/hooks/useSerialConsole.old.ts @@ -0,0 +1,216 @@ +import { useState, useCallback, useRef, useEffect } from "react"; + +export interface SerialConsoleMessage { + timestamp: Date; + text: string; + type: "received" | "sent"; +} + +export interface SerialConsoleSettings { + baudRate: number; + filterRegex: string; + replacePattern: string; + replaceWith: string; +} + +export interface UseSerialConsoleReturn { + isConnected: boolean; + isConnecting: boolean; + error: string | null; + messages: SerialConsoleMessage[]; + settings: SerialConsoleSettings; + connect: () => Promise; + disconnect: () => void; + sendMessage: (text: string) => Promise; + clearMessages: () => void; + updateSettings: (settings: Partial) => void; +} + +export function useSerialConsole(): UseSerialConsoleReturn { + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + const [messages, setMessages] = useState([]); + const [settings, setSettings] = useState({ + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", + }); + + const portRef = useRef(null); + const readerRef = useRef | null>( + null, + ); + const writerRef = useRef | null>( + null, + ); + + const disconnect = useCallback(() => { + if (readerRef.current) { + readerRef.current.cancel(); + readerRef.current = null; + } + if (writerRef.current) { + writerRef.current.close(); + writerRef.current = null; + } + if (portRef.current) { + portRef.current.close(); + portRef.current = null; + } + setIsConnected(false); + setError(null); + }, []); + + const processLine = useCallback( + (line: string): string | null => { + // Apply regex filter if set + if (settings.filterRegex) { + try { + const regex = new RegExp(settings.filterRegex); + if (!regex.test(line)) { + return null; + } + } catch { + // Invalid regex, skip filtering + } + } + + // Apply sed-style replacement if set + if (settings.replacePattern) { + try { + const regex = new RegExp(settings.replacePattern, "g"); + return line.replace(regex, settings.replaceWith); + } catch { + // Invalid regex, return original + return line; + } + } + + return line; + }, + [settings.filterRegex, settings.replacePattern, settings.replaceWith], + ); + + const connect = useCallback(async () => { + if (!("serial" in navigator)) { + setError("Web Serial API is not supported in this browser"); + return; + } + + setIsConnecting(true); + setError(null); + + try { + const port = await navigator.serial!.requestPort(); + await port.open({ baudRate: settings.baudRate }); + + portRef.current = port; + setIsConnected(true); + setIsConnecting(false); + + // Start reading from the port + const reader = port.readable?.getReader(); + if (reader) { + readerRef.current = reader; + const decoder = new TextDecoder(); + let buffer = ""; + + (async () => { + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const processedLine = processLine(line.trim()); + if (processedLine !== null && processedLine !== "") { + setMessages((prev) => [ + ...prev, + { + timestamp: new Date(), + text: processedLine, + type: "received", + }, + ]); + } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + } + } + })(); + } + + // Get writer for sending data + const writer = port.writable?.getWriter(); + if (writer) { + writerRef.current = writer; + } + } catch (err) { + setError((err as Error).message); + setIsConnecting(false); + } + }, [settings.baudRate, processLine]); + + const sendMessage = useCallback(async (text: string) => { + if (!writerRef.current) { + setError("No active connection"); + return; + } + + try { + const encoder = new TextEncoder(); + await writerRef.current.write(encoder.encode(text + "\n")); + + setMessages((prev) => [ + ...prev, + { + timestamp: new Date(), + text, + type: "sent", + }, + ]); + } catch (err) { + setError((err as Error).message); + } + }, []); + + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + const updateSettings = useCallback( + (newSettings: Partial) => { + setSettings((prev) => ({ ...prev, ...newSettings })); + }, + [], + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + disconnect(); + }; + }, [disconnect]); + + return { + isConnected, + isConnecting, + error, + messages, + settings, + connect, + disconnect, + sendMessage, + clearMessages, + updateSettings, + }; +} diff --git a/src/hooks/useSerialConsole.ts b/src/hooks/useSerialConsole.ts index b1b25c1..cb153e4 100644 --- a/src/hooks/useSerialConsole.ts +++ b/src/hooks/useSerialConsole.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useRef, useEffect } from "react"; +import type { RpcTransport } from "@zmkfirmware/zmk-studio-ts-client/transport/index"; export interface SerialConsoleMessage { timestamp: Date; @@ -20,6 +21,7 @@ export interface UseSerialConsoleReturn { messages: SerialConsoleMessage[]; settings: SerialConsoleSettings; connect: () => Promise; + connectWithTransport: (transport: RpcTransport) => void; disconnect: () => void; sendMessage: (text: string) => Promise; clearMessages: () => void; @@ -39,6 +41,7 @@ export function useSerialConsole(): UseSerialConsoleReturn { }); const portRef = useRef(null); + const transportRef = useRef(null); const readerRef = useRef | null>( null, ); @@ -59,6 +62,9 @@ export function useSerialConsole(): UseSerialConsoleReturn { portRef.current.close(); portRef.current = null; } + if (transportRef.current) { + transportRef.current = null; + } setIsConnected(false); setError(null); }, []); @@ -93,6 +99,64 @@ export function useSerialConsole(): UseSerialConsoleReturn { [settings.filterRegex, settings.replacePattern, settings.replaceWith], ); + const startReading = useCallback( + (readable: ReadableStream, writable: WritableStream) => { + const reader = readable.getReader(); + readerRef.current = reader; + + const writer = writable.getWriter(); + writerRef.current = writer; + + const decoder = new TextDecoder(); + let buffer = ""; + + (async () => { + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const processedLine = processLine(line.trim()); + if (processedLine !== null && processedLine !== "") { + setMessages((prev) => [ + ...prev, + { + timestamp: new Date(), + text: processedLine, + type: "received", + }, + ]); + } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + setError((err as Error).message); + setIsConnected(false); + } + } + })(); + }, + [processLine], + ); + + const connectWithTransport = useCallback( + (transport: RpcTransport) => { + transportRef.current = transport; + setIsConnected(true); + setIsConnecting(false); + setError(null); + + startReading(transport.readable, transport.writable); + }, + [startReading], + ); + const connect = useCallback(async () => { if (!("serial" in navigator)) { setError("Web Serial API is not supported in this browser"); @@ -110,55 +174,14 @@ export function useSerialConsole(): UseSerialConsoleReturn { setIsConnected(true); setIsConnecting(false); - // Start reading from the port - const reader = port.readable?.getReader(); - if (reader) { - readerRef.current = reader; - const decoder = new TextDecoder(); - let buffer = ""; - - (async () => { - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const processedLine = processLine(line.trim()); - if (processedLine !== null && processedLine !== "") { - setMessages((prev) => [ - ...prev, - { - timestamp: new Date(), - text: processedLine, - type: "received", - }, - ]); - } - } - } - } catch (err) { - if ((err as Error).name !== "AbortError") { - setError((err as Error).message); - } - } - })(); - } - - // Get writer for sending data - const writer = port.writable?.getWriter(); - if (writer) { - writerRef.current = writer; + if (port.readable && port.writable) { + startReading(port.readable, port.writable); } } catch (err) { setError((err as Error).message); setIsConnecting(false); } - }, [settings.baudRate, processLine]); + }, [settings.baudRate, startReading]); const sendMessage = useCallback(async (text: string) => { if (!writerRef.current) { @@ -208,6 +231,7 @@ export function useSerialConsole(): UseSerialConsoleReturn { messages, settings, connect, + connectWithTransport, disconnect, sendMessage, clearMessages, diff --git a/src/pages/DebugConsolePage.tsx b/src/pages/DebugConsolePage.tsx index 4bd7e8e..64a8194 100644 --- a/src/pages/DebugConsolePage.tsx +++ b/src/pages/DebugConsolePage.tsx @@ -1,17 +1,8 @@ import { IconTerminal2 } from "@tabler/icons-react"; -import { SerialConsole } from "../components/SerialConsole"; -import { useSerialConsoleContext } from "../contexts/SerialConsoleContextDef"; -import { useEffect } from "react"; +import { useAppState } from "../contexts/AppStateContextDef"; export function DebugConsolePage() { - const consoleContext = useSerialConsoleContext(); - - // When this page is active, show console in tab - useEffect(() => { - if (consoleContext.hasActiveConnection && consoleContext.position === "window") { - consoleContext.showInTab(); - } - }, [consoleContext]); + const appState = useAppState(); return (
@@ -31,23 +22,71 @@ export function DebugConsolePage() {
- {/* Console */} + {/* Console - render from app state */}
- - consoleContext.setConnectionState(connected) - } - /> +
+ {/* Show console messages/UI using the shared serial console instance */} + {appState.serialConsole.isConnected ? ( + <> + {/* Header */} +
+
+ + + Serial Console + + +
+ +
+ + {/* Messages */} +
+ {appState.serialConsole.messages.map((msg, idx) => ( +
+ + {msg.timestamp.toLocaleTimeString()} + + + {msg.type === "sent" ? "→" : "←"} + + {msg.text} +
+ ))} +
+ + ) : ( +
+
+ +

Console disconnected

+
+
+ )} +
{/* Info Box */}

- Tip: The console supports regex-based filtering and - sed-style word replacement. Click the settings icon to configure - these features. When you switch to another tab, the console will - automatically move to a draggable window if you have an active - connection. + Tip: The serial console connection is shared across the app. + When you switch to another tab, the console will move to a draggable window.

diff --git a/src/pages/__tests__/DebugConsolePage.test.tsx b/src/pages/__tests__/DebugConsolePage.test.tsx index e08a805..08e5cb9 100644 --- a/src/pages/__tests__/DebugConsolePage.test.tsx +++ b/src/pages/__tests__/DebugConsolePage.test.tsx @@ -1,27 +1,50 @@ import { render, screen } from "@testing-library/react"; import { DebugConsolePage } from "../DebugConsolePage"; -import { SerialConsoleContext } from "../../contexts/SerialConsoleContextDef"; +import { AppStateContext } from "../../contexts/AppStateContextDef"; +import type { AppStateContextValue } from "../../contexts/AppStateContextDef"; -// Mock the SerialConsole component -jest.mock("../../components/SerialConsole", () => ({ - SerialConsole: ({ onConnectionChange }: { onConnectionChange?: (connected: boolean) => void }) => { - return
Serial Console Component
; +// Mock the useSerialConsole hook +const mockSerialConsole = { + isConnected: false, + isConnecting: false, + error: null, + messages: [], + settings: { + baudRate: 115200, + filterRegex: "", + replacePattern: "", + replaceWith: "", }, -})); + connect: jest.fn(), + connectWithTransport: jest.fn(), + disconnect: jest.fn(), + sendMessage: jest.fn(), + clearMessages: jest.fn(), + updateSettings: jest.fn(), +}; describe("DebugConsolePage", () => { - const mockShowAsWindow = jest.fn(); - const mockShowInTab = jest.fn(); - const mockHide = jest.fn(); - const mockSetConnectionState = jest.fn(); + const mockOnConnect = jest.fn(); + const mockOnDisconnect = jest.fn(); + const mockOnSerialConnect = jest.fn(); + const mockOnSerialDisconnect = jest.fn(); + const mockOnConsoleTabActivated = jest.fn(); + const mockOnOtherTabActivated = jest.fn(); - const mockContextValue = { - position: "tab" as const, - hasActiveConnection: false, - showAsWindow: mockShowAsWindow, - showInTab: mockShowInTab, - hide: mockHide, - setConnectionState: mockSetConnectionState, + const mockContextValue: AppStateContextValue = { + state: "D", + zmkConnected: true, + serialConnected: true, + deviceName: "Test Device", + isLoading: false, + error: null, + onConnect: mockOnConnect, + onDisconnect: mockOnDisconnect, + onSerialConnect: mockOnSerialConnect, + onSerialDisconnect: mockOnSerialDisconnect, + onConsoleTabActivated: mockOnConsoleTabActivated, + onOtherTabActivated: mockOnOtherTabActivated, + serialConsole: mockSerialConsole, }; beforeEach(() => { @@ -30,9 +53,9 @@ describe("DebugConsolePage", () => { test("renders page header", () => { render( - + - , + , ); expect(screen.getByText("Debug Console")).toBeInTheDocument(); @@ -41,43 +64,55 @@ describe("DebugConsolePage", () => { ).toBeInTheDocument(); }); - test("renders SerialConsole component", () => { + test("renders info box with tip", () => { render( - + - , + , ); - expect(screen.getByTestId("serial-console")).toBeInTheDocument(); + expect(screen.getByText(/Tip:/i)).toBeInTheDocument(); + expect( + screen.getByText(/serial console connection is shared/i), + ).toBeInTheDocument(); }); - test("renders info box with tip", () => { + test("shows connected state when serial console is connected", () => { + const connectedContext = { + ...mockContextValue, + serialConnected: true, + serialConsole: { + ...mockSerialConsole, + isConnected: true, + }, + }; + render( - + - , + , ); - expect(screen.getByText(/Tip:/i)).toBeInTheDocument(); - expect( - screen.getByText(/regex-based filtering and sed-style word replacement/i), - ).toBeInTheDocument(); + expect(screen.getByText("Serial Console")).toBeInTheDocument(); + expect(screen.getByText("Disconnect")).toBeInTheDocument(); }); - test("moves console to tab when page is active with window position", () => { - const contextWithWindow = { + test("shows disconnected state when serial console is not connected", () => { + const disconnectedContext = { ...mockContextValue, - position: "window" as const, - hasActiveConnection: true, + serialConnected: false, + serialConsole: { + ...mockSerialConsole, + isConnected: false, + }, }; render( - + - , + , ); - // The effect should call showInTab when position is window and has active connection - expect(mockShowInTab).toHaveBeenCalled(); + expect(screen.getByText("Console disconnected")).toBeInTheDocument(); }); }); From ace90db01794547582551f793f0743b88fb834ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:45:51 +0000 Subject: [PATCH 5/5] Clean up old unused files Co-authored-by: cormoran <7994064+cormoran@users.noreply.github.com> --- src/App.old.tsx | 204 ---------------------- src/contexts/SerialConsoleContext.tsx | 42 ----- src/contexts/SerialConsoleContextDef.ts | 31 ---- src/hooks/useSerialConsole.old.ts | 216 ------------------------ 4 files changed, 493 deletions(-) delete mode 100644 src/App.old.tsx delete mode 100644 src/contexts/SerialConsoleContext.tsx delete mode 100644 src/contexts/SerialConsoleContextDef.ts delete mode 100644 src/hooks/useSerialConsole.old.ts diff --git a/src/App.old.tsx b/src/App.old.tsx deleted file mode 100644 index 3ae0d88..0000000 --- a/src/App.old.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useState, useContext, useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { - IconBattery2, - IconBluetooth, - IconHeartRateMonitor, - IconKeyboard, - IconPointer, - IconSettings, - IconTerminal2, -} from "@tabler/icons-react"; - -import { SplashScreen } from "./components/SplashScreen"; -import { - DeviceConnectionProvider, - ConnectionContext, -} from "./components/DeviceConnection"; -import { ThemeProvider } from "./contexts/ThemeContext"; -import { SerialConsoleProvider } from "./contexts/SerialConsoleContext"; -import { useSerialConsoleContext } from "./contexts/SerialConsoleContextDef"; -import { TabNavigation } from "./components/TabNavigation"; -import type { TabItem } from "./components/TabNavigation"; -import { AppLayout } from "./layouts/AppLayout"; -import { BatteryPage } from "./pages/BatteryPage"; -import { BLEConnectionsPage } from "./pages/BLEConnectionsPage"; -import { HealthCheckPage } from "./pages/HealthCheckPage"; -import { KeymapPage } from "./pages/KeymapPage"; -import { TrackballPage } from "./pages/TrackballPage"; -import { SettingsPage } from "./pages/SettingsPage"; -import { DebugConsolePage } from "./pages/DebugConsolePage"; -import { DraggableWindow } from "./components/DraggableWindow"; -import { SerialConsole } from "./components/SerialConsole"; - -function App() { - return ( - - - - - - - - ); -} - -function AppContent() { - const connection = useContext(ConnectionContext); - const consoleContext = useSerialConsoleContext(); - const [activeTab, setActiveTab] = useState("battery"); - const [showConsoleFallback, setShowConsoleFallback] = useState(false); - - // Tabs array - conditionally include debug console tab - const tabs: TabItem[] = [ - { - id: "battery", - label: "Battery", - icon: , - content: , - }, - { - id: "ble", - label: "BLE", - icon: , - content: , - }, - { - id: "health", - label: "Health", - icon: , - content: , - }, - { - id: "keymap", - label: "Keymap", - icon: , - content: , - }, - { - id: "trackball", - label: "Trackball", - icon: , - content: , - }, - { - id: "settings", - label: "Settings", - icon: , - content: , - }, - // Add debug console tab when connected to ZMK Studio - ...(connection.isConnected - ? [ - { - id: "console", - label: "Console", - icon: , - content: , - }, - ] - : []), - ]; - - // Handle tab changes - move console to/from window - const handleTabChange = (tabId: string) => { - setActiveTab(tabId); - - // If switching away from console tab while console has active connection - if ( - activeTab === "console" && - tabId !== "console" && - consoleContext.hasActiveConnection - ) { - consoleContext.showAsWindow(); - } - - // If switching to console tab while console is in window - if (tabId === "console" && consoleContext.position === "window") { - consoleContext.showInTab(); - } - }; - - // Monitor ZMK connection errors and show console fallback - useEffect(() => { - if (!connection.isConnected && connection.error && !connection.isLoading) { - // Check if this is an unexpected error (not user cancellation) - if ( - !connection.error.includes("cancelled") && - !connection.error.includes("User") && - !connection.error.includes("selected") - ) { - // Use setTimeout to avoid setState within effect - setTimeout(() => { - setShowConsoleFallback(true); - consoleContext.showAsWindow(); - }, 0); - } - } - }, [connection.error, connection.isConnected, connection.isLoading, consoleContext]); - - return ( - <> - - {!connection.isConnected && ( - - - - )} - - - {connection.isConnected && ( - - - - - - )} - - {/* Draggable Console Window */} - {consoleContext.position === "window" && ( - { - consoleContext.hide(); - setShowConsoleFallback(false); - }} - title="Serial Console" - > - - consoleContext.setConnectionState(connected) - } - /> - - )} - - ); -} - -export default App; diff --git a/src/contexts/SerialConsoleContext.tsx b/src/contexts/SerialConsoleContext.tsx deleted file mode 100644 index f79214a..0000000 --- a/src/contexts/SerialConsoleContext.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { type ReactNode, useState, useCallback } from "react"; -import { SerialConsoleContext } from "./SerialConsoleContextDef"; - -interface SerialConsoleProviderProps { - children: ReactNode; -} - -export function SerialConsoleProvider({ children }: SerialConsoleProviderProps) { - const [position, setPosition] = useState<"tab" | "window" | "hidden">("hidden"); - const [hasActiveConnection, setHasActiveConnection] = useState(false); - - const showAsWindow = useCallback(() => { - setPosition("window"); - }, []); - - const showInTab = useCallback(() => { - setPosition("tab"); - }, []); - - const hide = useCallback(() => { - setPosition("hidden"); - }, []); - - const setConnectionState = useCallback((connected: boolean) => { - setHasActiveConnection(connected); - }, []); - - return ( - - {children} - - ); -} diff --git a/src/contexts/SerialConsoleContextDef.ts b/src/contexts/SerialConsoleContextDef.ts deleted file mode 100644 index f4251de..0000000 --- a/src/contexts/SerialConsoleContextDef.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createContext, useContext } from "react"; - -export type ConsolePosition = "tab" | "window" | "hidden"; - -export interface SerialConsoleContextValue { - /** Current position of the console */ - position: ConsolePosition; - /** Whether the console has an active connection */ - hasActiveConnection: boolean; - /** Show console in a draggable window */ - showAsWindow: () => void; - /** Show console in the tab */ - showInTab: () => void; - /** Hide console */ - hide: () => void; - /** Set connection state */ - setConnectionState: (connected: boolean) => void; -} - -export const SerialConsoleContext = createContext({ - position: "hidden", - hasActiveConnection: false, - showAsWindow: () => {}, - showInTab: () => {}, - hide: () => {}, - setConnectionState: () => {}, -}); - -export function useSerialConsoleContext() { - return useContext(SerialConsoleContext); -} diff --git a/src/hooks/useSerialConsole.old.ts b/src/hooks/useSerialConsole.old.ts deleted file mode 100644 index b1b25c1..0000000 --- a/src/hooks/useSerialConsole.old.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; - -export interface SerialConsoleMessage { - timestamp: Date; - text: string; - type: "received" | "sent"; -} - -export interface SerialConsoleSettings { - baudRate: number; - filterRegex: string; - replacePattern: string; - replaceWith: string; -} - -export interface UseSerialConsoleReturn { - isConnected: boolean; - isConnecting: boolean; - error: string | null; - messages: SerialConsoleMessage[]; - settings: SerialConsoleSettings; - connect: () => Promise; - disconnect: () => void; - sendMessage: (text: string) => Promise; - clearMessages: () => void; - updateSettings: (settings: Partial) => void; -} - -export function useSerialConsole(): UseSerialConsoleReturn { - const [isConnected, setIsConnected] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [error, setError] = useState(null); - const [messages, setMessages] = useState([]); - const [settings, setSettings] = useState({ - baudRate: 115200, - filterRegex: "", - replacePattern: "", - replaceWith: "", - }); - - const portRef = useRef(null); - const readerRef = useRef | null>( - null, - ); - const writerRef = useRef | null>( - null, - ); - - const disconnect = useCallback(() => { - if (readerRef.current) { - readerRef.current.cancel(); - readerRef.current = null; - } - if (writerRef.current) { - writerRef.current.close(); - writerRef.current = null; - } - if (portRef.current) { - portRef.current.close(); - portRef.current = null; - } - setIsConnected(false); - setError(null); - }, []); - - const processLine = useCallback( - (line: string): string | null => { - // Apply regex filter if set - if (settings.filterRegex) { - try { - const regex = new RegExp(settings.filterRegex); - if (!regex.test(line)) { - return null; - } - } catch { - // Invalid regex, skip filtering - } - } - - // Apply sed-style replacement if set - if (settings.replacePattern) { - try { - const regex = new RegExp(settings.replacePattern, "g"); - return line.replace(regex, settings.replaceWith); - } catch { - // Invalid regex, return original - return line; - } - } - - return line; - }, - [settings.filterRegex, settings.replacePattern, settings.replaceWith], - ); - - const connect = useCallback(async () => { - if (!("serial" in navigator)) { - setError("Web Serial API is not supported in this browser"); - return; - } - - setIsConnecting(true); - setError(null); - - try { - const port = await navigator.serial!.requestPort(); - await port.open({ baudRate: settings.baudRate }); - - portRef.current = port; - setIsConnected(true); - setIsConnecting(false); - - // Start reading from the port - const reader = port.readable?.getReader(); - if (reader) { - readerRef.current = reader; - const decoder = new TextDecoder(); - let buffer = ""; - - (async () => { - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const processedLine = processLine(line.trim()); - if (processedLine !== null && processedLine !== "") { - setMessages((prev) => [ - ...prev, - { - timestamp: new Date(), - text: processedLine, - type: "received", - }, - ]); - } - } - } - } catch (err) { - if ((err as Error).name !== "AbortError") { - setError((err as Error).message); - } - } - })(); - } - - // Get writer for sending data - const writer = port.writable?.getWriter(); - if (writer) { - writerRef.current = writer; - } - } catch (err) { - setError((err as Error).message); - setIsConnecting(false); - } - }, [settings.baudRate, processLine]); - - const sendMessage = useCallback(async (text: string) => { - if (!writerRef.current) { - setError("No active connection"); - return; - } - - try { - const encoder = new TextEncoder(); - await writerRef.current.write(encoder.encode(text + "\n")); - - setMessages((prev) => [ - ...prev, - { - timestamp: new Date(), - text, - type: "sent", - }, - ]); - } catch (err) { - setError((err as Error).message); - } - }, []); - - const clearMessages = useCallback(() => { - setMessages([]); - }, []); - - const updateSettings = useCallback( - (newSettings: Partial) => { - setSettings((prev) => ({ ...prev, ...newSettings })); - }, - [], - ); - - // Cleanup on unmount - useEffect(() => { - return () => { - disconnect(); - }; - }, [disconnect]); - - return { - isConnected, - isConnecting, - error, - messages, - settings, - connect, - disconnect, - sendMessage, - clearMessages, - updateSettings, - }; -}