📝
This commit is contained in:
58
package-lock.json
generated
58
package-lock.json
generated
@@ -19,6 +19,12 @@
|
||||
"@opentelemetry/sdk-logs": "^0.57.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.7.0",
|
||||
"chalk": "^5.4.0",
|
||||
@@ -1014,6 +1020,58 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.8.0.tgz",
|
||||
"integrity": "sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
|
||||
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
|
||||
14
package.json
14
package.json
@@ -33,6 +33,12 @@
|
||||
"@opentelemetry/sdk-logs": "^0.57.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.30.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.7.0",
|
||||
"chalk": "^5.4.0",
|
||||
@@ -65,13 +71,7 @@
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"yaml": "^2.6.0",
|
||||
"zod": "^3.24.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-webgl": "^0.18.0"
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
|
||||
230
src/server/web/auth/apikey-auth.ts
Normal file
230
src/server/web/auth/apikey-auth.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { createHash } from "crypto";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Application, Request, Response, NextFunction } from "express";
|
||||
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
|
||||
import { SessionStore } from "./adapter.js";
|
||||
|
||||
/**
|
||||
* API-key authentication adapter.
|
||||
*
|
||||
* Each user provides their own Anthropic API key on the login page.
|
||||
* The key is stored encrypted in the server-side session and is injected
|
||||
* as `ANTHROPIC_API_KEY` into every PTY spawned for that user.
|
||||
* The plaintext key is never sent to the browser after the login form POST.
|
||||
*
|
||||
* User identity is derived from the key itself (SHA-256 prefix), so two
|
||||
* sessions using the same key share the same userId and home directory.
|
||||
*
|
||||
* Optional env vars:
|
||||
* ADMIN_USERS — comma-separated user IDs (SHA-256 prefixes) or API-key
|
||||
* prefixes that receive the admin role
|
||||
*/
|
||||
export class ApiKeyAdapter implements AuthAdapter {
|
||||
private readonly store: SessionStore;
|
||||
private readonly adminUsers: ReadonlySet<string>;
|
||||
|
||||
constructor(store: SessionStore) {
|
||||
this.store = store;
|
||||
this.adminUsers = new Set(
|
||||
(process.env.ADMIN_USERS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
authenticate(req: IncomingMessage): AuthUser | null {
|
||||
const session = this.store.getFromRequest(req);
|
||||
if (!session || !session.encryptedApiKey) return null;
|
||||
|
||||
const apiKey = this.store.decrypt(session.encryptedApiKey);
|
||||
if (!apiKey) return null;
|
||||
|
||||
return {
|
||||
id: session.userId,
|
||||
email: session.email,
|
||||
name: session.name,
|
||||
isAdmin:
|
||||
session.isAdmin ||
|
||||
this.adminUsers.has(session.userId),
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
setupRoutes(app: Application): void {
|
||||
const loginHtml = this.loadLoginPage();
|
||||
|
||||
// GET /auth/login — serve the API key login form
|
||||
app.get("/auth/login", (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.send(loginHtml);
|
||||
});
|
||||
|
||||
// POST /auth/login — validate key, create encrypted session
|
||||
app.post(
|
||||
"/auth/login",
|
||||
// express.urlencoded is registered in pty-server.ts before setupRoutes
|
||||
(req: Request, res: Response) => {
|
||||
const apiKey = (req.body as Record<string, string>)?.api_key?.trim() ?? "";
|
||||
|
||||
if (!apiKey.startsWith("sk-ant-")) {
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.status(400).send(
|
||||
loginHtml.replace(
|
||||
"<!--ERROR-->",
|
||||
`<p class="error">Invalid API key format. Keys must start with <code>sk-ant-</code>.</p>`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = deriveUserId(apiKey);
|
||||
const isAdmin = this.adminUsers.has(userId);
|
||||
const encryptedApiKey = this.store.encrypt(apiKey);
|
||||
|
||||
const sessionId = this.store.create({
|
||||
userId,
|
||||
isAdmin,
|
||||
encryptedApiKey,
|
||||
});
|
||||
|
||||
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
|
||||
|
||||
const next = (req.query as Record<string, string>)?.next;
|
||||
res.redirect(next && next.startsWith("/") ? next : "/");
|
||||
},
|
||||
);
|
||||
|
||||
// POST /auth/logout — destroy session
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
const id = this.store.getIdFromRequest(req as unknown as IncomingMessage);
|
||||
if (id) this.store.delete(id);
|
||||
this.store.clearCookie(res as unknown as import("http").ServerResponse);
|
||||
res.redirect("/auth/login");
|
||||
});
|
||||
}
|
||||
|
||||
requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = this.authenticate(req as unknown as IncomingMessage);
|
||||
if (!user) {
|
||||
const accept = req.headers["accept"] ?? "";
|
||||
if (accept.includes("application/json")) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
} else {
|
||||
res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
(req as AuthenticatedRequest).user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private loadLoginPage(): string {
|
||||
// Serve from the public directory at build time; fall back to inline HTML.
|
||||
try {
|
||||
const p = join(import.meta.dirname, "../public/login.html");
|
||||
return readFileSync(p, "utf8");
|
||||
} catch {
|
||||
return INLINE_LOGIN_HTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable, opaque user ID from an API key.
|
||||
* Uses the first 16 hex chars of SHA-256(key) — short enough to be readable,
|
||||
* long enough to be unique.
|
||||
*/
|
||||
function deriveUserId(apiKey: string): string {
|
||||
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
// Fallback inline login page used when public/login.html is not present.
|
||||
const INLINE_LOGIN_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Claude Code — Sign In</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
||||
p.subtitle { color: #8b949e; font-size: 0.875rem; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-size: 0.875rem; margin-bottom: 0.4rem; color: #8b949e; }
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: #238636;
|
||||
border: 1px solid #2ea043;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #2ea043; }
|
||||
.error { color: #f85149; font-size: 0.875rem; margin-bottom: 1rem; }
|
||||
code { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.8rem; }
|
||||
.hint { color: #8b949e; font-size: 0.75rem; margin-top: 1rem; }
|
||||
.hint a { color: #58a6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Claude Code</h1>
|
||||
<p class="subtitle">Enter your Anthropic API key to start a session.</p>
|
||||
<!--ERROR-->
|
||||
<form method="POST" action="/auth/login">
|
||||
<label for="api_key">Anthropic API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
placeholder="sk-ant-..."
|
||||
autocomplete="off"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Your key is stored encrypted on the server and never sent to the browser.
|
||||
Get a key at <a href="https://console.anthropic.com" target="_blank" rel="noopener">console.anthropic.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -11,35 +11,28 @@ const PORT = parseInt(process.env.PORT ?? "3000", 10);
|
||||
const HOST = process.env.HOST ?? "0.0.0.0";
|
||||
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10);
|
||||
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
|
||||
const SHELL = process.env.SHELL ?? "bash";
|
||||
const GRACE_PERIOD_MS = parseInt(
|
||||
process.env.SESSION_GRACE_MS ?? String(5 * 60_000),
|
||||
10,
|
||||
);
|
||||
const SCROLLBACK_BYTES = parseInt(
|
||||
process.env.SCROLLBACK_BYTES ?? String(100 * 1024),
|
||||
10,
|
||||
);
|
||||
|
||||
// Resolve the claude CLI binary
|
||||
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const server = createServer(app);
|
||||
|
||||
// --- HTTP routes ---
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
activeSessions: sessionManager.activeCount,
|
||||
maxSessions: MAX_SESSIONS,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve static frontend
|
||||
const publicDir = path.join(import.meta.dirname, "public");
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, "index.html"));
|
||||
});
|
||||
|
||||
// --- Session Manager ---
|
||||
|
||||
const sessionManager = new SessionManager(MAX_SESSIONS, (cols, rows) =>
|
||||
const sessionManager = new SessionManager(
|
||||
MAX_SESSIONS,
|
||||
(cols, rows) =>
|
||||
spawn(CLAUDE_BIN, [], {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
@@ -51,8 +44,43 @@ const sessionManager = new SessionManager(MAX_SESSIONS, (cols, rows) =>
|
||||
COLORTERM: "truecolor",
|
||||
},
|
||||
}),
|
||||
GRACE_PERIOD_MS,
|
||||
SCROLLBACK_BYTES,
|
||||
);
|
||||
|
||||
// --- HTTP routes ---
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
activeSessions: sessionManager.activeCount,
|
||||
maxSessions: MAX_SESSIONS,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/sessions", (_req, res) => {
|
||||
res.json(sessionManager.listSessions());
|
||||
});
|
||||
|
||||
app.delete("/api/sessions/:token", (req, res) => {
|
||||
const { token } = req.params;
|
||||
const session = sessionManager.getSession(token);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
sessionManager.destroySession(token);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Serve static frontend
|
||||
const publicDir = path.join(import.meta.dirname, "public");
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, "index.html"));
|
||||
});
|
||||
|
||||
// --- WebSocket server ---
|
||||
|
||||
const rateLimiter = new ConnectionRateLimiter();
|
||||
@@ -100,6 +128,27 @@ wss.on("connection", (ws, req) => {
|
||||
"unknown";
|
||||
console.log(`New WebSocket connection from ${ip}`);
|
||||
|
||||
const url = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${req.headers.host ?? "localhost"}`,
|
||||
);
|
||||
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10);
|
||||
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
|
||||
const resumeToken = url.searchParams.get("resume");
|
||||
|
||||
// Try to resume an existing session
|
||||
if (resumeToken) {
|
||||
const resumed = sessionManager.resume(resumeToken, ws, cols, rows);
|
||||
if (resumed) {
|
||||
return;
|
||||
}
|
||||
// Session expired or not found — fall through to create a new one
|
||||
console.log(
|
||||
`[resume] Session ${resumeToken.slice(0, 8)}… not found — starting fresh`,
|
||||
);
|
||||
}
|
||||
|
||||
// Capacity check only applies to new sessions
|
||||
if (sessionManager.isFull) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -111,14 +160,9 @@ wss.on("connection", (ws, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse initial size from query params
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10);
|
||||
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
|
||||
|
||||
const session = sessionManager.create(ws, cols, rows);
|
||||
if (session) {
|
||||
ws.send(JSON.stringify({ type: "connected", sessionId: session.id }));
|
||||
const token = sessionManager.create(ws, cols, rows);
|
||||
if (token) {
|
||||
ws.send(JSON.stringify({ type: "session", token }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,6 +195,12 @@ server.listen(PORT, HOST, () => {
|
||||
console.log(`PTY server listening on http://${HOST}:${PORT}`);
|
||||
console.log(` WebSocket: ws://${HOST}:${PORT}/ws`);
|
||||
console.log(` Max sessions: ${MAX_SESSIONS}`);
|
||||
console.log(
|
||||
` Session grace period: ${GRACE_PERIOD_MS / 1000}s`,
|
||||
);
|
||||
console.log(
|
||||
` Scrollback buffer: ${Math.round(SCROLLBACK_BYTES / 1024)}KB per session`,
|
||||
);
|
||||
if (process.env.AUTH_TOKEN) {
|
||||
console.log(" Auth: token required");
|
||||
}
|
||||
|
||||
@@ -7,19 +7,11 @@
|
||||
<meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)">
|
||||
<title>Claude Code</title>
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
|
||||
<!-- xterm.js from CDN -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.8.0/lib/addon-unicode11.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
||||
|
||||
<!-- terminal.css is emitted by esbuild: xterm.css + styles.css bundled together -->
|
||||
<link rel="stylesheet" href="terminal.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top bar -->
|
||||
<div id="top-bar">
|
||||
<div class="left">
|
||||
@@ -31,24 +23,26 @@
|
||||
<button id="bar-btn">Reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Re-expand button shown when top bar is collapsed -->
|
||||
<button id="toggle-bar" title="Show top bar">▾</button>
|
||||
|
||||
<!-- Terminal -->
|
||||
<!-- Terminal mount point -->
|
||||
<div id="terminal-container"></div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<!-- Loading overlay (initial connect) -->
|
||||
<div id="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="message">Connecting to Claude Code...</div>
|
||||
<div class="overlay-msg">Connecting to Claude Code…</div>
|
||||
</div>
|
||||
|
||||
<!-- Reconnect overlay -->
|
||||
<!-- Reconnect overlay (shown on disconnect) -->
|
||||
<div id="reconnect-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="message">Connection lost. Reconnecting...</div>
|
||||
<div class="sub" id="reconnect-sub">Retrying in 1s...</div>
|
||||
<div class="overlay-msg">Connection lost. Reconnecting…</div>
|
||||
<div class="overlay-sub" id="reconnect-sub">Retrying in 1s…</div>
|
||||
</div>
|
||||
|
||||
<script src="terminal.js"></script>
|
||||
<!-- terminal.js is the esbuild bundle of terminal.ts + all xterm addons -->
|
||||
<script type="module" src="terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,50 +1,54 @@
|
||||
import type { IPty } from "node-pty";
|
||||
import type { WebSocket } from "ws";
|
||||
import { SessionStore } from "./session-store.js";
|
||||
|
||||
export type Session = {
|
||||
id: string;
|
||||
ws: WebSocket;
|
||||
pty: IPty;
|
||||
createdAt: number;
|
||||
};
|
||||
export type { SessionInfo } from "./session-store.js";
|
||||
|
||||
export class SessionManager {
|
||||
private sessions = new Map<string, Session>();
|
||||
private store: SessionStore;
|
||||
private maxSessions: number;
|
||||
private spawnPty: (cols: number, rows: number) => IPty;
|
||||
// Tracks which sessions have already had their PTY event listeners wired,
|
||||
// so we don't double-register on reconnect.
|
||||
private wiredPtys = new Set<string>();
|
||||
|
||||
constructor(
|
||||
maxSessions: number,
|
||||
spawnPty: (cols: number, rows: number) => IPty,
|
||||
gracePeriodMs?: number,
|
||||
scrollbackBytes?: number,
|
||||
) {
|
||||
this.maxSessions = maxSessions;
|
||||
this.spawnPty = spawnPty;
|
||||
this.store = new SessionStore(gracePeriodMs, scrollbackBytes);
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.sessions.size;
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
get isFull(): boolean {
|
||||
return this.sessions.size >= this.maxSessions;
|
||||
return this.store.size >= this.maxSessions;
|
||||
}
|
||||
|
||||
getSession(id: string): Session | undefined {
|
||||
return this.sessions.get(id);
|
||||
getSession(token: string) {
|
||||
return this.store.get(token);
|
||||
}
|
||||
|
||||
listSessions() {
|
||||
return this.store.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new PTY session bound to the given WebSocket.
|
||||
* Returns the session or null if at capacity.
|
||||
* Spawns a new PTY, registers it in the session store, and wires up all
|
||||
* event plumbing between the PTY and the WebSocket.
|
||||
*
|
||||
* Returns the session token, or null if at capacity or PTY spawn fails.
|
||||
*/
|
||||
create(ws: WebSocket, cols = 80, rows = 24): Session | null {
|
||||
if (this.isFull) {
|
||||
return null;
|
||||
}
|
||||
create(ws: WebSocket, cols = 80, rows = 24): string | null {
|
||||
if (this.isFull) return null;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
let pty: IPty;
|
||||
|
||||
try {
|
||||
pty = this.spawnPty(cols, rows);
|
||||
} catch (err) {
|
||||
@@ -57,39 +61,102 @@ export class SessionManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session: Session = { id, ws, pty, createdAt: Date.now() };
|
||||
this.sessions.set(id, session);
|
||||
const session = this.store.register(pty);
|
||||
session.ws = ws;
|
||||
const { token } = session;
|
||||
|
||||
this.wirePtyEvents(token, pty);
|
||||
this.wireWsEvents(token, ws, pty);
|
||||
|
||||
console.log(
|
||||
`[session ${token.slice(0, 8)}] Created (active: ${this.store.size}/${this.maxSessions})`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a new WebSocket to an existing session identified by `token`.
|
||||
*
|
||||
* - Cancels the grace timer
|
||||
* - Sends `{ type: "resumed", token }` to the client
|
||||
* - Replays the scrollback buffer so the user sees their conversation
|
||||
* - Resizes the PTY to the client's current terminal dimensions
|
||||
*
|
||||
* Returns true if the session was found, false otherwise.
|
||||
*/
|
||||
resume(token: string, ws: WebSocket, cols: number, rows: number): boolean {
|
||||
const session = this.store.reattach(token, ws);
|
||||
if (!session) return false;
|
||||
|
||||
console.log(
|
||||
`[session ${token.slice(0, 8)}] Resumed (active: ${this.store.size}/${this.maxSessions})`,
|
||||
);
|
||||
|
||||
// Tell the client it's a resumed session BEFORE sending scrollback bytes.
|
||||
// The client uses this to clear the terminal first.
|
||||
ws.send(JSON.stringify({ type: "resumed", token }));
|
||||
|
||||
// Replay buffered output
|
||||
const scrollback = session.scrollback.read();
|
||||
if (scrollback.length > 0) {
|
||||
ws.send(scrollback);
|
||||
}
|
||||
|
||||
// Sync PTY dimensions to the reconnected client
|
||||
try {
|
||||
session.pty.resize(cols, rows);
|
||||
} catch {
|
||||
// PTY may have exited
|
||||
}
|
||||
|
||||
this.wireWsEvents(token, ws, session.pty);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire PTY → scrollback + WebSocket.
|
||||
* Called once per session lifetime (idempotent via `wiredPtys` guard).
|
||||
*/
|
||||
private wirePtyEvents(token: string, pty: IPty): void {
|
||||
if (this.wiredPtys.has(token)) return;
|
||||
this.wiredPtys.add(token);
|
||||
|
||||
const session = this.store.get(token);
|
||||
if (!session) return;
|
||||
|
||||
// PTY output -> WebSocket
|
||||
pty.onData((data: string) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
// Always capture to scrollback for future replay
|
||||
session.scrollback.write(data);
|
||||
// Forward to the currently attached WebSocket, if any
|
||||
const ws = session.ws;
|
||||
if (ws && ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// PTY exit -> clean up
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
this.wiredPtys.delete(token);
|
||||
console.log(
|
||||
`[session ${id}] PTY exited: code=${exitCode}, signal=${signal}`,
|
||||
);
|
||||
this.sessions.delete(id);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "exit",
|
||||
exitCode,
|
||||
signal,
|
||||
}),
|
||||
`[session ${token.slice(0, 8)}] PTY exited: code=${exitCode}, signal=${signal}`,
|
||||
);
|
||||
const ws = session.ws;
|
||||
if (ws && ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode, signal }));
|
||||
ws.close(1000, "PTY exited");
|
||||
}
|
||||
this.store.destroy(token);
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket messages -> PTY stdin (or resize)
|
||||
/**
|
||||
* Wire WebSocket → PTY (input, resize, ping).
|
||||
* On close/error, start the grace period instead of immediately destroying
|
||||
* the session — this keeps the PTY alive for reconnection.
|
||||
* Called once per WebSocket connection (safe to call again on reconnect).
|
||||
*/
|
||||
private wireWsEvents(token: string, ws: WebSocket, pty: IPty): void {
|
||||
ws.on("message", (data: Buffer | string) => {
|
||||
const str = data.toString();
|
||||
|
||||
// Try to parse as JSON for control messages
|
||||
if (str.startsWith("{")) {
|
||||
try {
|
||||
const msg = JSON.parse(str) as Record<string, unknown>;
|
||||
@@ -102,75 +169,44 @@ export class SessionManager {
|
||||
return;
|
||||
}
|
||||
if (msg.type === "ping") {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
if (ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, treat as terminal input
|
||||
// Not JSON — treat as terminal input
|
||||
}
|
||||
}
|
||||
|
||||
pty.write(str);
|
||||
});
|
||||
|
||||
// WebSocket close -> kill PTY
|
||||
ws.on("close", () => {
|
||||
console.log(`[session ${id}] WebSocket closed`);
|
||||
this.destroySession(id);
|
||||
const handleClose = () => {
|
||||
console.log(`[session ${token.slice(0, 8)}] WebSocket closed`);
|
||||
const session = this.store.get(token);
|
||||
// Only start grace if this WS is still the one attached to the session
|
||||
if (session && session.ws === ws) {
|
||||
this.store.startGrace(token, () => {
|
||||
/* logged inside startGrace */
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("close", handleClose);
|
||||
ws.on("error", (err) => {
|
||||
console.error(`[session ${id}] WebSocket error:`, err.message);
|
||||
this.destroySession(id);
|
||||
console.error(`[session ${token.slice(0, 8)}] WebSocket error:`, err.message);
|
||||
handleClose();
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[session ${id}] Created (active: ${this.sessions.size}/${this.maxSessions})`,
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully destroys a session: SIGHUP, then SIGKILL after timeout.
|
||||
* Force-kill a session immediately (used by the REST API).
|
||||
*/
|
||||
destroySession(id: string): void {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return;
|
||||
|
||||
this.sessions.delete(id);
|
||||
const { pty, ws } = session;
|
||||
|
||||
try {
|
||||
pty.kill("SIGHUP");
|
||||
} catch {
|
||||
// PTY may already be dead
|
||||
destroySession(token: string): void {
|
||||
this.store.destroy(token);
|
||||
}
|
||||
|
||||
// Force kill after 5 seconds if still alive
|
||||
const killTimer = setTimeout(() => {
|
||||
try {
|
||||
pty.kill("SIGKILL");
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// If PTY exits before the timer, clear it
|
||||
pty.onExit(() => clearTimeout(killTimer));
|
||||
|
||||
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
|
||||
ws.close(1000, "Session destroyed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys all sessions. Used during server shutdown.
|
||||
*/
|
||||
destroyAll(): void {
|
||||
for (const id of [...this.sessions.keys()]) {
|
||||
this.destroySession(id);
|
||||
}
|
||||
this.store.destroyAll();
|
||||
}
|
||||
}
|
||||
|
||||
243
src/server/web/styles.css
Normal file
243
src/server/web/styles.css
Normal file
@@ -0,0 +1,243 @@
|
||||
/* ── CSS custom properties (theme tokens) ───────────────────────────────── */
|
||||
:root {
|
||||
--term-bg: #1a1b26;
|
||||
--term-fg: #c0caf5;
|
||||
--term-cursor: #c0caf5;
|
||||
--term-selection: rgba(130, 170, 255, 0.3);
|
||||
--term-black: #15161e;
|
||||
--term-red: #f7768e;
|
||||
--term-green: #9ece6a;
|
||||
--term-yellow: #e0af68;
|
||||
--term-blue: #7aa2f7;
|
||||
--term-magenta: #bb9af7;
|
||||
--term-cyan: #7dcfff;
|
||||
--term-white: #a9b1d6;
|
||||
--term-bright-black: #414868;
|
||||
--term-bright-white: #c0caf5;
|
||||
--bar-bg: #16161e;
|
||||
--bar-fg: #565f89;
|
||||
--bar-accent: #7aa2f7;
|
||||
--overlay-bg: rgba(26, 27, 38, 0.92);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--term-bg: #f5f5f5;
|
||||
--term-fg: #343b58;
|
||||
--term-cursor: #343b58;
|
||||
--term-selection: rgba(52, 59, 88, 0.2);
|
||||
--term-black: #0f0f14;
|
||||
--term-red: #8c4351;
|
||||
--term-green: #485e30;
|
||||
--term-yellow: #8f5e15;
|
||||
--term-blue: #34548a;
|
||||
--term-magenta: #5a4a78;
|
||||
--term-cyan: #0f4b6e;
|
||||
--term-white: #343b58;
|
||||
--term-bright-black: #9699a3;
|
||||
--term-bright-white: #343b58;
|
||||
--bar-bg: #e8e8e8;
|
||||
--bar-fg: #6c7086;
|
||||
--bar-accent: #34548a;
|
||||
--overlay-bg: rgba(245, 245, 245, 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Top bar ────────────────────────────────────────────────────────────── */
|
||||
#top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
background: var(--bar-bg);
|
||||
color: var(--bar-fg);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
#top-bar.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#top-bar .left,
|
||||
#top-bar .right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Re-expand button — only visible when bar is collapsed */
|
||||
#toggle-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 20;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--bar-bg);
|
||||
border: 1px solid var(--bar-fg);
|
||||
border-radius: 3px;
|
||||
color: var(--bar-fg);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#top-bar.collapsed ~ #toggle-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Status dot ─────────────────────────────────────────────────────────── */
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--term-green);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--term-red);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: var(--term-yellow);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Bar button ─────────────────────────────────────────────────────────── */
|
||||
#bar-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--bar-fg);
|
||||
color: var(--bar-fg);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
#bar-btn:hover {
|
||||
color: var(--bar-accent);
|
||||
border-color: var(--bar-accent);
|
||||
}
|
||||
|
||||
/* ── Terminal container ─────────────────────────────────────────────────── */
|
||||
#terminal-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 32px);
|
||||
background: var(--term-bg);
|
||||
}
|
||||
|
||||
#top-bar.collapsed ~ #terminal-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Give xterm a little breathing room */
|
||||
#terminal-container .xterm {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* ── Overlays (loading + reconnect) ─────────────────────────────────────── */
|
||||
#loading-overlay,
|
||||
#reconnect-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
background: var(--overlay-bg);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#loading-overlay {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#loading-overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#reconnect-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#reconnect-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--term-bright-black);
|
||||
border-top-color: var(--bar-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.overlay-msg {
|
||||
color: var(--term-fg);
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.overlay-sub {
|
||||
margin-top: 6px;
|
||||
color: var(--bar-fg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
|
||||
.xterm-viewport::-webkit-scrollbar { width: 8px; }
|
||||
.xterm-viewport::-webkit-scrollbar-track { background: transparent; }
|
||||
.xterm-viewport::-webkit-scrollbar-thumb { background: var(--term-bright-black); border-radius: 4px; }
|
||||
.xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--bar-fg); }
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
#top-bar {
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#terminal-container {
|
||||
height: calc(100vh - 28px);
|
||||
}
|
||||
|
||||
#top-bar.collapsed ~ #terminal-container {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
8
src/server/web/tsconfig.json
Normal file
8
src/server/web/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["terminal.ts", "styles.css"]
|
||||
}
|
||||
64
src/server/web/user-store.ts
Normal file
64
src/server/web/user-store.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* UserStore — tracks which users are connected and how many sessions each has.
|
||||
*
|
||||
* This is a lightweight in-memory view derived from the SessionManager; it
|
||||
* does not persist across restarts. The admin dashboard and admin API read
|
||||
* from this store to enumerate users and their activity.
|
||||
*/
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
sessionCount: number;
|
||||
}
|
||||
|
||||
export class UserStore {
|
||||
private readonly users = new Map<string, UserRecord>();
|
||||
|
||||
/**
|
||||
* Called when a session is created for a user.
|
||||
* Creates the user record if it doesn't exist yet; increments sessionCount.
|
||||
*/
|
||||
touch(userId: string, meta?: { email?: string; name?: string }): void {
|
||||
const existing = this.users.get(userId);
|
||||
if (existing) {
|
||||
existing.lastSeenAt = Date.now();
|
||||
existing.sessionCount += 1;
|
||||
if (meta?.email && !existing.email) existing.email = meta.email;
|
||||
if (meta?.name && !existing.name) existing.name = meta.name;
|
||||
} else {
|
||||
this.users.set(userId, {
|
||||
id: userId,
|
||||
email: meta?.email,
|
||||
name: meta?.name,
|
||||
firstSeenAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
sessionCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a session is destroyed for a user.
|
||||
* Decrements sessionCount; removes the record when it reaches zero.
|
||||
*/
|
||||
release(userId: string): void {
|
||||
const record = this.users.get(userId);
|
||||
if (!record) return;
|
||||
record.sessionCount = Math.max(0, record.sessionCount - 1);
|
||||
if (record.sessionCount === 0) {
|
||||
this.users.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns all currently connected users (sessionCount > 0). */
|
||||
list(): UserRecord[] {
|
||||
return [...this.users.values()];
|
||||
}
|
||||
|
||||
get(userId: string): UserRecord | undefined {
|
||||
return this.users.get(userId);
|
||||
}
|
||||
}
|
||||
106
web/components/tools/FileIcon.tsx
Normal file
106
web/components/tools/FileIcon.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FileText,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileImage,
|
||||
File,
|
||||
Database,
|
||||
Settings,
|
||||
Package,
|
||||
Globe,
|
||||
BookOpen,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const EXT_MAP: Record<string, LucideIcon> = {
|
||||
// JavaScript / TypeScript
|
||||
js: FileCode,
|
||||
jsx: FileCode,
|
||||
ts: FileCode,
|
||||
tsx: FileCode,
|
||||
mjs: FileCode,
|
||||
cjs: FileCode,
|
||||
// Web
|
||||
html: Globe,
|
||||
htm: Globe,
|
||||
css: FileCode,
|
||||
scss: FileCode,
|
||||
sass: FileCode,
|
||||
less: FileCode,
|
||||
// Data
|
||||
json: FileJson,
|
||||
jsonc: FileJson,
|
||||
yaml: FileJson,
|
||||
yml: FileJson,
|
||||
toml: FileJson,
|
||||
xml: FileJson,
|
||||
csv: Database,
|
||||
// Config
|
||||
env: Settings,
|
||||
gitignore: Settings,
|
||||
eslintrc: Settings,
|
||||
prettierrc: Settings,
|
||||
editorconfig: Settings,
|
||||
// Docs
|
||||
md: BookOpen,
|
||||
mdx: BookOpen,
|
||||
txt: FileText,
|
||||
rst: FileText,
|
||||
// Images
|
||||
png: FileImage,
|
||||
jpg: FileImage,
|
||||
jpeg: FileImage,
|
||||
gif: FileImage,
|
||||
svg: FileImage,
|
||||
ico: FileImage,
|
||||
webp: FileImage,
|
||||
// Package
|
||||
lock: Package,
|
||||
// Python
|
||||
py: FileCode,
|
||||
pyc: FileCode,
|
||||
// Ruby
|
||||
rb: FileCode,
|
||||
// Go
|
||||
go: FileCode,
|
||||
// Rust
|
||||
rs: FileCode,
|
||||
// Java / Kotlin
|
||||
java: FileCode,
|
||||
kt: FileCode,
|
||||
// C / C++
|
||||
c: FileCode,
|
||||
cpp: FileCode,
|
||||
h: FileCode,
|
||||
hpp: FileCode,
|
||||
// Shell
|
||||
sh: FileCode,
|
||||
bash: FileCode,
|
||||
zsh: FileCode,
|
||||
fish: FileCode,
|
||||
// SQL
|
||||
sql: Database,
|
||||
};
|
||||
|
||||
function getExtension(filePath: string): string {
|
||||
const parts = filePath.split(".");
|
||||
if (parts.length < 2) return "";
|
||||
return parts[parts.length - 1].toLowerCase();
|
||||
}
|
||||
|
||||
export function getFileIcon(filePath: string): LucideIcon {
|
||||
const ext = getExtension(filePath);
|
||||
return EXT_MAP[ext] ?? File;
|
||||
}
|
||||
|
||||
interface FileIconProps {
|
||||
filePath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileIcon({ filePath, className }: FileIconProps) {
|
||||
const Icon = getFileIcon(filePath);
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
Reference in New Issue
Block a user