diff --git a/package-lock.json b/package-lock.json index bdd71ea..02dbca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -965,6 +966,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -988,6 +990,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3776,6 +3779,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4278,8 +4282,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4523,6 +4526,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4533,6 +4537,7 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4543,6 +4548,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4657,6 +4663,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -4934,6 +4941,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5329,6 +5337,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6041,8 +6050,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-case": { "version": "3.0.4", @@ -6194,6 +6202,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7223,6 +7232,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9113,6 +9123,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9824,7 +9835,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10421,6 +10431,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10469,7 +10480,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10485,7 +10495,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10563,6 +10572,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10572,6 +10582,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10591,6 +10602,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -10736,7 +10748,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -11500,6 +11513,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11622,6 +11636,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11818,6 +11833,7 @@ "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/runtime": "0.97.0", "fdir": "^6.5.0", @@ -12176,6 +12192,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/App.tsx b/src/App.tsx index 8ead759..689fc43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useContext } from "react"; +import { useState, useContext, useCallback } 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"; @@ -14,7 +15,9 @@ import { DeviceConnectionProvider, ConnectionContext, } from "./components/DeviceConnection"; +import { ConsoleContext } from "./contexts/ConsoleContext"; import { ThemeProvider } from "./contexts/ThemeContext"; +import { ConsoleProvider } from "./contexts/ConsoleContext"; import { TabNavigation } from "./components/TabNavigation"; import type { TabItem } from "./components/TabNavigation"; import { AppLayout } from "./layouts/AppLayout"; @@ -24,6 +27,9 @@ import { HealthCheckPage } from "./pages/HealthCheckPage"; import { KeymapPage } from "./pages/KeymapPage"; import { TrackballPage } from "./pages/TrackballPage"; import { SettingsPage } from "./pages/SettingsPage"; +import { ConsolePage } from "./pages/ConsolePage"; +import { DraggableWindow } from "./components/DraggableWindow"; +import { SerialConsole } from "./components/SerialConsole"; const tabs: TabItem[] = [ { @@ -62,22 +68,50 @@ const tabs: TabItem[] = [ icon: , content: , }, + { + id: "console", + label: "Console", + icon: , + content: , + }, ]; function App() { return ( - - - + + + + + ); } function AppContent() { const connection = useContext(ConnectionContext); + const consoleContext = useContext(ConsoleContext); const [activeTab, setActiveTab] = useState("battery"); + const handleTabChange = useCallback( + (tabId: string) => { + // Handle console tab transitions + if (activeTab === "console" && tabId !== "console") { + // Leaving console tab - convert to window mode + consoleContext?.exitToWindowMode(); + } else if (activeTab !== "console" && tabId === "console") { + // Entering console tab - restore snap state + consoleContext?.restoreSnapState(); + } + setActiveTab(tabId); + }, + [activeTab, consoleContext], + ); + + // Get floating console windows (not snapped) + const floatingConsoles = + consoleContext?.consoles.filter((c) => c.snapPosition === null) || []; + return ( <> @@ -90,6 +124,7 @@ function AppContent() { > @@ -114,11 +149,32 @@ function AppContent() { )} + + {/* Global floating console windows - shown across all tabs */} + {floatingConsoles.map((console) => ( + + consoleContext?.updateConsole(console.id, { position: pos }) + } + onDragStart={() => {}} + onDragEnd={() => {}} + zIndex={console.zIndex} + onFocus={() => consoleContext?.bringToFront(console.id)} + > + consoleContext?.removeConsole(console.id)} + existingPort={console.port} + /> + + ))} ); } diff --git a/src/components/DeviceConnection.tsx b/src/components/DeviceConnection.tsx index 6f5427c..13f84bd 100644 --- a/src/components/DeviceConnection.tsx +++ b/src/components/DeviceConnection.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from "react"; -import { createContext, useCallback } from "react"; +import { createContext, useCallback, useContext } 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 { connectReusableSerial } from "../lib/transport/reusableSerial"; +import { ConsoleContext } from "../contexts/ConsoleContext"; export type ConnectionMethod = "serial" | "ble" | "demo"; @@ -15,6 +16,7 @@ interface ConnectionContextValue { onDisconnect: () => void; isLoading: boolean; error: string | null; + onConnectWithFallback: (method: ConnectionMethod) => Promise; } const ConnectionContext = createContext({ @@ -24,6 +26,7 @@ const ConnectionContext = createContext({ onDisconnect: () => {}, isLoading: false, error: null, + onConnectWithFallback: async () => false, }); interface DeviceConnectionProviderProps { @@ -34,6 +37,7 @@ export function DeviceConnectionProvider({ children, }: DeviceConnectionProviderProps) { const zmkApp = useZMKApp(); + const consoleContext = useContext(ConsoleContext); const handleConnect = useCallback( async (method: ConnectionMethod) => { @@ -43,13 +47,61 @@ export function DeviceConnectionProvider({ } else if (method === "demo") { connectFn = connectDemo; } else { - connectFn = connectSerial; + // For serial, use reusable transport + connectFn = connectReusableSerial; } await zmkApp.connect(connectFn); }, [zmkApp], ); + // Connect with fallback to serial console if ZMK connection fails + const handleConnectWithFallback = useCallback( + async (method: ConnectionMethod): Promise => { + if (method !== "serial") { + // Non-serial connections don't have fallback + let connectFn; + if (method === "ble") { + connectFn = connectBLE; + } else if (method === "demo") { + connectFn = connectDemo; + } + if (connectFn) { + await zmkApp.connect(connectFn); + } + return true; + } + + // For serial connections, use reusable transport + let transport; + try { + transport = await connectReusableSerial(); + } catch (err) { + console.error("Failed to open serial port:", err); + return false; + } + + // Try ZMK connection + await zmkApp.connect(async () => transport); + + // Check if connection succeeded by checking error state + if (zmkApp.state.error) { + // ZMK connection failed, release the port and open serial console + console.log("ZMK connection failed, opening serial console..."); + + const port = transport.release(); + if (port && consoleContext) { + consoleContext.addConsoleFromPort(port); + } + + return false; + } + + return true; + }, + [zmkApp, consoleContext], + ); + const handleDisconnect = useCallback(() => { zmkApp.disconnect(); }, [zmkApp]); @@ -61,6 +113,7 @@ export function DeviceConnectionProvider({ onDisconnect: handleDisconnect, isLoading: zmkApp.state.isLoading, error: zmkApp.state.error, + onConnectWithFallback: handleConnectWithFallback, }; return ( diff --git a/src/components/DraggableWindow.tsx b/src/components/DraggableWindow.tsx new file mode 100644 index 0000000..b7c0cb7 --- /dev/null +++ b/src/components/DraggableWindow.tsx @@ -0,0 +1,225 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import type { ReactNode } from "react"; + +export interface WindowPosition { + x: number; + y: number; + width: number; + height: number; +} + +interface DraggableWindowProps { + children: ReactNode; + initialPosition?: Partial; + onPositionChange?: (position: WindowPosition) => void; + onDragStart?: () => void; + onDragEnd?: (position: WindowPosition) => void; + zIndex?: number; + onFocus?: () => void; +} + +export function DraggableWindow({ + children, + initialPosition = {}, + onPositionChange, + onDragStart, + onDragEnd, + zIndex = 1000, + onFocus, +}: DraggableWindowProps) { + const [position, setPosition] = useState({ + x: initialPosition.x ?? 100, + y: initialPosition.y ?? 100, + width: initialPosition.width ?? 600, + height: initialPosition.height ?? 400, + }); + + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [resizeDirection, setResizeDirection] = useState(""); + + const dragStartPos = useRef({ x: 0, y: 0 }); + const windowRef = useRef(null); + const resizeStartPos = useRef({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest(".window-content")) { + return; // Don't drag when interacting with content + } + + e.preventDefault(); + setIsDragging(true); + dragStartPos.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + onDragStart?.(); + onFocus?.(); + }, + [position.x, position.y, onDragStart, onFocus], + ); + + const handleResizeMouseDown = useCallback( + (e: React.MouseEvent, direction: string) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeDirection(direction); + resizeStartPos.current = { + x: e.clientX, + y: e.clientY, + width: position.width, + height: position.height, + }; + onFocus?.(); + }, + [position.width, position.height, onFocus], + ); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + const newPosition = { + ...position, + x: e.clientX - dragStartPos.current.x, + y: e.clientY - dragStartPos.current.y, + }; + setPosition(newPosition); + onPositionChange?.(newPosition); + } else if (isResizing) { + const deltaX = e.clientX - resizeStartPos.current.x; + const deltaY = e.clientY - resizeStartPos.current.y; + + let newWidth = position.width; + let newHeight = position.height; + let newX = position.x; + let newY = position.y; + + if (resizeDirection.includes("e")) { + newWidth = Math.max(300, resizeStartPos.current.width + deltaX); + } + if (resizeDirection.includes("s")) { + newHeight = Math.max(200, resizeStartPos.current.height + deltaY); + } + if (resizeDirection.includes("w")) { + const widthDelta = resizeStartPos.current.width - deltaX; + newWidth = Math.max(300, widthDelta); + newX = position.x + (resizeStartPos.current.width - newWidth); + } + if (resizeDirection.includes("n")) { + const heightDelta = resizeStartPos.current.height - deltaY; + newHeight = Math.max(200, heightDelta); + newY = position.y + (resizeStartPos.current.height - newHeight); + } + + const newPosition = { + x: newX, + y: newY, + width: newWidth, + height: newHeight, + }; + setPosition(newPosition); + onPositionChange?.(newPosition); + } + }; + + const handleMouseUp = () => { + if (isDragging) { + setIsDragging(false); + onDragEnd?.(position); + } + if (isResizing) { + setIsResizing(false); + setResizeDirection(""); + } + }; + + if (isDragging || isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [ + isDragging, + isResizing, + position, + resizeDirection, + onPositionChange, + onDragEnd, + ]); + + return ( +
+ {/* Window Header (draggable) */} +
+
{children}
+
+ + {/* Resize handles */} + {!isDragging && ( + <> + {/* Corner handles */} +
handleResizeMouseDown(e, "nw")} + /> +
handleResizeMouseDown(e, "ne")} + /> +
handleResizeMouseDown(e, "sw")} + /> +
handleResizeMouseDown(e, "se")} + /> + + {/* Edge handles */} +
handleResizeMouseDown(e, "n")} + /> +
handleResizeMouseDown(e, "s")} + /> +
handleResizeMouseDown(e, "w")} + /> +
handleResizeMouseDown(e, "e")} + /> + + )} +
+ ); +} diff --git a/src/components/SerialConsole.tsx b/src/components/SerialConsole.tsx new file mode 100644 index 0000000..bb3f906 --- /dev/null +++ b/src/components/SerialConsole.tsx @@ -0,0 +1,316 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { + IconTerminal, + IconSend, + IconFilter, + IconClearAll, + IconX, + IconSettings, +} from "@tabler/icons-react"; +import { useSerialPort } from "../hooks/useSerialPort"; +import type { SerialPortConfig } from "../hooks/useSerialPort"; + +interface SerialConsoleProps { + consoleId: string; + onClose?: () => void; + isSnapped?: boolean; + onSnapOut?: () => void; + existingPort?: SerialPort; +} + +export function SerialConsole({ + consoleId, + onClose, + isSnapped = false, + onSnapOut, + existingPort, +}: SerialConsoleProps) { + const { + isConnected, + isConnecting, + error, + connect, + connectWithPort, + disconnect, + sendData, + receivedData, + clearData, + } = useSerialPort(); + + const [showConfig, setShowConfig] = useState(!isConnected && !existingPort); + const [baudRate, setBaudRate] = useState("115200"); + const [inputText, setInputText] = useState(""); + const [filterRegex, setFilterRegex] = useState(""); + const [replacePattern, setReplacePattern] = useState(""); + const [replaceWith, setReplaceWith] = useState(""); + const [showFilters, setShowFilters] = useState(false); + + const terminalRef = useRef(null); + + // Auto-connect if we have an existing port + useEffect(() => { + if (existingPort && !isConnected) { + connectWithPort(existingPort, { baudRate: parseInt(baudRate, 10) }); + } + }, [existingPort, isConnected, connectWithPort, baudRate]); + + // Auto-scroll to bottom when new data arrives + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.scrollTop = terminalRef.current.scrollHeight; + } + }, [receivedData]); + + const handleConnect = useCallback(async () => { + const config: SerialPortConfig = { + baudRate: parseInt(baudRate, 10), + }; + await connect(config); + setShowConfig(false); + }, [connect, baudRate]); + + const handleDisconnect = useCallback(async () => { + await disconnect(); + setShowConfig(true); + }, [disconnect]); + + const handleSend = useCallback(async () => { + if (!inputText.trim()) return; + try { + await sendData(inputText + "\n"); + setInputText(""); + } catch (err) { + console.error("Failed to send:", err); + } + }, [inputText, sendData]); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + // Apply filtering and replacement + const processedData = useMemo(() => { + let lines = receivedData.split("\n"); + + // Apply regex filter + if (filterRegex.trim()) { + try { + const regex = new RegExp(filterRegex, "i"); + lines = lines.filter((line) => regex.test(line)); + } catch { + // Invalid regex, skip filtering + } + } + + // Apply sed-style replacement + if (replacePattern.trim() && replaceWith !== undefined) { + try { + const regex = new RegExp(replacePattern, "g"); + lines = lines.map((line) => line.replace(regex, replaceWith)); + } catch { + // Invalid regex, skip replacement + } + } + + return lines.join("\n"); + }, [receivedData, filterRegex, replacePattern, replaceWith]); + + return ( +
+ {/* Header */} +
+
+ + + Serial Console {consoleId.replace(/^console-/, "#")} + + {isConnected && ( +
+ )} +
+
+ + + {isSnapped && onSnapOut && ( + + )} + {onClose && ( + + )} +
+
+ + {/* Filters Panel */} + {showFilters && ( +
+
+ + setFilterRegex(e.target.value)} + placeholder="Regex pattern..." + className="flex-1 input-field text-xs" + /> +
+
+ + setReplacePattern(e.target.value)} + placeholder="Pattern..." + className="flex-1 input-field text-xs" + /> + + setReplaceWith(e.target.value)} + placeholder="Replacement..." + className="flex-1 input-field text-xs" + /> +
+
+ )} + + {/* Configuration Panel */} + {showConfig && ( +
+
+
+
+ +
+

+ Serial Port Configuration +

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

{error}

+
+ )} +
+
+
+ )} + + {/* Terminal Display */} + {!showConfig && isConnected && ( + <> +
+ {processedData} +
+ + {/* Input Area */} +
+ setInputText(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type message and press Enter..." + className="flex-1 input-field text-sm" + /> + + +
+ + )} +
+ ); +} diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 541186d..dbd9685 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -1,11 +1,12 @@ import { motion } from "framer-motion"; import { IconBluetooth, IconUsb, IconDeviceDesktop } from "@tabler/icons-react"; -import { useMemo } from "react"; +import { useMemo, useCallback } from "react"; import DyaLogo from "../assets/dya.svg?react"; import type { ConnectionMethod } from "./DeviceConnection"; interface SplashScreenProps { onConnect: (method: ConnectionMethod) => void; + onConnectWithFallback?: (method: ConnectionMethod) => Promise; isConnecting: boolean; error: string | null; } @@ -21,6 +22,7 @@ function DisabledSlash({ color }: { color: string }) { export function SplashScreen({ onConnect, + onConnectWithFallback, isConnecting, error, }: SplashScreenProps) { @@ -28,6 +30,14 @@ export function SplashScreen({ const isSerialAvailable = useMemo(() => "serial" in navigator, []); const isBLEAvailable = useMemo(() => "bluetooth" in navigator, []); + const handleSerialConnect = useCallback(async () => { + if (onConnectWithFallback) { + await onConnectWithFallback("serial"); + } else { + onConnect("serial"); + } + }, [onConnect, onConnectWithFallback]); + return ( +
+ + {/* Console Container */} +
+ {/* Snapped Consoles */} + {snappedConsoles.map((console) => ( +
+ removeConsole(console.id)} + isSnapped={true} + onSnapOut={() => handleSnapOut(console.id)} + existingPort={console.port} + /> +
+ ))} + + {/* Empty State */} + {consoles.length === 0 && ( +
+
+ +

+ No console connections +

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

+ Drag console windows to the edges to snap them into place. Multiple + consoles can share the same snap area. +

+
+
+
+ ); +} diff --git a/src/types/webserial.d.ts b/src/types/webserial.d.ts new file mode 100644 index 0000000..d641ef8 --- /dev/null +++ b/src/types/webserial.d.ts @@ -0,0 +1,44 @@ +// Web Serial API types +// Based on https://wicg.github.io/serial/ + +interface SerialPort extends EventTarget { + readonly readable: ReadableStream | null; + readonly writable: WritableStream | null; + + open(options: SerialOptions): Promise; + close(): Promise; + getInfo(): SerialPortInfo; + forget(): Promise; +} + +interface SerialOptions { + baudRate: number; + dataBits?: 7 | 8; + stopBits?: 1 | 2; + parity?: "none" | "even" | "odd"; + bufferSize?: number; + flowControl?: "none" | "hardware"; +} + +interface SerialPortInfo { + usbVendorId?: number; + usbProductId?: number; +} + +interface SerialPortFilter { + usbVendorId?: number; + usbProductId?: number; +} + +interface SerialPortRequestOptions { + filters?: SerialPortFilter[]; +} + +interface Serial extends EventTarget { + getPorts(): Promise; + requestPort(options?: SerialPortRequestOptions): Promise; +} + +interface Navigator { + readonly serial: Serial; +}