| author | |
| committer | |
| log | c7ae6b9471ab273e5b0c0ee700ae5c77ef69c330 |
| tree | 8ef7074810a9a0dc04350d7306b7ad9036378b9c |
| parent | bbcf9ba9db15ad02d98c417e3f5566ff2dedf4b7 |
8 files changed, 263 insertions(+), 7 deletions(-)
.test-favicon.mjs created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import path from "path"; | |
| 2 | import fs from "fs"; | |
| 3 | ||
| 4 | const FAVICON_CANDIDATES = [ | |
| 5 | "favicon.svg", "favicon.ico", "favicon.png", | |
| 6 | "public/favicon.svg", "public/favicon.ico", "public/favicon.png", | |
| 7 | "app/favicon.ico", "app/favicon.png", "app/icon.svg", "app/icon.png", "app/icon.ico", | |
| 8 | "src/favicon.ico", "src/favicon.svg", "src/app/favicon.ico", "src/app/icon.svg", "src/app/icon.png", | |
| 9 | ]; | |
| 10 | ||
| 11 | const ICON_SOURCE_FILES = [ | |
| 12 | "index.html", "public/index.html", | |
| 13 | "app/routes/__root.tsx", "src/routes/__root.tsx", | |
| 14 | "app/root.tsx", "src/root.tsx", "src/index.html", | |
| 15 | ]; | |
| 16 | ||
| 17 | const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; | |
| 18 | const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; | |
| 19 | ||
| 20 | function test(projectCwd) { | |
| 21 | console.log("--- Testing:", projectCwd, "---"); | |
| 22 | ||
| 23 | for (const c of FAVICON_CANDIDATES) { | |
| 24 | const full = path.join(projectCwd, c); | |
| 25 | if (fs.existsSync(full) && fs.statSync(full).isFile()) { | |
| 26 | console.log(" Phase 1 FOUND:", c); | |
| 27 | return; | |
| 28 | } | |
| 29 | } | |
| 30 | console.log(" Phase 1: no match"); | |
| 31 | ||
| 32 | for (const sf of ICON_SOURCE_FILES) { | |
| 33 | const full = path.join(projectCwd, sf); | |
| 34 | if (!fs.existsSync(full)) continue; | |
| 35 | const content = fs.readFileSync(full, "utf8"); | |
| 36 | const href = content.match(LINK_ICON_HTML_RE)?.[1] || content.match(LINK_ICON_OBJ_RE)?.[1]; | |
| 37 | if (href) { | |
| 38 | const clean = href.replace(/^\//, ""); | |
| 39 | const tryPaths = [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)]; | |
| 40 | for (const p of tryPaths) { | |
| 41 | if (fs.existsSync(p)) { | |
| 42 | console.log(" Phase 2 FOUND:", sf, "->", href, "->", p); | |
| 43 | return; | |
| 44 | } | |
| 45 | } | |
| 46 | console.log(" Phase 2 href found but file missing:", href); | |
| 47 | } | |
| 48 | } | |
| 49 | console.log(" Phase 2: no match"); | |
| 50 | } | |
| 51 | ||
| 52 | test("/Users/theo/Code/Work/ct/t3chat"); | |
| 53 | test("/Users/theo/Code/Work/lawn"); | |
| 54 | test(process.cwd()); |
README.md+1-1| ... | ... | @@ -91,7 +91,7 @@ npx t3 |
| 91 | 91 | |
| 92 | 92 | Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. |
| 93 | 93 | |
| 94 | - Default ports: server `3773`, web `5173` | |
| 94 | - Default ports: server `3773`, web `5733` | |
| 95 | 95 | - Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`) |
| 96 | 96 | - Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop` |
| 97 | 97 |
apps/desktop/scripts/dev-electron.mjs+1-1| ... | ... | @@ -5,7 +5,7 @@ import waitOn from "wait-on"; |
| 5 | 5 | |
| 6 | 6 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 7 | 7 | |
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); | |
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); | |
| 9 | 9 | const devServerUrl = `http://localhost:${port}`; |
| 10 | 10 | |
| 11 | 11 | await waitOn({ |
apps/server/package.json+1-1| ... | ... | @@ -10,7 +10,7 @@ |
| 10 | 10 | "type": "module", |
| 11 | 11 | "main": "./dist/index.mjs", |
| 12 | 12 | "scripts": { |
| 13 | "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts", | |
| 13 | "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5733} bun run src/index.ts", | |
| 14 | 14 | "build": "tsdown && node scripts/bundle-client.ts", |
| 15 | 15 | "start": "node dist/index.mjs", |
| 16 | 16 | "prepare": "effect-language-service patch", |
apps/server/src/wsServer.ts+158-1| ... | ... | @@ -230,8 +230,166 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 230 | 230 | }); |
| 231 | 231 | }); |
| 232 | 232 | |
| 233 | // Well-known favicon paths checked in order. | |
| 234 | const FAVICON_CANDIDATES = [ | |
| 235 | "favicon.svg", | |
| 236 | "favicon.ico", | |
| 237 | "favicon.png", | |
| 238 | "public/favicon.svg", | |
| 239 | "public/favicon.ico", | |
| 240 | "public/favicon.png", | |
| 241 | "app/favicon.ico", | |
| 242 | "app/favicon.png", | |
| 243 | "app/icon.svg", | |
| 244 | "app/icon.png", | |
| 245 | "app/icon.ico", | |
| 246 | "src/favicon.ico", | |
| 247 | "src/favicon.svg", | |
| 248 | "src/app/favicon.ico", | |
| 249 | "src/app/icon.svg", | |
| 250 | "src/app/icon.png", | |
| 251 | ]; | |
| 252 | ||
| 253 | // Files that may contain a <link rel="icon"> or icon metadata declaration. | |
| 254 | const ICON_SOURCE_FILES = [ | |
| 255 | "index.html", | |
| 256 | "public/index.html", | |
| 257 | "app/routes/__root.tsx", | |
| 258 | "src/routes/__root.tsx", | |
| 259 | "app/root.tsx", | |
| 260 | "src/root.tsx", | |
| 261 | "src/index.html", | |
| 262 | ]; | |
| 263 | ||
| 264 | // Extract icon href from HTML/TSX source content. | |
| 265 | // Matches <link rel="icon" href="…"> or { rel: "icon", href: "…" } patterns. | |
| 266 | const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i; | |
| 267 | const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i; | |
| 268 | function extractIconHref(source: string): string | null { | |
| 269 | const htmlMatch = source.match(LINK_ICON_HTML_RE); | |
| 270 | if (htmlMatch?.[1]) return htmlMatch[1]; | |
| 271 | const objMatch = source.match(LINK_ICON_OBJ_RE); | |
| 272 | if (objMatch?.[1]) return objMatch[1]; | |
| 273 | return null; | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * Resolve an icon href (e.g. "/grass-logo.svg") to a file on disk. | |
| 278 | * For href starting with "/" we look in public/ first, then project root. | |
| 279 | */ | |
| 280 | function resolveIconHref(projectCwd: string, href: string): string[] { | |
| 281 | const clean = href.replace(/^\//, ""); | |
| 282 | return [ | |
| 283 | path.join(projectCwd, "public", clean), | |
| 284 | path.join(projectCwd, clean), | |
| 285 | ]; | |
| 286 | } | |
| 287 | ||
| 288 | function serveFaviconFile( | |
| 289 | filePath: string, | |
| 290 | res: http.ServerResponse, | |
| 291 | ): void { | |
| 292 | const ext = path.extname(filePath); | |
| 293 | const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; | |
| 294 | fs.readFile(filePath, (readErr, data) => { | |
| 295 | if (readErr) { | |
| 296 | res.writeHead(500, { "Content-Type": "text/plain" }); | |
| 297 | res.end("Read error"); | |
| 298 | return; | |
| 299 | } | |
| 300 | res.writeHead(200, { | |
| 301 | "Content-Type": contentType, | |
| 302 | "Cache-Control": "public, max-age=3600", | |
| 303 | }); | |
| 304 | res.end(data); | |
| 305 | }); | |
| 306 | } | |
| 307 | ||
| 233 | 308 | // HTTP server — serves static files or redirects to Vite dev server |
| 234 | 309 | const httpServer = http.createServer((req, res) => { |
| 310 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 311 | ||
| 312 | // Serve project favicons from their working directories. | |
| 313 | // Handled before the dev redirect so it works in both modes. | |
| 314 | if (url.pathname === "/api/project-favicon") { | |
| 315 | const projectCwd = url.searchParams.get("cwd"); | |
| 316 | if (!projectCwd) { | |
| 317 | res.writeHead(400, { "Content-Type": "text/plain" }); | |
| 318 | res.end("Missing cwd parameter"); | |
| 319 | return; | |
| 320 | } | |
| 321 | ||
| 322 | // Phase 1: Check well-known favicon paths. | |
| 323 | const tryCandidates = (index: number) => { | |
| 324 | if (index >= FAVICON_CANDIDATES.length) { | |
| 325 | // Phase 2: Parse source files for icon link declarations. | |
| 326 | trySourceFiles(0); | |
| 327 | return; | |
| 328 | } | |
| 329 | const candidate = path.join(projectCwd, FAVICON_CANDIDATES[index]!); | |
| 330 | if (!candidate.startsWith(projectCwd + path.sep)) { | |
| 331 | tryCandidates(index + 1); | |
| 332 | return; | |
| 333 | } | |
| 334 | fs.stat(candidate, (err, stats) => { | |
| 335 | if (err || !stats?.isFile()) { | |
| 336 | tryCandidates(index + 1); | |
| 337 | return; | |
| 338 | } | |
| 339 | serveFaviconFile(candidate, res); | |
| 340 | }); | |
| 341 | }; | |
| 342 | ||
| 343 | // Phase 2: Read source/HTML files and extract icon href references. | |
| 344 | const trySourceFiles = (index: number) => { | |
| 345 | if (index >= ICON_SOURCE_FILES.length) { | |
| 346 | res.writeHead(404, { "Content-Type": "text/plain" }); | |
| 347 | res.end("No favicon found"); | |
| 348 | return; | |
| 349 | } | |
| 350 | const sourceFile = path.join(projectCwd, ICON_SOURCE_FILES[index]!); | |
| 351 | fs.readFile(sourceFile, "utf8", (err, content) => { | |
| 352 | if (err) { | |
| 353 | trySourceFiles(index + 1); | |
| 354 | return; | |
| 355 | } | |
| 356 | const href = extractIconHref(content); | |
| 357 | if (!href) { | |
| 358 | trySourceFiles(index + 1); | |
| 359 | return; | |
| 360 | } | |
| 361 | const candidates = resolveIconHref(projectCwd, href); | |
| 362 | tryResolvedPaths(candidates, 0, () => trySourceFiles(index + 1)); | |
| 363 | }); | |
| 364 | }; | |
| 365 | ||
| 366 | const tryResolvedPaths = ( | |
| 367 | paths: string[], | |
| 368 | index: number, | |
| 369 | onExhausted: () => void, | |
| 370 | ) => { | |
| 371 | if (index >= paths.length) { | |
| 372 | onExhausted(); | |
| 373 | return; | |
| 374 | } | |
| 375 | const candidate = paths[index]!; | |
| 376 | if (!candidate.startsWith(projectCwd + path.sep)) { | |
| 377 | tryResolvedPaths(paths, index + 1, onExhausted); | |
| 378 | return; | |
| 379 | } | |
| 380 | fs.stat(candidate, (err, stats) => { | |
| 381 | if (err || !stats?.isFile()) { | |
| 382 | tryResolvedPaths(paths, index + 1, onExhausted); | |
| 383 | return; | |
| 384 | } | |
| 385 | serveFaviconFile(candidate, res); | |
| 386 | }); | |
| 387 | }; | |
| 388 | ||
| 389 | tryCandidates(0); | |
| 390 | return; | |
| 391 | } | |
| 392 | ||
| 235 | 393 | // In dev mode, redirect to Vite dev server |
| 236 | 394 | if (devUrl) { |
| 237 | 395 | res.writeHead(302, { Location: devUrl.href }); |
| ... | ... | @@ -246,7 +404,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 246 | 404 | return; |
| 247 | 405 | } |
| 248 | 406 | |
| 249 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | |
| 250 | 407 | let filePath = path.join(staticDir, url.pathname); |
| 251 | 408 | |
| 252 | 409 | // SPA fallback: if no file extension and not found, serve index.html |
apps/web/src/components/Sidebar.tsx+46-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ChevronRightIcon, TerminalIcon } from "lucide-react"; | |
| 1 | import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react"; | |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | 3 | import { |
| 4 | 4 | DEFAULT_MODEL, |
| ... | ... | @@ -141,6 +141,50 @@ function T3Wordmark() { |
| 141 | 141 | ); |
| 142 | 142 | } |
| 143 | 143 | |
| 144 | /** | |
| 145 | * Derives the server's HTTP origin (scheme + host + port) from the same | |
| 146 | * sources WsTransport uses, converting ws(s) to http(s). | |
| 147 | */ | |
| 148 | function getServerHttpOrigin(): string { | |
| 149 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | |
| 150 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | |
| 151 | const wsUrl = | |
| 152 | bridgeUrl && bridgeUrl.length > 0 | |
| 153 | ? bridgeUrl | |
| 154 | : envUrl && envUrl.length > 0 | |
| 155 | ? envUrl | |
| 156 | : `ws://${window.location.hostname}:${window.location.port}`; | |
| 157 | // Parse to extract just the origin, dropping path/query (e.g. ?token=…) | |
| 158 | const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); | |
| 159 | try { | |
| 160 | return new URL(httpUrl).origin; | |
| 161 | } catch { | |
| 162 | return httpUrl; | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | const serverHttpOrigin = getServerHttpOrigin(); | |
| 167 | ||
| 168 | function ProjectFavicon({ cwd }: { cwd: string }) { | |
| 169 | const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); | |
| 170 | ||
| 171 | const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; | |
| 172 | ||
| 173 | if (status === "error") { | |
| 174 | return <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/50" />; | |
| 175 | } | |
| 176 | ||
| 177 | return ( | |
| 178 | <img | |
| 179 | src={src} | |
| 180 | alt="" | |
| 181 | className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`} | |
| 182 | onLoad={() => setStatus("loaded")} | |
| 183 | onError={() => setStatus("error")} | |
| 184 | /> | |
| 185 | ); | |
| 186 | } | |
| 187 | ||
| 144 | 188 | export default function Sidebar() { |
| 145 | 189 | const { state, dispatch } = useStore(); |
| 146 | 190 | const navigate = useNavigate(); |
| ... | ... | @@ -573,6 +617,7 @@ export default function Sidebar() { |
| 573 | 617 | project.expanded ? "rotate-90" : "" |
| 574 | 618 | }`} |
| 575 | 619 | /> |
| 620 | <ProjectFavicon cwd={project.cwd} /> | |
| 576 | 621 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 577 | 622 | {project.name} |
| 578 | 623 | </span> |
apps/web/vite.config.ts+1-1| ... | ... | @@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"; |
| 3 | 3 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; |
| 4 | 4 | import { defineConfig } from "vite"; |
| 5 | 5 | |
| 6 | const port = Number(process.env.PORT ?? 5173); | |
| 6 | const port = Number(process.env.PORT ?? 5733); | |
| 7 | 7 | |
| 8 | 8 | export default defineConfig({ |
| 9 | 9 | plugins: [ |
scripts/dev-runner.mjs+1-1| ... | ... | @@ -4,7 +4,7 @@ import path from "node:path"; |
| 4 | 4 | import { pathToFileURL } from "node:url"; |
| 5 | 5 | |
| 6 | 6 | const BASE_SERVER_PORT = 3773; |
| 7 | const BASE_WEB_PORT = 5173; | |
| 7 | const BASE_WEB_PORT = 5733; | |
| 8 | 8 | const MAX_HASH_OFFSET = 3000; |
| 9 | 9 | export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev"); |
| 10 | 10 | const MODE_ARGS = { |