e.stopPropagation()}
+ >
{dialog.type === "newFolder" && (
<>
New Folder
-
setInputValue(e.target.value)} onKeyDown={(e) => e.key === "Enter" && confirmNewFolder()} placeholder="Folder name" className="w-full bg-[#0d0d1a] text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 mb-3" />
+
setInputValue(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && confirmNewFolder()}
+ placeholder="Folder name"
+ className="w-full bg-[#0d0d1a] text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 mb-3"
+ />
- setDialog(null)} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors">Cancel
- Create
+ setDialog(null)}
+ className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ Cancel
+
+
+ Create
+
>
)}
{dialog.type === "newFile" && (
<>
New File
-
setInputValue(e.target.value)} onKeyDown={(e) => e.key === "Enter" && confirmNewFile()} placeholder="File name" className="w-full bg-[#0d0d1a] text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 mb-3" />
+
setInputValue(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && confirmNewFile()}
+ placeholder="File name"
+ className="w-full bg-[#0d0d1a] text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 mb-3"
+ />
- setDialog(null)} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors">Cancel
- Create
+ setDialog(null)}
+ className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ Cancel
+
+
+ Create
+
>
)}
{dialog.type === "rename" && (
<>
Rename
-
setInputValue(e.target.value)} onKeyDown={(e) => e.key === "Enter" && confirmRename()} className="w-full bg-[#0d0d1a] text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 mb-3" />
+
setInputValue(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && confirmRename()}
+ className="w-full bg-[#0d0d1a] text-white border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 mb-3"
+ />
- setDialog(null)} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors">Cancel
- Rename
+ setDialog(null)}
+ className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ Cancel
+
+
+ Rename
+
>
)}
@@ -544,11 +735,25 @@ function FileSessionView({ session }: { session: FileSession }) {
Are you sure you want to delete {dialog.entry.name} ?
- {dialog.entry.isDirectory &&
This will recursively delete the folder and all its contents.
}
+ {dialog.entry.isDirectory && (
+
+ This will recursively delete the folder and all its contents.
+
+ )}
{!dialog.entry.isDirectory &&
}
- setDialog(null)} className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors">Cancel
- Delete
+ setDialog(null)}
+ className="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ Cancel
+
+
+ Delete
+
>
)}
@@ -573,7 +778,12 @@ function FileSessionView({ session }: { session: FileSession }) {
- setDialog(null)} className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors">Close
+ setDialog(null)}
+ className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
+ >
+ Close
+
>
)}
diff --git a/app/components/files/ImageViewer.tsx b/app/components/files/ImageViewer.tsx
index 5902ec1..0813b97 100644
--- a/app/components/files/ImageViewer.tsx
+++ b/app/components/files/ImageViewer.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback } from "react";
+import { useCallback, useRef, useState } from "react";
import CopyPathButton from "./CopyPathButton";
interface ImageViewerProps {
@@ -38,17 +38,57 @@ export default function ImageViewer({ path, onClose }: ImageViewerProps) {
lastDistance.current = 0;
}, []);
+ // Desktop drag-to-pan (hand tool): drag scrolls the overflow container.
+ // window listeners so releasing outside the pane still ends the drag.
+ const [grabbing, setGrabbing] = useState(false);
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
+ const container = containerRef.current;
+ if (!container || e.button !== 0) return;
+ e.preventDefault();
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const startLeft = container.scrollLeft;
+ const startTop = container.scrollTop;
+ setGrabbing(true);
+
+ const onMove = (ev: MouseEvent) => {
+ container.scrollLeft = startLeft - (ev.clientX - startX);
+ container.scrollTop = startTop - (ev.clientY - startY);
+ };
+ const onUp = () => {
+ setGrabbing(false);
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ }, []);
+
return (
-
{path}
+
+ {path}
+
{/* Zoom controls */}
- -
- {Math.round(zoom * 100)}%
- +
+
+ -
+
+
+ {Math.round(zoom * 100)}%
+
+
+ +
+
-
+
+
+
-
+
+
+
diff --git a/app/components/terminal/InputBox.tsx b/app/components/terminal/InputBox.tsx
index 4e3e3cc..a34a662 100644
--- a/app/components/terminal/InputBox.tsx
+++ b/app/components/terminal/InputBox.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback, useEffect } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTerminalStore } from "~/stores/terminalStore";
@@ -38,10 +38,6 @@ const TERMINAL_GROUPS: QuickKeyGroup[] = [
const NAV_KEYS: QuickKey[] = [
{ label: "Enter", key: "\r", title: "Enter / Confirm" },
{ label: "Bksp", key: "\x7f", title: "Backspace" },
- { label: "\u2191", key: "\x1b[A", title: "Up / Previous command" },
- { label: "\u2193", key: "\x1b[B", title: "Down / Next command" },
- { label: "\u2190", key: "\x1b[D", title: "Cursor left" },
- { label: "\u2192", key: "\x1b[C", title: "Cursor right" },
];
// Less common nav keys (shown after NAV_KEYS)
@@ -50,6 +46,16 @@ const NAV_TAIL: QuickKey[] = [
{ label: "Tab", key: "\t", title: "Autocomplete" },
{ label: "PgUp", key: "\x1b[5~", title: "Page up" },
{ label: "PgDn", key: "\x1b[6~", title: "Page down" },
+ { label: "y", key: "y", title: "Yes — approve / confirm (tmux kill-pane, Claude prompts, etc.)" },
+ { label: "n", key: "n", title: "No — deny / decline" },
+];
+
+// Arrow keys pinned to the end of the bar
+const NAV_ARROWS: QuickKey[] = [
+ { label: "↑", key: "\x1b[A", title: "Up / Previous command" },
+ { label: "↓", key: "\x1b[B", title: "Down / Next command" },
+ { label: "←", key: "\x1b[D", title: "Cursor left" },
+ { label: "→", key: "\x1b[C", title: "Cursor right" },
];
const NANO_KEYS: QuickKey[] = [
@@ -93,7 +99,7 @@ const TMUX_KEYS: QuickKey[] = [
{ label: "3", key: "\x023", title: "Window 3" },
{ label: "4", key: "\x024", title: "Window 4" },
{ label: "5", key: "\x025", title: "Window 5" },
- { label: "\" hsplit", key: "\x02\"", title: "Split horizontal" },
+ { label: '" hsplit', key: '\x02"', title: "Split horizontal" },
{ label: "% vsplit", key: "\x02%", title: "Split vertical" },
{ label: "o pane", key: "\x02o", title: "Next pane" },
{ label: "x kill", key: "\x02x", title: "Kill pane" },
@@ -107,13 +113,13 @@ const TMUX_KEYS: QuickKey[] = [
];
// Common key combos shared across all coding CLIs
+// (y/n live in NAV_TAIL — always-visible below the input — since they're also
+// useful for tmux confirms and other action contexts.)
const CODE_COMMON_KEYS: QuickKey[] = [
{ label: "Ctrl+C", key: "\x03", title: "Cancel / interrupt / quit" },
{ label: "Ctrl+L", key: "\x0c", title: "Clear screen (Claude/Codex) / View logs (OpenCode)" },
{ label: "Ctrl+G", key: "\x07", title: "Open external editor (Claude/Codex)" },
{ label: "Esc Esc", key: "\x1b\x1b", title: "Rewind history (Claude) / Edit prev message (Codex)" },
- { label: "y", key: "y", title: "Yes (approve)" },
- { label: "n", key: "n", title: "No (deny)" },
];
// Per-vendor CLI groups: launch commands, keys, and slash commands
@@ -137,7 +143,11 @@ const CLI_VENDORS: CliVendor[] = [
launch: [
{ label: "claude", title: "Interactive mode", command: "claude\n" },
{ label: "yolo", title: "Skip all permission checks", command: "claude --dangerously-skip-permissions\n" },
- { label: "plan", title: "Read-only plan mode", command: "claude --allowedTools Read,Glob,Grep,WebSearch,WebFetch\n" },
+ {
+ label: "plan",
+ title: "Read-only plan mode",
+ command: "claude --allowedTools Read,Glob,Grep,WebSearch,WebFetch\n",
+ },
],
slashCmds: [
{ label: "/clear", title: "Clear conversation context", command: "/clear\n" },
@@ -160,7 +170,11 @@ const CLI_VENDORS: CliVendor[] = [
],
launch: [
{ label: "suggest", title: "Proposes changes for approval", command: "codex --approval-mode suggest\n" },
- { label: "auto-edit", title: "Applies file changes, asks for commands", command: "codex --approval-mode auto-edit\n" },
+ {
+ label: "auto-edit",
+ title: "Applies file changes, asks for commands",
+ command: "codex --approval-mode auto-edit\n",
+ },
{ label: "full-auto", title: "Runs without confirmation", command: "codex --approval-mode full-auto\n" },
],
slashCmds: [
@@ -181,9 +195,7 @@ const CLI_VENDORS: CliVendor[] = [
{ label: "Ctrl+S", key: "\x13", title: "Send message" },
{ label: "Ctrl+A", key: "\x01", title: "Switch session" },
],
- launch: [
- { label: "opencode", title: "Interactive mode", command: "opencode\n" },
- ],
+ launch: [{ label: "opencode", title: "Interactive mode", command: "opencode\n" }],
slashCmds: [
{ label: "/help", title: "Show available commands", command: "/help\n" },
{ label: "/exit", title: "Exit OpenCode", command: "/exit\n" },
@@ -230,9 +242,7 @@ function getStickyKey(ch: string, mode: StickyMode): string {
switch (mode) {
case "ctrl":
// Ctrl+Letter = control code, Ctrl+Digit = send via CSI u
- return isLetter
- ? String.fromCharCode(ch.charCodeAt(0) - 64)
- : `\x1b[${ch.charCodeAt(0)};5u`;
+ return isLetter ? String.fromCharCode(ch.charCodeAt(0) - 64) : `\x1b[${ch.charCodeAt(0)};5u`;
case "ctrl+shift":
return `\x1b[${ch.charCodeAt(0)};6u`;
case "alt":
@@ -253,12 +263,18 @@ export default function InputBox() {
const [activeGroup, setActiveGroup] = useState
(null);
const [tmuxSessions, setTmuxSessions] = useState([]);
const [tmuxLoading, setTmuxLoading] = useState(false);
- const [toolVersions, setToolVersions] = useState<{ tmux: string | null; nano: string | null; vim: string | null; claude: string | null; codex: string | null; opencode: string | null }>({ tmux: null, nano: null, vim: null, claude: null, codex: null, opencode: null });
+ const [toolVersions, setToolVersions] = useState<{
+ tmux: string | null;
+ nano: string | null;
+ vim: string | null;
+ claude: string | null;
+ codex: string | null;
+ opencode: string | null;
+ }>({ tmux: null, nano: null, vim: null, claude: null, codex: null, opencode: null });
const toolVersionsFetched = useRef(false);
const [tmuxNewName, setTmuxNewName] = useState("");
const [editorFileName, setEditorFileName] = useState("");
const [cdDirs, setCdDirs] = useState([]);
- const [cdCwd, setCdCwd] = useState("");
const [cdLoading, setCdLoading] = useState(false);
const [stickyMode, setStickyMode] = useState("ctrl");
const [codeVendorIdx, setCodeVendorIdx] = useState(0);
@@ -273,6 +289,7 @@ export default function InputBox() {
const sendInput = useTerminalStore((s) => s.sendInput);
const setInTmux = useTerminalStore((s) => s.setInTmux);
const setInEditor = useTerminalStore((s) => s.setInEditor);
+ const setCdCwd = useTerminalStore((s) => s.setCdCwd);
const sessions = useTerminalStore((s) => s.sessions);
// Close vendor dropdown on outside click
@@ -290,6 +307,7 @@ export default function InputBox() {
const activeSession = activeSessionId ? sessions[activeSessionId] : null;
const inTmux = activeSession?.inTmux ?? false;
const inEditor = activeSession?.inEditor ?? null;
+ const cdCwd = activeSession?.cdCwd ?? "";
// --- Handlers ---
const handleSend = () => {
@@ -307,15 +325,18 @@ export default function InputBox() {
ta.style.height = Math.min(ta.scrollHeight, 120) + "px";
};
- const handleQuickKey = useCallback((key: string) => {
- if (!activeSessionId) return;
- if (key.length === 2 && key.charCodeAt(0) < 0x20 && key.charCodeAt(1) >= 0x20) {
- sendInput(activeSessionId, key[0]);
- setTimeout(() => sendInput(activeSessionId, key[1]), 50);
- } else {
- sendInput(activeSessionId, key);
- }
- }, [activeSessionId, sendInput]);
+ const handleQuickKey = useCallback(
+ (key: string) => {
+ if (!activeSessionId) return;
+ if (key.length === 2 && key.charCodeAt(0) < 0x20 && key.charCodeAt(1) >= 0x20) {
+ sendInput(activeSessionId, key[0]);
+ setTimeout(() => sendInput(activeSessionId, key[1]), 50);
+ } else {
+ sendInput(activeSessionId, key);
+ }
+ },
+ [activeSessionId, sendInput],
+ );
// Long-press repeat with scroll detection
const repeatRef = useRef<{
@@ -326,16 +347,19 @@ export default function InputBox() {
startY: number;
} | null>(null);
- const startRepeat = useCallback((key: string, x: number, y: number) => {
- const timeout = setTimeout(() => {
- if (!repeatRef.current) return;
- repeatRef.current.fired = true;
- handleQuickKey(key);
- const interval = setInterval(() => handleQuickKey(key), 80);
- if (repeatRef.current) repeatRef.current.interval = interval;
- }, 120);
- repeatRef.current = { timeout, interval: null as any, fired: false, startX: x, startY: y };
- }, [handleQuickKey]);
+ const startRepeat = useCallback(
+ (key: string, x: number, y: number) => {
+ const timeout = setTimeout(() => {
+ if (!repeatRef.current) return;
+ repeatRef.current.fired = true;
+ handleQuickKey(key);
+ const interval = setInterval(() => handleQuickKey(key), 80);
+ if (repeatRef.current) repeatRef.current.interval = interval;
+ }, 120);
+ repeatRef.current = { timeout, interval: null as any, fired: false, startX: x, startY: y };
+ },
+ [handleQuickKey],
+ );
const cancelRepeat = useCallback(() => {
if (repeatRef.current) {
@@ -345,20 +369,29 @@ export default function InputBox() {
}
}, []);
- const finishRepeat = useCallback((key: string) => {
- if (repeatRef.current && !repeatRef.current.fired) handleQuickKey(key);
- cancelRepeat();
- }, [handleQuickKey, cancelRepeat]);
-
- const handlePointerMove = useCallback((e: React.PointerEvent) => {
- if (!repeatRef.current) return;
- if (Math.abs(e.clientX - repeatRef.current.startX) > 10 || Math.abs(e.clientY - repeatRef.current.startY) > 10) {
+ const finishRepeat = useCallback(
+ (key: string) => {
+ if (repeatRef.current && !repeatRef.current.fired) handleQuickKey(key);
cancelRepeat();
- }
- }, [cancelRepeat]);
+ },
+ [handleQuickKey, cancelRepeat],
+ );
+
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ if (!repeatRef.current) return;
+ if (Math.abs(e.clientX - repeatRef.current.startX) > 10 || Math.abs(e.clientY - repeatRef.current.startY) > 10) {
+ cancelRepeat();
+ }
+ },
+ [cancelRepeat],
+ );
const repeatProps = (key: string) => ({
- onPointerDown: (e: React.PointerEvent) => { e.preventDefault(); startRepeat(key, e.clientX, e.clientY); },
+ onPointerDown: (e: React.PointerEvent) => {
+ e.preventDefault();
+ startRepeat(key, e.clientX, e.clientY);
+ },
onPointerMove: handlePointerMove,
onPointerUp: () => finishRepeat(key),
onPointerLeave: cancelRepeat,
@@ -382,13 +415,22 @@ export default function InputBox() {
fetchToolVersions();
}, []);
+ // When the active terminal tab changes while the cd picker is open, resync
+ // to that terminal's current cwd (the cd panel is per-terminal stateful).
+ useEffect(() => {
+ if (activeGroup !== CD_TAB) return;
+ setCdDirs([]);
+ if (activeSessionId) fetchDirs();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeSessionId]);
+
const toggleGroup = (id: string) => {
if (activeGroup === id) {
setActiveGroup(null);
} else {
setActiveGroup(id);
if (id === TMUX_TAB && !inTmux) fetchTmuxSessions();
- if (id === CD_TAB) fetchDirs(cdCwd || undefined);
+ if (id === CD_TAB) fetchDirs();
if ([TMUX_TAB, NANO_TAB, VIM_TAB, CODE_TAB].includes(id)) fetchToolVersions();
}
};
@@ -400,7 +442,9 @@ export default function InputBox() {
const res = await fetch("/api/tmux/sessions");
const data = await res.json();
setTmuxSessions(data.sessions || []);
- } catch { setTmuxSessions([]); }
+ } catch {
+ setTmuxSessions([]);
+ }
setTmuxLoading(false);
};
@@ -430,16 +474,17 @@ export default function InputBox() {
}, 50);
};
- // cd directory picker
+ // cd directory picker — always scoped to the currently-active terminal session
const fetchDirs = async (dir?: string) => {
+ const sessionId = activeSessionId;
+ if (!sessionId) return;
setCdLoading(true);
try {
- // If no dir specified, detect the terminal's actual CWD first
+ // If no dir specified, detect this terminal's actual CWD first
let targetDir = dir;
if (!targetDir) {
try {
- const cwdQuery = activeSessionId ? `?sessionId=${activeSessionId}&inTmux=${inTmux}` : "";
- const cwdRes = await fetch(`/api/terminal/cwd${cwdQuery}`);
+ const cwdRes = await fetch(`/api/terminal/cwd?sessionId=${sessionId}&inTmux=${inTmux}`);
const cwdData = await cwdRes.json();
if (cwdData.cwd) targetDir = cwdData.cwd;
} catch {}
@@ -448,14 +493,19 @@ export default function InputBox() {
const res = await fetch(`/api/files/list${query}`);
const data = await res.json();
if (!data.error) {
- setCdCwd(data.dir);
- setCdDirs(
- (data.entries || [])
- .filter((e: { isDirectory: boolean }) => e.isDirectory)
- .map((e: { name: string }) => e.name)
- );
+ // Only apply the result if the user hasn't switched to a different tab while we were fetching
+ if (useTerminalStore.getState().activeSessionId === sessionId) {
+ setCdCwd(sessionId, data.dir);
+ setCdDirs(
+ (data.entries || [])
+ .filter((e: { isDirectory: boolean }) => e.isDirectory)
+ .map((e: { name: string }) => e.name),
+ );
+ }
}
- } catch { setCdDirs([]); }
+ } catch {
+ setCdDirs([]);
+ }
setCdLoading(false);
};
@@ -509,7 +559,8 @@ export default function InputBox() {
};
// --- Styles ---
- const tabBase = "px-2.5 py-0.5 text-[11px] rounded border whitespace-nowrap transition-colors shrink-0 select-none touch-manipulation";
+ const tabBase =
+ "px-2.5 py-0.5 text-[11px] rounded border whitespace-nowrap transition-colors shrink-0 select-none touch-manipulation";
const tabDisabledAction = `${tabBase} bg-[#151520] text-gray-600 border-gray-700/50 cursor-not-allowed`;
const tabDisabledApp = `${tabBase} bg-[#151520] text-gray-600 border-gray-700/50 cursor-not-allowed`;
// Action tabs (cmds, cd, code, sticky) — blue
@@ -520,9 +571,12 @@ export default function InputBox() {
const appTabOn = `${tabBase} bg-[#1a3e2a] text-green-200 border-green-500`;
// Popup action buttons: neutral gray
- const keyBtn = "px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-gray-400 hover:text-white hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-gray-700 whitespace-nowrap transition-colors select-none touch-manipulation";
- const exitBtn = "px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-red-400 hover:text-red-300 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-red-800 whitespace-nowrap transition-colors select-none touch-manipulation";
- const saveExitBtn = "px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-green-400 hover:text-green-300 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-green-800 whitespace-nowrap transition-colors select-none touch-manipulation";
+ const keyBtn =
+ "px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-gray-400 hover:text-white hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-gray-700 whitespace-nowrap transition-colors select-none touch-manipulation";
+ const exitBtn =
+ "px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-red-400 hover:text-red-300 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-red-800 whitespace-nowrap transition-colors select-none touch-manipulation";
+ const saveExitBtn =
+ "px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-green-400 hover:text-green-300 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-green-800 whitespace-nowrap transition-colors select-none touch-manipulation";
// --- Determine which tabs to show ---
// Editor mode (nano/vim): only editor tab + sticky (+ tmux if in tmux)
@@ -536,23 +590,35 @@ export default function InputBox() {
onClick={() => !disabled && toggleGroup(id)}
disabled={disabled}
title={title}
- className={disabled
- ? (kind === "app" ? tabDisabledApp : tabDisabledAction)
- : activeGroup === id
- ? (kind === "app" ? appTabOn : actionTabOn)
- : (kind === "app" ? appTabOff : actionTabOff)}
+ className={
+ disabled
+ ? kind === "app"
+ ? tabDisabledApp
+ : tabDisabledAction
+ : activeGroup === id
+ ? kind === "app"
+ ? appTabOn
+ : actionTabOn
+ : kind === "app"
+ ? appTabOff
+ : actionTabOff
+ }
>
{label}
);
// Resolve which key group to show in the popup
- const activeStandardGroup = activeGroup && ![STICKY_TAB, TMUX_TAB, NANO_TAB, VIM_TAB, CODE_TAB, GIT_TAB, CD_TAB].includes(activeGroup)
- ? TERMINAL_GROUPS.find((g) => g.label === activeGroup)
- : null;
+ const activeStandardGroup =
+ activeGroup && ![STICKY_TAB, TMUX_TAB, NANO_TAB, VIM_TAB, CODE_TAB, GIT_TAB, CD_TAB].includes(activeGroup)
+ ? TERMINAL_GROUPS.find((g) => g.label === activeGroup)
+ : null;
return (
-
+
{/* Tab bar */}
@@ -563,9 +629,29 @@ export default function InputBox() {
{!isEditorMode && tabBtn(CODE_TAB, "code", "Coding CLI launchers & keys", !activeSessionId)}
{!isEditorMode && tabBtn(GIT_TAB, "git", "Git actions", !activeSessionId)}
{/* App tabs (green) — nano/vim hidden in tmux mode, shown with active indicator in editor mode */}
- {!inTmux && tabBtn(NANO_TAB, inEditor === "nano" ? "nano \u2318" : "nano", inEditor === "nano" ? "nano commands" : "Open file in nano", !activeSessionId && inEditor !== "nano", "app")}
- {!inTmux && tabBtn(VIM_TAB, inEditor === "vim" ? "vim \u2318" : "vim", inEditor === "vim" ? "vim commands" : "Open file in vim", !activeSessionId && inEditor !== "vim", "app")}
- {tabBtn(TMUX_TAB, inTmux ? "tmux \u2318" : "tmux", inTmux ? "tmux commands" : "tmux sessions", !activeSessionId && !inTmux, "app")}
+ {!inTmux &&
+ tabBtn(
+ NANO_TAB,
+ inEditor === "nano" ? "nano \u2318" : "nano",
+ inEditor === "nano" ? "nano commands" : "Open file in nano",
+ !activeSessionId && inEditor !== "nano",
+ "app",
+ )}
+ {!inTmux &&
+ tabBtn(
+ VIM_TAB,
+ inEditor === "vim" ? "vim \u2318" : "vim",
+ inEditor === "vim" ? "vim commands" : "Open file in vim",
+ !activeSessionId && inEditor !== "vim",
+ "app",
+ )}
+ {tabBtn(
+ TMUX_TAB,
+ inTmux ? "tmux \u2318" : "tmux",
+ inTmux ? "tmux commands" : "tmux sessions",
+ !activeSessionId && !inTmux,
+ "app",
+ )}
@@ -574,7 +660,13 @@ export default function InputBox() {
{activeStandardGroup.keys.map((qk) => (
-
+
{qk.label}
))}
@@ -587,7 +679,12 @@ export default function InputBox() {
cd
- {cdCwd}
+
+ {cdCwd}
+
{cdLoading ? (
Loading...
@@ -675,7 +772,13 @@ export default function InputBox() {
{NANO_KEYS.map((qk) => (
-
+
{qk.label}
))}
@@ -688,25 +791,27 @@ export default function InputBox() {
{activeGroup === NANO_TAB && inEditor !== "nano" && (
{!toolVersions.nano ? (
-
nano is not installed. Install it via your package manager.
+
+ nano is not installed. Install it via your package manager.
+
) : (
-
- nano{toolVersions.nano ? ` v${toolVersions.nano}` : ""}
- setEditorFileName(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleOpenEditor("nano")}
- placeholder="filename or path..."
- className="flex-1 bg-[#1a1a2e] text-white border border-gray-700 rounded px-2 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-blue-500"
- />
- handleOpenEditor("nano")}
- disabled={!editorFileName.trim() || !activeSessionId}
- className="px-2 py-1 text-[11px] bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded transition-colors"
- >
- Open
-
-
+
+ nano{toolVersions.nano ? ` v${toolVersions.nano}` : ""}
+ setEditorFileName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleOpenEditor("nano")}
+ placeholder="filename or path..."
+ className="flex-1 bg-[#1a1a2e] text-white border border-gray-700 rounded px-2 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-blue-500"
+ />
+ handleOpenEditor("nano")}
+ disabled={!editorFileName.trim() || !activeSessionId}
+ className="px-2 py-1 text-[11px] bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded transition-colors"
+ >
+ Open
+
+
)}
)}
@@ -716,11 +821,22 @@ export default function InputBox() {
{VIM_KEYS.map((qk) => (
-
+
{qk.label}
))}
-
+
save+exit
@@ -732,25 +848,27 @@ export default function InputBox() {
{activeGroup === VIM_TAB && inEditor !== "vim" && (
{!toolVersions.vim ? (
-
vim is not installed. Install it via your package manager.
+
+ vim is not installed. Install it via your package manager.
+
) : (
-
- vim v{toolVersions.vim}
- setEditorFileName(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleOpenEditor("vim")}
- placeholder="filename or path..."
- className="flex-1 bg-[#1a1a2e] text-white border border-gray-700 rounded px-2 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-blue-500"
- />
- handleOpenEditor("vim")}
- disabled={!editorFileName.trim() || !activeSessionId}
- className="px-2 py-1 text-[11px] bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded transition-colors"
- >
- Open
-
-
+
+ vim v{toolVersions.vim}
+ setEditorFileName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleOpenEditor("vim")}
+ placeholder="filename or path..."
+ className="flex-1 bg-[#1a1a2e] text-white border border-gray-700 rounded px-2 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-blue-500"
+ />
+ handleOpenEditor("vim")}
+ disabled={!editorFileName.trim() || !activeSessionId}
+ className="px-2 py-1 text-[11px] bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded transition-colors"
+ >
+ Open
+
+
)}
)}
@@ -760,7 +878,13 @@ export default function InputBox() {
{TMUX_KEYS.map((qk) => (
-
+
{qk.label}
))}
@@ -773,142 +897,177 @@ export default function InputBox() {
{activeGroup === TMUX_TAB && !inTmux && (
{!toolVersions.tmux ? (
-
tmux is not installed. Install it via your package manager.
- ) : (<>
-
v{toolVersions.tmux}
- {tmuxLoading ? (
-
Loading...
- ) : tmuxSessions.length === 0 ? (
-
No tmux sessions running
+
+ tmux is not installed. Install it via your package manager.
+
) : (
-
- {tmuxSessions.map((s) => (
-
-
handleTmuxAttach(s.name)}
- className="flex-1 text-left px-2 py-1 text-[11px] bg-[#1a1a2e] text-gray-300 hover:text-white hover:bg-[#2a2a4a] rounded border border-gray-700 transition-colors"
- >
- {s.name}
- {s.windows}w
- {s.attached && (attached) }
-
-
{
- if (!activeSessionId) return;
- sendInput(activeSessionId, `tmux kill-session -t ${s.name}\n`);
- setTimeout(fetchTmuxSessions, 500);
- }}
- className="p-1 text-gray-500 hover:text-red-400 transition-colors"
- title={`Kill session ${s.name}`}
- >
-
-
+ <>
+
v{toolVersions.tmux}
+ {tmuxLoading ? (
+
Loading...
+ ) : tmuxSessions.length === 0 ? (
+
No tmux sessions running
+ ) : (
+
+ {tmuxSessions.map((s) => (
+
+
handleTmuxAttach(s.name)}
+ className="flex-1 text-left px-2 py-1 text-[11px] bg-[#1a1a2e] text-gray-300 hover:text-white hover:bg-[#2a2a4a] rounded border border-gray-700 transition-colors"
+ >
+ {s.name}
+ {s.windows}w
+ {s.attached && (attached) }
+
+
{
+ if (!activeSessionId) return;
+ sendInput(activeSessionId, `tmux kill-session -t ${s.name}\n`);
+ setTimeout(fetchTmuxSessions, 500);
+ }}
+ className="p-1 text-gray-500 hover:text-red-400 transition-colors"
+ title={`Kill session ${s.name}`}
+ >
+
+
+
+
+
+ ))}
- ))}
-
+ )}
+
+ setTmuxNewName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleTmuxNew()}
+ placeholder="New session name..."
+ className="flex-1 bg-[#1a1a2e] text-white border border-gray-700 rounded px-2 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-blue-500"
+ />
+
+ Create
+
+
+ >
)}
-
- setTmuxNewName(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleTmuxNew()}
- placeholder="New session name..."
- className="flex-1 bg-[#1a1a2e] text-white border border-gray-700 rounded px-2 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-blue-500"
- />
-
- Create
-
-
- >)}
)}
{/* Code tab: common keys + selected vendor panel */}
- {activeGroup === CODE_TAB && (() => {
- const vendor = CLI_VENDORS[codeVendorIdx] || CLI_VENDORS[0];
- return (
-
- {/* Common keys (no header) */}
-
- {CODE_COMMON_KEYS.map((qk) => (
-
- {qk.label}
-
- ))}
-
- {/* Selected vendor: toggle selector left, buttons right */}
-
-
setCodeVendorOpen((v) => !v)}
- className="text-[10px] text-gray-400 hover:text-white font-medium flex items-center gap-0.5 pt-0.5 select-none shrink-0"
- >
-
-
-
- {vendor.name}
-
- {codeVendorOpen && createPortal(
-
{
- const r = codeVendorBtnRef.current?.getBoundingClientRect();
- return r ? { left: r.left, bottom: window.innerHeight - r.top + 4 } : {};
- })()) }}
- >
- {CLI_VENDORS.map((v, i) => (
+ {activeGroup === CODE_TAB &&
+ (() => {
+ const vendor = CLI_VENDORS[codeVendorIdx] || CLI_VENDORS[0];
+ return (
+
+ {/* Common keys (no header) */}
+
+ {CODE_COMMON_KEYS.map((qk) => (
{ setCodeVendorIdx(i); setCodeVendorOpen(false); }}
- className={`block w-full text-left px-3 py-1 text-[11px] hover:bg-[#2a2a4a] transition-colors ${i === codeVendorIdx ? "text-blue-400" : "text-gray-300"}`}
+ key={qk.label}
+ {...repeatProps(qk.key)}
+ disabled={!activeSessionId}
+ title={qk.title}
+ className={keyBtn}
>
- {v.name}
+ {qk.label}
))}
-
,
- document.body
- )}
-
- {/* Launch buttons (purple) */}
- {vendor.launch.map((cmd) => (
+
+ {/* Selected vendor: toggle selector left, buttons right */}
+
{ if (activeSessionId) sendInput(activeSessionId, cmd.command); }}
- disabled={!activeSessionId}
- title={cmd.title}
- className="px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-purple-400 hover:text-purple-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-purple-800/60 whitespace-nowrap transition-colors select-none"
+ ref={codeVendorBtnRef}
+ onClick={() => setCodeVendorOpen((v) => !v)}
+ className="text-[10px] text-gray-400 hover:text-white font-medium flex items-center gap-0.5 pt-0.5 select-none shrink-0"
>
- {cmd.label}
-
- ))}
- {/* Vendor-specific keys */}
- {vendor.keys.map((qk) => (
-
- {qk.label}
-
- ))}
- {/* Slash commands (cyan) */}
- {vendor.slashCmds.map((cmd) => (
-
{ if (activeSessionId) sendInput(activeSessionId, cmd.command); }}
- disabled={!activeSessionId}
- title={cmd.title}
- className="px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-cyan-400 hover:text-cyan-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-cyan-800/60 whitespace-nowrap transition-colors select-none"
- >
- {cmd.label}
+
+
+
+ {vendor.name}
- ))}
+ {codeVendorOpen &&
+ createPortal(
+
{
+ const r = codeVendorBtnRef.current?.getBoundingClientRect();
+ return r ? { left: r.left, bottom: window.innerHeight - r.top + 4 } : {};
+ })(),
+ }}
+ >
+ {CLI_VENDORS.map((v, i) => (
+ {
+ setCodeVendorIdx(i);
+ setCodeVendorOpen(false);
+ }}
+ className={`block w-full text-left px-3 py-1 text-[11px] hover:bg-[#2a2a4a] transition-colors ${i === codeVendorIdx ? "text-blue-400" : "text-gray-300"}`}
+ >
+ {v.name}
+
+ ))}
+
,
+ document.body,
+ )}
+
+ {/* Launch buttons (purple) */}
+ {vendor.launch.map((cmd) => (
+ {
+ if (activeSessionId) sendInput(activeSessionId, cmd.command);
+ }}
+ disabled={!activeSessionId}
+ title={cmd.title}
+ className="px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-purple-400 hover:text-purple-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-purple-800/60 whitespace-nowrap transition-colors select-none"
+ >
+ {cmd.label}
+
+ ))}
+ {/* Vendor-specific keys */}
+ {vendor.keys.map((qk) => (
+
+ {qk.label}
+
+ ))}
+ {/* Slash commands (cyan) */}
+ {vendor.slashCmds.map((cmd) => (
+ {
+ if (activeSessionId) sendInput(activeSessionId, cmd.command);
+ }}
+ disabled={!activeSessionId}
+ title={cmd.title}
+ className="px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-cyan-400 hover:text-cyan-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-cyan-800/60 whitespace-nowrap transition-colors select-none"
+ >
+ {cmd.label}
+
+ ))}
+
+
-
-
- );
- })()}
+ );
+ })()}
{/* Git tab: quick actions + commit + config */}
{activeGroup === GIT_TAB && (
@@ -918,7 +1077,9 @@ export default function InputBox() {
{GIT_QUICK_CMDS.map((cmd) => (
{ if (activeSessionId) sendInput(activeSessionId, cmd.command); }}
+ onClick={() => {
+ if (activeSessionId) sendInput(activeSessionId, cmd.command);
+ }}
disabled={!activeSessionId}
title={cmd.title}
className={keyBtn}
@@ -975,8 +1136,10 @@ export default function InputBox() {
{
if (!activeSessionId) return;
- if (gitConfigName.trim()) sendInput(activeSessionId, `git config --global user.name '${gitConfigName.trim()}'\n`);
- if (gitConfigEmail.trim()) sendInput(activeSessionId, `git config --global user.email '${gitConfigEmail.trim()}'\n`);
+ if (gitConfigName.trim())
+ sendInput(activeSessionId, `git config --global user.name '${gitConfigName.trim()}'\n`);
+ if (gitConfigEmail.trim())
+ sendInput(activeSessionId, `git config --global user.email '${gitConfigEmail.trim()}'\n`);
}}
disabled={!activeSessionId || (!gitConfigName.trim() && !gitConfigEmail.trim())}
className="px-2 py-0.5 text-[11px] bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded transition-colors shrink-0"
@@ -1010,12 +1173,35 @@ export default function InputBox() {
{/* Always-visible nav keys */}
{NAV_KEYS.map((qk) => (
-
+
{qk.label}
))}
{NAV_TAIL.map((qk) => (
-
+
+ {qk.label}
+
+ ))}
+ {NAV_ARROWS.map((qk) => (
+
{qk.label}
))}
diff --git a/app/components/terminal/TerminalPage.tsx b/app/components/terminal/TerminalPage.tsx
index b22aff5..a4f2b82 100644
--- a/app/components/terminal/TerminalPage.tsx
+++ b/app/components/terminal/TerminalPage.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { useTerminalStore } from "~/stores/terminalStore";
-import TerminalTabs from "./TerminalTabs";
import TerminalPanel from "./TerminalPanel";
+import TerminalTabs from "./TerminalTabs";
export default function TerminalPage() {
const sessions = useTerminalStore((s) => s.sessions);
diff --git a/app/components/terminal/TerminalPanel.tsx b/app/components/terminal/TerminalPanel.tsx
index 6f0af44..22fd60b 100644
--- a/app/components/terminal/TerminalPanel.tsx
+++ b/app/components/terminal/TerminalPanel.tsx
@@ -1,7 +1,7 @@
-import { useEffect, useRef } from "react";
-import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
+import { Terminal } from "@xterm/xterm";
+import { useEffect, useRef } from "react";
import { useTerminalStore } from "~/stores/terminalStore";
import "@xterm/xterm/css/xterm.css";
diff --git a/app/components/terminal/TerminalTabs.tsx b/app/components/terminal/TerminalTabs.tsx
index c2852ed..ad16122 100644
--- a/app/components/terminal/TerminalTabs.tsx
+++ b/app/components/terminal/TerminalTabs.tsx
@@ -1,6 +1,6 @@
-import { useTerminalStore } from "~/stores/terminalStore";
-import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "~/lib/constants";
import RenamableTab from "~/components/RenamableTab";
+import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "~/lib/constants";
+import { useTerminalStore } from "~/stores/terminalStore";
export default function TerminalTabs() {
const sessions = useTerminalStore((s) => s.sessions);
diff --git a/app/lib/clipboard.ts b/app/lib/clipboard.ts
new file mode 100644
index 0000000..73ebb7a
--- /dev/null
+++ b/app/lib/clipboard.ts
@@ -0,0 +1,28 @@
+// Copy text to the clipboard, with a fallback for insecure contexts / older
+// mobile browsers where the async Clipboard API is unavailable. Must be called
+// within a user gesture (e.g. a click handler).
+export function copyText(text: string): Promise {
+ if (navigator.clipboard?.writeText) {
+ return navigator.clipboard.writeText(text).catch(() => {
+ fallbackCopy(text);
+ });
+ }
+ fallbackCopy(text);
+ return Promise.resolve();
+}
+
+function fallbackCopy(text: string): void {
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.position = "fixed";
+ ta.style.opacity = "0";
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ try {
+ document.execCommand("copy");
+ } catch {
+ /* nothing more we can do */
+ }
+ ta.remove();
+}
diff --git a/app/lib/markdown.test.ts b/app/lib/markdown.test.ts
new file mode 100644
index 0000000..23b25c2
--- /dev/null
+++ b/app/lib/markdown.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { rewriteLocalAssets } from "./markdown";
+
+const FILE = "/home/user/repo/README.md";
+
+describe("rewriteLocalAssets", () => {
+ it("rewrites a relative image src to the inline download URL", () => {
+ const out = rewriteLocalAssets(' ', FILE);
+ // The serialized HTML escapes & as & in the attribute; the browser
+ // decodes it back to & when loading, so the URL still works.
+ expect(out).toContain('src="/api/files/download?path=%2Fhome%2Fuser%2Frepo%2Fpublic%2Flogo.png&inline=1"');
+ });
+
+ it("resolves parent-relative refs against the file directory", () => {
+ const out = rewriteLocalAssets(' ', "/home/user/repo/docs/page.md");
+ expect(out).toContain("path=%2Fhome%2Fuser%2Frepo%2Fassets%2Fx.gif");
+ });
+
+ it("leaves external URLs untouched", () => {
+ const src = ' ';
+ expect(rewriteLocalAssets(src, FILE)).toContain('src="https://img.shields.io/badge/x.svg"');
+ });
+
+ it("rewrites video and source tags", () => {
+ const out = rewriteLocalAssets(' ', FILE);
+ expect(out).toContain("path=%2Fhome%2Fuser%2Frepo%2Fmedia%2Fdemo.mp4");
+ });
+
+ it("rewrites local anchors and opens them in a new tab", () => {
+ const out = rewriteLocalAssets('gif ', FILE);
+ expect(out).toContain("path=%2Fhome%2Fuser%2Frepo%2Fpublic%2Fdemo.gif");
+ expect(out).toContain('target="_blank"');
+ });
+
+ it("leaves in-page anchors untouched", () => {
+ expect(rewriteLocalAssets('Usage ', FILE)).toContain('href="#usage"');
+ });
+});
diff --git a/app/lib/markdown.ts b/app/lib/markdown.ts
new file mode 100644
index 0000000..36b588d
--- /dev/null
+++ b/app/lib/markdown.ts
@@ -0,0 +1,35 @@
+import { dirname, isExternalUrl, resolvePath, toInlineDownloadUrl } from "./paths";
+
+// Rewrite local media/links in rendered markdown HTML so they load through the
+// file download API. Relative references (e.g. `public/logo.png` in a README)
+// are resolved against the markdown file's directory; absolute on-disk paths are
+// served as-is. External URLs (http(s)/data/blob), protocol-relative URLs, and
+// in-page anchors are left untouched.
+//
+// Without this, a relative ` ` would resolve against
+// the app's route URL and 404 (the broken-media icon), since local files are
+// only served via /api/files/download.
+export function rewriteLocalAssets(html: string, filePath: string): string {
+ if (typeof DOMParser === "undefined") return html; // SSR / no DOM available
+ const dir = dirname(filePath);
+ const doc = new DOMParser().parseFromString(html, "text/html");
+
+ for (const el of Array.from(doc.querySelectorAll("img, video, audio, source"))) {
+ const src = el.getAttribute("src");
+ if (src && !isExternalUrl(src)) {
+ el.setAttribute("src", toInlineDownloadUrl(resolvePath(dir, src)));
+ }
+ }
+
+ // Anchors wrapping local media (common in READMEs) — open via the inline URL.
+ for (const a of Array.from(doc.querySelectorAll("a[href]"))) {
+ const href = a.getAttribute("href");
+ if (href && !isExternalUrl(href)) {
+ a.setAttribute("href", toInlineDownloadUrl(resolvePath(dir, href)));
+ a.setAttribute("target", "_blank");
+ a.setAttribute("rel", "noreferrer");
+ }
+ }
+
+ return doc.body.innerHTML;
+}
diff --git a/app/lib/paths.test.ts b/app/lib/paths.test.ts
new file mode 100644
index 0000000..0cb6b8e
--- /dev/null
+++ b/app/lib/paths.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import { dirname, isExternalUrl, normalizePath, resolvePath, toInlineDownloadUrl } from "./paths";
+
+describe("dirname", () => {
+ it("returns the directory of a file path", () => {
+ expect(dirname("/home/user/repo/README.md")).toBe("/home/user/repo");
+ });
+ it("handles root-level files", () => {
+ expect(dirname("/README.md")).toBe("/");
+ });
+ it("returns empty for a bare name", () => {
+ expect(dirname("README.md")).toBe("");
+ });
+});
+
+describe("normalizePath", () => {
+ it("collapses . and .. segments", () => {
+ expect(normalizePath("/a/b/../c/./d")).toBe("/a/c/d");
+ });
+ it("collapses duplicate slashes", () => {
+ expect(normalizePath("/a//b///c")).toBe("/a/b/c");
+ });
+ it("does not climb above root", () => {
+ expect(normalizePath("/a/../../b")).toBe("/b");
+ });
+ it("keeps leading .. for relative paths", () => {
+ expect(normalizePath("../a/b")).toBe("../a/b");
+ });
+});
+
+describe("resolvePath", () => {
+ it("resolves a relative ref against the base dir", () => {
+ expect(resolvePath("/home/user/repo", "public/logo.png")).toBe("/home/user/repo/public/logo.png");
+ });
+ it("resolves parent refs", () => {
+ expect(resolvePath("/home/user/repo/docs", "../public/logo.png")).toBe("/home/user/repo/public/logo.png");
+ });
+ it("treats an absolute ref as-is (normalized)", () => {
+ expect(resolvePath("/home/user/repo", "/etc/hosts")).toBe("/etc/hosts");
+ });
+ it("handles ./ prefixes", () => {
+ expect(resolvePath("/repo", "./a/b.png")).toBe("/repo/a/b.png");
+ });
+});
+
+describe("isExternalUrl", () => {
+ it.each([
+ "https://example.com/x.png",
+ "http://example.com",
+ "data:image/png;base64,AAAA",
+ "blob:abc",
+ "mailto:a@b.com",
+ "//cdn.example.com/x.png",
+ "#section",
+ ])("treats %s as external", (url) => {
+ expect(isExternalUrl(url)).toBe(true);
+ });
+
+ it.each(["public/logo.png", "./a.png", "../b/c.png", "/abs/on/disk.png"])("treats %s as local", (url) => {
+ expect(isExternalUrl(url)).toBe(false);
+ });
+});
+
+describe("toInlineDownloadUrl", () => {
+ it("builds an inline download URL with the path encoded", () => {
+ expect(toInlineDownloadUrl("/home/u/a b.png")).toBe("/api/files/download?path=%2Fhome%2Fu%2Fa%20b.png&inline=1");
+ });
+});
diff --git a/app/lib/paths.ts b/app/lib/paths.ts
new file mode 100644
index 0000000..6f9625e
--- /dev/null
+++ b/app/lib/paths.ts
@@ -0,0 +1,48 @@
+// POSIX-style path helpers for resolving references inside files (e.g. relative
+// image/link paths in a markdown preview). Pure and dependency-free so they can
+// be unit-tested in isolation.
+
+/** Directory portion of an absolute file path. */
+export function dirname(path: string): string {
+ const i = path.lastIndexOf("/");
+ if (i < 0) return "";
+ if (i === 0) return "/";
+ return path.slice(0, i);
+}
+
+/** Normalize a path, collapsing "." and ".." segments and duplicate slashes. */
+export function normalizePath(path: string): string {
+ const isAbs = path.startsWith("/");
+ const out: string[] = [];
+ for (const seg of path.split("/")) {
+ if (seg === "" || seg === ".") continue;
+ if (seg === "..") {
+ if (out.length && out[out.length - 1] !== "..") out.pop();
+ else if (!isAbs) out.push("..");
+ } else {
+ out.push(seg);
+ }
+ }
+ return (isAbs ? "/" : "") + out.join("/");
+}
+
+/** Resolve a (possibly relative) reference against a base directory. */
+export function resolvePath(fromDir: string, ref: string): string {
+ if (ref.startsWith("/")) return normalizePath(ref);
+ const base = fromDir.endsWith("/") ? fromDir : `${fromDir}/`;
+ return normalizePath(base + ref);
+}
+
+/**
+ * True for references the browser can already load on its own — absolute URLs
+ * (http:, https:, data:, blob:, mailto:, …), protocol-relative ("//host"), and
+ * in-page anchors ("#section"). These should not be rewritten to a local path.
+ */
+export function isExternalUrl(src: string): boolean {
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(src);
+}
+
+/** URL that serves a local file inline (so images/video render in-page). */
+export function toInlineDownloadUrl(absPath: string): string {
+ return `/api/files/download?path=${encodeURIComponent(absPath)}&inline=1`;
+}
diff --git a/app/root.tsx b/app/root.tsx
index 6cbcf46..670bd1f 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -1,12 +1,5 @@
-import {
- isRouteErrorResponse,
- Links,
- Meta,
- Scripts,
- ScrollRestoration,
-} from "react-router";
-
import { lazy, Suspense } from "react";
+import { isRouteErrorResponse, Links, Meta, Scripts, ScrollRestoration } from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import ClientOnly from "./components/ClientOnly";
@@ -74,10 +67,7 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
- details =
- error.status === 404
- ? "The requested page could not be found."
- : error.statusText || details;
+ details = error.status === 404 ? "The requested page could not be found." : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
diff --git a/app/routes.ts b/app/routes.ts
index bcc6955..de6170d 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -1,4 +1,4 @@
-import { type RouteConfig, index, route } from "@react-router/dev/routes";
+import { index, type RouteConfig, route } from "@react-router/dev/routes";
export default [
index("routes/_index.tsx"),
diff --git a/app/routes/api/files.download.ts b/app/routes/api/files.download.ts
index d187528..70300a1 100644
--- a/app/routes/api/files.download.ts
+++ b/app/routes/api/files.download.ts
@@ -1,6 +1,6 @@
import { readFile, stat } from "fs/promises";
-import { basename } from "path";
import { lookup } from "mime-types";
+import { basename } from "path";
import type { Route } from "./+types/files.download";
export async function loader({ request }: Route.LoaderArgs) {
diff --git a/app/routes/api/files.read.ts b/app/routes/api/files.read.ts
index 3dbc770..319e6f4 100644
--- a/app/routes/api/files.read.ts
+++ b/app/routes/api/files.read.ts
@@ -29,10 +29,7 @@ export async function loader({ request }: Route.LoaderArgs) {
return Response.json({ path: filePath, content, mimeType, size: stats.size });
}
- return Response.json(
- { error: "File too large for text preview", size: stats.size, mimeType },
- { status: 413 }
- );
+ return Response.json({ error: "File too large for text preview", size: stats.size, mimeType }, { status: 413 });
} catch (err: any) {
return Response.json({ error: err.message }, { status: 400 });
}
diff --git a/app/routes/api/system-info.ts b/app/routes/api/system-info.ts
index 94c367b..977a068 100644
--- a/app/routes/api/system-info.ts
+++ b/app/routes/api/system-info.ts
@@ -1,5 +1,5 @@
import { execSync } from "child_process";
-import { platform, release, arch, hostname, totalmem, freemem, cpus, uptime } from "os";
+import { arch, cpus, freemem, hostname, platform, release, totalmem, uptime } from "os";
import type { Route } from "./+types/system-info";
function run(cmd: string): string {
@@ -37,12 +37,18 @@ export async function loader({ request }: Route.LoaderArgs) {
shell: run("echo $SHELL") || run("echo %COMSPEC%") || null,
node: process.version,
tmux: run("tmux -V")?.replace(/^tmux\s+/i, "") || null,
- nano: os === "darwin"
- ? (run("which nano") ? "pico" : null)
- : (run("nano --version")?.match(/nano\s+([\d.]+)/i)?.[1] || (run("which nano") ? "installed" : null)),
+ nano:
+ os === "darwin"
+ ? run("which nano")
+ ? "pico"
+ : null
+ : run("nano --version")?.match(/nano\s+([\d.]+)/i)?.[1] || (run("which nano") ? "installed" : null),
vim: run("vim --version").match(/Vi IMproved\s+([\d.]+)/)?.[1] || null,
git: run("git --version")?.replace(/^git version\s+/i, "") || null,
- python: run("python3 --version")?.replace(/^Python\s+/i, "") || run("python --version")?.replace(/^Python\s+/i, "") || null,
+ python:
+ run("python3 --version")?.replace(/^Python\s+/i, "") ||
+ run("python --version")?.replace(/^Python\s+/i, "") ||
+ null,
};
// Linux-specific: try to get distro name
diff --git a/app/routes/api/terminal.cwd.ts b/app/routes/api/terminal.cwd.ts
index b0b0ca7..ea00682 100644
--- a/app/routes/api/terminal.cwd.ts
+++ b/app/routes/api/terminal.cwd.ts
@@ -1,52 +1,81 @@
-import { execSync } from "child_process";
-import { readlinkSync } from "fs";
-import type { Route } from "./+types/terminal.cwd";
+import { execFileSync, execSync } from "child_process";
+import { readFileSync, readlinkSync } from "fs";
import { getPtyPid } from "../../../server/pty-manager";
+import type { Route } from "./+types/terminal.cwd";
+
+function getDirectChildren(pid: number): number[] {
+ try {
+ return execSync(`pgrep -P ${pid}`, { encoding: "utf-8", timeout: 2000 })
+ .trim()
+ .split("\n")
+ .map((s) => parseInt(s.trim(), 10))
+ .filter((n) => Number.isFinite(n));
+ } catch {
+ return [];
+ }
+}
+
+function readComm(pid: number): string {
+ try {
+ return readFileSync(`/proc/${pid}/comm`, "utf-8").trim();
+ } catch {
+ return "";
+ }
+}
+
+function readTty(pid: number): string | null {
+ try {
+ return readlinkSync(`/proc/${pid}/fd/0`);
+ } catch {
+ return null;
+ }
+}
+
+function findDeepestDescendant(pid: number, depth = 0): number {
+ if (depth > 16) return pid;
+ const children = getDirectChildren(pid);
+ if (children.length === 0) return pid;
+ return findDeepestDescendant(children[children.length - 1], depth + 1);
+}
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const sessionId = url.searchParams.get("sessionId");
- const inTmux = url.searchParams.get("inTmux") === "true";
-
- // If in tmux, ask tmux for the active pane's current path
- if (inTmux) {
- try {
- const cwd = execSync("tmux display-message -p '#{pane_current_path}'", {
- encoding: "utf-8",
- timeout: 2000,
- }).trim();
- if (cwd) return Response.json({ cwd });
- } catch {
- // Fall through to PTY-based detection
- }
- }
- // Otherwise read CWD from the PTY process via /proc
if (sessionId) {
- const pid = getPtyPid(sessionId);
- if (pid) {
- try {
- // The PTY's child shell — read its CWD
- const children = execSync(`pgrep -P ${pid}`, { encoding: "utf-8", timeout: 2000 }).trim().split("\n");
- // Get the deepest child (the actual shell, not tmux client)
- const targetPid = children[children.length - 1]?.trim();
- if (targetPid) {
- const cwd = readlinkSync(`/proc/${targetPid}/cwd`);
+ const ptyPid = getPtyPid(sessionId);
+ if (ptyPid) {
+ // If a tmux client is a direct child of this PTY, ask tmux about THAT
+ // client's active pane. Querying tmux without -c returns whichever pane
+ // is globally focused on the server — wrong when multiple terminals
+ // each have their own tmux client.
+ for (const childPid of getDirectChildren(ptyPid)) {
+ const name = readComm(childPid);
+ if (name !== "tmux" && !name.startsWith("tmux:")) continue;
+ const tty = readTty(childPid);
+ if (!tty || !tty.startsWith("/dev/")) continue;
+ try {
+ const cwd = execFileSync("tmux", ["display-message", "-p", "-c", tty, "#{pane_current_path}"], {
+ encoding: "utf-8",
+ timeout: 2000,
+ }).trim();
if (cwd) return Response.json({ cwd });
+ } catch {
+ // Fall through to PTY-based detection
}
- } catch {
- // Fall through
}
- // Try the PTY process itself
+
+ // No tmux client: walk to the deepest descendant of the PTY and read its cwd.
try {
- const cwd = readlinkSync(`/proc/${pid}/cwd`);
+ const cwd = readlinkSync(`/proc/${findDeepestDescendant(ptyPid)}/cwd`);
if (cwd) return Response.json({ cwd });
- } catch {
- // Fall through
- }
+ } catch {}
+ try {
+ const cwd = readlinkSync(`/proc/${ptyPid}/cwd`);
+ if (cwd) return Response.json({ cwd });
+ } catch {}
}
}
- // Fallback
return Response.json({ cwd: process.env.DEFAULT_CWD || process.env.HOME || "/" });
}
diff --git a/app/stores/browserStore.ts b/app/stores/browserStore.ts
index 21156dd..1d72cca 100644
--- a/app/stores/browserStore.ts
+++ b/app/stores/browserStore.ts
@@ -2,7 +2,7 @@ import { create } from "zustand";
export interface BrowserTab {
id: string;
- port: string; // "" = pending input
+ port: string; // "" = pending input
refreshKey: number;
error: string | null;
}
@@ -38,9 +38,7 @@ export const useBrowserStore = create((set, get) => ({
let newActive = activeTabId;
if (activeTabId === id) {
const idx = tabs.findIndex((t) => t.id === id);
- newActive = newTabs.length > 0
- ? newTabs[Math.min(idx, newTabs.length - 1)].id
- : null;
+ newActive = newTabs.length > 0 ? newTabs[Math.min(idx, newTabs.length - 1)].id : null;
}
set({ tabs: newTabs, activeTabId: newActive });
},
@@ -49,19 +47,19 @@ export const useBrowserStore = create((set, get) => ({
setTabPort: (id, port) => {
set((s) => ({
- tabs: s.tabs.map((t) => t.id === id ? { ...t, port, error: null, refreshKey: 0 } : t),
+ tabs: s.tabs.map((t) => (t.id === id ? { ...t, port, error: null, refreshKey: 0 } : t)),
}));
},
setTabError: (id, error) => {
set((s) => ({
- tabs: s.tabs.map((t) => t.id === id ? { ...t, error } : t),
+ tabs: s.tabs.map((t) => (t.id === id ? { ...t, error } : t)),
}));
},
refreshTab: (id) => {
set((s) => ({
- tabs: s.tabs.map((t) => t.id === id ? { ...t, refreshKey: t.refreshKey + 1 } : t),
+ tabs: s.tabs.map((t) => (t.id === id ? { ...t, refreshKey: t.refreshKey + 1 } : t)),
}));
},
}));
diff --git a/app/stores/fileStore.ts b/app/stores/fileStore.ts
index 0d2dc47..1f7fb68 100644
--- a/app/stores/fileStore.ts
+++ b/app/stores/fileStore.ts
@@ -69,11 +69,7 @@ export const useFileStore = create((set, get) => ({
set({
sessions: newSessions,
activeSessionId:
- activeSessionId === id
- ? remaining.length > 0
- ? remaining[remaining.length - 1]
- : null
- : activeSessionId,
+ activeSessionId === id ? (remaining.length > 0 ? remaining[remaining.length - 1] : null) : activeSessionId,
});
},
diff --git a/app/stores/terminalStore.ts b/app/stores/terminalStore.ts
index 2ca412f..9c0e65f 100644
--- a/app/stores/terminalStore.ts
+++ b/app/stores/terminalStore.ts
@@ -1,8 +1,8 @@
-import { create } from "zustand";
-import type { Terminal } from "@xterm/xterm";
import type { FitAddon } from "@xterm/addon-fit";
-import { getSocket } from "~/lib/socket";
+import type { Terminal } from "@xterm/xterm";
+import { create } from "zustand";
import { DEFAULT_FONT_SIZE } from "~/lib/constants";
+import { getSocket } from "~/lib/socket";
interface TerminalSession {
id: string;
@@ -14,6 +14,7 @@ interface TerminalSession {
outputBuffer: string[];
inTmux: boolean;
inEditor: "nano" | "vim" | null;
+ cdCwd: string;
}
interface TerminalState {
@@ -35,6 +36,7 @@ interface TerminalState {
setDefaultCwd: (cwd: string) => void;
setInTmux: (sessionId: string, inTmux: boolean) => void;
setInEditor: (sessionId: string, editor: "nano" | "vim" | null) => void;
+ setCdCwd: (sessionId: string, cwd: string) => void;
}
let socketInitialized = false;
@@ -77,7 +79,7 @@ export const useTerminalStore = create((set, get) => ({
set({ socketConnected: false });
// Mark all sessions as disconnected
const { sessions } = get();
- const updated: Record = {};
+ const updated: Record = {};
for (const [id, session] of Object.entries(sessions)) {
updated[id] = { ...session, status: "disconnected" };
}
@@ -99,9 +101,7 @@ export const useTerminalStore = create((set, get) => ({
[data.sessionId]: {
...session,
status: "connected",
- outputBuffer: session.terminal
- ? session.outputBuffer
- : [...session.outputBuffer, connectedMsg],
+ outputBuffer: session.terminal ? session.outputBuffer : [...session.outputBuffer, connectedMsg],
},
},
});
@@ -143,9 +143,7 @@ export const useTerminalStore = create((set, get) => ({
...session,
status: "error",
error: data.error,
- outputBuffer: session.terminal
- ? session.outputBuffer
- : [...session.outputBuffer, errorMsg],
+ outputBuffer: session.terminal ? session.outputBuffer : [...session.outputBuffer, errorMsg],
},
},
});
@@ -186,6 +184,7 @@ export const useTerminalStore = create((set, get) => ({
outputBuffer: [],
inTmux: false,
inEditor: null,
+ cdCwd: "",
},
},
activeSessionId: sessionId,
@@ -304,4 +303,10 @@ export const useTerminalStore = create((set, get) => ({
if (!session) return;
set({ sessions: { ...sessions, [sessionId]: { ...session, inEditor: editor } } });
},
+ setCdCwd: (sessionId, cwd) => {
+ const { sessions } = get();
+ const session = sessions[sessionId];
+ if (!session) return;
+ set({ sessions: { ...sessions, [sessionId]: { ...session, cdCwd: cwd } } });
+ },
}));
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..d11a710
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,51 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": true,
+ "includes": ["app/**/*.ts", "app/**/*.tsx", "server/**/*.ts"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 120
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "a11y": {
+ "noSvgWithoutTitle": "off",
+ "useButtonType": "off",
+ "noStaticElementInteractions": "off",
+ "useKeyWithClickEvents": "off",
+ "noAutofocus": "off",
+ "useMediaCaption": "off"
+ },
+ "suspicious": {
+ "noExplicitAny": "warn",
+ "noArrayIndexKey": "warn"
+ },
+ "security": {
+ "noDangerouslySetInnerHtml": "warn"
+ },
+ "correctness": {
+ "useExhaustiveDependencies": "warn",
+ "noUnusedFunctionParameters": "warn"
+ },
+ "style": {
+ "noNonNullAssertion": "warn"
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ }
+}
diff --git a/package.json b/package.json
index d41ef1b..4c80f20 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "otgcode",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"type": "module",
"author": "David Liu (davindicode)",
@@ -9,12 +9,19 @@
"type": "git",
"url": "https://github.com/davindicode/otgcode.git"
},
+ "engines": {
+ "node": ">=20.19",
+ "pnpm": ">=10"
+ },
"scripts": {
"build": "react-router build",
"dev": "tsx server/dev.ts",
"start": "tsx server/index.ts",
"start:tunnel": "tsx server/index.ts --tunnel",
- "typecheck": "react-router typegen && tsc"
+ "typecheck": "react-router typegen && tsc",
+ "lint": "biome ci .",
+ "format": "biome check --write .",
+ "test": "vitest run"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -40,18 +47,25 @@
"zustand": "^5.0.12"
},
"devDependencies": {
+ "@biomejs/biome": "2.5.0",
"@react-router/dev": "7.12.0",
"@tailwindcss/vite": "^4.1.13",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/busboy": "^1.5.4",
"@types/express": "^5.0.6",
"@types/mime-types": "^3.0.1",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^4.1.8",
+ "jsdom": "^29.1.1",
"tailwindcss": "^4.1.13",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"vite": "^7.1.7",
- "vite-tsconfig-paths": "^5.1.4"
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^4.1.8"
}
}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a1e3b63..f302f41 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,12 +72,24 @@ importers:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
devDependencies:
+ '@biomejs/biome':
+ specifier: 2.5.0
+ version: 2.5.0
'@react-router/dev':
specifier: 7.12.0
version: 7.12.0(@react-router/serve@7.12.0(react-router@7.12.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(react-router@7.12.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
'@tailwindcss/vite':
specifier: ^4.1.13
version: 4.2.1(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/busboy':
specifier: ^1.5.4
version: 1.5.4
@@ -96,6 +108,12 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
+ '@vitest/coverage-v8':
+ specifier: ^4.1.8
+ version: 4.1.8(vitest@4.1.8)
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
tailwindcss:
specifier: ^4.1.13
version: 4.2.1
@@ -111,9 +129,30 @@ importers:
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+ vitest:
+ specifier: ^4.1.8
+ version: 4.1.8(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
packages:
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -184,10 +223,18 @@ packages:
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
@@ -201,6 +248,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/plugin-syntax-jsx@7.28.6':
resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==}
engines: {node: '>=6.9.0'}
@@ -231,6 +283,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -243,6 +299,111 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
+ '@biomejs/biome@2.5.0':
+ resolution: {integrity: sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==}
+ engines: {node: '>=14.21.3'}
+ hasBin: true
+
+ '@biomejs/cli-darwin-arm64@2.5.0':
+ resolution: {integrity: sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==}
+ engines: {node: '>=14.21.3'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@biomejs/cli-darwin-x64@2.5.0':
+ resolution: {integrity: sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==}
+ engines: {node: '>=14.21.3'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@biomejs/cli-linux-arm64-musl@2.5.0':
+ resolution: {integrity: sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==}
+ engines: {node: '>=14.21.3'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@biomejs/cli-linux-arm64@2.5.0':
+ resolution: {integrity: sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==}
+ engines: {node: '>=14.21.3'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@biomejs/cli-linux-x64-musl@2.5.0':
+ resolution: {integrity: sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==}
+ engines: {node: '>=14.21.3'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@biomejs/cli-linux-x64@2.5.0':
+ resolution: {integrity: sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==}
+ engines: {node: '>=14.21.3'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@biomejs/cli-win32-arm64@2.5.0':
+ resolution: {integrity: sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==}
+ engines: {node: '>=14.21.3'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@biomejs/cli-win32-x64@2.5.0':
+ resolution: {integrity: sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==}
+ engines: {node: '>=14.21.3'}
+ cpu: [x64]
+ os: [win32]
+
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.2.1':
+ resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.1.3':
+ resolution: {integrity: sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.5':
+ resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
@@ -399,6 +560,15 @@ packages:
cpu: [x64]
os: [win32]
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -720,6 +890,9 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@tailwindcss/node@4.2.1':
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
@@ -814,18 +987,56 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/busboy@1.5.4':
resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -873,6 +1084,44 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+ '@vitest/coverage-v8@4.1.8':
+ resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
+ peerDependencies:
+ '@vitest/browser': 4.1.8
+ vitest: 4.1.8
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
+ '@vitest/expect@4.1.8':
+ resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
+
+ '@vitest/mocker@4.1.8':
+ resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.8':
+ resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
+
+ '@vitest/runner@4.1.8':
+ resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
+
+ '@vitest/snapshot@4.1.8':
+ resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
+
+ '@vitest/spy@4.1.8':
+ resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
+
+ '@vitest/utils@4.1.8':
+ resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
+
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
@@ -890,12 +1139,34 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ ast-v8-to-istanbul@1.0.4:
+ resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==}
+
babel-dead-code-elimination@1.0.12:
resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
@@ -912,6 +1183,9 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
body-parser@1.20.4:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -955,6 +1229,10 @@ packages:
caniuse-lite@1.0.30001780:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -1008,9 +1286,20 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -1028,6 +1317,9 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -1052,6 +1344,12 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
@@ -1088,6 +1386,10 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -1099,6 +1401,9 @@ packages:
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-module-lexer@2.1.0:
+ resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
+
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -1115,6 +1420,9 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -1126,6 +1434,10 @@ packages:
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
engines: {node: '>=6'}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
express@4.22.1:
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'}
@@ -1216,6 +1528,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -1224,6 +1540,13 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -1244,6 +1567,10 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -1267,6 +1594,9 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -1274,13 +1604,37 @@ packages:
resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
engines: {node: '>=18'}
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ jsdom@29.1.1:
+ resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsesc@3.0.2:
resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==}
engines: {node: '>=6'}
@@ -1372,15 +1726,30 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lru-cache@11.5.1:
+ resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
+ engines: {node: 20 || >=22}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magicast@0.5.3:
+ resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
+
make-cancellable-promise@2.0.0:
resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==}
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
make-event-props@2.0.0:
resolution: {integrity: sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==}
@@ -1398,6 +1767,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -1450,6 +1822,10 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
@@ -1497,6 +1873,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
+ obug@2.1.3:
+ resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
+ engines: {node: '>=12.20.0'}
+
on-finished@2.3.0:
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
engines: {node: '>= 0.8'}
@@ -1516,6 +1896,9 @@ packages:
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
engines: {node: '>=18'}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -1559,10 +1942,18 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
qs@6.14.2:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
@@ -1584,6 +1975,9 @@ packages:
peerDependencies:
react: ^19.2.4
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-pdf@10.4.1:
resolution: {integrity: sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==}
peerDependencies:
@@ -1616,6 +2010,14 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -1640,6 +2042,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1690,6 +2096,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
socket.io-adapter@2.5.6:
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
@@ -1716,6 +2125,9 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
@@ -1723,10 +2135,24 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tailwindcss@4.2.1:
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
@@ -1737,10 +2163,28 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@7.4.2:
+ resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==}
+
+ tldts@7.4.2:
+ resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==}
+ hasBin: true
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -1749,6 +2193,14 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ tough-cookie@6.0.1:
+ resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
@@ -1780,6 +2232,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici@7.27.2:
+ resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
+ engines: {node: '>=20.18.1'}
+
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -1859,9 +2315,71 @@ packages:
yaml:
optional: true
+ vitest@4.1.8:
+ resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.8
+ '@vitest/browser-preview': 4.1.8
+ '@vitest/browser-webdriverio': 4.1.8
+ '@vitest/coverage-istanbul': 4.1.8
+ '@vitest/coverage-v8': 4.1.8
+ '@vitest/ui': 4.1.8
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -1877,6 +2395,13 @@ packages:
utf-8-validate:
optional: true
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
xmlhttprequest-ssl@2.1.2:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
@@ -1904,6 +2429,28 @@ packages:
snapshots:
+ '@adobe/css-tools@4.5.0': {}
+
+ '@asamuzakjp/css-color@5.1.11':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -2014,8 +2561,12 @@ snapshots:
'@babel/helper-string-parser@7.27.1': {}
+ '@babel/helper-string-parser@7.29.7': {}
+
'@babel/helper-validator-identifier@7.28.5': {}
+ '@babel/helper-validator-identifier@7.29.7': {}
+
'@babel/helper-validator-option@7.27.1': {}
'@babel/helpers@7.29.2':
@@ -2027,6 +2578,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -2067,6 +2622,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -2090,6 +2647,76 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@bcoe/v8-coverage@1.0.2': {}
+
+ '@biomejs/biome@2.5.0':
+ optionalDependencies:
+ '@biomejs/cli-darwin-arm64': 2.5.0
+ '@biomejs/cli-darwin-x64': 2.5.0
+ '@biomejs/cli-linux-arm64': 2.5.0
+ '@biomejs/cli-linux-arm64-musl': 2.5.0
+ '@biomejs/cli-linux-x64': 2.5.0
+ '@biomejs/cli-linux-x64-musl': 2.5.0
+ '@biomejs/cli-win32-arm64': 2.5.0
+ '@biomejs/cli-win32-x64': 2.5.0
+
+ '@biomejs/cli-darwin-arm64@2.5.0':
+ optional: true
+
+ '@biomejs/cli-darwin-x64@2.5.0':
+ optional: true
+
+ '@biomejs/cli-linux-arm64-musl@2.5.0':
+ optional: true
+
+ '@biomejs/cli-linux-arm64@2.5.0':
+ optional: true
+
+ '@biomejs/cli-linux-x64-musl@2.5.0':
+ optional: true
+
+ '@biomejs/cli-linux-x64@2.5.0':
+ optional: true
+
+ '@biomejs/cli-win32-arm64@2.5.0':
+ optional: true
+
+ '@biomejs/cli-win32-x64@2.5.0':
+ optional: true
+
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@esbuild/aix-ppc64@0.27.4':
optional: true
@@ -2168,6 +2795,8 @@ snapshots:
'@esbuild/win32-x64@0.27.4':
optional: true
+ '@exodus/bytes@1.15.1': {}
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2424,6 +3053,8 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@tailwindcss/node@4.2.1':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -2492,6 +3123,42 @@ snapshots:
tailwindcss: 4.2.1
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
+ '@types/aria-query@5.0.4': {}
+
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -2501,6 +3168,11 @@ snapshots:
dependencies:
'@types/node': 22.19.15
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.19.15
@@ -2509,6 +3181,8 @@ snapshots:
dependencies:
'@types/node': 22.19.15
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1':
@@ -2564,6 +3238,61 @@ snapshots:
dependencies:
'@types/node': 22.19.15
+ '@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.8
+ ast-v8-to-istanbul: 1.0.4
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.3
+ obug: 2.1.3
+ std-env: 4.1.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.8(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+
+ '@vitest/expect@4.1.8':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.8(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))':
+ dependencies:
+ '@vitest/spy': 4.1.8
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+
+ '@vitest/pretty-format@4.1.8':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.8':
+ dependencies:
+ '@vitest/utils': 4.1.8
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.8':
+ dependencies:
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/utils': 4.1.8
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.8': {}
+
+ '@vitest/utils@4.1.8':
+ dependencies:
+ '@vitest/pretty-format': 4.1.8
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
'@xterm/addon-fit@0.11.0': {}
'@xterm/addon-web-links@0.12.0': {}
@@ -2580,11 +3309,29 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@5.2.0: {}
+
arg@5.0.2: {}
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
+ aria-query@5.3.2: {}
+
array-flatten@1.1.1:
optional: true
+ assertion-error@2.0.1: {}
+
+ ast-v8-to-istanbul@1.0.4:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.0
@@ -2603,6 +3350,10 @@ snapshots:
safe-buffer: 5.1.2
optional: true
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
body-parser@1.20.4:
dependencies:
bytes: 3.1.2
@@ -2670,6 +3421,8 @@ snapshots:
caniuse-lite@1.0.30001780: {}
+ chai@6.2.2: {}
+
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -2721,8 +3474,22 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
csstype@3.2.3: {}
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
debug@2.6.9:
dependencies:
ms: 2.0.0
@@ -2732,6 +3499,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js@10.6.0: {}
+
dedent@1.7.2: {}
depd@2.0.0: {}
@@ -2743,6 +3512,10 @@ snapshots:
detect-libc@2.1.2: {}
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -2797,12 +3570,16 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ entities@8.0.0: {}
+
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
+ es-module-lexer@2.1.0: {}
+
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -2840,12 +3617,18 @@ snapshots:
escape-html@1.0.3: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
etag@1.8.1: {}
eventemitter3@4.0.7: {}
exit-hook@2.2.1: {}
+ expect-type@1.3.0: {}
+
express@4.22.1:
dependencies:
accepts: 1.3.8
@@ -2999,12 +3782,22 @@ snapshots:
graceful-fs@4.2.11: {}
+ has-flag@4.0.0: {}
+
has-symbols@1.1.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ html-escaper@2.0.2: {}
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -3041,6 +3834,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
+ indent-string@4.0.0: {}
+
inherits@2.0.4: {}
ipaddr.js@1.9.1: {}
@@ -3055,14 +3850,57 @@ snapshots:
is-plain-object@5.0.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-promise@4.0.0: {}
isbot@5.1.36: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
jiti@2.6.1: {}
+ js-tokens@10.0.0: {}
+
js-tokens@4.0.0: {}
+ jsdom@29.1.1:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.1
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.1
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.1
+ undici: 7.27.2
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
jsesc@3.0.2: {}
json5@2.2.3: {}
@@ -3122,16 +3960,30 @@ snapshots:
dependencies:
js-tokens: 4.0.0
+ lru-cache@11.5.1: {}
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ magicast@0.5.3:
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.0
+ source-map-js: 1.2.1
+
make-cancellable-promise@2.0.0: {}
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.7.4
+
make-event-props@2.0.0: {}
marked@14.0.0: {}
@@ -3140,6 +3992,8 @@ snapshots:
math-intrinsics@1.1.0: {}
+ mdn-data@2.27.1: {}
+
media-typer@0.3.0:
optional: true
@@ -3177,6 +4031,8 @@ snapshots:
mime@1.6.0:
optional: true
+ min-indent@1.0.1: {}
+
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
@@ -3219,6 +4075,8 @@ snapshots:
object-inspect@1.13.4: {}
+ obug@2.1.3: {}
+
on-finished@2.3.0:
dependencies:
ee-first: 1.1.1
@@ -3237,6 +4095,10 @@ snapshots:
p-map@7.0.4: {}
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
parseurl@1.3.3: {}
path-to-regexp@0.1.12:
@@ -3272,11 +4134,19 @@ snapshots:
prettier@3.8.1: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ punycode@2.3.1: {}
+
qs@6.14.2:
dependencies:
side-channel: 1.1.0
@@ -3303,6 +4173,8 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
+ react-is@17.0.2: {}
+
react-pdf@10.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
clsx: 2.1.1
@@ -3332,6 +4204,13 @@ snapshots:
readdirp@4.1.2: {}
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
+ require-from-string@2.0.2: {}
+
requires-port@1.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -3385,6 +4264,10 @@ snapshots:
safer-buffer@2.1.2: {}
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -3477,6 +4360,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siginfo@2.0.0: {}
+
socket.io-adapter@2.5.6:
dependencies:
debug: 4.4.3
@@ -3529,29 +4414,63 @@ snapshots:
source-map@0.6.1:
optional: true
+ stackback@0.0.2: {}
+
state-local@1.0.7: {}
statuses@2.0.2: {}
+ std-env@4.1.0: {}
+
streamsearch@1.1.0: {}
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ symbol-tree@3.2.4: {}
+
tailwindcss@4.2.1: {}
tapable@2.3.0: {}
tiny-invariant@1.3.3: {}
+ tinybench@2.9.0: {}
+
+ tinyexec@1.2.4: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
+ tinyrainbow@3.1.0: {}
+
+ tldts-core@7.4.2: {}
+
+ tldts@7.4.2:
+ dependencies:
+ tldts-core: 7.4.2
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
toidentifier@1.0.1: {}
+ tough-cookie@6.0.1:
+ dependencies:
+ tldts: 7.4.2
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
tsconfck@3.1.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -3579,6 +4498,8 @@ snapshots:
undici-types@6.21.0: {}
+ undici@7.27.2: {}
+
unpipe@1.0.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
@@ -3643,14 +4564,68 @@ snapshots:
lightningcss: 1.31.1
tsx: 4.21.0
+ vitest@4.1.8(@types/node@22.19.15)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)):
+ dependencies:
+ '@vitest/expect': 4.1.8
+ '@vitest/mocker': 4.1.8(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/runner': 4.1.8
+ '@vitest/snapshot': 4.1.8
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
+ es-module-lexer: 2.1.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.3
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 4.1.0
+ tinybench: 2.9.0
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.19.15
+ '@vitest/coverage-v8': 4.1.8(vitest@4.1.8)
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - msw
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
wrappy@1.0.2: {}
ws@8.18.3: {}
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
xmlhttprequest-ssl@2.1.2: {}
yallist@3.1.1: {}
diff --git a/server/dev.ts b/server/dev.ts
index 41012c2..1c5b71c 100644
--- a/server/dev.ts
+++ b/server/dev.ts
@@ -1,13 +1,13 @@
import "dotenv/config";
+import Busboy from "busboy";
import express from "express";
+import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, unlinkSync } from "fs";
import { createServer } from "http";
-import { createWriteStream, createReadStream, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
-import { join } from "path";
import { tmpdir } from "os";
-import Busboy from "busboy";
+import { join } from "path";
import { Server as SocketIOServer } from "socket.io";
-import { registerSocketHandlers } from "./socket-handlers.js";
import { mountProxy } from "./proxy.js";
+import { registerSocketHandlers } from "./socket-handlers.js";
const PORT = parseInt(process.env.OTG_PORT || "7777", 10);
@@ -123,7 +123,10 @@ async function main() {
app.post("/api/files/upload-finalize", express.json(), (req, res) => {
const { uploadId, dir, fileName, totalChunks } = req.body as {
- uploadId: string; dir: string; fileName: string; totalChunks: number;
+ uploadId: string;
+ dir: string;
+ fileName: string;
+ totalChunks: number;
};
if (!uploadId || !dir || !fileName || !totalChunks) {
@@ -157,7 +160,10 @@ async function main() {
if (!res.headersSent) res.status(500).json({ error: `Chunk ${i} missing: ${err.message}` });
});
rs.pipe(ws, { end: false });
- rs.on("end", () => { i++; writeNext(); });
+ rs.on("end", () => {
+ i++;
+ writeNext();
+ });
};
ws.on("error", (err: Error) => {
@@ -175,11 +181,7 @@ async function main() {
// Reject known noise paths before they hit React Router
app.use((req, res, next) => {
- if (
- req.url.startsWith("/apple-touch-icon") ||
- req.url.startsWith("/.well-known/") ||
- req.url === "/favicon.ico"
- ) {
+ if (req.url.startsWith("/apple-touch-icon") || req.url.startsWith("/.well-known/") || req.url === "/favicon.ico") {
res.status(404).end();
return;
}
diff --git a/server/index.ts b/server/index.ts
index c300cfd..48b987f 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -1,13 +1,13 @@
import "dotenv/config";
+import Busboy from "busboy";
import express from "express";
+import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, unlinkSync } from "fs";
import { createServer } from "http";
-import { createWriteStream, createReadStream, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
-import { join } from "path";
import { tmpdir } from "os";
-import Busboy from "busboy";
+import { join } from "path";
import { Server as SocketIOServer } from "socket.io";
-import { registerSocketHandlers } from "./socket-handlers.js";
import { mountProxy } from "./proxy.js";
+import { registerSocketHandlers } from "./socket-handlers.js";
import { startTunnel } from "./tunnel.js";
const PORT = parseInt(process.env.OTG_PORT || "7777", 10);
@@ -129,7 +129,10 @@ async function main() {
// Chunked upload: finalize — assemble chunks into destination file
app.post("/api/files/upload-finalize", express.json(), (req, res) => {
const { uploadId, dir, fileName, totalChunks } = req.body as {
- uploadId: string; dir: string; fileName: string; totalChunks: number;
+ uploadId: string;
+ dir: string;
+ fileName: string;
+ totalChunks: number;
};
if (!uploadId || !dir || !fileName || !totalChunks) {
@@ -164,7 +167,10 @@ async function main() {
if (!res.headersSent) res.status(500).json({ error: `Chunk ${i} missing: ${err.message}` });
});
rs.pipe(ws, { end: false });
- rs.on("end", () => { i++; writeNext(); });
+ rs.on("end", () => {
+ i++;
+ writeNext();
+ });
};
ws.on("error", (err: Error) => {
@@ -190,17 +196,13 @@ async function main() {
express.static(new URL("../build/client/assets", import.meta.url).pathname, {
immutable: true,
maxAge: "1y",
- })
+ }),
);
app.use(express.static(new URL("../build/client", import.meta.url).pathname, { maxAge: "1h" }));
// Reject known browser probe paths before they hit React Router
app.use((req, res, next) => {
- if (
- req.url.startsWith("/apple-touch-icon") ||
- req.url.startsWith("/.well-known/") ||
- req.url === "/favicon.ico"
- ) {
+ if (req.url.startsWith("/apple-touch-icon") || req.url.startsWith("/.well-known/") || req.url === "/favicon.ico") {
res.status(404).end();
return;
}
diff --git a/server/proxy.ts b/server/proxy.ts
index b9308b5..73bb25a 100644
--- a/server/proxy.ts
+++ b/server/proxy.ts
@@ -1,6 +1,6 @@
import type { Express } from "express";
-import { createProxyMiddleware, type RequestHandler } from "http-proxy-middleware";
import type { Server } from "http";
+import { createProxyMiddleware, type RequestHandler } from "http-proxy-middleware";
const BLOCKED_PORTS = new Set();
for (let p = 1; p <= 1023; p++) {
@@ -47,46 +47,31 @@ function getProxy(port: number): RequestHandler {
const prefix = `/proxy/${port}`;
// Rewrite absolute paths in src, href, action attributes (HTML)
- body = body.replace(
- /((?:src|href|action)\s*=\s*["'])\/((?!proxy\/)[^"']*["'])/gi,
- `$1${prefix}/$2`
- );
+ body = body.replace(/((?:src|href|action)\s*=\s*["'])\/((?!proxy\/)[^"']*["'])/gi, `$1${prefix}/$2`);
// Rewrite CSS url() with absolute paths
- body = body.replace(
- /url\(\s*['"]?\/((?!proxy\/)[^'")]+)['"]?\s*\)/gi,
- `url('${prefix}/$1')`
- );
+ body = body.replace(/url\(\s*['"]?\/((?!proxy\/)[^'")]+)['"]?\s*\)/gi, `url('${prefix}/$1')`);
// Rewrite ES module imports: import "x", import x from "x", import * as x from "x"
body = body.replace(
/(\bimport\s+(?:[\w*{}\s,]+\s+from\s+)?["'])\/((?!proxy\/)[^"']+["'])/g,
- `$1${prefix}/$2`
+ `$1${prefix}/$2`,
);
// Rewrite dynamic import("/path")
- body = body.replace(
- /(\bimport\s*\(\s*["'])\/((?!proxy\/)[^"']+["']\s*\))/g,
- `$1${prefix}/$2`
- );
+ body = body.replace(/(\bimport\s*\(\s*["'])\/((?!proxy\/)[^"']+["']\s*\))/g, `$1${prefix}/$2`);
// Rewrite export ... from "/path"
body = body.replace(
/(\bexport\s+(?:[\w*{}\s,]+\s+)?from\s+["'])\/((?!proxy\/)[^"']+["'])/g,
- `$1${prefix}/$2`
+ `$1${prefix}/$2`,
);
// Rewrite manifest/config paths
- body = body.replace(
- /"manifestPath"\s*:\s*"\/((?!proxy\/)[^"]+)"/g,
- `"manifestPath":"${prefix}/$1"`
- );
+ body = body.replace(/"manifestPath"\s*:\s*"\/((?!proxy\/)[^"]+)"/g, `"manifestPath":"${prefix}/$1"`);
// Rewrite new URL("/path", ...) patterns
- body = body.replace(
- /(new\s+URL\s*\(\s*["'])\/((?!proxy\/)[^"']+["'])/g,
- `$1${prefix}/$2`
- );
+ body = body.replace(/(new\s+URL\s*\(\s*["'])\/((?!proxy\/)[^"']+["'])/g, `$1${prefix}/$2`);
(res as any).end(body);
});
diff --git a/server/pty-manager.ts b/server/pty-manager.ts
index c845821..672cc65 100644
--- a/server/pty-manager.ts
+++ b/server/pty-manager.ts
@@ -1,5 +1,5 @@
-import * as pty from "node-pty";
import { existsSync } from "fs";
+import * as pty from "node-pty";
function getDefaultShell(): string {
if (process.platform === "win32") {
@@ -33,7 +33,7 @@ export function createPty(
rows?: number;
onData: (data: string) => void;
onExit: (exitCode: number) => void;
- }
+ },
): void {
if (sessions.has(sessionId)) {
killPty(sessionId);
diff --git a/server/socket-handlers.ts b/server/socket-handlers.ts
index 6ea8306..0f0673e 100644
--- a/server/socket-handlers.ts
+++ b/server/socket-handlers.ts
@@ -1,5 +1,5 @@
import type { Server, Socket } from "socket.io";
-import { createPty, writePty, resizePty, killPty } from "./pty-manager.js";
+import { createPty, killPty, resizePty, writePty } from "./pty-manager.js";
export function registerSocketHandlers(io: Server): void {
io.on("connection", (socket: Socket) => {
diff --git a/server/tunnel.ts b/server/tunnel.ts
index 7b76c1d..17fb173 100644
--- a/server/tunnel.ts
+++ b/server/tunnel.ts
@@ -1,27 +1,22 @@
-import { spawn, type ChildProcess } from "child_process";
-import { existsSync, mkdtempSync } from "fs";
-import { join } from "path";
+import { type ChildProcess, spawn } from "child_process";
+import { existsSync, writeFileSync } from "fs";
import { tmpdir } from "os";
+import { join } from "path";
// Main OTG Code tunnel
let mainTunnelProcess: ChildProcess | null = null;
let mainTunnelUrl: string | null = null;
-// Check if ~/.cloudflared/config.yml exists (named tunnel config that would override quick tunnels)
-function hasNamedTunnelConfig(): boolean {
- const home = process.env.HOME || process.env.USERPROFILE || "";
- if (!home) return false;
- return existsSync(join(home, ".cloudflared", "config.yml")) ||
- existsSync(join(home, ".cloudflared", "config.yaml"));
-}
-
-// Build spawn options — use temp HOME to bypass named tunnel config if present
-function tunnelSpawnOptions(): { env?: Record } {
- if (hasNamedTunnelConfig()) {
- const cfHome = mkdtempSync(join(tmpdir(), "cloudflared-"));
- return { env: { ...process.env, HOME: cfHome } as Record };
+// Cloudflared loads ~/.cloudflared/config.yml AND /etc/cloudflared/config.yml (plus
+// /usr/local/etc/cloudflared/...) by default. If any of those configs define an
+// ingress catch-all (e.g. `http_status: 404`), it will hijack our quick-tunnel
+// traffic. Passing an explicit --config to a stub bypasses all default search paths.
+function stubConfigPath(): string {
+ const stub = join(tmpdir(), `otgcode-cloudflared-stub-${process.pid}.yml`);
+ if (!existsSync(stub)) {
+ writeFileSync(stub, "no-autoupdate: true\n");
}
- return {};
+ return stub;
}
function findCloudflared(): string {
@@ -36,52 +31,122 @@ function findCloudflared(): string {
return "cloudflared";
}
-// Start the main OTG Code tunnel
-export async function startTunnel(port: number): Promise {
+// How long to wait for a quick-tunnel URL before giving up on one attempt.
+const TUNNEL_STARTUP_TIMEOUT_MS = 30_000;
+// How many times to retry when cloudflared exits or times out before emitting a URL.
+const TUNNEL_MAX_ATTEMPTS = 3;
+
+// Spawn cloudflared once and resolve with the quick-tunnel URL, or null if it
+// exits / errors / times out before emitting one.
+function spawnTunnelOnce(port: number): Promise {
return new Promise((resolve) => {
const cfBin = findCloudflared();
+ let proc: ChildProcess;
try {
- const args = ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://localhost:${port}`];
- mainTunnelProcess = spawn(cfBin, args, {
- ...tunnelSpawnOptions(),
+ const args = [
+ "tunnel",
+ "--config",
+ stubConfigPath(),
+ "--no-autoupdate",
+ "--protocol",
+ "http2",
+ "--url",
+ `http://localhost:${port}`,
+ ];
+ proc = spawn(cfBin, args, {
stdio: ["ignore", "pipe", "pipe"],
});
- } catch {
- console.log(" cloudflared not found, skipping tunnel");
+ } catch (err) {
+ console.log(` cloudflared failed to spawn: ${(err as Error).message}`);
resolve(null);
return;
}
+ mainTunnelProcess = proc;
+
let resolved = false;
+ const settle = (url: string | null) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeout);
+ resolve(url);
+ };
+
const timeout = setTimeout(() => {
- if (!resolved) { resolved = true; resolve(null); }
- }, 30_000);
+ if (!resolved) {
+ console.log(" cloudflared timed out before producing a tunnel URL");
+ settle(null);
+ }
+ }, TUNNEL_STARTUP_TIMEOUT_MS);
const handleData = (data: Buffer) => {
const text = data.toString();
+ // Surface cloudflared's own progress/errors so pre-URL failures aren't
+ // silent. Once we have a URL, stop — the rest is benign runtime noise
+ // (ICMP/ping_group_range warnings, etc.).
+ if (!resolved) {
+ for (const line of text.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ if (/Requesting new quick Tunnel|ERR |error=|failed/i.test(trimmed)) {
+ console.log(` [cloudflared] ${trimmed}`);
+ }
+ }
+ }
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (match && !resolved) {
- resolved = true;
- clearTimeout(timeout);
mainTunnelUrl = match[0];
console.log(`\n Tunnel URL: ${match[0]}\n`);
- resolve(match[0]);
+ settle(match[0]);
}
};
- mainTunnelProcess.stdout?.on("data", handleData);
- mainTunnelProcess.stderr?.on("data", handleData);
- mainTunnelProcess.on("error", () => {
- if (!resolved) { resolved = true; clearTimeout(timeout); resolve(null); }
+ proc.stdout?.on("data", handleData);
+ proc.stderr?.on("data", handleData);
+ proc.on("error", (err) => {
+ if (!resolved) {
+ console.log(` cloudflared error: ${err.message}`);
+ settle(null);
+ }
});
- mainTunnelProcess.on("exit", () => {
- mainTunnelProcess = null;
- mainTunnelUrl = null;
+ proc.on("exit", (code, signal) => {
+ // If we already have a URL this is a later teardown; otherwise it died early.
+ if (!resolved) {
+ console.log(` cloudflared exited early (code=${code} signal=${signal}) before producing a URL`);
+ settle(null);
+ }
+ if (mainTunnelProcess === proc) {
+ mainTunnelProcess = null;
+ mainTunnelUrl = null;
+ }
});
});
}
+// Start the main OTG Code tunnel, retrying on early exit / timeout since
+// trycloudflare quick-tunnel registration is intermittently flaky.
+export async function startTunnel(port: number): Promise {
+ for (let attempt = 1; attempt <= TUNNEL_MAX_ATTEMPTS; attempt++) {
+ const url = await spawnTunnelOnce(port);
+ if (url) return url;
+
+ // Make sure a dead/stalled process is cleaned up before retrying.
+ if (mainTunnelProcess) {
+ mainTunnelProcess.kill();
+ mainTunnelProcess = null;
+ }
+
+ if (attempt < TUNNEL_MAX_ATTEMPTS) {
+ console.log(` Tunnel attempt ${attempt}/${TUNNEL_MAX_ATTEMPTS} failed, retrying...`);
+ } else {
+ console.log(`\n Could not establish a Cloudflare tunnel after ${TUNNEL_MAX_ATTEMPTS} attempts.`);
+ console.log(` Server is still running locally at http://localhost:${port}\n`);
+ }
+ }
+ return null;
+}
+
export function getMainTunnelUrl(): string | null {
return mainTunnelUrl;
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..395c97e
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ environment: "jsdom",
+ include: ["app/**/*.test.ts", "app/**/*.test.tsx", "server/**/*.test.ts"],
+ },
+});