diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7036d7..91c117d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,14 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Lint & format check + run: pnpm run lint + - name: TypeScript check run: pnpm run typecheck + - name: Test + run: pnpm test + - name: Build run: pnpm run build diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0e8df97 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.1] - 2026-06-13 + +### Added +- **Download** action in the file explorer dropdown — download any file without + opening it first (works on mobile and desktop). +- **Copy path** action in the file explorer dropdown — copy the absolute path of + a file or folder to paste into the CLI, with a clipboard fallback for contexts + where the Clipboard API is unavailable. +- Editor **Plain** view mode backed by a native textarea, so mobile native + selection and Select-All work for copying code/logs (Monaco's custom-rendered + editor does not support this on touch). Available on desktop too. +- Image viewer: desktop **click-and-drag to pan** a zoomed image (hand tool), + matching the touch swipe/pinch controls on mobile. + +### Changed +- Arrow keys are now pinned to the end of the persistent terminal key bar + (Enter · Bksp → Esc · Tab · PgUp · PgDn · y · n → ↑ · ↓ · ← · →). +- Moved y/n keys into the persistent nav bar and removed duplicate code-tab keys. + +### Fixed +- Cloudflare quick tunnel robustness: cloudflared output is now logged, the + promise resolves immediately on early exit instead of a 30s silent wait, and + startup retries up to 3 times before falling back to local-only. +- Quick tunnel is isolated from any system-wide `cloudflared` config so a global + ingress catch-all can no longer hijack quick-tunnel traffic. +- File explorer dropdown menu now stays attached to its entry on scroll, clamps + to the pane boundary instead of overflowing on mobile, and flips upward for + bottom entries so it is never clipped. +- Markdown preview now renders local images/video referenced by relative or + absolute on-disk paths (e.g. a README's `public/logo.png`) by routing them + through the file API, instead of showing a broken-media icon. +- Per-terminal `cd` picker state and correct tmux pane resolution. + +## [0.1.0] - 2026-04-19 + +- Initial public release. + +[Unreleased]: https://github.com/davindicode/otgcode/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/davindicode/otgcode/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/davindicode/otgcode/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..230c694 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,95 @@ +# Contributing to OTG Code + +Thanks for your interest in improving OTG Code! This guide covers the dev +setup, conventions, and PR workflow. + +## Prerequisites + +- **Node.js** `>= 20.19` +- **pnpm** `>= 10` (`corepack enable` will provide it) + +## Setup + +```bash +pnpm install +``` + +### Run it + +```bash +pnpm dev # local dev server (no tunnel) +bash start.sh # production build + server + Cloudflare quick tunnel +``` + +By default the app runs on http://localhost:7777 (override with `OTG_PORT`). + +## Checks (run before opening a PR) + +CI runs these on every PR; they must pass. + +```bash +pnpm lint # Biome lint + format check (biome ci) +pnpm format # auto-fix formatting and safe lint issues +pnpm typecheck # React Router typegen + tsc +pnpm run build # production build +``` + +## Code style + +Formatting and linting are handled by [Biome](https://biomejs.dev) — config in +`biome.json`. Run `pnpm format` to auto-apply. Don't hand-format; let Biome +decide. Match the conventions of the surrounding code. + +## Commit messages — Conventional Commits + +This project uses [Conventional Commits](https://www.conventionalcommits.org). +It keeps history readable and makes the `CHANGELOG.md` easy to assemble per +release. + +Common types: + +- `feat:` — a new feature +- `fix:` — a bug fix +- `chore:`, `docs:`, `style:`, `refactor:`, `test:`, `ci:` — supporting changes + +Example: `fix: flip file menu upward near the bottom of the pane` + +Security fixes should use `fix:` and are listed under a **Security** section in +the changelog — there is no separate security-advisory file. + +## Pull request workflow + +1. Branch off `main`. +2. Make your change; keep commits focused and Conventionally named. +3. Ensure `pnpm lint`, `pnpm typecheck`, and `pnpm run build` pass. +4. Open a PR against `main` with a short description of what and why. + +## Releasing (maintainers) + +Releases are cut manually: + +1. Update the version in `package.json` and add a section to `CHANGELOG.md` + (group changes under Added / Changed / Fixed / Security). +2. Merge to `main`. +3. Tag and push: `git tag -a vX.Y.Z -m "Release vX.Y.Z" && git push origin vX.Y.Z`. +4. Create a GitHub Release for the tag, using the changelog section as the notes. + +The in-app version (shown in the header) is read from `package.json` at build +time, so bumping `package.json` is enough to update it. + +## Project layout + +``` +app/ React Router (SSR) frontend + components/ UI — browser/, files/, terminal/ + routes/ routes + routes/api/ (file ops, tmux, system info) + stores/ Zustand stores + lib/ shared client helpers (clipboard, socket, constants) +server/ Node backend + index.ts Express + Socket.IO entry + pty-manager.ts node-pty terminal sessions + socket-handlers.ts realtime terminal I/O + proxy.ts reverse proxy + tunnel.ts Cloudflare quick-tunnel management +start.sh builds, installs cloudflared, runs server + tunnel +``` diff --git a/README.md b/README.md index 8150163..220d68c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Work on your VPS or home machine from anywhere — phone, tablet, or desktop — with zero client-side installation. A mobile-optimised terminal built for coding CLIs, with file explorer, code editor, and localhost proxy — all in a single browser tab, accessible via a single Cloudflare Quick Tunnel.

- Version + Version Status: alpha License Node >= 20 diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index 1da7e8e..5d53b64 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -1,12 +1,12 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; +import { useUiStore } from "~/stores/uiStore"; +import BrowserPage from "./browser/BrowserPage"; +import FilesPage from "./files/FilesPage"; import Header from "./Header"; import MobileTabBar from "./MobileTabBar"; import ResizablePanels from "./ResizablePanels"; -import TerminalPage from "./terminal/TerminalPage"; -import FilesPage from "./files/FilesPage"; -import BrowserPage from "./browser/BrowserPage"; import InputBox from "./terminal/InputBox"; -import { useUiStore } from "~/stores/uiStore"; +import TerminalPage from "./terminal/TerminalPage"; // Desktop layout requires landscape orientation AND at least 768px width, // OR at least 1024px width in any orientation. @@ -15,7 +15,7 @@ const DESKTOP_QUERY = "(min-width: 1024px), (min-width: 768px) and (orientation: function useIsDesktop() { const [isDesktop, setIsDesktop] = useState( - typeof window !== "undefined" ? window.matchMedia(DESKTOP_QUERY).matches : true + typeof window !== "undefined" ? window.matchMedia(DESKTOP_QUERY).matches : true, ); useEffect(() => { const mq = window.matchMedia(DESKTOP_QUERY); @@ -59,10 +59,7 @@ export default function AppShell() { -

+
ReactNode }) { const [mounted, setMounted] = useState(false); diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 4db8f0e..3291609 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTerminalStore } from "~/stores/terminalStore"; declare const __APP_VERSION__: string; @@ -23,11 +23,16 @@ function SystemInfoPopup({ onClose }: { onClose: () => void }) { }, [onClose]); return ( -
+
System Info
{!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..726602e 100644 --- a/app/components/files/CodeEditor.tsx +++ b/app/components/files/CodeEditor.tsx @@ -1,6 +1,7 @@ -import { lazy, Suspense, useState, useMemo } from "react"; -import CopyPathButton from "./CopyPathButton"; import { marked } from "marked"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { rewriteLocalAssets } from "~/lib/markdown"; +import CopyPathButton from "./CopyPathButton"; const MonacoEditor = lazy(() => import("@monaco-editor/react")); @@ -53,11 +54,11 @@ function getLanguage(path: string): string { return map[ext] || "plaintext"; } -function MarkdownPreview({ content, fontSize }: { content: string; fontSize: number }) { +function MarkdownPreview({ content, fontSize, path }: { content: string; fontSize: number; path: string }) { const html = useMemo(() => { marked.setOptions({ breaks: true, gfm: true }); - return marked.parse(content) as string; - }, [content]); + return rewriteLocalAssets(marked.parse(content) as string, path); + }, [content, path]); return (
- Could not parse notebook -
+
Could not parse notebook
); } @@ -133,11 +132,11 @@ function NotebookPreview({ content, fontSize }: { content: string; fontSize: num
{/* Cell header */}
- + {cell.cell_type} [{i + 1}] @@ -152,19 +151,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 +171,9 @@ function NotebookPreview({ content, fontSize }: { content: string; fontSize: num dangerouslySetInnerHTML={{ __html: html }} /> ) : imgData ? ( - output + output ) : text ? ( -
-                        {text}
-                      
+
{text}
) : null}
); @@ -203,7 +191,7 @@ export default function CodeEditor({ path, content, onSave, onClose }: CodeEdito const canPreview = PREVIEWABLE.has(ext); const [value, setValue] = useState(content); const [dirty, setDirty] = useState(false); - const [mode, setMode] = useState<"edit" | "preview">(canPreview ? "preview" : "edit"); + const [mode, setMode] = useState<"edit" | "plain" | "preview">(canPreview ? "preview" : "edit"); const [editorFontSize, setEditorFontSize] = useState(6); const isHtml = ext === "html" || ext === "htm"; const [htmlZoom, setHtmlZoom] = useState(100); @@ -224,39 +212,64 @@ export default function CodeEditor({ path, content, onSave, onClose }: CodeEdito
{/* Toolbar */}
- {path} + + {path} +
- {canPreview && ( -
- +
+ + + {canPreview && ( -
- )} + )} +
{/* Font size / Zoom controls */} {isHtml && mode === "preview" ? (
- - - + + +
) : (
@@ -281,7 +294,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 && *}
@@ -310,14 +338,24 @@ export default function CodeEditor({ path, content, onSave, onClose }: CodeEdito ) : ext === "html" || ext === "htm" ? ( ) : ( - + ) + ) : mode === "plain" ? ( + // Native textarea: mobile gets real selection handles + OS "Select All", + // which Monaco's custom-rendered editor does not support on touch. +