Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<p align="center">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.</p>

<p align="center">
<img src="https://img.shields.io/badge/version-0.1.0-blue" alt="Version">
<img src="https://img.shields.io/github/package-json/v/davindicode/otgcode?color=blue" alt="Version">
<img src="https://img.shields.io/badge/status-alpha-orange" alt="Status: alpha">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
<img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" alt="Node >= 20">
Expand Down
17 changes: 7 additions & 10 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -59,10 +59,7 @@ export default function AppShell() {
<TerminalPage />
<InputBox />
</div>
<div
className="flex-1 flex flex-col min-h-0"
style={{ display: activeTab === "files" ? "flex" : "none" }}
>
<div className="flex-1 flex flex-col min-h-0" style={{ display: activeTab === "files" ? "flex" : "none" }}>
<FilesPage />
</div>
<div
Expand Down
2 changes: 1 addition & 1 deletion app/components/ClientOnly.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect, type ReactNode } from "react";
import { type ReactNode, useEffect, useState } from "react";

export default function ClientOnly({ children }: { children: () => ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? <>{children()}</> : null;

Check notice on line 6 in app/components/ClientOnly.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

lint/complexity/noUselessFragments

This fragment is unnecessary.
}
47 changes: 27 additions & 20 deletions app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,11 +23,16 @@ function SystemInfoPopup({ onClose }: { onClose: () => void }) {
}, [onClose]);

return (
<div ref={popupRef} className="absolute right-2 top-10 z-50 bg-[#16162a] border border-gray-700 rounded-lg shadow-xl w-72 max-h-80 overflow-y-auto">
<div
ref={popupRef}
className="absolute right-2 top-10 z-50 bg-[#16162a] border border-gray-700 rounded-lg shadow-xl w-72 max-h-80 overflow-y-auto"
>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
<span className="text-xs font-medium text-gray-300">System Info</span>
<button onClick={onClose} className="text-gray-500 hover:text-white">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{!info ? (
Expand All @@ -39,12 +44,14 @@ function SystemInfoPopup({ onClose }: { onClose: () => void }) {
</div>
) : (
<div className="px-3 py-2 space-y-1">
{Object.entries(info).map(([key, val]) => val ? (
<div key={key} className="flex justify-between gap-2 text-[11px]">
<span className="text-gray-500 shrink-0">{key}</span>
<span className="text-gray-300 text-right break-all">{val}</span>
</div>
) : null)}
{Object.entries(info).map(([key, val]) =>
val ? (
<div key={key} className="flex justify-between gap-2 text-[11px]">
<span className="text-gray-500 shrink-0">{key}</span>
<span className="text-gray-300 text-right break-all">{val}</span>
</div>
) : null,
)}
</div>
)}
</div>
Expand All @@ -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"
Expand All @@ -82,7 +87,11 @@ export default function Header() {
title="System info"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<span
Expand All @@ -94,13 +103,11 @@ export default function Header() {
: "bg-red-500"
}`}
/>
<span className={`text-xs ${
status === "online"
? "text-green-500"
: status === "reconnecting"
? "text-yellow-500"
: "text-red-400"
}`}>
<span
className={`text-xs ${
status === "online" ? "text-green-500" : status === "reconnecting" ? "text-yellow-500" : "text-red-400"
}`}
>
{status}
</span>
</div>
Expand Down
23 changes: 19 additions & 4 deletions app/components/MobileTabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { useUiStore, type TabId } from "~/stores/uiStore";
import { type TabId, useUiStore } from "~/stores/uiStore";

const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
{
id: "files",
label: "Files",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
),
},
Expand All @@ -15,7 +20,12 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
label: "Terminal",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
),
},
Expand All @@ -24,7 +34,12 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
label: "Localhost",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
),
},
Expand Down
2 changes: 1 addition & 1 deletion app/components/RenamableTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import { useEffect, useRef, useState } from "react";

interface RenamableTabProps {
name: string;
Expand All @@ -25,7 +25,7 @@
const [editValue, setEditValue] = useState(name);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {

Check warning on line 28 in app/components/RenamableTab.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

lint/correctness/useExhaustiveDependencies

This hook does not specify its dependency on name.
if (editing) {
setEditValue(name);
setTimeout(() => {
Expand Down
Loading
Loading