From a83b692651e2607efe5016e0da741a6370285ae4 Mon Sep 17 00:00:00 2001 From: davindicode Date: Sat, 16 May 2026 19:19:45 +0200 Subject: [PATCH 01/18] fix: per-terminal cd picker state and correct tmux pane resolution The cd directory picker was sharing a single component-level useState across all terminal tabs, so its path stayed frozen to whichever terminal was opened first. Move cdCwd onto the per-session TerminalSession in the store and resync the picker whenever the active terminal changes. On the server, /api/terminal/cwd was calling `tmux display-message -p` with no target, returning the globally-attached pane regardless of which terminal asked. Locate the tmux client that's a direct child of the requesting PTY, read its tty, and query tmux with `-c ` so each terminal resolves to its own pane. Also walk the PTY's deepest descendant for the non-tmux path so grandchildren are handled correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/components/terminal/InputBox.tsx | 38 +++++++---- app/routes/api/terminal.cwd.ts | 98 ++++++++++++++++++---------- app/stores/terminalStore.ts | 9 +++ 3 files changed, 99 insertions(+), 46 deletions(-) diff --git a/app/components/terminal/InputBox.tsx b/app/components/terminal/InputBox.tsx index 4e3e3cc..5a9636c 100644 --- a/app/components/terminal/InputBox.tsx +++ b/app/components/terminal/InputBox.tsx @@ -258,7 +258,6 @@ export default function InputBox() { 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 +272,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 +290,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 = () => { @@ -382,13 +383,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(); } }; @@ -430,16 +440,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,12 +459,15 @@ 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([]); } setCdLoading(false); diff --git a/app/routes/api/terminal.cwd.ts b/app/routes/api/terminal.cwd.ts index b0b0ca7..7abf706 100644 --- a/app/routes/api/terminal.cwd.ts +++ b/app/routes/api/terminal.cwd.ts @@ -1,52 +1,82 @@ -import { execSync } from "child_process"; -import { readlinkSync } from "fs"; +import { execFileSync, execSync } from "child_process"; +import { readlinkSync, readFileSync } from "fs"; import type { Route } from "./+types/terminal.cwd"; import { getPtyPid } from "../../../server/pty-manager"; +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/terminalStore.ts b/app/stores/terminalStore.ts index 2ca412f..da062cd 100644 --- a/app/stores/terminalStore.ts +++ b/app/stores/terminalStore.ts @@ -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; @@ -186,6 +188,7 @@ export const useTerminalStore = create((set, get) => ({ outputBuffer: [], inTmux: false, inEditor: null, + cdCwd: "", }, }, activeSessionId: sessionId, @@ -304,4 +307,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 } } }); + }, })); From e2e383abb4e087094bd8cf6186c337d124d6405d Mon Sep 17 00:00:00 2001 From: davindicode Date: Sat, 16 May 2026 19:20:07 +0200 Subject: [PATCH 02/18] ux: move y/n keys to persistent nav, drop code-tab duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit y/n approve/deny are useful across more than just the code action tab — tmux kill-pane and other CLI confirms also expect them. Promote them into NAV_TAIL (the always-visible row beneath the text input) and remove the duplicate entries from CODE_COMMON_KEYS so users only see them in one place. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/components/terminal/InputBox.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/components/terminal/InputBox.tsx b/app/components/terminal/InputBox.tsx index 5a9636c..5d4714b 100644 --- a/app/components/terminal/InputBox.tsx +++ b/app/components/terminal/InputBox.tsx @@ -50,6 +50,8 @@ 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" }, ]; const NANO_KEYS: QuickKey[] = [ @@ -107,13 +109,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 From 5c027724c104b35d145de3ce3e98d2ea126e5bd3 Mon Sep 17 00:00:00 2001 From: davindicode Date: Fri, 22 May 2026 16:34:10 +0200 Subject: [PATCH 03/18] fix: isolate quick tunnel from system-wide cloudflared config Pass --config so cloudflared skips all default config search paths (~/.cloudflared/, /etc/cloudflared/, /usr/local/etc/cloudflared/). The previous HOME-swap workaround only covered the user-home path, so a system-wide named-tunnel config at /etc/cloudflared/config.yml with an ingress catch-all would silently hijack quick-tunnel traffic and return empty 404s. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/tunnel.ts | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/server/tunnel.ts b/server/tunnel.ts index 7b76c1d..a4d9bb1 100644 --- a/server/tunnel.ts +++ b/server/tunnel.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from "child_process"; -import { existsSync, mkdtempSync } from "fs"; +import { existsSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -7,21 +7,16 @@ import { tmpdir } from "os"; 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 { @@ -42,9 +37,8 @@ export async function startTunnel(port: number): Promise { const cfBin = findCloudflared(); try { - const args = ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", `http://localhost:${port}`]; + const args = ["tunnel", "--config", stubConfigPath(), "--no-autoupdate", "--protocol", "http2", "--url", `http://localhost:${port}`]; mainTunnelProcess = spawn(cfBin, args, { - ...tunnelSpawnOptions(), stdio: ["ignore", "pipe", "pipe"], }); } catch { From 31f781a132583a2a69ef1e7b4d4feb0962eb0393 Mon Sep 17 00:00:00 2001 From: davindicode Date: Tue, 9 Jun 2026 09:25:58 +0200 Subject: [PATCH 04/18] quicktunnel robustness fixes --- server/tunnel.ts | 96 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/server/tunnel.ts b/server/tunnel.ts index a4d9bb1..5ee75e3 100644 --- a/server/tunnel.ts +++ b/server/tunnel.ts @@ -31,51 +31,113 @@ 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", "--config", stubConfigPath(), "--no-autoupdate", "--protocol", "http2", "--url", `http://localhost:${port}`]; - mainTunnelProcess = spawn(cfBin, args, { + 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; } From ba4235b4f07ea271c68e13e4925c161cb9d58004 Mon Sep 17 00:00:00 2001 From: davindicode Date: Tue, 9 Jun 2026 10:31:32 +0200 Subject: [PATCH 05/18] download button dropdown --- app/components/files/FileList.tsx | 11 ++++++++++- app/components/files/FilesPage.tsx | 12 +++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/components/files/FileList.tsx b/app/components/files/FileList.tsx index 8d20107..03e2f1f 100644 --- a/app/components/files/FileList.tsx +++ b/app/components/files/FileList.tsx @@ -15,6 +15,7 @@ interface FileListProps { onDelete: (entry: FileEntry) => void; onInfo: (entry: FileEntry) => void; onRename: (entry: FileEntry) => void; + onDownload: (entry: FileEntry) => void; uploadQueue?: UploadFile[]; cancelUpload?: (index: number) => void; disabled?: boolean; @@ -33,7 +34,7 @@ interface ContextMenuState { y: number; } -export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, uploadQueue, cancelUpload, disabled }: FileListProps) { +export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, onDownload, uploadQueue, cancelUpload, disabled }: FileListProps) { const [menu, setMenu] = useState(null); const menuRef = useRef(null); const longPressTimer = useRef | null>(null); @@ -198,6 +199,14 @@ export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, > Rename + {!menu.entry.isDirectory && ( + + )} ))} + {NAV_ARROWS.map((qk) => ( + + ))} ); From b99f4245fa5f5f20f2decd7c507ccef3879c5238 Mon Sep 17 00:00:00 2001 From: davindicode Date: Fri, 12 Jun 2026 08:32:56 +0200 Subject: [PATCH 07/18] feat: add "Copy path" to file explorer dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Copy path action to the file/folder context menu (3-dot, right-click, long-press) so the absolute path can be grabbed without opening the entry — useful for pasting into the CLI on both mobile and desktop. Works for files and directories, with a textarea+execCommand fallback for contexts where the Clipboard API is unavailable. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/files/FileList.tsx | 9 ++++++++- app/components/files/FilesPage.tsx | 29 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/components/files/FileList.tsx b/app/components/files/FileList.tsx index 4e52de8..439e2a3 100644 --- a/app/components/files/FileList.tsx +++ b/app/components/files/FileList.tsx @@ -16,6 +16,7 @@ interface FileListProps { onInfo: (entry: FileEntry) => void; onRename: (entry: FileEntry) => void; onDownload: (entry: FileEntry) => void; + onCopyPath: (entry: FileEntry) => void; uploadQueue?: UploadFile[]; cancelUpload?: (index: number) => void; disabled?: boolean; @@ -38,7 +39,7 @@ interface ContextMenuState { vpBottom: number; // anchor bottom within the visible container (flip decision) } -export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, onDownload, uploadQueue, cancelUpload, disabled }: FileListProps) { +export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, onDownload, onCopyPath, uploadQueue, cancelUpload, disabled }: FileListProps) { const [menu, setMenu] = useState(null); const [menuStyle, setMenuStyle] = useState<{ left: number; top: number; visible: boolean }>({ left: 0, top: 0, visible: false }); const scrollRef = useRef(null); @@ -271,6 +272,12 @@ export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, > Rename + {!menu.entry.isDirectory && ( {!info ? ( @@ -39,12 +44,14 @@ function SystemInfoPopup({ onClose }: { onClose: () => void }) { ) : (
- {Object.entries(info).map(([key, val]) => val ? ( -
- {key} - {val} -
- ) : null)} + {Object.entries(info).map(([key, val]) => + val ? ( +
+ {key} + {val} +
+ ) : null, + )}
)} @@ -56,9 +63,7 @@ export default function Header() { const sessions = useTerminalStore((s) => s.sessions); const [showInfo, setShowInfo] = useState(false); - const hasActiveSessions = Object.values(sessions).some( - (s) => s.status === "connected" || s.status === "connecting" - ); + const hasActiveSessions = Object.values(sessions).some((s) => s.status === "connected" || s.status === "connecting"); const status = !socketConnected ? "offline" @@ -82,7 +87,11 @@ export default function Header() { title="System info" > - + - + {status} diff --git a/app/components/MobileTabBar.tsx b/app/components/MobileTabBar.tsx index 2a82f82..3658c56 100644 --- a/app/components/MobileTabBar.tsx +++ b/app/components/MobileTabBar.tsx @@ -1,4 +1,4 @@ -import { useUiStore, type TabId } from "~/stores/uiStore"; +import { type TabId, useUiStore } from "~/stores/uiStore"; const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [ { @@ -6,7 +6,12 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [ label: "Files", icon: ( - + ), }, @@ -15,7 +20,12 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [ label: "Terminal", icon: ( - + ), }, @@ -24,7 +34,12 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [ label: "Localhost", icon: ( - + ), }, diff --git a/app/components/RenamableTab.tsx b/app/components/RenamableTab.tsx index b479b1e..1b99705 100644 --- a/app/components/RenamableTab.tsx +++ b/app/components/RenamableTab.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; interface RenamableTabProps { name: string; diff --git a/app/components/ResizablePanels.tsx b/app/components/ResizablePanels.tsx index 8f97f91..63a0388 100644 --- a/app/components/ResizablePanels.tsx +++ b/app/components/ResizablePanels.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback, type ReactNode } from "react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; interface ResizablePanelsProps { left: ReactNode; @@ -16,39 +16,42 @@ export default function ResizablePanels({ left, center, right }: ResizablePanels const [rightWidth, setRightWidth] = useState(30); const dragging = useRef<"left" | "right" | null>(null); - const handlePointerDown = useCallback((divider: "left" | "right") => { - dragging.current = divider; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; + const handlePointerDown = useCallback( + (divider: "left" | "right") => { + dragging.current = divider; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; - const handlePointerMove = (e: PointerEvent) => { - if (!containerRef.current || !dragging.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const x = ((e.clientX - rect.left) / rect.width) * 100; + const handlePointerMove = (e: PointerEvent) => { + if (!containerRef.current || !dragging.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; - if (dragging.current === "left") { - // Left divider: can go from 0% up to (100% - rightWidth - MIN_DIVIDER_GAP) - const maxLeft = 100 - rightWidth - MIN_DIVIDER_GAP; - setLeftWidth(Math.max(0, Math.min(maxLeft, x))); - } else { - // Right divider: fromRight can go from 0% up to (100% - leftWidth - MIN_DIVIDER_GAP) - const fromRight = 100 - x; - const maxRight = 100 - leftWidth - MIN_DIVIDER_GAP; - setRightWidth(Math.max(0, Math.min(maxRight, fromRight))); - } - }; + if (dragging.current === "left") { + // Left divider: can go from 0% up to (100% - rightWidth - MIN_DIVIDER_GAP) + const maxLeft = 100 - rightWidth - MIN_DIVIDER_GAP; + setLeftWidth(Math.max(0, Math.min(maxLeft, x))); + } else { + // Right divider: fromRight can go from 0% up to (100% - leftWidth - MIN_DIVIDER_GAP) + const fromRight = 100 - x; + const maxRight = 100 - leftWidth - MIN_DIVIDER_GAP; + setRightWidth(Math.max(0, Math.min(maxRight, fromRight))); + } + }; - const handlePointerUp = () => { - dragging.current = null; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - }; + const handlePointerUp = () => { + dragging.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp); - }, [leftWidth, rightWidth]); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + }, + [leftWidth, rightWidth], + ); const centerWidth = 100 - leftWidth - rightWidth; @@ -62,25 +65,15 @@ export default function ResizablePanels({ left, center, right }: ResizablePanels )} {/* Left divider */} -
handlePointerDown("left")} - > +
handlePointerDown("left")}>
{/* Center panel */} - {centerWidth > 0 && ( -
- {center} -
- )} + {centerWidth > 0 &&
{center}
} {/* Right divider */} -
handlePointerDown("right")} - > +
handlePointerDown("right")}>
diff --git a/app/components/browser/BrowserPage.tsx b/app/components/browser/BrowserPage.tsx index 1fff436..9ddfd1f 100644 --- a/app/components/browser/BrowserPage.tsx +++ b/app/components/browser/BrowserPage.tsx @@ -1,6 +1,6 @@ -import { useState, useRef, useEffect, useCallback } from "react"; -import { useBrowserStore, type BrowserTab } from "~/stores/browserStore"; +import { useCallback, useEffect, useRef, useState } from "react"; import { isPortBlocked } from "~/lib/constants"; +import { type BrowserTab, useBrowserStore } from "~/stores/browserStore"; type ProxyState = | { status: "idle" } @@ -23,7 +23,10 @@ function useProxyCheck(port: string | undefined): { }, [port]); useEffect(() => { - if (!port) { setState({ status: "idle" }); return; } + if (!port) { + setState({ status: "idle" }); + return; + } const url = `${window.location.origin}/proxy/${port}/`; setState({ status: "checking" }); @@ -45,7 +48,10 @@ function useProxyCheck(port: string | undefined): { setState({ status: "ready", url }); }); - return () => { clearTimeout(timeout); controller.abort(); }; + return () => { + clearTimeout(timeout); + controller.abort(); + }; }, [port, retryCount]); return { state, retry }; @@ -74,11 +80,13 @@ function PortRow({ tab }: { tab: BrowserTab }) { )} - {state.status === "ready" && ( -
- )} + {state.status === "ready" &&
} {state.status === "unreachable" && ( -
+
)} {/* Port label */} @@ -95,12 +103,22 @@ function PortRow({ tab }: { tab: BrowserTab }) { title={copied ? "Copied!" : "Copy URL"} > {copied ? ( - + ) : ( - + )} @@ -116,7 +134,11 @@ function PortRow({ tab }: { tab: BrowserTab }) { > Go - + )} @@ -179,9 +201,7 @@ function AddPortRow() { - {blocked && ( - {blocked} - )} + {blocked && {blocked}}
); } diff --git a/app/components/files/Breadcrumbs.tsx b/app/components/files/Breadcrumbs.tsx index d69aeae..469dc08 100644 --- a/app/components/files/Breadcrumbs.tsx +++ b/app/components/files/Breadcrumbs.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; interface BreadcrumbsProps { path: string; @@ -40,7 +40,9 @@ export default function Breadcrumbs({ path, onNavigate, onGoUp, canGoUp, disable const parts = path.split("/").filter(Boolean); return ( -
+
{/* Back button */} {parts.map((part, i) => { @@ -108,7 +107,11 @@ export default function Breadcrumbs({ path, onNavigate, onGoUp, canGoUp, disable ) : ( - + )} diff --git a/app/components/files/CodeEditor.tsx b/app/components/files/CodeEditor.tsx index 0991e3e..cdaeb70 100644 --- a/app/components/files/CodeEditor.tsx +++ b/app/components/files/CodeEditor.tsx @@ -1,6 +1,6 @@ -import { lazy, Suspense, useState, useMemo } from "react"; -import CopyPathButton from "./CopyPathButton"; import { marked } from "marked"; +import { lazy, Suspense, useMemo, useState } from "react"; +import CopyPathButton from "./CopyPathButton"; const MonacoEditor = lazy(() => import("@monaco-editor/react")); @@ -121,9 +121,7 @@ function NotebookPreview({ content, fontSize }: { content: string; fontSize: num if (cells.length === 0) { return ( -
- Could not parse notebook -
+
Could not parse notebook
); } @@ -133,11 +131,11 @@ function NotebookPreview({ content, fontSize }: { content: string; fontSize: num
{/* Cell header */}
- + {cell.cell_type} [{i + 1}] @@ -152,19 +150,14 @@ function NotebookPreview({ content, fontSize }: { content: string; fontSize: num }} /> ) : ( -
-                {joinSource(cell.source)}
-              
+
{joinSource(cell.source)}
)}
{/* Cell outputs */} {cell.outputs && cell.outputs.length > 0 && (
{cell.outputs.map((output, j) => { - const text = - joinSource(output.text) || - joinSource(output.data?.["text/plain"]) || - ""; + const text = joinSource(output.text) || joinSource(output.data?.["text/plain"]) || ""; const html = joinSource(output.data?.["text/html"]); const imgRaw = output.data?.["image/png"]; const imgData = Array.isArray(imgRaw) ? imgRaw[0] : imgRaw; @@ -177,15 +170,9 @@ function NotebookPreview({ content, fontSize }: { content: string; fontSize: num dangerouslySetInnerHTML={{ __html: html }} /> ) : imgData ? ( - output + output ) : text ? ( -
-                        {text}
-                      
+
{text}
) : null}
); @@ -224,7 +211,12 @@ export default function CodeEditor({ path, content, onSave, onClose }: CodeEdito
{/* Toolbar */}
- {path} + + {path} +
{canPreview && ( @@ -232,9 +224,7 @@ export default function CodeEditor({ path, content, onSave, onClose }: CodeEdito - - + + +
) : (
@@ -281,7 +284,13 @@ export default function CodeEditor({ path, content, onSave, onClose }: CodeEdito className="p-1 bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white rounded transition-colors" title="Download" > - + + + {dirty && *}
@@ -315,9 +333,7 @@ export default function CodeEditor({ path, content, onSave, onClose }: CodeEdito ) : ( - Loading editor... -
+
Loading editor...
} > {copied ? ( - + ) : ( diff --git a/app/components/files/FileList.tsx b/app/components/files/FileList.tsx index 439e2a3..a98e653 100644 --- a/app/components/files/FileList.tsx +++ b/app/components/files/FileList.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useLayoutEffect } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { FileEntry } from "~/stores/fileStore"; interface UploadFile { @@ -32,16 +32,31 @@ function formatSize(bytes: number): string { interface ContextMenuState { entry: FileEntry; rightAlign: boolean; // 3-dot button: right-edge align; point menus: left-align - anchorX: number; // content-x: button right edge (rightAlign) or click point - topDown: number; // content-y for the menu top when opening downward - bottomUp: number; // content-y of the anchor top, used when flipping upward - vpTop: number; // anchor top within the visible container (flip decision) - vpBottom: number; // anchor bottom within the visible container (flip decision) + anchorX: number; // content-x: button right edge (rightAlign) or click point + topDown: number; // content-y for the menu top when opening downward + bottomUp: number; // content-y of the anchor top, used when flipping upward + vpTop: number; // anchor top within the visible container (flip decision) + vpBottom: number; // anchor bottom within the visible container (flip decision) } -export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, onDownload, onCopyPath, uploadQueue, cancelUpload, disabled }: FileListProps) { +export default function FileList({ + entries, + onOpen, + onDelete, + onInfo, + onRename, + onDownload, + onCopyPath, + uploadQueue, + cancelUpload, + disabled, +}: FileListProps) { const [menu, setMenu] = useState(null); - const [menuStyle, setMenuStyle] = useState<{ left: number; top: number; visible: boolean }>({ left: 0, top: 0, visible: false }); + const [menuStyle, setMenuStyle] = useState<{ left: number; top: number; visible: boolean }>({ + left: 0, + top: 0, + visible: false, + }); const scrollRef = useRef(null); const menuRef = useRef(null); const longPressTimer = useRef | null>(null); @@ -153,98 +168,112 @@ export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, }; if (entries.length === 0 && (!uploadQueue || uploadQueue.length === 0)) { - return ( -
- Empty directory -
- ); + return
Empty directory
; } return (
- {entries.map((entry) => ( -
- {/* Clickable file/folder area */} - + {/* Three-dot menu button — separate from file click */} + +
+ ))} +
+ + {/* Upload queue inline rows */} + {uploadQueue && + uploadQueue.length > 0 && + uploadQueue.map((file, i) => ( +
+ + {file.status === "uploading" || file.status === "pending" ? ( + + + + + ) : file.status === "done" ? ( + + ) : ( - - + + )} - {/* Name */} - {entry.name} - {/* Size */} - {!entry.isDirectory && ( - {formatSize(entry.size)} - )} - - {/* Three-dot menu button — separate from file click */} - -
- ))} -
- - {/* Upload queue inline rows */} - {uploadQueue && uploadQueue.length > 0 && uploadQueue.map((file, i) => ( -
- - {file.status === "uploading" || file.status === "pending" ? ( - - - - - ) : file.status === "done" ? ( - - - - ) : ( - - - + {file.name} + + {file.status === "uploading" + ? `${file.progress}%` + : file.status === "error" + ? file.error || "error" + : file.status} + + {(file.status === "pending" || file.status === "uploading") && cancelUpload && ( + )} - - {file.name} - - {file.status === "uploading" ? `${file.progress}%` : file.status === "error" ? (file.error || "error") : file.status} - - {(file.status === "pending" || file.status === "uploading") && cancelUpload && ( - - )} -
- ))} +
+ ))} {/* Context menu */} {menu && ( @@ -257,9 +286,7 @@ export default function FileList({ entries, onOpen, onDelete, onInfo, onRename, visibility: menuStyle.visible ? "visible" : "hidden", }} > -
- {menu.entry.name} -
+
{menu.entry.name}
@@ -77,16 +90,28 @@ function PdfViewer({ path, onClose }: { path: string; onClose: () => void }) { return (
- {path} + + {path} +
- {numPages > 0 && ( - {numPages} pg - )} + {numPages > 0 && {numPages} pg}
- - - + + +
void }) { download title="Download" > - + + +
@@ -109,12 +142,8 @@ function PdfViewer({ path, onClose }: { path: string; onClose: () => void }) { Loading PDF...
- } - error={ -
Failed to load PDF
- } + loading={
Loading PDF...
} + error={
Failed to load PDF
} >
{Array.from({ length: numPages }, (_, i) => ( @@ -161,7 +190,12 @@ export default function FileViewer({ path, content, onSave, onClose }: FileViewe return (
- {path} + + {path} +
- + + +
diff --git a/app/components/files/FilesPage.tsx b/app/components/files/FilesPage.tsx index ff69ec4..b339a1f 100644 --- a/app/components/files/FilesPage.tsx +++ b/app/components/files/FilesPage.tsx @@ -1,9 +1,9 @@ -import { useEffect, useCallback, useState, useRef } from "react"; -import { useFileStore, type FileEntry, type FileSession } from "~/stores/fileStore"; +import { useCallback, useEffect, useRef, useState } from "react"; +import RenamableTab from "~/components/RenamableTab"; +import { type FileEntry, type FileSession, useFileStore } from "~/stores/fileStore"; import Breadcrumbs from "./Breadcrumbs"; import FileList from "./FileList"; import FileViewer from "./FileViewer"; -import RenamableTab from "~/components/RenamableTab"; // Clipboard fallback for insecure contexts / older mobile browsers where // navigator.clipboard is unavailable. Must run within a user gesture. @@ -52,7 +52,12 @@ function FileTabs() { const folderIcon = ( - + ); @@ -92,10 +97,7 @@ function FileSessionView({ session }: { session: FileSession }) { const [dialog, setDialog] = useState(null); const [inputValue, setInputValue] = useState(""); - const patch = useCallback( - (p: Partial) => updateSession(id, p), - [id, updateSession] - ); + const patch = useCallback((p: Partial) => updateSession(id, p), [id, updateSession]); const loadDirectory = useCallback( async (dir: string, showHidden?: boolean) => { @@ -113,7 +115,7 @@ function FileSessionView({ session }: { session: FileSession }) { patch({ error: err.message, loading: false }); } }, - [id, session.showHidden] + [id, session.showHidden], ); const didInit = useRef(false); @@ -141,7 +143,6 @@ function FileSessionView({ session }: { session: FileSession }) { const cancelledIndices = useRef>(new Set()); const uploading = uploadQueue.some((f) => f.status === "pending" || f.status === "uploading"); - const fullPath = (name: string) => (cwd === "/" ? `/${name}` : `${cwd}/${name}`); const handleCopyPath = (entry: FileEntry) => { @@ -215,7 +216,10 @@ function FileSessionView({ session }: { session: FileSession }) { } }; - const handleCloseFile = () => { patch({ selectedFile: null, fileContent: null }); setFileError(null); }; + const handleCloseFile = () => { + patch({ selectedFile: null, fileContent: null }); + setFileError(null); + }; const toggleHidden = (val: boolean) => { patch({ showHidden: val }); @@ -223,27 +227,47 @@ function FileSessionView({ session }: { session: FileSession }) { }; // --- Actions --- - const handleNewFolder = () => { setInputValue(""); setDialog({ type: "newFolder" }); }; + const handleNewFolder = () => { + setInputValue(""); + setDialog({ type: "newFolder" }); + }; const confirmNewFolder = async () => { const name = inputValue.trim(); if (!name) return; try { - const res = await fetch("/api/files/mkdir", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: fullPath(name) }) }); + const res = await fetch("/api/files/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: fullPath(name) }), + }); const data = await res.json(); - if (data.error) patch({ error: data.error }); else await loadDirectory(cwd); - } catch (err: any) { patch({ error: err.message }); } + if (data.error) patch({ error: data.error }); + else await loadDirectory(cwd); + } catch (err: any) { + patch({ error: err.message }); + } setDialog(null); }; - const handleNewFile = () => { setInputValue(""); setDialog({ type: "newFile" }); }; + const handleNewFile = () => { + setInputValue(""); + setDialog({ type: "newFile" }); + }; const confirmNewFile = async () => { const name = inputValue.trim(); if (!name) return; try { - const res = await fetch("/api/files/write", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: fullPath(name), content: "" }) }); + const res = await fetch("/api/files/write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: fullPath(name), content: "" }), + }); const data = await res.json(); - if (data.error) patch({ error: data.error }); else await loadDirectory(cwd); - } catch (err: any) { patch({ error: err.message }); } + if (data.error) patch({ error: data.error }); + else await loadDirectory(cwd); + } catch (err: any) { + patch({ error: err.message }); + } setDialog(null); }; @@ -251,23 +275,43 @@ function FileSessionView({ session }: { session: FileSession }) { const confirmDelete = async () => { if (dialog?.type !== "delete") return; try { - const res = await fetch("/api/files/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: fullPath(dialog.entry.name) }) }); + const res = await fetch("/api/files/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: fullPath(dialog.entry.name) }), + }); const data = await res.json(); - if (data.error) patch({ error: data.error }); else await loadDirectory(cwd); - } catch (err: any) { patch({ error: err.message }); } + if (data.error) patch({ error: data.error }); + else await loadDirectory(cwd); + } catch (err: any) { + patch({ error: err.message }); + } setDialog(null); }; - const handleRename = (entry: FileEntry) => { setInputValue(entry.name); setDialog({ type: "rename", entry }); }; + const handleRename = (entry: FileEntry) => { + setInputValue(entry.name); + setDialog({ type: "rename", entry }); + }; const confirmRename = async () => { if (dialog?.type !== "rename") return; const newName = inputValue.trim(); - if (!newName || newName === dialog.entry.name) { setDialog(null); return; } + if (!newName || newName === dialog.entry.name) { + setDialog(null); + return; + } try { - const res = await fetch("/api/files/rename", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oldPath: fullPath(dialog.entry.name), newPath: fullPath(newName) }) }); + const res = await fetch("/api/files/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oldPath: fullPath(dialog.entry.name), newPath: fullPath(newName) }), + }); const data = await res.json(); - if (data.error) patch({ error: data.error }); else await loadDirectory(cwd); - } catch (err: any) { patch({ error: err.message }); } + if (data.error) patch({ error: data.error }); + else await loadDirectory(cwd); + } catch (err: any) { + patch({ error: err.message }); + } setDialog(null); }; @@ -280,7 +324,9 @@ function FileSessionView({ session }: { session: FileSession }) { uploadXhrs.current.delete(index); } cancelledIndices.current.add(index); - setUploadQueue((q) => q.map((f, i) => i === index && f.status !== "done" ? { ...f, status: "cancelled", progress: 0 } : f)); + setUploadQueue((q) => + q.map((f, i) => (i === index && f.status !== "done" ? { ...f, status: "cancelled", progress: 0 } : f)), + ); }; // Auto-clear upload queue when all jobs reach a terminal status @@ -314,10 +360,18 @@ function FileSessionView({ session }: { session: FileSession }) { try { const data = JSON.parse(xhr.responseText); resolve(!data.error); - } catch { resolve(false); } + } catch { + resolve(false); + } + }; + xhr.onerror = () => { + uploadXhrs.current.delete(index); + resolve(false); + }; + xhr.onabort = () => { + uploadXhrs.current.delete(index); + resolve(false); }; - xhr.onerror = () => { uploadXhrs.current.delete(index); resolve(false); }; - xhr.onabort = () => { uploadXhrs.current.delete(index); resolve(false); }; xhr.open("POST", "/api/files/upload-chunk"); xhr.send(formData); @@ -326,7 +380,7 @@ function FileSessionView({ session }: { session: FileSession }) { const uploadFile = async (file: File, index: number): Promise => { if (cancelledIndices.current.has(index)) return; - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "uploading" } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "uploading" } : f))); // Small files: direct upload if (file.size <= CHUNK_SIZE) { @@ -341,7 +395,7 @@ function FileSessionView({ session }: { session: FileSession }) { xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100); - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, progress: pct } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, progress: pct } : f))); } }; xhr.onload = () => { @@ -349,21 +403,24 @@ function FileSessionView({ session }: { session: FileSession }) { try { const data = JSON.parse(xhr.responseText); if (data.error) { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: data.error } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "error", error: data.error } : f))); } else { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "done", progress: 100 } : f))); } } catch { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "done", progress: 100 } : f))); } resolve(); }; xhr.onerror = () => { uploadXhrs.current.delete(index); - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: "Network error" } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "error", error: "Network error" } : f))); + resolve(); + }; + xhr.onabort = () => { + uploadXhrs.current.delete(index); resolve(); }; - xhr.onabort = () => { uploadXhrs.current.delete(index); resolve(); }; xhr.open("POST", "/api/files/upload"); xhr.send(formData); @@ -376,7 +433,7 @@ function FileSessionView({ session }: { session: FileSession }) { for (let c = 0; c < totalChunks; c++) { if (cancelledIndices.current.has(index)) { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "cancelled", progress: 0 } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "cancelled", progress: 0 } : f))); return; } const start = c * CHUNK_SIZE; @@ -385,11 +442,13 @@ function FileSessionView({ session }: { session: FileSession }) { const ok = await uploadChunk(blob, uploadId, c, index); if (!ok) { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: `Chunk ${c + 1}/${totalChunks} failed` } : f)); + setUploadQueue((q) => + q.map((f, i) => (i === index ? { ...f, status: "error", error: `Chunk ${c + 1}/${totalChunks} failed` } : f)), + ); return; } const pct = Math.round(((c + 1) / totalChunks) * 100); - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, progress: pct } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, progress: pct } : f))); } // Finalize: assemble chunks on server @@ -401,12 +460,12 @@ function FileSessionView({ session }: { session: FileSession }) { }); const data = await res.json(); if (data.error) { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: data.error } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "error", error: data.error } : f))); } else { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "done", progress: 100 } : f))); } } catch (err: any) { - setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: err.message } : f)); + setUploadQueue((q) => q.map((f, i) => (i === index ? { ...f, status: "error", error: err.message } : f))); } }; @@ -419,9 +478,10 @@ function FileSessionView({ session }: { session: FileSession }) { const conflicts = fileArray.filter((f) => existingNames.has(f.name)); if (conflicts.length > 0) { const names = conflicts.map((f) => f.name).join(", "); - const msg = conflicts.length === 1 - ? `"${names}" already exists. Overwrite?` - : `${conflicts.length} files already exist (${names}). Overwrite?`; + const msg = + conflicts.length === 1 + ? `"${names}" already exists. Overwrite?` + : `${conflicts.length} files already exist (${names}). Overwrite?`; if (!window.confirm(msg)) { if (uploadInputRef.current) uploadInputRef.current.value = ""; return; @@ -463,7 +523,11 @@ function FileSessionView({ session }: { session: FileSession }) { return (
- + {fileError} {selectedFile} @@ -476,43 +540,77 @@ function FileSessionView({ session }: { session: FileSession }) {
); } - return ( - - ); + return ; } return ( <> - {} : handleNavigate} onGoUp={uploading ? () => {} : handleGoUp} canGoUp={cwd !== "/" && !uploading} disabled={uploading} /> + {} : handleNavigate} + onGoUp={uploading ? () => {} : handleGoUp} + canGoUp={cwd !== "/" && !uploading} + disabled={uploading} + /> {/* Toolbar */}
- - - - handleUpload(e.target.files)} />
-
@@ -538,40 +644,110 @@ function FileSessionView({ session }: { session: FileSession }) { Loading...
) : ( - + )} {/* Dialogs */} {dialog && ( -
setDialog(null)}> -
e.stopPropagation()}> +
setDialog(null)} + > +
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" + />
- - + +
)} {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" + />
- - + +
)} {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" + />
- - + +
)} @@ -581,11 +757,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 &&
}
- - + +
)} @@ -610,7 +800,12 @@ function FileSessionView({ session }: { session: FileSession }) {
- +
)} diff --git a/app/components/files/ImageViewer.tsx b/app/components/files/ImageViewer.tsx index 5902ec1..30bd5c1 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 { @@ -41,14 +41,28 @@ export default function ImageViewer({ path, onClose }: ImageViewerProps) { return (
- {path} + + {path} +
{/* Zoom controls */}
- - - + + +
- + + +
@@ -76,8 +98,12 @@ export default function ImageViewer({ path, onClose }: ImageViewerProps) {
diff --git a/app/components/terminal/InputBox.tsx b/app/components/terminal/InputBox.tsx index 658fad7..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"; @@ -99,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" }, @@ -143,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" }, @@ -166,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: [ @@ -187,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" }, @@ -236,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": @@ -259,7 +263,14 @@ 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(""); @@ -314,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<{ @@ -333,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) { @@ -352,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, @@ -416,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); }; @@ -471,11 +499,13 @@ export default function InputBox() { setCdDirs( (data.entries || []) .filter((e: { isDirectory: boolean }) => e.isDirectory) - .map((e: { name: string }) => e.name) + .map((e: { name: string }) => e.name), ); } } - } catch { setCdDirs([]); } + } catch { + setCdDirs([]); + } setCdLoading(false); }; @@ -529,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 @@ -540,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) @@ -556,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 */}
@@ -583,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", + )}
@@ -594,7 +660,13 @@ export default function InputBox() {
{activeStandardGroup.keys.map((qk) => ( - ))} @@ -607,7 +679,12 @@ export default function InputBox() {
cd - {cdCwd} + + {cdCwd} +
{cdLoading ? ( Loading... @@ -695,7 +772,13 @@ export default function InputBox() {
{NANO_KEYS.map((qk) => ( - ))} @@ -708,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" - /> - -
+
+ 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" + /> + +
)}
)} @@ -736,11 +821,22 @@ export default function InputBox() {
{VIM_KEYS.map((qk) => ( - ))} - -
+
+ 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" + /> + +
)}
)} @@ -780,7 +878,13 @@ export default function InputBox() {
{TMUX_KEYS.map((qk) => ( - ))} @@ -793,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) => ( -
- - + <> + v{toolVersions.tmux} + {tmuxLoading ? ( + Loading... + ) : tmuxSessions.length === 0 ? ( + No tmux sessions running + ) : ( +
+ {tmuxSessions.map((s) => ( +
+ + +
+ ))}
- ))} -
+ )} +
+ 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" + /> + +
+ )} -
- 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" - /> - -
- )}
)} {/* 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) => ( - - ))} -
- {/* Selected vendor: toggle selector left, buttons right */} -
- - {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) => ( ))} -
, - document.body - )} -
- {/* Launch buttons (purple) */} - {vendor.launch.map((cmd) => ( - - ))} - {/* Vendor-specific keys */} - {vendor.keys.map((qk) => ( - - ))} - {/* Slash commands (cyan) */} - {vendor.slashCmds.map((cmd) => ( +
+ {/* Selected vendor: toggle selector left, buttons right */} +
- ))} + {codeVendorOpen && + createPortal( +
{ + const r = codeVendorBtnRef.current?.getBoundingClientRect(); + return r ? { left: r.left, bottom: window.innerHeight - r.top + 4 } : {}; + })(), + }} + > + {CLI_VENDORS.map((v, i) => ( + + ))} +
, + document.body, + )} +
+ {/* Launch buttons (purple) */} + {vendor.launch.map((cmd) => ( + + ))} + {/* Vendor-specific keys */} + {vendor.keys.map((qk) => ( + + ))} + {/* Slash commands (cyan) */} + {vendor.slashCmds.map((cmd) => ( + + ))} +
+
-
-
- ); - })()} + ); + })()} {/* Git tab: quick actions + commit + config */} {activeGroup === GIT_TAB && ( @@ -938,7 +1077,9 @@ export default function InputBox() { {GIT_QUICK_CMDS.map((cmd) => ( ))} {NAV_TAIL.map((qk) => ( - ))} {NAV_ARROWS.map((qk) => ( - ))} 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/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 7abf706..ea00682 100644 --- a/app/routes/api/terminal.cwd.ts +++ b/app/routes/api/terminal.cwd.ts @@ -1,7 +1,7 @@ import { execFileSync, execSync } from "child_process"; -import { readlinkSync, readFileSync } from "fs"; -import type { Route } from "./+types/terminal.cwd"; +import { readFileSync, readlinkSync } from "fs"; import { getPtyPid } from "../../../server/pty-manager"; +import type { Route } from "./+types/terminal.cwd"; function getDirectChildren(pid: number): number[] { try { @@ -55,11 +55,10 @@ export async function loader({ request }: Route.LoaderArgs) { 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(); + 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 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 da062cd..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; @@ -79,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" }; } @@ -101,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], }, }, }); @@ -145,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], }, }, }); 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 5ee75e3..17fb173 100644 --- a/server/tunnel.ts +++ b/server/tunnel.ts @@ -1,7 +1,7 @@ -import { spawn, type ChildProcess } from "child_process"; +import { type ChildProcess, spawn } from "child_process"; import { existsSync, writeFileSync } from "fs"; -import { join } from "path"; import { tmpdir } from "os"; +import { join } from "path"; // Main OTG Code tunnel let mainTunnelProcess: ChildProcess | null = null; @@ -44,7 +44,16 @@ function spawnTunnelOnce(port: number): Promise { let proc: ChildProcess; try { - const args = ["tunnel", "--config", stubConfigPath(), "--no-autoupdate", "--protocol", "http2", "--url", `http://localhost:${port}`]; + const args = [ + "tunnel", + "--config", + stubConfigPath(), + "--no-autoupdate", + "--protocol", + "http2", + "--url", + `http://localhost:${port}`, + ]; proc = spawn(cfBin, args, { stdio: ["ignore", "pipe", "pipe"], }); From 54645e36676d7a2a3f0ac43ffb1dd9f86f133990 Mon Sep 17 00:00:00 2001 From: davindicode Date: Sat, 13 Jun 2026 09:01:19 +0200 Subject: [PATCH 12/18] feat: mobile-friendly text selection/copy in the editor Monaco renders text in its own layer, so mobile browsers' native selection handles and "Select All" don't work on it. Add: - a one-tap "Copy" button that copies the whole file contents to the clipboard - a "Plain" view mode backed by a read-only native