| author | |
| committer | |
| log | 37e17e9207125a357f8121799a6f5ff44d90eff5 |
| tree | e5100a5a5ea9c3ca50f3bb170718b125a8dd87e7 |
| parent | fe86836b43dbcb49598d8e7a728c7febb9584b3c |
| parent | 749a2076d8ad713eef9ef575ff40437abd3adcd8 |
Co-authored-by: codex <codex@users.noreply.github.com>134 files changed, 11993 insertions(+), 1003 deletions(-)
.gitignore+1| ... | ... | @@ -10,3 +10,4 @@ packages/*/dist |
| 10 | 10 | .env.local |
| 11 | 11 | build/ |
| 12 | 12 | .logs/ |
| 13 | release/ |
.oxlintrc.json+8-1| ... | ... | @@ -1,6 +1,13 @@ |
| 1 | 1 | { |
| 2 | 2 | "$schema": "./node_modules/oxlint/configuration_schema.json", |
| 3 | "ignorePatterns": ["dist", "dist-electron", "node_modules", "bun.lock", "*.tsbuildinfo"], | |
| 3 | "ignorePatterns": [ | |
| 4 | "dist", | |
| 5 | "dist-electron", | |
| 6 | "node_modules", | |
| 7 | "bun.lock", | |
| 8 | "*.tsbuildinfo", | |
| 9 | "**/routeTree.gen.ts" | |
| 10 | ], | |
| 4 | 11 | "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], |
| 5 | 12 | "categories": { |
| 6 | 13 | "correctness": "warn", |
KEYBINDINGS.md+1| ... | ... | @@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged |
| 53 | 53 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state |
| 54 | 54 | - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) |
| 55 | 55 | - `editor.openFavorite`: open current project/worktree in the last-used editor |
| 56 | - `script.{id}.run`: run a project script by id (for example `script.test.run`) | |
| 56 | 57 | |
| 57 | 58 | ### Key Syntax |
| 58 | 59 |
README.md+14| ... | ... | @@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop |
| 56 | 56 | bun run build |
| 57 | 57 | bun run start |
| 58 | 58 | |
| 59 | # Build a shareable macOS .dmg (arm64 by default) | |
| 60 | bun run dist:desktop:dmg | |
| 61 | ||
| 59 | 62 | # Or from any project directory after publishing: |
| 60 | 63 | npx t3 |
| 61 | 64 | ``` |
| ... | ... | @@ -69,6 +72,17 @@ npx t3 |
| 69 | 72 | - `bun run build` — Builds contracts, web app, and server through Turbo. |
| 70 | 73 | - `bun run typecheck` — Strict TypeScript checks for all packages. |
| 71 | 74 | - `bun run test` — Runs workspace tests. |
| 75 | - `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. | |
| 76 | - `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. | |
| 77 | ||
| 78 | ### Desktop `.dmg` packaging notes | |
| 79 | ||
| 80 | - Default build is unsigned/not notarized for local sharing. | |
| 81 | - The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. | |
| 82 | - Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL). | |
| 83 | - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. | |
| 84 | - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. | |
| 85 | - To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` | |
| 72 | 86 | |
| 73 | 87 | ### Running multiple dev instances |
| 74 | 88 |
TODO.md+3-1| ... | ... | @@ -2,6 +2,8 @@ |
| 2 | 2 | |
| 3 | 3 | ## Small things |
| 4 | 4 | |
| 5 | - [ ] Plan mode | |
| 6 | - [ ] Submitting new messages should scroll to bottom | |
| 5 | 7 | - [ ] 1 click copy in codeblocks |
| 6 | 8 | - [x] Pasting/adding images to submit with prompts |
| 7 | 9 | - [ ] Only show last 10 threads for a given project |
| ... | ... | @@ -12,7 +14,7 @@ |
| 12 | 14 | - [x] "Response" bar should ONLY appear on work completed |
| 13 | 15 | - [x] "Response" bar should ONLY appear above the FINAL message in a series |
| 14 | 16 | - [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc) |
| 15 | - [ ] Insta focus input on new thread creation | |
| 17 | - [x] Insta focus input on new thread creation | |
| 16 | 18 | - [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it |
| 17 | 19 | - [x] Making new thread should auto-focus input box |
| 18 | 20 | - [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank) |
apps/desktop/.gitignore+1| ... | ... | @@ -1 +1,2 @@ |
| 1 | 1 | dist-electron/ |
| 2 | .electron-runtime/ |
apps/desktop/package.json+2-1| ... | ... | @@ -2,13 +2,14 @@ |
| 2 | 2 | "name": "@t3tools/desktop", |
| 3 | 3 | "version": "0.0.0", |
| 4 | 4 | "private": true, |
| 5 | "productName": "T3 Code (Alpha)", | |
| 5 | 6 | "main": "dist-electron/main.js", |
| 6 | 7 | "scripts": { |
| 7 | 8 | "dev": "bun run --parallel dev:bundle dev:electron", |
| 8 | 9 | "dev:bundle": "tsdown --watch", |
| 9 | 10 | "dev:electron": "bun run scripts/dev-electron.mjs", |
| 10 | 11 | "build": "tsdown", |
| 11 | "start": "electron dist-electron/main.js", | |
| 12 | "start": "bun run scripts/start-electron.mjs", | |
| 12 | 13 | "typecheck": "tsc --noEmit", |
| 13 | 14 | "test": "vitest run --passWithNoTests", |
| 14 | 15 | "smoke-test": "node scripts/smoke-test.mjs" |
apps/desktop/resources/icon.icns created| Binary files /dev/null and b/apps/desktop/resources/icon.icns differ |
apps/desktop/resources/icon.ico created| Binary files /dev/null and b/apps/desktop/resources/icon.ico differ |
apps/desktop/resources/icon.png created| Binary files /dev/null and b/apps/desktop/resources/icon.png differ |
apps/desktop/scripts/dev-electron.mjs+48-6| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 1 | import { spawnSync } from "node:child_process"; | |
| 2 | 2 | |
| 3 | import electronmon from "electronmon"; | |
| 3 | 4 | import waitOn from "wait-on"; |
| 4 | 5 | |
| 6 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | |
| 7 | ||
| 5 | 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); |
| 6 | 9 | const devServerUrl = `http://localhost:${port}`; |
| 7 | 10 | |
| ... | ... | @@ -14,18 +17,57 @@ await waitOn({ |
| 14 | 17 | ], |
| 15 | 18 | }); |
| 16 | 19 | |
| 17 | const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; | |
| 18 | 20 | const childEnv = { ...process.env }; |
| 19 | 21 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 20 | 22 | |
| 21 | const child = spawn(command, ["dist-electron/main.js"], { | |
| 22 | stdio: "inherit", | |
| 23 | const monitor = await electronmon({ | |
| 24 | cwd: desktopDir, | |
| 25 | args: ["dist-electron/main.js"], | |
| 23 | 26 | env: { |
| 24 | 27 | ...childEnv, |
| 25 | 28 | VITE_DEV_SERVER_URL: devServerUrl, |
| 26 | 29 | }, |
| 30 | electronPath: resolveElectronPath(), | |
| 27 | 31 | }); |
| 28 | 32 | |
| 29 | child.on("exit", (code) => { | |
| 30 | process.exit(code ?? 0); | |
| 33 | let shuttingDown = false; | |
| 34 | ||
| 35 | function killChildTree(signal) { | |
| 36 | if (process.platform === "win32") { | |
| 37 | return; | |
| 38 | } | |
| 39 | ||
| 40 | // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. | |
| 41 | spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); | |
| 42 | } | |
| 43 | ||
| 44 | async function shutdown(exitCode) { | |
| 45 | if (shuttingDown) return; | |
| 46 | shuttingDown = true; | |
| 47 | ||
| 48 | try { | |
| 49 | await Promise.race([ | |
| 50 | monitor.destroy(), | |
| 51 | new Promise((resolve) => setTimeout(resolve, 1_500)), | |
| 52 | ]); | |
| 53 | } catch { | |
| 54 | // Best effort only; fallback process kill below handles stubborn children. | |
| 55 | } | |
| 56 | ||
| 57 | killChildTree("TERM"); | |
| 58 | setTimeout(() => { | |
| 59 | killChildTree("KILL"); | |
| 60 | }, 1_200).unref(); | |
| 61 | ||
| 62 | process.exit(exitCode); | |
| 63 | } | |
| 64 | ||
| 65 | process.once("SIGINT", () => { | |
| 66 | void shutdown(130); | |
| 67 | }); | |
| 68 | process.once("SIGTERM", () => { | |
| 69 | void shutdown(143); | |
| 70 | }); | |
| 71 | process.once("SIGHUP", () => { | |
| 72 | void shutdown(129); | |
| 31 | 73 | }); |
apps/desktop/scripts/electron-launcher.mjs created+173| ... | ... | @@ -0,0 +1,173 @@ |
| 1 | // This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" | |
| 2 | ||
| 3 | import { spawnSync } from "node:child_process"; | |
| 4 | import { | |
| 5 | copyFileSync, | |
| 6 | cpSync, | |
| 7 | existsSync, | |
| 8 | mkdirSync, | |
| 9 | readFileSync, | |
| 10 | readdirSync, | |
| 11 | rmSync, | |
| 12 | statSync, | |
| 13 | writeFileSync, | |
| 14 | } from "node:fs"; | |
| 15 | import { createRequire } from "node:module"; | |
| 16 | import { dirname, join, resolve } from "node:path"; | |
| 17 | import { fileURLToPath } from "node:url"; | |
| 18 | ||
| 19 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | |
| 20 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 21 | const APP_BUNDLE_ID = "com.t3tools.t3code"; | |
| 22 | const LAUNCHER_VERSION = 1; | |
| 23 | ||
| 24 | const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| 25 | export const desktopDir = resolve(__dirname, ".."); | |
| 26 | ||
| 27 | function setPlistString(plistPath, key, value) { | |
| 28 | const replaceResult = spawnSync( | |
| 29 | "plutil", | |
| 30 | ["-replace", key, "-string", value, plistPath], | |
| 31 | { | |
| 32 | encoding: "utf8", | |
| 33 | }, | |
| 34 | ); | |
| 35 | if (replaceResult.status === 0) { | |
| 36 | return; | |
| 37 | } | |
| 38 | ||
| 39 | const insertResult = spawnSync( | |
| 40 | "plutil", | |
| 41 | ["-insert", key, "-string", value, plistPath], | |
| 42 | { | |
| 43 | encoding: "utf8", | |
| 44 | }, | |
| 45 | ); | |
| 46 | if (insertResult.status === 0) { | |
| 47 | return; | |
| 48 | } | |
| 49 | ||
| 50 | const details = [replaceResult.stderr, insertResult.stderr] | |
| 51 | .filter(Boolean) | |
| 52 | .join("\n"); | |
| 53 | throw new Error( | |
| 54 | `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), | |
| 55 | ); | |
| 56 | } | |
| 57 | ||
| 58 | function patchMainBundleInfoPlist(appBundlePath, iconPath) { | |
| 59 | const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); | |
| 60 | setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); | |
| 61 | setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); | |
| 62 | setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); | |
| 63 | setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); | |
| 64 | ||
| 65 | const resourcesDir = join(appBundlePath, "Contents", "Resources"); | |
| 66 | copyFileSync(iconPath, join(resourcesDir, "icon.icns")); | |
| 67 | copyFileSync(iconPath, join(resourcesDir, "electron.icns")); | |
| 68 | } | |
| 69 | ||
| 70 | function patchHelperBundleInfoPlists(appBundlePath) { | |
| 71 | const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); | |
| 72 | if (!existsSync(frameworksDir)) { | |
| 73 | return; | |
| 74 | } | |
| 75 | ||
| 76 | for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { | |
| 77 | if (!entry.isDirectory() || !entry.name.endsWith(".app")) { | |
| 78 | continue; | |
| 79 | } | |
| 80 | if (!entry.name.startsWith("Electron Helper")) { | |
| 81 | continue; | |
| 82 | } | |
| 83 | ||
| 84 | const helperPlistPath = join( | |
| 85 | frameworksDir, | |
| 86 | entry.name, | |
| 87 | "Contents", | |
| 88 | "Info.plist", | |
| 89 | ); | |
| 90 | if (!existsSync(helperPlistPath)) { | |
| 91 | continue; | |
| 92 | } | |
| 93 | ||
| 94 | const suffix = entry.name | |
| 95 | .replace("Electron Helper", "") | |
| 96 | .replace(".app", "") | |
| 97 | .trim(); | |
| 98 | const helperName = suffix | |
| 99 | ? `${APP_DISPLAY_NAME} Helper ${suffix}` | |
| 100 | : `${APP_DISPLAY_NAME} Helper`; | |
| 101 | const helperIdSuffix = suffix | |
| 102 | .replace(/[()]/g, "") | |
| 103 | .trim() | |
| 104 | .toLowerCase() | |
| 105 | .replace(/\s+/g, "-"); | |
| 106 | const helperBundleId = helperIdSuffix | |
| 107 | ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` | |
| 108 | : `${APP_BUNDLE_ID}.helper`; | |
| 109 | ||
| 110 | setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); | |
| 111 | setPlistString(helperPlistPath, "CFBundleName", helperName); | |
| 112 | setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | function readJson(path) { | |
| 117 | try { | |
| 118 | return JSON.parse(readFileSync(path, "utf8")); | |
| 119 | } catch { | |
| 120 | return null; | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | function buildMacLauncher(electronBinaryPath) { | |
| 125 | const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); | |
| 126 | const runtimeDir = join(desktopDir, ".electron-runtime"); | |
| 127 | const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); | |
| 128 | const targetBinaryPath = join( | |
| 129 | targetAppBundlePath, | |
| 130 | "Contents", | |
| 131 | "MacOS", | |
| 132 | "Electron", | |
| 133 | ); | |
| 134 | const iconPath = join(desktopDir, "resources", "icon.icns"); | |
| 135 | const metadataPath = join(runtimeDir, "metadata.json"); | |
| 136 | ||
| 137 | mkdirSync(runtimeDir, { recursive: true }); | |
| 138 | ||
| 139 | const expectedMetadata = { | |
| 140 | launcherVersion: LAUNCHER_VERSION, | |
| 141 | sourceAppBundlePath, | |
| 142 | sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, | |
| 143 | iconMtimeMs: statSync(iconPath).mtimeMs, | |
| 144 | }; | |
| 145 | ||
| 146 | const currentMetadata = readJson(metadataPath); | |
| 147 | if ( | |
| 148 | existsSync(targetBinaryPath) && | |
| 149 | currentMetadata && | |
| 150 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) | |
| 151 | ) { | |
| 152 | return targetBinaryPath; | |
| 153 | } | |
| 154 | ||
| 155 | rmSync(targetAppBundlePath, { recursive: true, force: true }); | |
| 156 | cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); | |
| 157 | patchMainBundleInfoPlist(targetAppBundlePath, iconPath); | |
| 158 | patchHelperBundleInfoPlists(targetAppBundlePath); | |
| 159 | writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); | |
| 160 | ||
| 161 | return targetBinaryPath; | |
| 162 | } | |
| 163 | ||
| 164 | export function resolveElectronPath() { | |
| 165 | const require = createRequire(import.meta.url); | |
| 166 | const electronBinaryPath = require("electron"); | |
| 167 | ||
| 168 | if (process.platform !== "darwin") { | |
| 169 | return electronBinaryPath; | |
| 170 | } | |
| 171 | ||
| 172 | return buildMacLauncher(electronBinaryPath); | |
| 173 | } |
apps/desktop/scripts/start-electron.mjs created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | ||
| 3 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | |
| 4 | ||
| 5 | const childEnv = { ...process.env }; | |
| 6 | delete childEnv.ELECTRON_RUN_AS_NODE; | |
| 7 | ||
| 8 | const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { | |
| 9 | stdio: "inherit", | |
| 10 | cwd: desktopDir, | |
| 11 | env: childEnv, | |
| 12 | }); | |
| 13 | ||
| 14 | child.on("exit", (code, signal) => { | |
| 15 | if (signal) { | |
| 16 | process.kill(process.pid, signal); | |
| 17 | return; | |
| 18 | } | |
| 19 | process.exit(code ?? 0); | |
| 20 | }); |
apps/desktop/src/main.ts+289-14| ... | ... | @@ -5,7 +5,16 @@ import net from "node:net"; |
| 5 | 5 | import os from "node:os"; |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; | |
| 8 | import { | |
| 9 | app, | |
| 10 | BrowserWindow, | |
| 11 | dialog, | |
| 12 | ipcMain, | |
| 13 | Menu, | |
| 14 | type MenuItemConstructorOptions, | |
| 15 | protocol, | |
| 16 | shell, | |
| 17 | } from "electron"; | |
| 9 | 18 | |
| 10 | 19 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 11 | 20 | import { fixPath } from "./fixPath"; |
| ... | ... | @@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 16 | 25 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 17 | 26 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 18 | 27 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 19 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 20 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); | |
| 21 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); | |
| 28 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 22 | 29 | const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); |
| 30 | const DESKTOP_SCHEME = "t3"; | |
| 31 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 23 | 32 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 33 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 34 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; | |
| 24 | 35 | |
| 25 | 36 | let mainWindow: BrowserWindow | null = null; |
| 26 | 37 | let backendProcess: ChildProcess | null = null; |
| ... | ... | @@ -30,6 +41,235 @@ let backendWsUrl = ""; |
| 30 | 41 | let restartAttempt = 0; |
| 31 | 42 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 32 | 43 | let isQuitting = false; |
| 44 | let desktopProtocolRegistered = false; | |
| 45 | ||
| 46 | protocol.registerSchemesAsPrivileged([ | |
| 47 | { | |
| 48 | scheme: DESKTOP_SCHEME, | |
| 49 | privileges: { | |
| 50 | standard: true, | |
| 51 | secure: true, | |
| 52 | supportFetchAPI: true, | |
| 53 | corsEnabled: true, | |
| 54 | }, | |
| 55 | }, | |
| 56 | ]); | |
| 57 | ||
| 58 | function resolveAppRoot(): string { | |
| 59 | if (!app.isPackaged) { | |
| 60 | return ROOT_DIR; | |
| 61 | } | |
| 62 | return app.getAppPath(); | |
| 63 | } | |
| 64 | ||
| 65 | function resolveBackendEntry(): string { | |
| 66 | return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); | |
| 67 | } | |
| 68 | ||
| 69 | function resolveBackendCwd(): string { | |
| 70 | if (!app.isPackaged) { | |
| 71 | return resolveAppRoot(); | |
| 72 | } | |
| 73 | return os.homedir(); | |
| 74 | } | |
| 75 | ||
| 76 | function resolveDesktopStaticDir(): string | null { | |
| 77 | const appRoot = resolveAppRoot(); | |
| 78 | const candidates = [ | |
| 79 | path.join(appRoot, "apps/server/dist/client"), | |
| 80 | path.join(appRoot, "apps/web/dist"), | |
| 81 | ]; | |
| 82 | ||
| 83 | for (const candidate of candidates) { | |
| 84 | if (fs.existsSync(path.join(candidate, "index.html"))) { | |
| 85 | return candidate; | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { | |
| 93 | const url = new URL(requestUrl); | |
| 94 | const rawPath = decodeURIComponent(url.pathname); | |
| 95 | const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, ""); | |
| 96 | if (normalizedPath.includes("..")) { | |
| 97 | return path.join(staticRoot, "index.html"); | |
| 98 | } | |
| 99 | ||
| 100 | const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; | |
| 101 | const resolvedPath = path.join(staticRoot, requestedPath); | |
| 102 | ||
| 103 | if (path.extname(resolvedPath)) { | |
| 104 | return resolvedPath; | |
| 105 | } | |
| 106 | ||
| 107 | const nestedIndex = path.join(resolvedPath, "index.html"); | |
| 108 | if (fs.existsSync(nestedIndex)) { | |
| 109 | return nestedIndex; | |
| 110 | } | |
| 111 | ||
| 112 | return path.join(staticRoot, "index.html"); | |
| 113 | } | |
| 114 | ||
| 115 | function isStaticAssetRequest(requestUrl: string): boolean { | |
| 116 | try { | |
| 117 | const url = new URL(requestUrl); | |
| 118 | return path.extname(url.pathname).length > 0; | |
| 119 | } catch { | |
| 120 | return false; | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | function registerDesktopProtocol(): void { | |
| 125 | if (isDevelopment || desktopProtocolRegistered) return; | |
| 126 | ||
| 127 | const staticRoot = resolveDesktopStaticDir(); | |
| 128 | if (!staticRoot) { | |
| 129 | throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); | |
| 130 | } | |
| 131 | ||
| 132 | const staticRootResolved = path.resolve(staticRoot); | |
| 133 | const staticRootPrefix = `${staticRootResolved}${path.sep}`; | |
| 134 | const fallbackIndex = path.join(staticRootResolved, "index.html"); | |
| 135 | ||
| 136 | protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { | |
| 137 | try { | |
| 138 | const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); | |
| 139 | const resolvedCandidate = path.resolve(candidate); | |
| 140 | const isInRoot = | |
| 141 | resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); | |
| 142 | const isAssetRequest = isStaticAssetRequest(request.url); | |
| 143 | ||
| 144 | if (!isInRoot || !fs.existsSync(resolvedCandidate)) { | |
| 145 | if (isAssetRequest) { | |
| 146 | callback({ error: -6 }); | |
| 147 | return; | |
| 148 | } | |
| 149 | callback({ path: fallbackIndex }); | |
| 150 | return; | |
| 151 | } | |
| 152 | ||
| 153 | callback({ path: resolvedCandidate }); | |
| 154 | } catch { | |
| 155 | callback({ path: fallbackIndex }); | |
| 156 | } | |
| 157 | }); | |
| 158 | ||
| 159 | desktopProtocolRegistered = true; | |
| 160 | } | |
| 161 | ||
| 162 | function dispatchMenuAction(action: string): void { | |
| 163 | const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 164 | const targetWindow = existingWindow ?? createWindow(); | |
| 165 | if (!existingWindow) { | |
| 166 | mainWindow = targetWindow; | |
| 167 | } | |
| 168 | ||
| 169 | const send = () => { | |
| 170 | if (targetWindow.isDestroyed()) return; | |
| 171 | targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); | |
| 172 | if (!targetWindow.isVisible()) { | |
| 173 | targetWindow.show(); | |
| 174 | } | |
| 175 | targetWindow.focus(); | |
| 176 | }; | |
| 177 | ||
| 178 | if (targetWindow.webContents.isLoadingMainFrame()) { | |
| 179 | targetWindow.webContents.once("did-finish-load", send); | |
| 180 | return; | |
| 181 | } | |
| 182 | ||
| 183 | send(); | |
| 184 | } | |
| 185 | ||
| 186 | function configureApplicationMenu(): void { | |
| 187 | const template: MenuItemConstructorOptions[] = []; | |
| 188 | ||
| 189 | if (process.platform === "darwin") { | |
| 190 | template.push({ | |
| 191 | label: app.name, | |
| 192 | submenu: [ | |
| 193 | { role: "about" }, | |
| 194 | { | |
| 195 | label: "Settings...", | |
| 196 | accelerator: "CmdOrCtrl+,", | |
| 197 | click: () => dispatchMenuAction("open-settings"), | |
| 198 | }, | |
| 199 | { type: "separator" }, | |
| 200 | { role: "services" }, | |
| 201 | { type: "separator" }, | |
| 202 | { role: "hide" }, | |
| 203 | { role: "hideOthers" }, | |
| 204 | { role: "unhide" }, | |
| 205 | { type: "separator" }, | |
| 206 | { role: "quit" }, | |
| 207 | ], | |
| 208 | }); | |
| 209 | } | |
| 210 | ||
| 211 | template.push( | |
| 212 | { | |
| 213 | label: "File", | |
| 214 | submenu: [ | |
| 215 | ...(process.platform === "darwin" | |
| 216 | ? [] | |
| 217 | : [ | |
| 218 | { | |
| 219 | label: "Settings...", | |
| 220 | accelerator: "CmdOrCtrl+,", | |
| 221 | click: () => dispatchMenuAction("open-settings"), | |
| 222 | }, | |
| 223 | { type: "separator" as const }, | |
| 224 | ]), | |
| 225 | { role: process.platform === "darwin" ? "close" : "quit" }, | |
| 226 | ], | |
| 227 | }, | |
| 228 | { role: "editMenu" }, | |
| 229 | { role: "viewMenu" }, | |
| 230 | { role: "windowMenu" }, | |
| 231 | ); | |
| 232 | ||
| 233 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)); | |
| 234 | } | |
| 235 | ||
| 236 | function resolveResourcePath(fileName: string): string | null { | |
| 237 | const candidates = [ | |
| 238 | path.join(__dirname, "../resources", fileName), | |
| 239 | path.join(process.resourcesPath, "resources", fileName), | |
| 240 | path.join(process.resourcesPath, fileName), | |
| 241 | ]; | |
| 242 | ||
| 243 | for (const candidate of candidates) { | |
| 244 | if (fs.existsSync(candidate)) { | |
| 245 | return candidate; | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | return null; | |
| 250 | } | |
| 251 | ||
| 252 | function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { | |
| 253 | return resolveResourcePath(`icon.${ext}`); | |
| 254 | } | |
| 255 | ||
| 256 | function configureAppIdentity(): void { | |
| 257 | app.setName(APP_DISPLAY_NAME); | |
| 258 | app.setAboutPanelOptions({ | |
| 259 | applicationName: APP_DISPLAY_NAME, | |
| 260 | }); | |
| 261 | ||
| 262 | if (process.platform === "win32") { | |
| 263 | app.setAppUserModelId(APP_USER_MODEL_ID); | |
| 264 | } | |
| 265 | ||
| 266 | if (process.platform === "darwin" && app.dock) { | |
| 267 | const iconPath = resolveIconPath("png"); | |
| 268 | if (iconPath) { | |
| 269 | app.dock.setIcon(iconPath); | |
| 270 | } | |
| 271 | } | |
| 272 | } | |
| 33 | 273 | |
| 34 | 274 | async function reserveLoopbackPort(): Promise<number> { |
| 35 | 275 | return new Promise((resolve, reject) => { |
| ... | ... | @@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void { |
| 76 | 316 | function startBackend(): void { |
| 77 | 317 | if (isQuitting || backendProcess) return; |
| 78 | 318 | |
| 79 | if (!fs.existsSync(BACKEND_ENTRY)) { | |
| 80 | scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); | |
| 319 | const backendEntry = resolveBackendEntry(); | |
| 320 | if (!fs.existsSync(backendEntry)) { | |
| 321 | scheduleBackendRestart(`missing server entry at ${backendEntry}`); | |
| 81 | 322 | return; |
| 82 | 323 | } |
| 83 | 324 | |
| 84 | const child = spawn(process.execPath, [BACKEND_ENTRY], { | |
| 85 | cwd: ROOT_DIR, | |
| 325 | const child = spawn(process.execPath, [backendEntry], { | |
| 326 | cwd: resolveBackendCwd(), | |
| 86 | 327 | // In Electron main, process.execPath points to the Electron binary. |
| 87 | 328 | // Run the child in Node mode so this backend process does not become a GUI app instance. |
| 88 | 329 | env: { |
| ... | ... | @@ -124,10 +365,10 @@ function stopBackend(): void { |
| 124 | 365 | backendProcess = null; |
| 125 | 366 | if (!child) return; |
| 126 | 367 | |
| 127 | if (!child.killed) { | |
| 368 | if (child.exitCode === null && child.signalCode === null) { | |
| 128 | 369 | child.kill("SIGTERM"); |
| 129 | 370 | setTimeout(() => { |
| 130 | if (!child.killed) { | |
| 371 | if (child.exitCode === null && child.signalCode === null) { | |
| 131 | 372 | child.kill("SIGKILL"); |
| 132 | 373 | } |
| 133 | 374 | }, 2_000).unref(); |
| ... | ... | @@ -204,6 +445,13 @@ function registerIpcHandlers(): void { |
| 204 | 445 | }); |
| 205 | 446 | } |
| 206 | 447 | |
| 448 | function getIconOption(): { icon: string } | Record<string, never> { | |
| 449 | if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle | |
| 450 | const ext = process.platform === "win32" ? "ico" : "png"; | |
| 451 | const iconPath = resolveIconPath(ext); | |
| 452 | return iconPath ? { icon: iconPath } : {}; | |
| 453 | } | |
| 454 | ||
| 207 | 455 | function createWindow(): BrowserWindow { |
| 208 | 456 | const window = new BrowserWindow({ |
| 209 | 457 | width: 1100, |
| ... | ... | @@ -212,6 +460,8 @@ function createWindow(): BrowserWindow { |
| 212 | 460 | minHeight: 620, |
| 213 | 461 | show: false, |
| 214 | 462 | autoHideMenuBar: true, |
| 463 | ...getIconOption(), | |
| 464 | title: APP_DISPLAY_NAME, | |
| 215 | 465 | titleBarStyle: "hiddenInset", |
| 216 | 466 | trafficLightPosition: { x: 16, y: 18 }, |
| 217 | 467 | webPreferences: { |
| ... | ... | @@ -223,6 +473,13 @@ function createWindow(): BrowserWindow { |
| 223 | 473 | }); |
| 224 | 474 | |
| 225 | 475 | window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); |
| 476 | window.on("page-title-updated", (event) => { | |
| 477 | event.preventDefault(); | |
| 478 | window.setTitle(APP_DISPLAY_NAME); | |
| 479 | }); | |
| 480 | window.webContents.on("did-finish-load", () => { | |
| 481 | window.setTitle(APP_DISPLAY_NAME); | |
| 482 | }); | |
| 226 | 483 | window.once("ready-to-show", () => { |
| 227 | 484 | window.show(); |
| 228 | 485 | }); |
| ... | ... | @@ -231,10 +488,7 @@ function createWindow(): BrowserWindow { |
| 231 | 488 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); |
| 232 | 489 | window.webContents.openDevTools({ mode: "detach" }); |
| 233 | 490 | } else { |
| 234 | if (!fs.existsSync(WEB_ENTRY)) { | |
| 235 | throw new Error(`Web bundle missing at ${WEB_ENTRY}`); | |
| 236 | } | |
| 237 | void window.loadFile(WEB_ENTRY); | |
| 491 | void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); | |
| 238 | 492 | } |
| 239 | 493 | |
| 240 | 494 | window.on("closed", () => { |
| ... | ... | @@ -246,6 +500,8 @@ function createWindow(): BrowserWindow { |
| 246 | 500 | return window; |
| 247 | 501 | } |
| 248 | 502 | |
| 503 | configureAppIdentity(); | |
| 504 | ||
| 249 | 505 | async function bootstrap(): Promise<void> { |
| 250 | 506 | backendPort = await reserveLoopbackPort(); |
| 251 | 507 | backendAuthToken = randomBytes(24).toString("hex"); |
| ... | ... | @@ -263,6 +519,9 @@ app.on("before-quit", () => { |
| 263 | 519 | }); |
| 264 | 520 | |
| 265 | 521 | app.whenReady().then(() => { |
| 522 | configureAppIdentity(); | |
| 523 | configureApplicationMenu(); | |
| 524 | registerDesktopProtocol(); | |
| 266 | 525 | void bootstrap(); |
| 267 | 526 | |
| 268 | 527 | app.on("activate", () => { |
| ... | ... | @@ -277,3 +536,19 @@ app.on("window-all-closed", () => { |
| 277 | 536 | app.quit(); |
| 278 | 537 | } |
| 279 | 538 | }); |
| 539 | ||
| 540 | if (process.platform !== "win32") { | |
| 541 | process.on("SIGINT", () => { | |
| 542 | if (isQuitting) return; | |
| 543 | isQuitting = true; | |
| 544 | stopBackend(); | |
| 545 | app.quit(); | |
| 546 | }); | |
| 547 | ||
| 548 | process.on("SIGTERM", () => { | |
| 549 | if (isQuitting) return; | |
| 550 | isQuitting = true; | |
| 551 | stopBackend(); | |
| 552 | app.quit(); | |
| 553 | }); | |
| 554 | } |
apps/desktop/src/preload.ts+12| ... | ... | @@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | 4 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 5 | 5 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 6 | 6 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 7 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 7 | 8 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 8 | 9 | |
| 9 | 10 | contextBridge.exposeInMainWorld("desktopBridge", { |
| ... | ... | @@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 13 | 14 | showContextMenu: (items: { id: string; label: string }[]) => |
| 14 | 15 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, |
| 15 | 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>, |
| 17 | onMenuAction: (listener: (action: string) => void) => { | |
| 18 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { | |
| 19 | if (typeof action !== "string") return; | |
| 20 | listener(action); | |
| 21 | }; | |
| 22 | ||
| 23 | ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); | |
| 24 | return () => { | |
| 25 | ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); | |
| 26 | }; | |
| 27 | }, | |
| 16 | 28 | }); |
apps/server/src/codexAppServerManager.test.ts+152| ... | ... | @@ -43,6 +43,36 @@ function createSendTurnHarness() { |
| 43 | 43 | return { manager, context, requireSession, sendRequest, updateSession }; |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | function createThreadControlHarness() { | |
| 47 | const manager = new CodexAppServerManager(); | |
| 48 | const context = { | |
| 49 | session: { | |
| 50 | sessionId: "sess_1", | |
| 51 | provider: "codex", | |
| 52 | status: "ready", | |
| 53 | threadId: "thread_1", | |
| 54 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 55 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 56 | }, | |
| 57 | }; | |
| 58 | ||
| 59 | const requireSession = vi | |
| 60 | .spyOn( | |
| 61 | manager as unknown as { requireSession: (sessionId: string) => unknown }, | |
| 62 | "requireSession", | |
| 63 | ) | |
| 64 | .mockReturnValue(context); | |
| 65 | const sendRequest = vi.spyOn( | |
| 66 | manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> }, | |
| 67 | "sendRequest", | |
| 68 | ); | |
| 69 | const updateSession = vi | |
| 70 | .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") | |
| 71 | .mockImplementation(() => {}); | |
| 72 | ||
| 73 | return { manager, context, requireSession, sendRequest, updateSession }; | |
| 74 | } | |
| 75 | ||
| 46 | 76 | describe("classifyCodexStderrLine", () => { |
| 47 | 77 | it("ignores empty lines", () => { |
| 48 | 78 | expect(classifyCodexStderrLine(" ")).toBeNull(); |
| ... | ... | @@ -113,6 +143,40 @@ describe("isRecoverableThreadResumeError", () => { |
| 113 | 143 | }); |
| 114 | 144 | }); |
| 115 | 145 | |
| 146 | describe("startSession", () => { | |
| 147 | it("emits session/startFailed when resolving cwd throws before process launch", async () => { | |
| 148 | const manager = new CodexAppServerManager(); | |
| 149 | const events: Array<{ method: string; kind: string; message?: string }> = []; | |
| 150 | manager.on("event", (event) => { | |
| 151 | events.push({ | |
| 152 | method: event.method, | |
| 153 | kind: event.kind, | |
| 154 | ...(event.message ? { message: event.message } : {}), | |
| 155 | }); | |
| 156 | }); | |
| 157 | ||
| 158 | const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { | |
| 159 | throw new Error("cwd missing"); | |
| 160 | }); | |
| 161 | try { | |
| 162 | await expect( | |
| 163 | manager.startSession({ | |
| 164 | provider: "codex", | |
| 165 | }), | |
| 166 | ).rejects.toThrow("cwd missing"); | |
| 167 | expect(events).toHaveLength(1); | |
| 168 | expect(events[0]).toEqual({ | |
| 169 | method: "session/startFailed", | |
| 170 | kind: "error", | |
| 171 | message: "cwd missing", | |
| 172 | }); | |
| 173 | } finally { | |
| 174 | processCwd.mockRestore(); | |
| 175 | manager.stopAll(); | |
| 176 | } | |
| 177 | }); | |
| 178 | }); | |
| 179 | ||
| 116 | 180 | describe("sendTurn", () => { |
| 117 | 181 | it("sends text and image user input items to turn/start", async () => { |
| 118 | 182 | const { manager, context, requireSession, sendRequest, updateSession } = |
| ... | ... | @@ -198,3 +262,91 @@ describe("sendTurn", () => { |
| 198 | 262 | ).rejects.toThrow("Turn input must include text or attachments."); |
| 199 | 263 | }); |
| 200 | 264 | }); |
| 265 | ||
| 266 | describe("thread checkpoint control", () => { | |
| 267 | it("reads thread turns from thread/read", async () => { | |
| 268 | const { manager, context, requireSession, sendRequest } = createThreadControlHarness(); | |
| 269 | sendRequest.mockResolvedValue({ | |
| 270 | thread: { | |
| 271 | id: "thread_1", | |
| 272 | turns: [ | |
| 273 | { | |
| 274 | id: "turn_1", | |
| 275 | items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], | |
| 276 | }, | |
| 277 | ], | |
| 278 | }, | |
| 279 | }); | |
| 280 | ||
| 281 | const result = await manager.readThread("sess_1"); | |
| 282 | ||
| 283 | expect(requireSession).toHaveBeenCalledWith("sess_1"); | |
| 284 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { | |
| 285 | threadId: "thread_1", | |
| 286 | includeTurns: true, | |
| 287 | }); | |
| 288 | expect(result).toEqual({ | |
| 289 | threadId: "thread_1", | |
| 290 | turns: [ | |
| 291 | { | |
| 292 | id: "turn_1", | |
| 293 | items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], | |
| 294 | }, | |
| 295 | ], | |
| 296 | }); | |
| 297 | }); | |
| 298 | ||
| 299 | it("reads thread turns from flat thread/read responses", async () => { | |
| 300 | const { manager, context, sendRequest } = createThreadControlHarness(); | |
| 301 | sendRequest.mockResolvedValue({ | |
| 302 | threadId: "thread_1", | |
| 303 | turns: [ | |
| 304 | { | |
| 305 | id: "turn_1", | |
| 306 | items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], | |
| 307 | }, | |
| 308 | ], | |
| 309 | }); | |
| 310 | ||
| 311 | const result = await manager.readThread("sess_1"); | |
| 312 | ||
| 313 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { | |
| 314 | threadId: "thread_1", | |
| 315 | includeTurns: true, | |
| 316 | }); | |
| 317 | expect(result).toEqual({ | |
| 318 | threadId: "thread_1", | |
| 319 | turns: [ | |
| 320 | { | |
| 321 | id: "turn_1", | |
| 322 | items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], | |
| 323 | }, | |
| 324 | ], | |
| 325 | }); | |
| 326 | }); | |
| 327 | ||
| 328 | it("rolls back turns via thread/rollback and resets session running state", async () => { | |
| 329 | const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); | |
| 330 | sendRequest.mockResolvedValue({ | |
| 331 | thread: { | |
| 332 | id: "thread_1", | |
| 333 | turns: [], | |
| 334 | }, | |
| 335 | }); | |
| 336 | ||
| 337 | const result = await manager.rollbackThread("sess_1", 2); | |
| 338 | ||
| 339 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { | |
| 340 | threadId: "thread_1", | |
| 341 | numTurns: 2, | |
| 342 | }); | |
| 343 | expect(updateSession).toHaveBeenCalledWith(context, { | |
| 344 | status: "ready", | |
| 345 | activeTurnId: undefined, | |
| 346 | }); | |
| 347 | expect(result).toEqual({ | |
| 348 | threadId: "thread_1", | |
| 349 | turns: [], | |
| 350 | }); | |
| 351 | }); | |
| 352 | }); |
apps/server/src/codexAppServerManager.ts+135-35| ... | ... | @@ -65,6 +65,16 @@ interface JsonRpcNotification { |
| 65 | 65 | params?: unknown; |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | export interface CodexThreadTurnSnapshot { | |
| 69 | id: string; | |
| 70 | items: unknown[]; | |
| 71 | } | |
| 72 | ||
| 73 | export interface CodexThreadSnapshot { | |
| 74 | threadId: string; | |
| 75 | turns: CodexThreadTurnSnapshot[]; | |
| 76 | } | |
| 77 | ||
| 68 | 78 | const ANSI_ESCAPE_CHAR = String.fromCharCode(27); |
| 69 | 79 | const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); |
| 70 | 80 | const CODEX_STDERR_LOG_REGEX = |
| ... | ... | @@ -138,40 +148,48 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 138 | 148 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| 139 | 149 | const sessionId = randomUUID(); |
| 140 | 150 | const now = new Date().toISOString(); |
| 151 | let context: CodexSessionContext | undefined; | |
| 141 | 152 | |
| 142 | const session: ProviderSession = { | |
| 143 | sessionId, | |
| 144 | provider: "codex", | |
| 145 | status: "connecting", | |
| 146 | model: normalizeCodexModelSlug(input.model), | |
| 147 | cwd: input.cwd, | |
| 148 | createdAt: now, | |
| 149 | updatedAt: now, | |
| 150 | }; | |
| 153 | try { | |
| 154 | const resolvedCwd = input.cwd ?? process.cwd(); | |
| 155 | ||
| 156 | const session: ProviderSession = { | |
| 157 | sessionId, | |
| 158 | provider: "codex", | |
| 159 | status: "connecting", | |
| 160 | model: normalizeCodexModelSlug(input.model), | |
| 161 | cwd: resolvedCwd, | |
| 162 | createdAt: now, | |
| 163 | updatedAt: now, | |
| 164 | }; | |
| 151 | 165 | |
| 152 | const child = spawn("codex", ["app-server"], { | |
| 153 | cwd: input.cwd, | |
| 154 | env: process.env, | |
| 155 | stdio: ["pipe", "pipe", "pipe"], | |
| 156 | }); | |
| 157 | const output = readline.createInterface({ input: child.stdout }); | |
| 158 | ||
| 159 | const context: CodexSessionContext = { | |
| 160 | session, | |
| 161 | child, | |
| 162 | output, | |
| 163 | pending: new Map(), | |
| 164 | pendingApprovals: new Map(), | |
| 165 | nextRequestId: 1, | |
| 166 | stopping: false, | |
| 167 | }; | |
| 166 | const codexBinaryPath = input.codexBinaryPath?.trim() || "codex"; | |
| 167 | const codexHomePath = input.codexHomePath?.trim(); | |
| 168 | const child = spawn(codexBinaryPath, ["app-server"], { | |
| 169 | cwd: resolvedCwd, | |
| 170 | env: { | |
| 171 | ...process.env, | |
| 172 | ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}), | |
| 173 | }, | |
| 174 | stdio: ["pipe", "pipe", "pipe"], | |
| 175 | }); | |
| 176 | const output = readline.createInterface({ input: child.stdout }); | |
| 177 | ||
| 178 | context = { | |
| 179 | session, | |
| 180 | child, | |
| 181 | output, | |
| 182 | pending: new Map(), | |
| 183 | pendingApprovals: new Map(), | |
| 184 | nextRequestId: 1, | |
| 185 | stopping: false, | |
| 186 | }; | |
| 168 | 187 | |
| 169 | this.sessions.set(sessionId, context); | |
| 170 | this.attachProcessListeners(context); | |
| 188 | this.sessions.set(sessionId, context); | |
| 189 | this.attachProcessListeners(context); | |
| 171 | 190 | |
| 172 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); | |
| 191 | this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); | |
| 173 | 192 | |
| 174 | try { | |
| 175 | 193 | await this.sendRequest(context, "initialize", { |
| 176 | 194 | clientInfo: { |
| 177 | 195 | name: "t3code_desktop", |
| ... | ... | @@ -241,12 +259,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 241 | 259 | return { ...context.session }; |
| 242 | 260 | } catch (error) { |
| 243 | 261 | const message = error instanceof Error ? error.message : "Failed to start Codex session."; |
| 244 | this.updateSession(context, { | |
| 245 | status: "error", | |
| 246 | lastError: message, | |
| 247 | }); | |
| 248 | this.emitErrorEvent(context, "session/startFailed", message); | |
| 249 | this.stopSession(sessionId); | |
| 262 | if (context) { | |
| 263 | this.updateSession(context, { | |
| 264 | status: "error", | |
| 265 | lastError: message, | |
| 266 | }); | |
| 267 | this.emitErrorEvent(context, "session/startFailed", message); | |
| 268 | this.stopSession(sessionId); | |
| 269 | } else { | |
| 270 | this.emitEvent({ | |
| 271 | id: randomUUID(), | |
| 272 | kind: "error", | |
| 273 | provider: "codex", | |
| 274 | sessionId, | |
| 275 | createdAt: new Date().toISOString(), | |
| 276 | method: "session/startFailed", | |
| 277 | message, | |
| 278 | }); | |
| 279 | } | |
| 250 | 280 | throw new Error(message, { cause: error }); |
| 251 | 281 | } |
| 252 | 282 | } |
| ... | ... | @@ -331,6 +361,41 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 331 | 361 | }); |
| 332 | 362 | } |
| 333 | 363 | |
| 364 | async readThread(sessionId: string): Promise<CodexThreadSnapshot> { | |
| 365 | const context = this.requireSession(sessionId); | |
| 366 | const threadId = context.session.threadId; | |
| 367 | if (!threadId) { | |
| 368 | throw new Error("Session is missing a thread id."); | |
| 369 | } | |
| 370 | ||
| 371 | const response = await this.sendRequest(context, "thread/read", { | |
| 372 | threadId, | |
| 373 | includeTurns: true, | |
| 374 | }); | |
| 375 | return this.parseThreadSnapshot("thread/read", response); | |
| 376 | } | |
| 377 | ||
| 378 | async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> { | |
| 379 | const context = this.requireSession(sessionId); | |
| 380 | const threadId = context.session.threadId; | |
| 381 | if (!threadId) { | |
| 382 | throw new Error("Session is missing a thread id."); | |
| 383 | } | |
| 384 | if (!Number.isInteger(numTurns) || numTurns < 1) { | |
| 385 | throw new Error("numTurns must be an integer >= 1."); | |
| 386 | } | |
| 387 | ||
| 388 | const response = await this.sendRequest(context, "thread/rollback", { | |
| 389 | threadId, | |
| 390 | numTurns, | |
| 391 | }); | |
| 392 | this.updateSession(context, { | |
| 393 | status: "ready", | |
| 394 | activeTurnId: undefined, | |
| 395 | }); | |
| 396 | return this.parseThreadSnapshot("thread/rollback", response); | |
| 397 | } | |
| 398 | ||
| 334 | 399 | async respondToRequest( |
| 335 | 400 | sessionId: string, |
| 336 | 401 | requestId: string, |
| ... | ... | @@ -742,6 +807,31 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 742 | 807 | return undefined; |
| 743 | 808 | } |
| 744 | 809 | |
| 810 | private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot { | |
| 811 | const responseRecord = this.readObject(response); | |
| 812 | const thread = this.readObject(responseRecord, "thread"); | |
| 813 | const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId"); | |
| 814 | if (!threadId) { | |
| 815 | throw new Error(`${method} response did not include a thread id.`); | |
| 816 | } | |
| 817 | ||
| 818 | const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; | |
| 819 | const turns = turnsRaw.map((turnValue, index) => { | |
| 820 | const turn = this.readObject(turnValue); | |
| 821 | const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; | |
| 822 | const items = this.readArray(turn, "items") ?? []; | |
| 823 | return { | |
| 824 | id: turnId, | |
| 825 | items, | |
| 826 | }; | |
| 827 | }); | |
| 828 | ||
| 829 | return { | |
| 830 | threadId, | |
| 831 | turns, | |
| 832 | }; | |
| 833 | } | |
| 834 | ||
| 745 | 835 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 746 | 836 | if (!value || typeof value !== "object") { |
| 747 | 837 | return false; |
| ... | ... | @@ -823,6 +913,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 823 | 913 | return target as Record<string, unknown>; |
| 824 | 914 | } |
| 825 | 915 | |
| 916 | private readArray(value: unknown, key?: string): unknown[] | undefined { | |
| 917 | const target = | |
| 918 | key === undefined | |
| 919 | ? value | |
| 920 | : value && typeof value === "object" | |
| 921 | ? (value as Record<string, unknown>)[key] | |
| 922 | : undefined; | |
| 923 | return Array.isArray(target) ? target : undefined; | |
| 924 | } | |
| 925 | ||
| 826 | 926 | private readString(value: unknown, key: string): string | undefined { |
| 827 | 927 | if (!value || typeof value !== "object") { |
| 828 | 928 | return undefined; |
apps/server/src/filesystemCheckpointStore.test.ts created+243| ... | ... | @@ -0,0 +1,243 @@ |
| 1 | import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { describe, expect, it } from "vitest"; | |
| 7 | ||
| 8 | import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore"; | |
| 9 | import { runProcess } from "./processRunner"; | |
| 10 | ||
| 11 | async function runGit(cwd: string, args: readonly string[]): Promise<string> { | |
| 12 | const result = await runProcess("git", args, { | |
| 13 | cwd, | |
| 14 | allowNonZeroExit: true, | |
| 15 | }); | |
| 16 | if (result.code !== 0) { | |
| 17 | throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`); | |
| 18 | } | |
| 19 | return result.stdout.trim(); | |
| 20 | } | |
| 21 | ||
| 22 | function exists(filePath: string): boolean { | |
| 23 | return fs.existsSync(filePath); | |
| 24 | } | |
| 25 | ||
| 26 | async function createRepo(): Promise<string> { | |
| 27 | const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-test-")); | |
| 28 | await runGit(cwd, ["init", "--initial-branch=main"]); | |
| 29 | await runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 30 | await runGit(cwd, ["config", "user.name", "Test User"]); | |
| 31 | await writeFile(path.join(cwd, "README.md"), "v1\n"); | |
| 32 | await writeFile(path.join(cwd, "tracked.txt"), "tracked-v1\n"); | |
| 33 | await runGit(cwd, ["add", "."]); | |
| 34 | await runGit(cwd, ["commit", "-m", "Initial"]); | |
| 35 | return cwd; | |
| 36 | } | |
| 37 | ||
| 38 | async function createRepoWithoutHead(): Promise<string> { | |
| 39 | const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-empty-test-")); | |
| 40 | await runGit(cwd, ["init", "--initial-branch=main"]); | |
| 41 | return cwd; | |
| 42 | } | |
| 43 | ||
| 44 | describe("FilesystemCheckpointStore", () => { | |
| 45 | it("captures refs without mutating branch history and restores workspace state", async () => { | |
| 46 | const cwd = await createRepo(); | |
| 47 | const store = new FilesystemCheckpointStore(); | |
| 48 | const threadId = "thr_1"; | |
| 49 | ||
| 50 | try { | |
| 51 | const headBefore = await runGit(cwd, ["rev-parse", "HEAD"]); | |
| 52 | ||
| 53 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 54 | const rootRef = checkpointRefForThreadTurn(threadId, 0); | |
| 55 | const rootCommit = await runGit(cwd, ["rev-parse", "--verify", `${rootRef}^{commit}`]); | |
| 56 | expect(rootCommit.length).toBeGreaterThan(0); | |
| 57 | expect(await runGit(cwd, ["rev-parse", "HEAD"])).toBe(headBefore); | |
| 58 | ||
| 59 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 60 | await rm(path.join(cwd, "tracked.txt")); | |
| 61 | await writeFile(path.join(cwd, "notes.md"), "notes\n"); | |
| 62 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 63 | ||
| 64 | await writeFile(path.join(cwd, "README.md"), "v3\n"); | |
| 65 | await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); | |
| 66 | await writeFile(path.join(cwd, "tracked.txt"), "tracked-new\n"); | |
| 67 | ||
| 68 | const restoredRoot = await store.restoreCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 69 | expect(restoredRoot).toBe(true); | |
| 70 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); | |
| 71 | expect(await readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("tracked-v1\n"); | |
| 72 | expect(exists(path.join(cwd, "notes.md"))).toBe(false); | |
| 73 | expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); | |
| 74 | ||
| 75 | const restoredTurn1 = await store.restoreCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 76 | expect(restoredTurn1).toBe(true); | |
| 77 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v2\n"); | |
| 78 | expect(exists(path.join(cwd, "tracked.txt"))).toBe(false); | |
| 79 | expect(await readFile(path.join(cwd, "notes.md"), "utf8")).toBe("notes\n"); | |
| 80 | expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); | |
| 81 | } finally { | |
| 82 | await rm(cwd, { recursive: true, force: true }); | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | it("returns false when restoring a missing checkpoint", async () => { | |
| 87 | const cwd = await createRepo(); | |
| 88 | const store = new FilesystemCheckpointStore(); | |
| 89 | ||
| 90 | try { | |
| 91 | const restored = await store.restoreCheckpoint({ | |
| 92 | cwd, | |
| 93 | threadId: "thr_2", | |
| 94 | turnCount: 999, | |
| 95 | }); | |
| 96 | expect(restored).toBe(false); | |
| 97 | } finally { | |
| 98 | await rm(cwd, { recursive: true, force: true }); | |
| 99 | } | |
| 100 | }); | |
| 101 | ||
| 102 | it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => { | |
| 103 | const cwd = await createRepo(); | |
| 104 | const store = new FilesystemCheckpointStore(); | |
| 105 | const threadId = "thr_2b"; | |
| 106 | ||
| 107 | try { | |
| 108 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 109 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 110 | ||
| 111 | await writeFile(path.join(cwd, "README.md"), "v3\n"); | |
| 112 | await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); | |
| 113 | ||
| 114 | const restored = await store.restoreCheckpoint({ | |
| 115 | cwd, | |
| 116 | threadId, | |
| 117 | turnCount: 0, | |
| 118 | }); | |
| 119 | ||
| 120 | expect(restored).toBe(true); | |
| 121 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); | |
| 122 | expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); | |
| 123 | } finally { | |
| 124 | await rm(cwd, { recursive: true, force: true }); | |
| 125 | } | |
| 126 | }); | |
| 127 | ||
| 128 | it("prunes refs newer than the retained turn", async () => { | |
| 129 | const cwd = await createRepo(); | |
| 130 | const store = new FilesystemCheckpointStore(); | |
| 131 | const threadId = "thr_3"; | |
| 132 | ||
| 133 | try { | |
| 134 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 135 | await writeFile(path.join(cwd, "README.md"), "turn1\n"); | |
| 136 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 137 | await writeFile(path.join(cwd, "README.md"), "turn2\n"); | |
| 138 | await store.captureCheckpoint({ cwd, threadId, turnCount: 2 }); | |
| 139 | ||
| 140 | await store.pruneAfterTurn({ | |
| 141 | cwd, | |
| 142 | threadId, | |
| 143 | maxTurnCount: 1, | |
| 144 | }); | |
| 145 | ||
| 146 | expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 0 })).toBe(true); | |
| 147 | expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 1 })).toBe(true); | |
| 148 | expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 2 })).toBe(false); | |
| 149 | } finally { | |
| 150 | await rm(cwd, { recursive: true, force: true }); | |
| 151 | } | |
| 152 | }); | |
| 153 | ||
| 154 | it("returns a unified patch between checkpoint turns", async () => { | |
| 155 | const cwd = await createRepo(); | |
| 156 | const store = new FilesystemCheckpointStore(); | |
| 157 | const threadId = "thr_4"; | |
| 158 | ||
| 159 | try { | |
| 160 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 161 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 162 | await writeFile(path.join(cwd, "new.txt"), "new file\n"); | |
| 163 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 164 | ||
| 165 | const diff = await store.diffCheckpoints({ | |
| 166 | cwd, | |
| 167 | threadId, | |
| 168 | fromTurnCount: 0, | |
| 169 | toTurnCount: 1, | |
| 170 | }); | |
| 171 | ||
| 172 | expect(diff).toContain("diff --git a/README.md b/README.md"); | |
| 173 | expect(diff).toContain("diff --git a/new.txt b/new.txt"); | |
| 174 | } finally { | |
| 175 | await rm(cwd, { recursive: true, force: true }); | |
| 176 | } | |
| 177 | }); | |
| 178 | ||
| 179 | it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => { | |
| 180 | const cwd = await createRepo(); | |
| 181 | const store = new FilesystemCheckpointStore(); | |
| 182 | const threadId = "thr_5"; | |
| 183 | ||
| 184 | try { | |
| 185 | await writeFile(path.join(cwd, "README.md"), "v2\n"); | |
| 186 | await writeFile(path.join(cwd, "new.txt"), "new file\n"); | |
| 187 | await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); | |
| 188 | ||
| 189 | const diff = await store.diffCheckpoints({ | |
| 190 | cwd, | |
| 191 | threadId, | |
| 192 | fromTurnCount: 0, | |
| 193 | toTurnCount: 1, | |
| 194 | }); | |
| 195 | ||
| 196 | expect(diff).toContain("diff --git a/README.md b/README.md"); | |
| 197 | expect(diff).toContain("diff --git a/new.txt b/new.txt"); | |
| 198 | } finally { | |
| 199 | await rm(cwd, { recursive: true, force: true }); | |
| 200 | } | |
| 201 | }); | |
| 202 | ||
| 203 | it("detects non-repo folders", async () => { | |
| 204 | const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-")); | |
| 205 | const store = new FilesystemCheckpointStore(); | |
| 206 | ||
| 207 | try { | |
| 208 | expect(await store.isGitRepository(cwd)).toBe(false); | |
| 209 | } finally { | |
| 210 | await rm(cwd, { recursive: true, force: true }); | |
| 211 | } | |
| 212 | }); | |
| 213 | ||
| 214 | it("returns false when repository path does not exist", async () => { | |
| 215 | const store = new FilesystemCheckpointStore(); | |
| 216 | const cwd = path.join(os.tmpdir(), `t3-fs-checkpoints-missing-${Date.now()}`); | |
| 217 | ||
| 218 | expect(await store.isGitRepository(cwd)).toBe(false); | |
| 219 | }); | |
| 220 | ||
| 221 | it("restores checkpoint without reset when repository has no HEAD commit", async () => { | |
| 222 | const cwd = await createRepoWithoutHead(); | |
| 223 | const store = new FilesystemCheckpointStore(); | |
| 224 | const threadId = "thr-no-head"; | |
| 225 | ||
| 226 | try { | |
| 227 | await writeFile(path.join(cwd, "README.md"), "first\n"); | |
| 228 | await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); | |
| 229 | ||
| 230 | await writeFile(path.join(cwd, "README.md"), "second\n"); | |
| 231 | const restored = await store.restoreCheckpoint({ | |
| 232 | cwd, | |
| 233 | threadId, | |
| 234 | turnCount: 0, | |
| 235 | }); | |
| 236 | ||
| 237 | expect(restored).toBe(true); | |
| 238 | expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("first\n"); | |
| 239 | } finally { | |
| 240 | await rm(cwd, { recursive: true, force: true }); | |
| 241 | } | |
| 242 | }); | |
| 243 | }); |
apps/server/src/filesystemCheckpointStore.ts created+322| ... | ... | @@ -0,0 +1,322 @@ |
| 1 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { randomUUID } from "node:crypto"; | |
| 5 | ||
| 6 | import { runProcess } from "./processRunner"; | |
| 7 | ||
| 8 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 9 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 10 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 11 | ||
| 12 | function quoteGitCommand(args: readonly string[]): string { | |
| 13 | return `git ${args.join(" ")}`; | |
| 14 | } | |
| 15 | ||
| 16 | function checkpointRefThreadSegment(threadId: string): string { | |
| 17 | return Buffer.from(threadId, "utf8").toString("base64url"); | |
| 18 | } | |
| 19 | ||
| 20 | export function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { | |
| 21 | if (!Number.isInteger(turnCount) || turnCount < 0) { | |
| 22 | throw new Error(`Invalid checkpoint turn count: ${turnCount}`); | |
| 23 | } | |
| 24 | return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; | |
| 25 | } | |
| 26 | ||
| 27 | interface GitOptions { | |
| 28 | env?: NodeJS.ProcessEnv; | |
| 29 | stdin?: string; | |
| 30 | allowNonZeroExit?: boolean; | |
| 31 | } | |
| 32 | ||
| 33 | export class FilesystemCheckpointStore { | |
| 34 | async isGitRepository(cwd: string): Promise<boolean> { | |
| 35 | try { | |
| 36 | const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], { | |
| 37 | allowNonZeroExit: true, | |
| 38 | }); | |
| 39 | return result.code === 0 && result.stdout.trim() === "true"; | |
| 40 | } catch { | |
| 41 | return false; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | async captureCheckpoint(input: { | |
| 46 | cwd: string; | |
| 47 | threadId: string; | |
| 48 | turnCount: number; | |
| 49 | }): Promise<void> { | |
| 50 | const { cwd, threadId, turnCount } = input; | |
| 51 | const ref = checkpointRefForThreadTurn(threadId, turnCount); | |
| 52 | ||
| 53 | const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")); | |
| 54 | ||
| 55 | try { | |
| 56 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | |
| 57 | const commitEnv: NodeJS.ProcessEnv = { | |
| 58 | ...process.env, | |
| 59 | GIT_INDEX_FILE: tempIndexPath, | |
| 60 | GIT_AUTHOR_NAME: "T3 Code", | |
| 61 | GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", | |
| 62 | GIT_COMMITTER_NAME: "T3 Code", | |
| 63 | GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", | |
| 64 | }; | |
| 65 | ||
| 66 | const hasHead = await this.hasHeadCommit(cwd); | |
| 67 | if (hasHead) { | |
| 68 | await this.runGit(cwd, ["read-tree", "HEAD"], { env: commitEnv }); | |
| 69 | } | |
| 70 | ||
| 71 | await this.runGit(cwd, ["add", "-A", "--", "."], { env: commitEnv }); | |
| 72 | const writeTreeResult = await this.runGit(cwd, ["write-tree"], { env: commitEnv }); | |
| 73 | const treeOid = writeTreeResult.stdout.trim(); | |
| 74 | if (!treeOid) { | |
| 75 | throw new Error("git write-tree returned an empty tree oid."); | |
| 76 | } | |
| 77 | ||
| 78 | const message = `t3 checkpoint thread=${threadId} turn=${turnCount}`; | |
| 79 | const commitTreeResult = await this.runGit(cwd, ["commit-tree", treeOid], { | |
| 80 | env: commitEnv, | |
| 81 | stdin: `${message}\n`, | |
| 82 | }); | |
| 83 | const commitOid = commitTreeResult.stdout.trim(); | |
| 84 | if (!commitOid) { | |
| 85 | throw new Error("git commit-tree returned an empty commit oid."); | |
| 86 | } | |
| 87 | ||
| 88 | await this.runGit(cwd, ["update-ref", ref, commitOid]); | |
| 89 | } finally { | |
| 90 | await rm(tempDir, { recursive: true, force: true }); | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | async hasCheckpoint(input: { | |
| 95 | cwd: string; | |
| 96 | threadId: string; | |
| 97 | turnCount: number; | |
| 98 | }): Promise<boolean> { | |
| 99 | const { cwd, threadId, turnCount } = input; | |
| 100 | const ref = checkpointRefForThreadTurn(threadId, turnCount); | |
| 101 | const commit = await this.resolveCheckpointCommit(cwd, ref); | |
| 102 | return commit !== null; | |
| 103 | } | |
| 104 | ||
| 105 | async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise<boolean> { | |
| 106 | const { cwd, threadId } = input; | |
| 107 | const rootRef = checkpointRefForThreadTurn(threadId, 0); | |
| 108 | const existing = await this.resolveCheckpointCommit(cwd, rootRef); | |
| 109 | if (existing) { | |
| 110 | return true; | |
| 111 | } | |
| 112 | ||
| 113 | const headCommit = await this.resolveHeadCommit(cwd); | |
| 114 | if (!headCommit) { | |
| 115 | return false; | |
| 116 | } | |
| 117 | ||
| 118 | await this.runGit(cwd, ["update-ref", rootRef, headCommit]); | |
| 119 | return true; | |
| 120 | } | |
| 121 | ||
| 122 | async restoreCheckpoint(input: { | |
| 123 | cwd: string; | |
| 124 | threadId: string; | |
| 125 | turnCount: number; | |
| 126 | }): Promise<boolean> { | |
| 127 | const { cwd, threadId, turnCount } = input; | |
| 128 | const ref = checkpointRefForThreadTurn(threadId, turnCount); | |
| 129 | let commitOid = await this.resolveCheckpointCommit(cwd, ref); | |
| 130 | if (!commitOid && turnCount === 0) { | |
| 131 | commitOid = await this.resolveHeadCommit(cwd); | |
| 132 | } | |
| 133 | if (!commitOid) { | |
| 134 | return false; | |
| 135 | } | |
| 136 | ||
| 137 | await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); | |
| 138 | await this.runGit(cwd, ["clean", "-fd", "--", "."]); | |
| 139 | if (await this.hasHeadCommit(cwd)) { | |
| 140 | await this.runGit(cwd, ["reset", "--quiet", "--", "."]); | |
| 141 | } | |
| 142 | return true; | |
| 143 | } | |
| 144 | ||
| 145 | async diffCheckpoints(input: { | |
| 146 | cwd: string; | |
| 147 | threadId: string; | |
| 148 | fromTurnCount: number; | |
| 149 | toTurnCount: number; | |
| 150 | }): Promise<string> { | |
| 151 | const { cwd, threadId, fromTurnCount, toTurnCount } = input; | |
| 152 | if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) { | |
| 153 | throw new Error(`Invalid from turn count: ${fromTurnCount}`); | |
| 154 | } | |
| 155 | if (!Number.isInteger(toTurnCount) || toTurnCount < 0) { | |
| 156 | throw new Error(`Invalid to turn count: ${toTurnCount}`); | |
| 157 | } | |
| 158 | if (fromTurnCount > toTurnCount) { | |
| 159 | throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`); | |
| 160 | } | |
| 161 | ||
| 162 | const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount); | |
| 163 | const toRef = checkpointRefForThreadTurn(threadId, toTurnCount); | |
| 164 | let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef); | |
| 165 | const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef); | |
| 166 | if (!fromCommitOid && fromTurnCount === 0) { | |
| 167 | const headCommit = await this.resolveHeadCommit(cwd); | |
| 168 | if (headCommit) { | |
| 169 | fromCommitOid = headCommit; | |
| 170 | } | |
| 171 | } | |
| 172 | if (!fromCommitOid) { | |
| 173 | throw new Error( | |
| 174 | `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`, | |
| 175 | ); | |
| 176 | } | |
| 177 | if (!toCommitOid) { | |
| 178 | throw new Error( | |
| 179 | `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`, | |
| 180 | ); | |
| 181 | } | |
| 182 | ||
| 183 | const result = await this.runGit(cwd, [ | |
| 184 | "diff", | |
| 185 | "--patch", | |
| 186 | "--minimal", | |
| 187 | "--no-color", | |
| 188 | fromCommitOid, | |
| 189 | toCommitOid, | |
| 190 | ]); | |
| 191 | return result.stdout; | |
| 192 | } | |
| 193 | ||
| 194 | private async resolveHeadCommit(cwd: string): Promise<string | null> { | |
| 195 | const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { | |
| 196 | allowNonZeroExit: true, | |
| 197 | }); | |
| 198 | if (result.code !== 0) { | |
| 199 | return null; | |
| 200 | } | |
| 201 | const commit = result.stdout.trim(); | |
| 202 | return commit.length > 0 ? commit : null; | |
| 203 | } | |
| 204 | ||
| 205 | async pruneAfterTurn(input: { | |
| 206 | cwd: string; | |
| 207 | threadId: string; | |
| 208 | maxTurnCount: number; | |
| 209 | }): Promise<void> { | |
| 210 | const { cwd, threadId, maxTurnCount } = input; | |
| 211 | if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { | |
| 212 | throw new Error(`Invalid max turn count: ${maxTurnCount}`); | |
| 213 | } | |
| 214 | ||
| 215 | const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`; | |
| 216 | const result = await this.runGit( | |
| 217 | cwd, | |
| 218 | ["for-each-ref", "--format=%(refname)", threadRefPrefix], | |
| 219 | { | |
| 220 | allowNonZeroExit: true, | |
| 221 | }, | |
| 222 | ); | |
| 223 | if (result.code !== 0) { | |
| 224 | return; | |
| 225 | } | |
| 226 | ||
| 227 | const refsToDelete = result.stdout | |
| 228 | .split(/\r?\n/g) | |
| 229 | .map((line) => line.trim()) | |
| 230 | .filter((line) => line.length > 0) | |
| 231 | .filter((refName) => { | |
| 232 | const turn = this.readRefTurnCount(refName); | |
| 233 | return turn !== null && turn > maxTurnCount; | |
| 234 | }); | |
| 235 | ||
| 236 | const batchSize = 32; | |
| 237 | const batches = Array.from( | |
| 238 | { length: Math.ceil(refsToDelete.length / batchSize) }, | |
| 239 | (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize), | |
| 240 | ); | |
| 241 | await batches.reduce<Promise<void>>((pending, batch) => { | |
| 242 | return pending.then(async () => { | |
| 243 | await Promise.all( | |
| 244 | batch.map((refName) => | |
| 245 | this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), | |
| 246 | ), | |
| 247 | ); | |
| 248 | }); | |
| 249 | }, Promise.resolve()); | |
| 250 | } | |
| 251 | ||
| 252 | private async hasHeadCommit(cwd: string): Promise<boolean> { | |
| 253 | const result = await this.runGit(cwd, ["rev-parse", "--verify", "HEAD"], { | |
| 254 | allowNonZeroExit: true, | |
| 255 | }); | |
| 256 | return result.code === 0; | |
| 257 | } | |
| 258 | ||
| 259 | private async resolveCheckpointCommit(cwd: string, ref: string): Promise<string | null> { | |
| 260 | const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { | |
| 261 | allowNonZeroExit: true, | |
| 262 | }); | |
| 263 | if (result.code !== 0) { | |
| 264 | return null; | |
| 265 | } | |
| 266 | const commit = result.stdout.trim(); | |
| 267 | return commit.length > 0 ? commit : null; | |
| 268 | } | |
| 269 | ||
| 270 | private readRefTurnCount(ref: string): number | null { | |
| 271 | const segments = ref.split("/"); | |
| 272 | const rawTurn = segments.at(-1); | |
| 273 | if (!rawTurn) { | |
| 274 | return null; | |
| 275 | } | |
| 276 | const turn = Number.parseInt(rawTurn, 10); | |
| 277 | return Number.isInteger(turn) && turn >= 0 ? turn : null; | |
| 278 | } | |
| 279 | ||
| 280 | private async runGit( | |
| 281 | cwd: string, | |
| 282 | args: readonly string[], | |
| 283 | options: GitOptions = {}, | |
| 284 | ): Promise<Awaited<ReturnType<typeof runProcess>>> { | |
| 285 | let result: Awaited<ReturnType<typeof runProcess>>; | |
| 286 | try { | |
| 287 | result = await runProcess("git", args, { | |
| 288 | cwd, | |
| 289 | env: options.env, | |
| 290 | stdin: options.stdin, | |
| 291 | timeoutMs: DEFAULT_TIMEOUT_MS, | |
| 292 | allowNonZeroExit: true, | |
| 293 | maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, | |
| 294 | outputMode: "truncate", | |
| 295 | }); | |
| 296 | } catch (error) { | |
| 297 | const message = error instanceof Error ? error.message : String(error); | |
| 298 | throw new Error(`Failed to run ${quoteGitCommand(args)}: ${message}`, { cause: error }); | |
| 299 | } | |
| 300 | ||
| 301 | if (result.timedOut) { | |
| 302 | throw new Error(`${quoteGitCommand(args)} timed out.`); | |
| 303 | } | |
| 304 | ||
| 305 | if (result.stdoutTruncated || result.stderrTruncated) { | |
| 306 | throw new Error( | |
| 307 | `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`, | |
| 308 | ); | |
| 309 | } | |
| 310 | ||
| 311 | if (!options.allowNonZeroExit && result.code !== 0) { | |
| 312 | const stderr = result.stderr.trim(); | |
| 313 | throw new Error( | |
| 314 | stderr.length > 0 | |
| 315 | ? `${quoteGitCommand(args)} failed: ${stderr}` | |
| 316 | : `${quoteGitCommand(args)} failed with code ${result.code ?? "null"}.`, | |
| 317 | ); | |
| 318 | } | |
| 319 | ||
| 320 | return result; | |
| 321 | } | |
| 322 | } |
apps/server/src/git.test.ts+73| ... | ... | @@ -617,6 +617,32 @@ describe("git integration", () => { |
| 617 | 617 | await removeGitWorktree({ cwd: tmp.path, path: wtPath }); |
| 618 | 618 | expect(existsSync(wtPath)).toBe(false); |
| 619 | 619 | }); |
| 620 | ||
| 621 | it("removeGitWorktree force removes a dirty worktree", async () => { | |
| 622 | await using tmp = await makeTmpDir(); | |
| 623 | await initRepoWithCommit(tmp.path); | |
| 624 | ||
| 625 | const wtPath = path.join(tmp.path, "wt-dirty-dir"); | |
| 626 | const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( | |
| 627 | (b) => b.current, | |
| 628 | )!.name; | |
| 629 | ||
| 630 | await createGitWorktree({ | |
| 631 | cwd: tmp.path, | |
| 632 | branch: currentBranch, | |
| 633 | newBranch: "wt-dirty", | |
| 634 | path: wtPath, | |
| 635 | }); | |
| 636 | expect(existsSync(wtPath)).toBe(true); | |
| 637 | ||
| 638 | await writeFile(path.join(wtPath, "README.md"), "dirty change\n"); | |
| 639 | ||
| 640 | await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow(); | |
| 641 | expect(existsSync(wtPath)).toBe(true); | |
| 642 | ||
| 643 | await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true }); | |
| 644 | expect(existsSync(wtPath)).toBe(false); | |
| 645 | }); | |
| 620 | 646 | }); |
| 621 | 647 | |
| 622 | 648 | // ── Full flow: local branch checkout ── |
| ... | ... | @@ -760,6 +786,53 @@ describe("git integration", () => { |
| 760 | 786 | expect(dirty.hasWorkingTreeChanges).toBe(true); |
| 761 | 787 | }); |
| 762 | 788 | |
| 789 | it("includes command context when worktree removal fails", async () => { | |
| 790 | await using tmp = await makeTmpDir(); | |
| 791 | await initRepoWithCommit(tmp.path); | |
| 792 | const core = new GitCoreService(); | |
| 793 | const missingWorktreePath = path.join(tmp.path, "missing-worktree"); | |
| 794 | ||
| 795 | const error = await core | |
| 796 | .removeWorktree({ cwd: tmp.path, path: missingWorktreePath }) | |
| 797 | .then(() => null) | |
| 798 | .catch((err: unknown) => err); | |
| 799 | ||
| 800 | expect(error).toBeInstanceOf(Error); | |
| 801 | const message = (error as Error).message; | |
| 802 | expect(message).toContain("git worktree remove"); | |
| 803 | expect(message).toContain(`cwd: ${tmp.path}`); | |
| 804 | expect(message).toContain(missingWorktreePath); | |
| 805 | }); | |
| 806 | ||
| 807 | it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => { | |
| 808 | await using remote = await makeTmpDir(); | |
| 809 | await using source = await makeTmpDir(); | |
| 810 | await using clone = await makeTmpDir(); | |
| 811 | await git(remote.path, "init --bare"); | |
| 812 | ||
| 813 | await initRepoWithCommit(source.path); | |
| 814 | const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( | |
| 815 | (branch) => branch.current, | |
| 816 | )!.name; | |
| 817 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | |
| 818 | await git(source.path, `push -u origin ${initialBranch}`); | |
| 819 | ||
| 820 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | |
| 821 | await git(clone.path, "config user.email 'test@test.com'"); | |
| 822 | await git(clone.path, "config user.name 'Test'"); | |
| 823 | await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`); | |
| 824 | await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); | |
| 825 | await git(clone.path, "add CHANGELOG.md"); | |
| 826 | await git(clone.path, "commit -m 'remote update'"); | |
| 827 | await git(clone.path, `push origin ${initialBranch}`); | |
| 828 | ||
| 829 | const core = new GitCoreService(); | |
| 830 | const details = await core.statusDetails(source.path); | |
| 831 | expect(details.branch).toBe(initialBranch); | |
| 832 | expect(details.aheadCount).toBe(0); | |
| 833 | expect(details.behindCount).toBe(1); | |
| 834 | }); | |
| 835 | ||
| 763 | 836 | it("prepares commit context by auto-staging and creates commit", async () => { |
| 764 | 837 | await using tmp = await makeTmpDir(); |
| 765 | 838 | await initRepoWithCommit(tmp.path); |
apps/server/src/git.ts+78-10| ... | ... | @@ -65,6 +65,8 @@ interface ExecuteGitOptions { |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; |
| 68 | const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; | |
| 69 | const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; | |
| 68 | 70 | |
| 69 | 71 | /** Spawn git directly with an argv array — no shell, no quoting needed. */ |
| 70 | 72 | function runGit( |
| ... | ... | @@ -216,6 +218,8 @@ async function executeGit( |
| 216 | 218 | } |
| 217 | 219 | |
| 218 | 220 | export class GitCoreService { |
| 221 | private readonly lastUpstreamRefreshAt = new Map<string, number>(); | |
| 222 | ||
| 219 | 223 | async status(raw: GitStatusInput): Promise<GitStatusResult> { |
| 220 | 224 | const input = gitStatusInputSchema.parse(raw); |
| 221 | 225 | const details = await this.statusDetails(input.cwd); |
| ... | ... | @@ -231,6 +235,8 @@ export class GitCoreService { |
| 231 | 235 | } |
| 232 | 236 | |
| 233 | 237 | async statusDetails(cwd: string): Promise<GitStatusDetails> { |
| 238 | await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); | |
| 239 | ||
| 234 | 240 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ |
| 235 | 241 | this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), |
| 236 | 242 | this.gitStdout(cwd, ["diff", "--numstat"]), |
| ... | ... | @@ -540,10 +546,22 @@ export class GitCoreService { |
| 540 | 546 | } |
| 541 | 547 | |
| 542 | 548 | async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> { |
| 543 | await executeGit(input.cwd, ["worktree", "remove", input.path], { | |
| 544 | timeoutMs: 15_000, | |
| 545 | fallbackErrorMessage: "git worktree remove failed", | |
| 546 | }); | |
| 549 | const args = ["worktree", "remove"]; | |
| 550 | if (input.force) { | |
| 551 | args.push("--force"); | |
| 552 | } | |
| 553 | args.push(input.path); | |
| 554 | try { | |
| 555 | await executeGit(input.cwd, args, { | |
| 556 | timeoutMs: 15_000, | |
| 557 | fallbackErrorMessage: "git worktree remove failed", | |
| 558 | }); | |
| 559 | } catch (error) { | |
| 560 | const detail = error instanceof Error ? error.message : String(error); | |
| 561 | throw new Error(`${commandLabel(args)} failed (cwd: ${input.cwd}): ${detail}`, { | |
| 562 | cause: error, | |
| 563 | }); | |
| 564 | } | |
| 547 | 565 | } |
| 548 | 566 | |
| 549 | 567 | async createBranch(input: GitCreateBranchInput): Promise<void> { |
| ... | ... | @@ -553,7 +571,9 @@ export class GitCoreService { |
| 553 | 571 | }); |
| 554 | 572 | } |
| 555 | 573 | |
| 556 | private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> { | |
| 574 | private async resolveCurrentUpstream( | |
| 575 | cwd: string, | |
| 576 | ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> { | |
| 557 | 577 | const upstreamRef = trimStdout( |
| 558 | 578 | await this.gitStdout( |
| 559 | 579 | cwd, |
| ... | ... | @@ -562,24 +582,72 @@ export class GitCoreService { |
| 562 | 582 | ), |
| 563 | 583 | ); |
| 564 | 584 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { |
| 565 | return; | |
| 585 | return null; | |
| 566 | 586 | } |
| 567 | 587 | |
| 568 | 588 | const separatorIndex = upstreamRef.indexOf("/"); |
| 569 | 589 | if (separatorIndex <= 0) { |
| 570 | return; | |
| 590 | return null; | |
| 571 | 591 | } |
| 572 | 592 | const remoteName = upstreamRef.slice(0, separatorIndex); |
| 573 | 593 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); |
| 574 | 594 | if (remoteName.length === 0) { |
| 575 | return; | |
| 595 | return null; | |
| 576 | 596 | } |
| 577 | 597 | if (upstreamBranch.length === 0) { |
| 598 | return null; | |
| 599 | } | |
| 600 | ||
| 601 | return { | |
| 602 | upstreamRef, | |
| 603 | remoteName, | |
| 604 | upstreamBranch, | |
| 605 | }; | |
| 606 | } | |
| 607 | ||
| 608 | private async fetchUpstreamRef( | |
| 609 | cwd: string, | |
| 610 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 611 | ): Promise<void> { | |
| 612 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 613 | await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true); | |
| 614 | } | |
| 615 | ||
| 616 | private async fetchUpstreamRefForStatus( | |
| 617 | cwd: string, | |
| 618 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 619 | ): Promise<void> { | |
| 620 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 621 | await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { | |
| 622 | allowNonZeroExit: true, | |
| 623 | timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, | |
| 624 | }); | |
| 625 | } | |
| 626 | ||
| 627 | private async refreshStatusUpstreamIfStale(cwd: string): Promise<void> { | |
| 628 | const upstream = await this.resolveCurrentUpstream(cwd); | |
| 629 | if (!upstream) { | |
| 578 | 630 | return; |
| 579 | 631 | } |
| 580 | 632 | |
| 581 | const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`; | |
| 582 | await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true); | |
| 633 | const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; | |
| 634 | const now = Date.now(); | |
| 635 | const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0; | |
| 636 | if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { | |
| 637 | return; | |
| 638 | } | |
| 639 | ||
| 640 | // Record attempt time first to avoid repeated fetch spikes when status is polled often. | |
| 641 | this.lastUpstreamRefreshAt.set(cacheKey, now); | |
| 642 | await this.fetchUpstreamRefForStatus(cwd, upstream); | |
| 643 | } | |
| 644 | ||
| 645 | private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> { | |
| 646 | const upstream = await this.resolveCurrentUpstream(cwd); | |
| 647 | if (!upstream) { | |
| 648 | return; | |
| 649 | } | |
| 650 | await this.fetchUpstreamRef(cwd, upstream); | |
| 583 | 651 | } |
| 584 | 652 | |
| 585 | 653 | async checkoutBranch(input: GitCheckoutInput): Promise<void> { |
apps/server/src/keybindings.test.ts+158-2| ... | ... | @@ -1,8 +1,34 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { assert, afterEach, describe, expect, it, vi } from "vitest"; | |
| 2 | 5 | |
| 3 | import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; | |
| 6 | import { | |
| 7 | compileResolvedKeybindingRule, | |
| 8 | parseKeybindingShortcut, | |
| 9 | upsertKeybindingRule, | |
| 10 | } from "./keybindings"; | |
| 4 | 11 | |
| 5 | 12 | describe("server keybindings", () => { |
| 13 | const tempDirs: string[] = []; | |
| 14 | const logger = { | |
| 15 | warn: vi.fn(), | |
| 16 | }; | |
| 17 | ||
| 18 | function makeTempDir(prefix: string): string { | |
| 19 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 20 | tempDirs.push(dir); | |
| 21 | return dir; | |
| 22 | } | |
| 23 | ||
| 24 | afterEach(() => { | |
| 25 | vi.restoreAllMocks(); | |
| 26 | logger.warn.mockReset(); | |
| 27 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 28 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 29 | } | |
| 30 | }); | |
| 31 | ||
| 6 | 32 | it("parses shortcuts including plus key", () => { |
| 7 | 33 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { |
| 8 | 34 | key: "j", |
| ... | ... | @@ -65,5 +91,135 @@ describe("server keybindings", () => { |
| 65 | 91 | when: "terminalFocus && (", |
| 66 | 92 | }), |
| 67 | 93 | ); |
| 94 | ||
| 95 | assert.isNull( | |
| 96 | compileResolvedKeybindingRule({ | |
| 97 | key: "mod+d", | |
| 98 | command: "terminal.split", | |
| 99 | when: `${"!".repeat(300)}terminalFocus`, | |
| 100 | }), | |
| 101 | ); | |
| 102 | }); | |
| 103 | ||
| 104 | it("upserts custom keybindings to ~/.t3/keybindings.json", () => { | |
| 105 | const fakeHome = makeTempDir("t3code-keybindings-upsert-"); | |
| 106 | const configDir = path.join(fakeHome, ".t3"); | |
| 107 | fs.mkdirSync(configDir, { recursive: true }); | |
| 108 | const configPath = path.join(configDir, "keybindings.json"); | |
| 109 | fs.writeFileSync( | |
| 110 | configPath, | |
| 111 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 112 | "utf8", | |
| 113 | ); | |
| 114 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 115 | ||
| 116 | const resolved = upsertKeybindingRule(logger, { | |
| 117 | key: "mod+shift+r", | |
| 118 | command: "script.run-tests.run", | |
| 119 | }); | |
| 120 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | |
| 121 | key: string; | |
| 122 | command: string; | |
| 123 | }>; | |
| 124 | ||
| 125 | expect(persisted).toEqual([ | |
| 126 | { key: "mod+j", command: "terminal.toggle" }, | |
| 127 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 128 | ]); | |
| 129 | expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); | |
| 130 | }); | |
| 131 | ||
| 132 | it("replaces existing custom keybinding for the same command", () => { | |
| 133 | const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); | |
| 134 | const configDir = path.join(fakeHome, ".t3"); | |
| 135 | fs.mkdirSync(configDir, { recursive: true }); | |
| 136 | const configPath = path.join(configDir, "keybindings.json"); | |
| 137 | fs.writeFileSync( | |
| 138 | configPath, | |
| 139 | JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), | |
| 140 | "utf8", | |
| 141 | ); | |
| 142 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 143 | ||
| 144 | upsertKeybindingRule(logger, { | |
| 145 | key: "mod+shift+r", | |
| 146 | command: "script.run-tests.run", | |
| 147 | }); | |
| 148 | ||
| 149 | const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ | |
| 150 | key: string; | |
| 151 | command: string; | |
| 152 | }>; | |
| 153 | expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); | |
| 154 | }); | |
| 155 | ||
| 156 | it("refuses to overwrite malformed keybindings config", () => { | |
| 157 | const fakeHome = makeTempDir("t3code-keybindings-malformed-"); | |
| 158 | const configDir = path.join(fakeHome, ".t3"); | |
| 159 | fs.mkdirSync(configDir, { recursive: true }); | |
| 160 | const configPath = path.join(configDir, "keybindings.json"); | |
| 161 | fs.writeFileSync(configPath, "{ not-json", "utf8"); | |
| 162 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 163 | ||
| 164 | expect(() => | |
| 165 | upsertKeybindingRule(logger, { | |
| 166 | key: "mod+shift+r", | |
| 167 | command: "script.run-tests.run", | |
| 168 | }), | |
| 169 | ).toThrow(/Unable to parse keybindings config/); | |
| 170 | ||
| 171 | expect(fs.readFileSync(configPath, "utf8")).toBe("{ not-json"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("reports non-array config parse errors without duplicate prefix", () => { | |
| 175 | const fakeHome = makeTempDir("t3code-keybindings-non-array-"); | |
| 176 | const configDir = path.join(fakeHome, ".t3"); | |
| 177 | fs.mkdirSync(configDir, { recursive: true }); | |
| 178 | const configPath = path.join(configDir, "keybindings.json"); | |
| 179 | fs.writeFileSync(configPath, JSON.stringify({ key: "mod+j", command: "terminal.toggle" }), "utf8"); | |
| 180 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 181 | ||
| 182 | expect(() => | |
| 183 | upsertKeybindingRule(logger, { | |
| 184 | key: "mod+shift+r", | |
| 185 | command: "script.run-tests.run", | |
| 186 | }), | |
| 187 | ).toThrow(new RegExp(`Unable to parse keybindings config at ${configPath}: expected JSON array`)); | |
| 188 | ||
| 189 | expect(() => | |
| 190 | upsertKeybindingRule(logger, { | |
| 191 | key: "mod+shift+r", | |
| 192 | command: "script.run-tests.run", | |
| 193 | }), | |
| 194 | ).not.toThrow( | |
| 195 | new RegExp(`Unable to parse keybindings config at ${configPath}: Unable to parse keybindings config at`), | |
| 196 | ); | |
| 197 | }); | |
| 198 | ||
| 199 | it("cleans up temp files when atomic keybinding write fails", () => { | |
| 200 | const fakeHome = makeTempDir("t3code-keybindings-atomic-"); | |
| 201 | const configDir = path.join(fakeHome, ".t3"); | |
| 202 | fs.mkdirSync(configDir, { recursive: true }); | |
| 203 | const configPath = path.join(configDir, "keybindings.json"); | |
| 204 | fs.writeFileSync(configPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8"); | |
| 205 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 206 | vi.spyOn(fs, "renameSync").mockImplementation(() => { | |
| 207 | throw new Error("rename failed"); | |
| 208 | }); | |
| 209 | ||
| 210 | expect(() => | |
| 211 | upsertKeybindingRule(logger, { | |
| 212 | key: "mod+shift+r", | |
| 213 | command: "script.run-tests.run", | |
| 214 | }), | |
| 215 | ).toThrow(/rename failed/); | |
| 216 | ||
| 217 | expect(fs.readFileSync(configPath, "utf8")).toBe( | |
| 218 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 219 | ); | |
| 220 | const tempFiles = fs | |
| 221 | .readdirSync(configDir) | |
| 222 | .filter((file) => file.includes("keybindings.json.") && file.endsWith(".tmp")); | |
| 223 | expect(tempFiles).toEqual([]); | |
| 68 | 224 | }); |
| 69 | 225 | }); |
apps/server/src/keybindings.ts+145-53| ... | ... | @@ -24,6 +24,8 @@ type WhenToken = |
| 24 | 24 | | { type: "lparen" } |
| 25 | 25 | | { type: "rparen" }; |
| 26 | 26 | |
| 27 | const MAX_WHEN_EXPRESSION_DEPTH = 256; | |
| 28 | ||
| 27 | 29 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 28 | 30 | { key: "mod+j", command: "terminal.toggle" }, |
| 29 | 31 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| ... | ... | @@ -159,7 +161,10 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe |
| 159 | 161 | if (!tokens || tokens.length === 0) return null; |
| 160 | 162 | let index = 0; |
| 161 | 163 | |
| 162 | const parsePrimary = (): KeybindingWhenNode | null => { | |
| 164 | const parsePrimary = (depth: number): KeybindingWhenNode | null => { | |
| 165 | if (depth > MAX_WHEN_EXPRESSION_DEPTH) { | |
| 166 | return null; | |
| 167 | } | |
| 163 | 168 | const token = tokens[index]; |
| 164 | 169 | if (!token) return null; |
| 165 | 170 | |
| ... | ... | @@ -170,7 +175,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe |
| 170 | 175 | |
| 171 | 176 | if (token.type === "lparen") { |
| 172 | 177 | index += 1; |
| 173 | const expressionNode = parseOr(); | |
| 178 | const expressionNode = parseOr(depth + 1); | |
| 174 | 179 | const closeToken = tokens[index]; |
| 175 | 180 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { |
| 176 | 181 | return null; |
| ... | ... | @@ -182,24 +187,34 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe |
| 182 | 187 | return null; |
| 183 | 188 | }; |
| 184 | 189 | |
| 185 | const parseUnary = (): KeybindingWhenNode | null => { | |
| 186 | const token = tokens[index]; | |
| 187 | if (token?.type === "not") { | |
| 190 | const parseUnary = (depth: number): KeybindingWhenNode | null => { | |
| 191 | let notCount = 0; | |
| 192 | while (tokens[index]?.type === "not") { | |
| 188 | 193 | index += 1; |
| 189 | const node = parseUnary(); | |
| 190 | if (!node) return null; | |
| 191 | return { type: "not", node }; | |
| 194 | notCount += 1; | |
| 195 | if (notCount > MAX_WHEN_EXPRESSION_DEPTH) { | |
| 196 | return null; | |
| 197 | } | |
| 192 | 198 | } |
| 193 | return parsePrimary(); | |
| 199 | ||
| 200 | let node = parsePrimary(depth); | |
| 201 | if (!node) return null; | |
| 202 | ||
| 203 | while (notCount > 0) { | |
| 204 | node = { type: "not", node }; | |
| 205 | notCount -= 1; | |
| 206 | } | |
| 207 | ||
| 208 | return node; | |
| 194 | 209 | }; |
| 195 | 210 | |
| 196 | const parseAnd = (): KeybindingWhenNode | null => { | |
| 197 | let left = parseUnary(); | |
| 211 | const parseAnd = (depth: number): KeybindingWhenNode | null => { | |
| 212 | let left = parseUnary(depth); | |
| 198 | 213 | if (!left) return null; |
| 199 | 214 | |
| 200 | 215 | while (tokens[index]?.type === "and") { |
| 201 | 216 | index += 1; |
| 202 | const right = parseUnary(); | |
| 217 | const right = parseUnary(depth); | |
| 203 | 218 | if (!right) return null; |
| 204 | 219 | left = { type: "and", left, right }; |
| 205 | 220 | } |
| ... | ... | @@ -207,13 +222,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe |
| 207 | 222 | return left; |
| 208 | 223 | }; |
| 209 | 224 | |
| 210 | const parseOr = (): KeybindingWhenNode | null => { | |
| 211 | let left = parseAnd(); | |
| 225 | const parseOr = (depth: number): KeybindingWhenNode | null => { | |
| 226 | let left = parseAnd(depth); | |
| 212 | 227 | if (!left) return null; |
| 213 | 228 | |
| 214 | 229 | while (tokens[index]?.type === "or") { |
| 215 | 230 | index += 1; |
| 216 | const right = parseAnd(); | |
| 231 | const right = parseAnd(depth); | |
| 217 | 232 | if (!right) return null; |
| 218 | 233 | left = { type: "or", left, right }; |
| 219 | 234 | } |
| ... | ... | @@ -221,7 +236,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe |
| 221 | 236 | return left; |
| 222 | 237 | }; |
| 223 | 238 | |
| 224 | const ast = parseOr(); | |
| 239 | const ast = parseOr(0); | |
| 225 | 240 | if (!ast || index !== tokens.length) return null; |
| 226 | 241 | return ast; |
| 227 | 242 | } |
| ... | ... | @@ -274,51 +289,49 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig { |
| 274 | 289 | } |
| 275 | 290 | |
| 276 | 291 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); |
| 292 | const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); | |
| 293 | const MAX_KEYBINDINGS = 256; | |
| 277 | 294 | |
| 278 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | |
| 279 | if (custom.length === 0) { | |
| 280 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 281 | } | |
| 282 | ||
| 283 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 284 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 285 | (binding) => !overriddenCommands.has(binding.command), | |
| 286 | ); | |
| 287 | const merged = [...retainedDefaults, ...custom]; | |
| 288 | ||
| 289 | if (merged.length <= 256) { | |
| 290 | return merged; | |
| 295 | class KeybindingsConfigParseError extends Error { | |
| 296 | constructor(configPath: string, detail: string, options?: { cause?: unknown }) { | |
| 297 | super(`Unable to parse keybindings config at ${configPath}: ${detail}`, options); | |
| 291 | 298 | } |
| 299 | } | |
| 292 | 300 | |
| 293 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 294 | return merged.slice(-256); | |
| 301 | function resolveKeybindingsConfigPath(): string { | |
| 302 | return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); | |
| 295 | 303 | } |
| 296 | 304 | |
| 297 | export function loadResolvedKeybindingsConfig( | |
| 305 | function loadCustomKeybindingsConfig( | |
| 298 | 306 | logger: KeybindingsLogger, |
| 299 | ): ResolvedKeybindingsConfig { | |
| 300 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | |
| 307 | options?: { | |
| 308 | throwOnUnreadableConfig?: boolean; | |
| 309 | }, | |
| 310 | ): KeybindingsConfig { | |
| 311 | const configPath = resolveKeybindingsConfigPath(); | |
| 301 | 312 | try { |
| 302 | 313 | const raw = fs.readFileSync(configPath, "utf8"); |
| 303 | 314 | const parsed = JSON.parse(raw) as unknown; |
| 304 | 315 | if (!Array.isArray(parsed)) { |
| 316 | if (options?.throwOnUnreadableConfig) { | |
| 317 | throw new KeybindingsConfigParseError(configPath, "expected JSON array"); | |
| 318 | } | |
| 305 | 319 | logger.warn("ignoring keybindings config with unsupported format; expected array", { |
| 306 | 320 | path: configPath, |
| 307 | 321 | }); |
| 308 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 322 | return []; | |
| 309 | 323 | } |
| 310 | 324 | |
| 311 | const sanitized: ResolvedKeybindingsConfig = []; | |
| 325 | const sanitized: KeybindingsConfig = []; | |
| 312 | 326 | let invalidEntries = 0; |
| 313 | 327 | for (const entry of parsed) { |
| 314 | 328 | const result = keybindingRuleSchema.safeParse(entry); |
| 315 | 329 | if (result.success) { |
| 316 | const compiled = compileResolvedKeybindingRule(result.data); | |
| 317 | if (!compiled) { | |
| 330 | if (!compileResolvedKeybindingRule(result.data)) { | |
| 318 | 331 | invalidEntries += 1; |
| 319 | 332 | continue; |
| 320 | 333 | } |
| 321 | sanitized.push(compiled); | |
| 334 | sanitized.push(result.data); | |
| 322 | 335 | continue; |
| 323 | 336 | } |
| 324 | 337 | invalidEntries += 1; |
| ... | ... | @@ -330,26 +343,105 @@ export function loadResolvedKeybindingsConfig( |
| 330 | 343 | totalEntries: parsed.length, |
| 331 | 344 | }); |
| 332 | 345 | } |
| 333 | const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); | |
| 334 | const mergedBeforeCapLength = | |
| 335 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 336 | .length + sanitized.length; | |
| 337 | const merged = mergeWithDefaultKeybindings(sanitized); | |
| 338 | if (mergedBeforeCapLength > 256) { | |
| 339 | logger.warn("truncating merged keybindings config to max entries", { | |
| 340 | path: configPath, | |
| 341 | maxEntries: 256, | |
| 342 | }); | |
| 343 | } | |
| 344 | return merged; | |
| 346 | return sanitized; | |
| 345 | 347 | } catch (error) { |
| 346 | 348 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { |
| 347 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 349 | return []; | |
| 350 | } | |
| 351 | if (options?.throwOnUnreadableConfig) { | |
| 352 | if (error instanceof KeybindingsConfigParseError) { | |
| 353 | throw error; | |
| 354 | } | |
| 355 | throw new KeybindingsConfigParseError( | |
| 356 | configPath, | |
| 357 | error instanceof Error ? error.message : String(error), | |
| 358 | { cause: error }, | |
| 359 | ); | |
| 348 | 360 | } |
| 349 | 361 | logger.warn("ignoring malformed keybindings config", { |
| 350 | 362 | path: configPath, |
| 351 | 363 | error: error instanceof Error ? error.message : String(error), |
| 352 | 364 | }); |
| 365 | return []; | |
| 366 | } | |
| 367 | } | |
| 368 | ||
| 369 | function writeConfigAtomically(configPath: string, config: KeybindingsConfig): void { | |
| 370 | const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`; | |
| 371 | try { | |
| 372 | fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); | |
| 373 | fs.renameSync(tempPath, configPath); | |
| 374 | } catch (error) { | |
| 375 | fs.rmSync(tempPath, { force: true }); | |
| 376 | throw error; | |
| 377 | } | |
| 378 | } | |
| 379 | ||
| 380 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | |
| 381 | if (custom.length === 0) { | |
| 382 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 383 | } | |
| 384 | ||
| 385 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 386 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 387 | (binding) => !overriddenCommands.has(binding.command), | |
| 388 | ); | |
| 389 | const merged = [...retainedDefaults, ...custom]; | |
| 390 | ||
| 391 | if (merged.length <= MAX_KEYBINDINGS) { | |
| 392 | return merged; | |
| 393 | } | |
| 394 | ||
| 395 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 396 | return merged.slice(-MAX_KEYBINDINGS); | |
| 397 | } | |
| 398 | ||
| 399 | export function loadResolvedKeybindingsConfig( | |
| 400 | logger: KeybindingsLogger, | |
| 401 | ): ResolvedKeybindingsConfig { | |
| 402 | const customConfig = loadCustomKeybindingsConfig(logger); | |
| 403 | const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); | |
| 404 | const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); | |
| 405 | const mergedBeforeCapLength = | |
| 406 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | |
| 407 | .length + compiledCustomConfig.length; | |
| 408 | const merged = mergeWithDefaultKeybindings(compiledCustomConfig); | |
| 409 | if (mergedBeforeCapLength > MAX_KEYBINDINGS) { | |
| 410 | logger.warn("truncating merged keybindings config to max entries", { | |
| 411 | path: resolveKeybindingsConfigPath(), | |
| 412 | maxEntries: MAX_KEYBINDINGS, | |
| 413 | }); | |
| 414 | } | |
| 415 | return merged; | |
| 416 | } | |
| 417 | ||
| 418 | export function upsertKeybindingRule( | |
| 419 | logger: KeybindingsLogger, | |
| 420 | rawRule: unknown, | |
| 421 | ): ResolvedKeybindingsConfig { | |
| 422 | const rule = keybindingRuleSchema.parse(rawRule); | |
| 423 | if (!compileResolvedKeybindingRule(rule)) { | |
| 424 | throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); | |
| 425 | } | |
| 426 | ||
| 427 | const configPath = resolveKeybindingsConfigPath(); | |
| 428 | const customConfig = loadCustomKeybindingsConfig(logger, { | |
| 429 | throwOnUnreadableConfig: true, | |
| 430 | }); | |
| 431 | const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; | |
| 432 | const cappedConfig = | |
| 433 | nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; | |
| 434 | ||
| 435 | if (nextConfig.length > MAX_KEYBINDINGS) { | |
| 436 | logger.warn("truncating keybindings config to max entries", { | |
| 437 | path: configPath, | |
| 438 | maxEntries: MAX_KEYBINDINGS, | |
| 439 | }); | |
| 353 | 440 | } |
| 354 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 441 | ||
| 442 | fs.mkdirSync(path.dirname(configPath), { recursive: true }); | |
| 443 | writeConfigAtomically(configPath, cappedConfig); | |
| 444 | ||
| 445 | const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); | |
| 446 | return mergeWithDefaultKeybindings(compiledCustomConfig); | |
| 355 | 447 | } |
apps/server/src/projectRegistry.test.ts created+113| ... | ... | @@ -0,0 +1,113 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { ProjectRegistry } from "./projectRegistry"; | |
| 8 | ||
| 9 | const tempDirs: string[] = []; | |
| 10 | ||
| 11 | function makeTempDir(prefix: string): string { | |
| 12 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 13 | tempDirs.push(dir); | |
| 14 | return dir; | |
| 15 | } | |
| 16 | ||
| 17 | afterEach(() => { | |
| 18 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 19 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 20 | } | |
| 21 | }); | |
| 22 | ||
| 23 | describe("ProjectRegistry scripts", () => { | |
| 24 | it("stores and reloads project scripts", () => { | |
| 25 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 26 | const projectDir = makeTempDir("t3code-project-registry-project-"); | |
| 27 | const registry = new ProjectRegistry(stateDir); | |
| 28 | ||
| 29 | const created = registry.add({ cwd: projectDir }).project; | |
| 30 | const updated = registry.updateScripts({ | |
| 31 | id: created.id, | |
| 32 | scripts: [ | |
| 33 | { | |
| 34 | id: "run", | |
| 35 | name: "Run", | |
| 36 | command: "bun run dev", | |
| 37 | icon: "play", | |
| 38 | runOnWorktreeCreate: false, | |
| 39 | }, | |
| 40 | { | |
| 41 | id: "setup", | |
| 42 | name: "Setup", | |
| 43 | command: "bun install", | |
| 44 | icon: "configure", | |
| 45 | runOnWorktreeCreate: true, | |
| 46 | }, | |
| 47 | ], | |
| 48 | }); | |
| 49 | ||
| 50 | expect(updated.project.scripts).toHaveLength(2); | |
| 51 | expect(updated.project.scripts[1]?.id).toBe("setup"); | |
| 52 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true); | |
| 53 | ||
| 54 | const reloaded = new ProjectRegistry(stateDir); | |
| 55 | const listed = reloaded.list(); | |
| 56 | expect(listed).toHaveLength(1); | |
| 57 | expect(listed[0]?.scripts).toHaveLength(2); | |
| 58 | expect(listed[0]?.scripts[0]?.id).toBe("run"); | |
| 59 | expect(listed[0]?.scripts[1]?.id).toBe("setup"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("deduplicates script ids and allows only one setup script", () => { | |
| 63 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 64 | const projectDir = makeTempDir("t3code-project-registry-project-"); | |
| 65 | const registry = new ProjectRegistry(stateDir); | |
| 66 | const created = registry.add({ cwd: projectDir }).project; | |
| 67 | ||
| 68 | const updated = registry.updateScripts({ | |
| 69 | id: created.id, | |
| 70 | scripts: [ | |
| 71 | { | |
| 72 | id: "setup", | |
| 73 | name: "Setup", | |
| 74 | command: "echo setup", | |
| 75 | icon: "configure", | |
| 76 | runOnWorktreeCreate: true, | |
| 77 | }, | |
| 78 | { | |
| 79 | id: "setup", | |
| 80 | name: "Setup Duplicate", | |
| 81 | command: "echo duplicate", | |
| 82 | icon: "configure", | |
| 83 | runOnWorktreeCreate: true, | |
| 84 | }, | |
| 85 | { | |
| 86 | id: "another", | |
| 87 | name: "Another Setup", | |
| 88 | command: "echo another", | |
| 89 | icon: "play", | |
| 90 | runOnWorktreeCreate: true, | |
| 91 | }, | |
| 92 | ], | |
| 93 | }); | |
| 94 | ||
| 95 | expect(updated.project.scripts).toHaveLength(2); | |
| 96 | expect(updated.project.scripts[0]?.id).toBe("setup"); | |
| 97 | expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true); | |
| 98 | expect(updated.project.scripts[1]?.id).toBe("another"); | |
| 99 | expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false); | |
| 100 | }); | |
| 101 | ||
| 102 | it("throws when updating scripts for an unknown project id", () => { | |
| 103 | const stateDir = makeTempDir("t3code-project-registry-state-"); | |
| 104 | const registry = new ProjectRegistry(stateDir); | |
| 105 | ||
| 106 | expect(() => | |
| 107 | registry.updateScripts({ | |
| 108 | id: "missing-project", | |
| 109 | scripts: [], | |
| 110 | }), | |
| 111 | ).toThrowError("Project not found"); | |
| 112 | }); | |
| 113 | }); |
apps/server/src/projectRegistry.ts+66-3| ... | ... | @@ -8,11 +8,31 @@ import { |
| 8 | 8 | type ProjectListResult, |
| 9 | 9 | type ProjectRecord, |
| 10 | 10 | type ProjectRemoveInput, |
| 11 | type ProjectScript, | |
| 12 | type ProjectUpdateScriptsInput, | |
| 13 | type ProjectUpdateScriptsResult, | |
| 14 | normalizeProjectScripts, | |
| 11 | 15 | projectAddInputSchema, |
| 12 | 16 | projectRecordSchema, |
| 13 | 17 | projectRemoveInputSchema, |
| 18 | projectScriptsSchema, | |
| 19 | projectUpdateScriptsInputSchema, | |
| 14 | 20 | } from "@t3tools/contracts"; |
| 15 | 21 | |
| 22 | function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 23 | const cloned: ProjectScript[] = []; | |
| 24 | for (const script of scripts) { | |
| 25 | cloned.push({ | |
| 26 | id: script.id, | |
| 27 | name: script.name, | |
| 28 | command: script.command, | |
| 29 | icon: script.icon, | |
| 30 | runOnWorktreeCreate: script.runOnWorktreeCreate, | |
| 31 | }); | |
| 32 | } | |
| 33 | return cloned; | |
| 34 | } | |
| 35 | ||
| 16 | 36 | function normalizeCwd(rawCwd: string): string { |
| 17 | 37 | const resolved = path.resolve(rawCwd.trim()); |
| 18 | 38 | const normalized = path.normalize(resolved); |
| ... | ... | @@ -48,7 +68,10 @@ export class ProjectRegistry { |
| 48 | 68 | } |
| 49 | 69 | |
| 50 | 70 | list(): ProjectListResult { |
| 51 | return this.projects.map((project) => ({ ...project })); | |
| 71 | return this.projects.map((project) => ({ | |
| 72 | ...project, | |
| 73 | scripts: cloneScripts(project.scripts), | |
| 74 | })); | |
| 52 | 75 | } |
| 53 | 76 | |
| 54 | 77 | add(raw: ProjectAddInput): ProjectAddResult { |
| ... | ... | @@ -60,7 +83,13 @@ export class ProjectRegistry { |
| 60 | 83 | |
| 61 | 84 | const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); |
| 62 | 85 | if (existing) { |
| 63 | return { project: { ...existing }, created: false }; | |
| 86 | return { | |
| 87 | project: { | |
| 88 | ...existing, | |
| 89 | scripts: cloneScripts(existing.scripts), | |
| 90 | }, | |
| 91 | created: false, | |
| 92 | }; | |
| 64 | 93 | } |
| 65 | 94 | |
| 66 | 95 | const now = new Date().toISOString(); |
| ... | ... | @@ -68,13 +97,20 @@ export class ProjectRegistry { |
| 68 | 97 | id: randomUUID(), |
| 69 | 98 | cwd: normalizedCwd, |
| 70 | 99 | name: inferProjectName(normalizedCwd), |
| 100 | scripts: [], | |
| 71 | 101 | createdAt: now, |
| 72 | 102 | updatedAt: now, |
| 73 | 103 | }; |
| 74 | 104 | |
| 75 | 105 | this.projects = [project, ...this.projects]; |
| 76 | 106 | this.persist(); |
| 77 | return { project: { ...project }, created: true }; | |
| 107 | return { | |
| 108 | project: { | |
| 109 | ...project, | |
| 110 | scripts: cloneScripts(project.scripts), | |
| 111 | }, | |
| 112 | created: true, | |
| 113 | }; | |
| 78 | 114 | } |
| 79 | 115 | |
| 80 | 116 | remove(raw: ProjectRemoveInput): void { |
| ... | ... | @@ -88,6 +124,31 @@ export class ProjectRegistry { |
| 88 | 124 | this.persist(); |
| 89 | 125 | } |
| 90 | 126 | |
| 127 | updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { | |
| 128 | const input = projectUpdateScriptsInputSchema.parse(raw); | |
| 129 | const index = this.projects.findIndex((project) => project.id === input.id); | |
| 130 | if (index < 0) { | |
| 131 | throw new Error(`Project not found: ${input.id}`); | |
| 132 | } | |
| 133 | ||
| 134 | const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); | |
| 135 | const nextUpdatedAt = new Date().toISOString(); | |
| 136 | const nextProject: ProjectRecord = { | |
| 137 | ...this.projects[index]!, | |
| 138 | scripts: nextScripts, | |
| 139 | updatedAt: nextUpdatedAt, | |
| 140 | }; | |
| 141 | ||
| 142 | const nextProjects = [...this.projects]; | |
| 143 | nextProjects[index] = nextProject; | |
| 144 | this.projects = nextProjects; | |
| 145 | this.persist(); | |
| 146 | ||
| 147 | return { | |
| 148 | project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) }, | |
| 149 | }; | |
| 150 | } | |
| 151 | ||
| 91 | 152 | private loadFromDisk(): ProjectRecord[] { |
| 92 | 153 | const loaded = this.readPersistedProjects(); |
| 93 | 154 | const deduped = new Map<string, ProjectRecord>(); |
| ... | ... | @@ -107,6 +168,7 @@ export class ProjectRegistry { |
| 107 | 168 | cwd: normalizedCwd, |
| 108 | 169 | name: |
| 109 | 170 | project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), |
| 171 | scripts: normalizeProjectScripts(project.scripts), | |
| 110 | 172 | }; |
| 111 | 173 | deduped.set(normalizedCwd, normalizedProject); |
| 112 | 174 | normalizedProjects.push(normalizedProject); |
| ... | ... | @@ -121,6 +183,7 @@ export class ProjectRegistry { |
| 121 | 183 | source.id !== project.id || |
| 122 | 184 | source.cwd !== project.cwd || |
| 123 | 185 | source.name !== project.name || |
| 186 | JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) || | |
| 124 | 187 | source.createdAt !== project.createdAt || |
| 125 | 188 | source.updatedAt !== project.updatedAt |
| 126 | 189 | ); |
apps/server/src/providerManager.test.ts+818-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | 2 | |
| 3 | 3 | import { ProviderManager } from "./providerManager"; |
| 4 | 4 | |
| ... | ... | @@ -59,4 +59,821 @@ describe("ProviderManager", () => { |
| 59 | 59 | |
| 60 | 60 | manager.dispose(); |
| 61 | 61 | }); |
| 62 | ||
| 63 | it("rejects checkpoint operations for unknown sessions", async () => { | |
| 64 | const manager = new ProviderManager(); | |
| 65 | ||
| 66 | await expect( | |
| 67 | manager.listCheckpoints({ | |
| 68 | sessionId: "missing-session", | |
| 69 | }), | |
| 70 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 71 | ||
| 72 | await expect( | |
| 73 | manager.revertToCheckpoint({ | |
| 74 | sessionId: "missing-session", | |
| 75 | turnCount: 0, | |
| 76 | }), | |
| 77 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 78 | ||
| 79 | await expect( | |
| 80 | manager.getCheckpointDiff({ | |
| 81 | sessionId: "missing-session", | |
| 82 | fromTurnCount: 0, | |
| 83 | toTurnCount: 1, | |
| 84 | }), | |
| 85 | ).rejects.toThrow("Unknown provider session: missing-session"); | |
| 86 | ||
| 87 | manager.dispose(); | |
| 88 | }); | |
| 89 | ||
| 90 | it("derives checkpoints from thread turns", async () => { | |
| 91 | const manager = new ProviderManager(); | |
| 92 | const codex = ( | |
| 93 | manager as unknown as { | |
| 94 | codex: { | |
| 95 | hasSession: (sessionId: string) => boolean; | |
| 96 | readThread: (sessionId: string) => Promise<{ | |
| 97 | threadId: string; | |
| 98 | turns: Array<{ id: string; items: unknown[] }>; | |
| 99 | }>; | |
| 100 | }; | |
| 101 | } | |
| 102 | ).codex; | |
| 103 | ||
| 104 | codex.hasSession = () => true; | |
| 105 | codex.readThread = async () => ({ | |
| 106 | threadId: "thr_1", | |
| 107 | turns: [ | |
| 108 | { | |
| 109 | id: "turn_1", | |
| 110 | items: [ | |
| 111 | { | |
| 112 | type: "userMessage", | |
| 113 | content: [{ type: "text", text: "Refactor the logger" }], | |
| 114 | }, | |
| 115 | { | |
| 116 | type: "agentMessage", | |
| 117 | text: "I refactored it.", | |
| 118 | }, | |
| 119 | ], | |
| 120 | }, | |
| 121 | ], | |
| 122 | }); | |
| 123 | ||
| 124 | const result = await manager.listCheckpoints({ | |
| 125 | sessionId: "sess_1", | |
| 126 | }); | |
| 127 | ||
| 128 | expect(result).toEqual({ | |
| 129 | threadId: "thr_1", | |
| 130 | checkpoints: [ | |
| 131 | { | |
| 132 | id: "root", | |
| 133 | turnCount: 0, | |
| 134 | messageCount: 0, | |
| 135 | label: "Start of conversation", | |
| 136 | isCurrent: false, | |
| 137 | }, | |
| 138 | { | |
| 139 | id: "turn_1", | |
| 140 | turnCount: 1, | |
| 141 | messageCount: 2, | |
| 142 | label: "Turn 1", | |
| 143 | preview: "Refactor the logger", | |
| 144 | isCurrent: true, | |
| 145 | }, | |
| 146 | ], | |
| 147 | }); | |
| 148 | ||
| 149 | manager.dispose(); | |
| 150 | }); | |
| 151 | ||
| 152 | it("rolls back from a selected checkpoint turn count", async () => { | |
| 153 | const manager = new ProviderManager(); | |
| 154 | const internals = manager as unknown as { | |
| 155 | codex: { | |
| 156 | hasSession: (sessionId: string) => boolean; | |
| 157 | readThread: (sessionId: string) => Promise<{ | |
| 158 | threadId: string; | |
| 159 | turns: Array<{ id: string; items: unknown[] }>; | |
| 160 | }>; | |
| 161 | rollbackThread: ( | |
| 162 | sessionId: string, | |
| 163 | numTurns: number, | |
| 164 | ) => Promise<{ | |
| 165 | threadId: string; | |
| 166 | turns: Array<{ id: string; items: unknown[] }>; | |
| 167 | }>; | |
| 168 | }; | |
| 169 | filesystemCheckpointStore: { | |
| 170 | hasCheckpoint: (input: { | |
| 171 | cwd: string; | |
| 172 | threadId: string; | |
| 173 | turnCount: number; | |
| 174 | }) => Promise<boolean>; | |
| 175 | restoreCheckpoint: (input: { | |
| 176 | cwd: string; | |
| 177 | threadId: string; | |
| 178 | turnCount: number; | |
| 179 | }) => Promise<boolean>; | |
| 180 | pruneAfterTurn: (input: { | |
| 181 | cwd: string; | |
| 182 | threadId: string; | |
| 183 | maxTurnCount: number; | |
| 184 | }) => Promise<void>; | |
| 185 | }; | |
| 186 | sessionCheckpointCwds: Map<string, string>; | |
| 187 | }; | |
| 188 | ||
| 189 | const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; | |
| 190 | internals.codex.hasSession = () => true; | |
| 191 | internals.codex.readThread = async () => ({ | |
| 192 | threadId: "thr_1", | |
| 193 | turns: [ | |
| 194 | { id: "turn_1", items: [] }, | |
| 195 | { id: "turn_2", items: [] }, | |
| 196 | { id: "turn_3", items: [] }, | |
| 197 | ], | |
| 198 | }); | |
| 199 | internals.codex.rollbackThread = async (sessionId, numTurns) => { | |
| 200 | rollbackCalls.push({ sessionId, numTurns }); | |
| 201 | return { | |
| 202 | threadId: "thr_1", | |
| 203 | turns: [{ id: "turn_1", items: [] }], | |
| 204 | }; | |
| 205 | }; | |
| 206 | const hasCheckpoint = vi.fn(async () => true); | |
| 207 | const restoreCheckpoint = vi.fn(async () => true); | |
| 208 | const pruneAfterTurn = vi.fn(async () => undefined); | |
| 209 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | |
| 210 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | |
| 211 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | |
| 212 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 213 | ||
| 214 | const result = await manager.revertToCheckpoint({ | |
| 215 | sessionId: "sess_1", | |
| 216 | turnCount: 1, | |
| 217 | }); | |
| 218 | ||
| 219 | expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]); | |
| 220 | expect(result.threadId).toBe("thr_1"); | |
| 221 | expect(result.turnCount).toBe(1); | |
| 222 | expect(result.messageCount).toBe(0); | |
| 223 | expect(result.rolledBackTurns).toBe(2); | |
| 224 | expect(result.checkpoints).toHaveLength(2); | |
| 225 | expect(hasCheckpoint).toHaveBeenCalledWith({ | |
| 226 | cwd: "/repo", | |
| 227 | threadId: "thr_1", | |
| 228 | turnCount: 1, | |
| 229 | }); | |
| 230 | expect(restoreCheckpoint).toHaveBeenCalledWith({ | |
| 231 | cwd: "/repo", | |
| 232 | threadId: "thr_1", | |
| 233 | turnCount: 1, | |
| 234 | }); | |
| 235 | expect(pruneAfterTurn).toHaveBeenCalledWith({ | |
| 236 | cwd: "/repo", | |
| 237 | threadId: "thr_1", | |
| 238 | maxTurnCount: 1, | |
| 239 | }); | |
| 240 | ||
| 241 | manager.dispose(); | |
| 242 | }); | |
| 243 | ||
| 244 | it("returns diff text for a checkpoint range", async () => { | |
| 245 | const manager = new ProviderManager(); | |
| 246 | const internals = manager as unknown as { | |
| 247 | codex: { | |
| 248 | hasSession: (sessionId: string) => boolean; | |
| 249 | readThread: (sessionId: string) => Promise<{ | |
| 250 | threadId: string; | |
| 251 | turns: Array<{ id: string; items: unknown[] }>; | |
| 252 | }>; | |
| 253 | }; | |
| 254 | filesystemCheckpointStore: { | |
| 255 | diffCheckpoints: (input: { | |
| 256 | cwd: string; | |
| 257 | threadId: string; | |
| 258 | fromTurnCount: number; | |
| 259 | toTurnCount: number; | |
| 260 | }) => Promise<string>; | |
| 261 | }; | |
| 262 | sessionCheckpointCwds: Map<string, string>; | |
| 263 | }; | |
| 264 | ||
| 265 | internals.codex.hasSession = () => true; | |
| 266 | internals.codex.readThread = async () => ({ | |
| 267 | threadId: "thr_1", | |
| 268 | turns: [ | |
| 269 | { id: "turn_1", items: [] }, | |
| 270 | { id: "turn_2", items: [] }, | |
| 271 | ], | |
| 272 | }); | |
| 273 | const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); | |
| 274 | internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; | |
| 275 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 276 | ||
| 277 | const result = await manager.getCheckpointDiff({ | |
| 278 | sessionId: "sess_1", | |
| 279 | fromTurnCount: 1, | |
| 280 | toTurnCount: 2, | |
| 281 | }); | |
| 282 | ||
| 283 | expect(result).toEqual({ | |
| 284 | threadId: "thr_1", | |
| 285 | fromTurnCount: 1, | |
| 286 | toTurnCount: 2, | |
| 287 | diff: "diff --git a/a.ts b/a.ts", | |
| 288 | }); | |
| 289 | expect(diffCheckpoints).toHaveBeenCalledWith({ | |
| 290 | cwd: "/repo", | |
| 291 | threadId: "thr_1", | |
| 292 | fromTurnCount: 1, | |
| 293 | toTurnCount: 2, | |
| 294 | }); | |
| 295 | ||
| 296 | manager.dispose(); | |
| 297 | }); | |
| 298 | ||
| 299 | it("emits checkpoint/captured after turn completion checkpoint capture", async () => { | |
| 300 | const manager = new ProviderManager(); | |
| 301 | const events: Array<{ method: string; payload?: unknown; threadId?: string }> = []; | |
| 302 | manager.on("event", (event) => { | |
| 303 | events.push({ | |
| 304 | method: event.method, | |
| 305 | ...(event.payload !== undefined ? { payload: event.payload } : {}), | |
| 306 | ...(event.threadId ? { threadId: event.threadId } : {}), | |
| 307 | }); | |
| 308 | }); | |
| 309 | ||
| 310 | const internals = manager as unknown as { | |
| 311 | onCodexEvent: (event: { | |
| 312 | id: string; | |
| 313 | kind: "notification"; | |
| 314 | provider: "codex"; | |
| 315 | sessionId: string; | |
| 316 | createdAt: string; | |
| 317 | method: string; | |
| 318 | threadId: string; | |
| 319 | }) => void; | |
| 320 | codex: { | |
| 321 | hasSession: (sessionId: string) => boolean; | |
| 322 | readThread: (sessionId: string) => Promise<{ | |
| 323 | threadId: string; | |
| 324 | turns: Array<{ id: string; items: unknown[] }>; | |
| 325 | }>; | |
| 326 | }; | |
| 327 | filesystemCheckpointStore: { | |
| 328 | captureCheckpoint: (input: { | |
| 329 | cwd: string; | |
| 330 | threadId: string; | |
| 331 | turnCount: number; | |
| 332 | }) => Promise<void>; | |
| 333 | }; | |
| 334 | sessionCheckpointCwds: Map<string, string>; | |
| 335 | }; | |
| 336 | ||
| 337 | internals.codex.hasSession = () => true; | |
| 338 | internals.codex.readThread = async () => ({ | |
| 339 | threadId: "thr_1", | |
| 340 | turns: [{ id: "turn_1", items: [] }], | |
| 341 | }); | |
| 342 | const captureCheckpoint = vi.fn(async () => undefined); | |
| 343 | internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; | |
| 344 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 345 | ||
| 346 | internals.onCodexEvent({ | |
| 347 | id: "evt-turn-complete", | |
| 348 | kind: "notification", | |
| 349 | provider: "codex", | |
| 350 | sessionId: "sess_1", | |
| 351 | createdAt: "2026-02-19T00:00:00.000Z", | |
| 352 | method: "turn/completed", | |
| 353 | threadId: "thr_1", | |
| 354 | }); | |
| 355 | ||
| 356 | await vi.waitFor(() => { | |
| 357 | expect(captureCheckpoint).toHaveBeenCalledWith({ | |
| 358 | cwd: "/repo", | |
| 359 | threadId: "thr_1", | |
| 360 | turnCount: 1, | |
| 361 | }); | |
| 362 | expect(events.some((event) => event.method === "checkpoint/captured")).toBe(true); | |
| 363 | }); | |
| 364 | ||
| 365 | const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured"); | |
| 366 | expect(checkpointCapturedEvent?.threadId).toBe("thr_1"); | |
| 367 | expect(checkpointCapturedEvent?.payload).toEqual({ | |
| 368 | threadId: "thr_1", | |
| 369 | turnCount: 1, | |
| 370 | }); | |
| 371 | ||
| 372 | manager.dispose(); | |
| 373 | }); | |
| 374 | ||
| 375 | it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => { | |
| 376 | const manager = new ProviderManager(); | |
| 377 | const internals = manager as unknown as { | |
| 378 | codex: { | |
| 379 | hasSession: (sessionId: string) => boolean; | |
| 380 | listSessions: () => Array<{ | |
| 381 | sessionId: string; | |
| 382 | provider: "codex"; | |
| 383 | status: "ready"; | |
| 384 | createdAt: string; | |
| 385 | updatedAt: string; | |
| 386 | cwd?: string; | |
| 387 | }>; | |
| 388 | readThread: (sessionId: string) => Promise<{ | |
| 389 | threadId: string; | |
| 390 | turns: Array<{ id: string; items: unknown[] }>; | |
| 391 | }>; | |
| 392 | }; | |
| 393 | filesystemCheckpointStore: { | |
| 394 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 395 | ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>; | |
| 396 | captureCheckpoint: (input: { | |
| 397 | cwd: string; | |
| 398 | threadId: string; | |
| 399 | turnCount: number; | |
| 400 | }) => Promise<void>; | |
| 401 | diffCheckpoints: (input: { | |
| 402 | cwd: string; | |
| 403 | threadId: string; | |
| 404 | fromTurnCount: number; | |
| 405 | toTurnCount: number; | |
| 406 | }) => Promise<string>; | |
| 407 | }; | |
| 408 | sessionCheckpointCwds: Map<string, string>; | |
| 409 | }; | |
| 410 | ||
| 411 | internals.codex.hasSession = () => true; | |
| 412 | internals.codex.listSessions = () => [ | |
| 413 | { | |
| 414 | sessionId: "sess_1", | |
| 415 | provider: "codex", | |
| 416 | status: "ready", | |
| 417 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 418 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 419 | cwd: "/repo", | |
| 420 | }, | |
| 421 | ]; | |
| 422 | internals.codex.readThread = async () => ({ | |
| 423 | threadId: "thr_1", | |
| 424 | turns: [ | |
| 425 | { id: "turn_1", items: [] }, | |
| 426 | { id: "turn_2", items: [] }, | |
| 427 | ], | |
| 428 | }); | |
| 429 | const isGitRepository = vi.fn(async () => true); | |
| 430 | const ensureRootCheckpoint = vi.fn(async () => true); | |
| 431 | const captureCheckpoint = vi.fn(async () => undefined); | |
| 432 | const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); | |
| 433 | internals.filesystemCheckpointStore.isGitRepository = isGitRepository; | |
| 434 | internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; | |
| 435 | internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; | |
| 436 | internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; | |
| 437 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 438 | ||
| 439 | const result = await manager.getCheckpointDiff({ | |
| 440 | sessionId: "sess_1", | |
| 441 | fromTurnCount: 1, | |
| 442 | toTurnCount: 2, | |
| 443 | }); | |
| 444 | ||
| 445 | expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); | |
| 446 | expect(isGitRepository).toHaveBeenCalledWith("/repo"); | |
| 447 | expect(captureCheckpoint).toHaveBeenCalledWith({ | |
| 448 | cwd: "/repo", | |
| 449 | threadId: "thr_1", | |
| 450 | turnCount: 2, | |
| 451 | }); | |
| 452 | expect(ensureRootCheckpoint).toHaveBeenCalledWith({ | |
| 453 | cwd: "/repo", | |
| 454 | threadId: "thr_1", | |
| 455 | }); | |
| 456 | expect(diffCheckpoints).toHaveBeenCalledWith({ | |
| 457 | cwd: "/repo", | |
| 458 | threadId: "thr_1", | |
| 459 | fromTurnCount: 1, | |
| 460 | toTurnCount: 2, | |
| 461 | }); | |
| 462 | expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo"); | |
| 463 | ||
| 464 | manager.dispose(); | |
| 465 | }); | |
| 466 | ||
| 467 | it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => { | |
| 468 | const manager = new ProviderManager(); | |
| 469 | const internals = manager as unknown as { | |
| 470 | codex: { | |
| 471 | hasSession: (sessionId: string) => boolean; | |
| 472 | listSessions: () => Array<{ | |
| 473 | sessionId: string; | |
| 474 | provider: "codex"; | |
| 475 | status: "ready"; | |
| 476 | createdAt: string; | |
| 477 | updatedAt: string; | |
| 478 | cwd?: string; | |
| 479 | }>; | |
| 480 | readThread: (sessionId: string) => Promise<{ | |
| 481 | threadId: string; | |
| 482 | turns: Array<{ id: string; items: unknown[] }>; | |
| 483 | }>; | |
| 484 | }; | |
| 485 | filesystemCheckpointStore: { | |
| 486 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 487 | ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>; | |
| 488 | captureCheckpoint: (input: { | |
| 489 | cwd: string; | |
| 490 | threadId: string; | |
| 491 | turnCount: number; | |
| 492 | }) => Promise<void>; | |
| 493 | diffCheckpoints: (input: { | |
| 494 | cwd: string; | |
| 495 | threadId: string; | |
| 496 | fromTurnCount: number; | |
| 497 | toTurnCount: number; | |
| 498 | }) => Promise<string>; | |
| 499 | }; | |
| 500 | sessionCheckpointCwds: Map<string, string>; | |
| 501 | }; | |
| 502 | ||
| 503 | const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); | |
| 504 | try { | |
| 505 | internals.codex.hasSession = () => true; | |
| 506 | internals.codex.listSessions = () => [ | |
| 507 | { | |
| 508 | sessionId: "sess_1", | |
| 509 | provider: "codex", | |
| 510 | status: "ready", | |
| 511 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 512 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 513 | }, | |
| 514 | ]; | |
| 515 | internals.codex.readThread = async () => ({ | |
| 516 | threadId: "thr_1", | |
| 517 | turns: [{ id: "turn_1", items: [] }], | |
| 518 | }); | |
| 519 | const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); | |
| 520 | const ensureRootCheckpoint = vi.fn(async () => true); | |
| 521 | const captureCheckpoint = vi.fn(async () => undefined); | |
| 522 | const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); | |
| 523 | internals.filesystemCheckpointStore.isGitRepository = isGitRepository; | |
| 524 | internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; | |
| 525 | internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; | |
| 526 | internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; | |
| 527 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 528 | ||
| 529 | const result = await manager.getCheckpointDiff({ | |
| 530 | sessionId: "sess_1", | |
| 531 | fromTurnCount: 0, | |
| 532 | toTurnCount: 1, | |
| 533 | }); | |
| 534 | ||
| 535 | expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); | |
| 536 | expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo"); | |
| 537 | expect(captureCheckpoint).toHaveBeenCalledWith({ | |
| 538 | cwd: "/fallback-repo", | |
| 539 | threadId: "thr_1", | |
| 540 | turnCount: 1, | |
| 541 | }); | |
| 542 | expect(ensureRootCheckpoint).toHaveBeenCalledWith({ | |
| 543 | cwd: "/fallback-repo", | |
| 544 | threadId: "thr_1", | |
| 545 | }); | |
| 546 | expect(diffCheckpoints).toHaveBeenCalledWith({ | |
| 547 | cwd: "/fallback-repo", | |
| 548 | threadId: "thr_1", | |
| 549 | fromTurnCount: 0, | |
| 550 | toTurnCount: 1, | |
| 551 | }); | |
| 552 | expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo"); | |
| 553 | } finally { | |
| 554 | processCwd.mockRestore(); | |
| 555 | manager.dispose(); | |
| 556 | } | |
| 557 | }); | |
| 558 | ||
| 559 | it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => { | |
| 560 | const manager = new ProviderManager(); | |
| 561 | const internals = manager as unknown as { | |
| 562 | codex: { | |
| 563 | hasSession: (sessionId: string) => boolean; | |
| 564 | listSessions: () => Array<{ | |
| 565 | sessionId: string; | |
| 566 | provider: "codex"; | |
| 567 | status: "ready"; | |
| 568 | createdAt: string; | |
| 569 | updatedAt: string; | |
| 570 | cwd?: string; | |
| 571 | }>; | |
| 572 | }; | |
| 573 | filesystemCheckpointStore: { | |
| 574 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 575 | }; | |
| 576 | sessionCheckpointCwds: Map<string, string>; | |
| 577 | }; | |
| 578 | ||
| 579 | const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); | |
| 580 | try { | |
| 581 | internals.codex.hasSession = () => true; | |
| 582 | internals.codex.listSessions = () => [ | |
| 583 | { | |
| 584 | sessionId: "sess_1", | |
| 585 | provider: "codex", | |
| 586 | status: "ready", | |
| 587 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 588 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 589 | cwd: "/session-cwd", | |
| 590 | }, | |
| 591 | ]; | |
| 592 | const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); | |
| 593 | internals.filesystemCheckpointStore.isGitRepository = isGitRepository; | |
| 594 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 595 | ||
| 596 | await expect( | |
| 597 | manager.getCheckpointDiff({ | |
| 598 | sessionId: "sess_1", | |
| 599 | fromTurnCount: 0, | |
| 600 | toTurnCount: 1, | |
| 601 | }), | |
| 602 | ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); | |
| 603 | expect(isGitRepository).toHaveBeenCalledWith("/session-cwd"); | |
| 604 | expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo"); | |
| 605 | } finally { | |
| 606 | processCwd.mockRestore(); | |
| 607 | manager.dispose(); | |
| 608 | } | |
| 609 | }); | |
| 610 | ||
| 611 | it("fails revert when filesystem checkpoints are unavailable for the session", async () => { | |
| 612 | const manager = new ProviderManager(); | |
| 613 | const internals = manager as unknown as { | |
| 614 | codex: { | |
| 615 | hasSession: (sessionId: string) => boolean; | |
| 616 | listSessions: () => Array<{ | |
| 617 | sessionId: string; | |
| 618 | provider: "codex"; | |
| 619 | status: "ready"; | |
| 620 | createdAt: string; | |
| 621 | updatedAt: string; | |
| 622 | cwd?: string; | |
| 623 | }>; | |
| 624 | }; | |
| 625 | filesystemCheckpointStore: { | |
| 626 | isGitRepository: (cwd: string) => Promise<boolean>; | |
| 627 | }; | |
| 628 | sessionCheckpointCwds: Map<string, string>; | |
| 629 | }; | |
| 630 | ||
| 631 | internals.codex.hasSession = () => true; | |
| 632 | internals.codex.listSessions = () => [ | |
| 633 | { | |
| 634 | sessionId: "sess_1", | |
| 635 | provider: "codex", | |
| 636 | status: "ready", | |
| 637 | createdAt: "2026-02-18T00:00:00.000Z", | |
| 638 | updatedAt: "2026-02-18T00:00:00.000Z", | |
| 639 | }, | |
| 640 | ]; | |
| 641 | internals.filesystemCheckpointStore.isGitRepository = async () => false; | |
| 642 | internals.sessionCheckpointCwds.delete("sess_1"); | |
| 643 | ||
| 644 | await expect( | |
| 645 | manager.revertToCheckpoint({ | |
| 646 | sessionId: "sess_1", | |
| 647 | turnCount: 1, | |
| 648 | }), | |
| 649 | ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); | |
| 650 | ||
| 651 | manager.dispose(); | |
| 652 | }); | |
| 653 | ||
| 654 | it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { | |
| 655 | const manager = new ProviderManager(); | |
| 656 | const internals = manager as unknown as { | |
| 657 | codex: { | |
| 658 | hasSession: (sessionId: string) => boolean; | |
| 659 | readThread: (sessionId: string) => Promise<{ | |
| 660 | threadId: string; | |
| 661 | turns: Array<{ id: string; items: unknown[] }>; | |
| 662 | }>; | |
| 663 | rollbackThread: ( | |
| 664 | sessionId: string, | |
| 665 | numTurns: number, | |
| 666 | ) => Promise<{ | |
| 667 | threadId: string; | |
| 668 | turns: Array<{ id: string; items: unknown[] }>; | |
| 669 | }>; | |
| 670 | }; | |
| 671 | filesystemCheckpointStore: { | |
| 672 | hasCheckpoint: (input: { | |
| 673 | cwd: string; | |
| 674 | threadId: string; | |
| 675 | turnCount: number; | |
| 676 | }) => Promise<boolean>; | |
| 677 | restoreCheckpoint: (input: { | |
| 678 | cwd: string; | |
| 679 | threadId: string; | |
| 680 | turnCount: number; | |
| 681 | }) => Promise<boolean>; | |
| 682 | pruneAfterTurn: (input: { | |
| 683 | cwd: string; | |
| 684 | threadId: string; | |
| 685 | maxTurnCount: number; | |
| 686 | }) => Promise<void>; | |
| 687 | }; | |
| 688 | sessionCheckpointCwds: Map<string, string>; | |
| 689 | }; | |
| 690 | ||
| 691 | internals.codex.hasSession = () => true; | |
| 692 | const callOrder: string[] = []; | |
| 693 | internals.codex.readThread = async () => ({ | |
| 694 | threadId: "thr_1", | |
| 695 | turns: [ | |
| 696 | { id: "turn_1", items: [] }, | |
| 697 | { id: "turn_2", items: [] }, | |
| 698 | ], | |
| 699 | }); | |
| 700 | internals.codex.rollbackThread = async () => { | |
| 701 | callOrder.push("rollback"); | |
| 702 | return { | |
| 703 | threadId: "thr_1", | |
| 704 | turns: [{ id: "turn_1", items: [] }], | |
| 705 | }; | |
| 706 | }; | |
| 707 | const hasCheckpoint = vi.fn(async () => true); | |
| 708 | const restoreCheckpoint = vi.fn(async () => { | |
| 709 | callOrder.push("restore"); | |
| 710 | return true; | |
| 711 | }); | |
| 712 | const pruneAfterTurn = vi.fn(async () => { | |
| 713 | callOrder.push("prune"); | |
| 714 | return undefined; | |
| 715 | }); | |
| 716 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | |
| 717 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | |
| 718 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | |
| 719 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 720 | ||
| 721 | const result = await manager.revertToCheckpoint({ | |
| 722 | sessionId: "sess_1", | |
| 723 | turnCount: 1, | |
| 724 | }); | |
| 725 | ||
| 726 | expect(result.turnCount).toBe(1); | |
| 727 | expect(hasCheckpoint).toHaveBeenCalledWith({ | |
| 728 | cwd: "/repo", | |
| 729 | threadId: "thr_1", | |
| 730 | turnCount: 1, | |
| 731 | }); | |
| 732 | expect(restoreCheckpoint).toHaveBeenCalledWith({ | |
| 733 | cwd: "/repo", | |
| 734 | threadId: "thr_1", | |
| 735 | turnCount: 1, | |
| 736 | }); | |
| 737 | expect(pruneAfterTurn).toHaveBeenCalledWith({ | |
| 738 | cwd: "/repo", | |
| 739 | threadId: "thr_1", | |
| 740 | maxTurnCount: 1, | |
| 741 | }); | |
| 742 | expect(callOrder).toEqual(["restore", "rollback", "prune"]); | |
| 743 | ||
| 744 | manager.dispose(); | |
| 745 | }); | |
| 746 | ||
| 747 | it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => { | |
| 748 | const manager = new ProviderManager(); | |
| 749 | const internals = manager as unknown as { | |
| 750 | codex: { | |
| 751 | hasSession: (sessionId: string) => boolean; | |
| 752 | readThread: (sessionId: string) => Promise<{ | |
| 753 | threadId: string; | |
| 754 | turns: Array<{ id: string; items: unknown[] }>; | |
| 755 | }>; | |
| 756 | rollbackThread: ( | |
| 757 | sessionId: string, | |
| 758 | numTurns: number, | |
| 759 | ) => Promise<{ | |
| 760 | threadId: string; | |
| 761 | turns: Array<{ id: string; items: unknown[] }>; | |
| 762 | }>; | |
| 763 | }; | |
| 764 | filesystemCheckpointStore: { | |
| 765 | hasCheckpoint: (input: { | |
| 766 | cwd: string; | |
| 767 | threadId: string; | |
| 768 | turnCount: number; | |
| 769 | }) => Promise<boolean>; | |
| 770 | restoreCheckpoint: (input: { | |
| 771 | cwd: string; | |
| 772 | threadId: string; | |
| 773 | turnCount: number; | |
| 774 | }) => Promise<boolean>; | |
| 775 | pruneAfterTurn: (input: { | |
| 776 | cwd: string; | |
| 777 | threadId: string; | |
| 778 | maxTurnCount: number; | |
| 779 | }) => Promise<void>; | |
| 780 | }; | |
| 781 | sessionCheckpointCwds: Map<string, string>; | |
| 782 | }; | |
| 783 | ||
| 784 | internals.codex.hasSession = () => true; | |
| 785 | internals.codex.readThread = async () => ({ | |
| 786 | threadId: "thr_1", | |
| 787 | turns: [ | |
| 788 | { id: "turn_1", items: [] }, | |
| 789 | { id: "turn_2", items: [] }, | |
| 790 | ], | |
| 791 | }); | |
| 792 | internals.codex.rollbackThread = async () => ({ | |
| 793 | threadId: "thr_1", | |
| 794 | turns: [], | |
| 795 | }); | |
| 796 | const hasCheckpoint = vi.fn(async () => false); | |
| 797 | const restoreCheckpoint = vi.fn(async () => true); | |
| 798 | const pruneAfterTurn = vi.fn(async () => undefined); | |
| 799 | internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; | |
| 800 | internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; | |
| 801 | internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; | |
| 802 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 803 | ||
| 804 | const result = await manager.revertToCheckpoint({ | |
| 805 | sessionId: "sess_1", | |
| 806 | turnCount: 0, | |
| 807 | }); | |
| 808 | ||
| 809 | expect(result.turnCount).toBe(0); | |
| 810 | expect(hasCheckpoint).not.toHaveBeenCalled(); | |
| 811 | expect(restoreCheckpoint).toHaveBeenCalledWith({ | |
| 812 | cwd: "/repo", | |
| 813 | threadId: "thr_1", | |
| 814 | turnCount: 0, | |
| 815 | }); | |
| 816 | expect(pruneAfterTurn).toHaveBeenCalledWith({ | |
| 817 | cwd: "/repo", | |
| 818 | threadId: "thr_1", | |
| 819 | maxTurnCount: 0, | |
| 820 | }); | |
| 821 | ||
| 822 | manager.dispose(); | |
| 823 | }); | |
| 824 | ||
| 825 | it("fails before rollback when filesystem checkpoint is missing", async () => { | |
| 826 | const manager = new ProviderManager(); | |
| 827 | const internals = manager as unknown as { | |
| 828 | codex: { | |
| 829 | hasSession: (sessionId: string) => boolean; | |
| 830 | readThread: (sessionId: string) => Promise<{ | |
| 831 | threadId: string; | |
| 832 | turns: Array<{ id: string; items: unknown[] }>; | |
| 833 | }>; | |
| 834 | rollbackThread: ( | |
| 835 | sessionId: string, | |
| 836 | numTurns: number, | |
| 837 | ) => Promise<{ | |
| 838 | threadId: string; | |
| 839 | turns: Array<{ id: string; items: unknown[] }>; | |
| 840 | }>; | |
| 841 | }; | |
| 842 | filesystemCheckpointStore: { | |
| 843 | hasCheckpoint: (input: { | |
| 844 | cwd: string; | |
| 845 | threadId: string; | |
| 846 | turnCount: number; | |
| 847 | }) => Promise<boolean>; | |
| 848 | }; | |
| 849 | sessionCheckpointCwds: Map<string, string>; | |
| 850 | }; | |
| 851 | ||
| 852 | const rollbackSpy = vi.fn(async () => ({ | |
| 853 | threadId: "thr_1", | |
| 854 | turns: [{ id: "turn_1", items: [] }], | |
| 855 | })); | |
| 856 | ||
| 857 | internals.codex.hasSession = () => true; | |
| 858 | internals.codex.readThread = async () => ({ | |
| 859 | threadId: "thr_1", | |
| 860 | turns: [ | |
| 861 | { id: "turn_1", items: [] }, | |
| 862 | { id: "turn_2", items: [] }, | |
| 863 | ], | |
| 864 | }); | |
| 865 | internals.codex.rollbackThread = rollbackSpy; | |
| 866 | internals.filesystemCheckpointStore.hasCheckpoint = async () => false; | |
| 867 | internals.sessionCheckpointCwds.set("sess_1", "/repo"); | |
| 868 | ||
| 869 | await expect( | |
| 870 | manager.revertToCheckpoint({ | |
| 871 | sessionId: "sess_1", | |
| 872 | turnCount: 1, | |
| 873 | }), | |
| 874 | ).rejects.toThrow("Filesystem checkpoint is unavailable for turn 1 in thread thr_1."); | |
| 875 | expect(rollbackSpy).not.toHaveBeenCalled(); | |
| 876 | ||
| 877 | manager.dispose(); | |
| 878 | }); | |
| 62 | 879 | }); |
apps/server/src/providerManager.ts+440| ... | ... | @@ -1,10 +1,18 @@ |
| 1 | 1 | import { EventEmitter } from "node:events"; |
| 2 | 2 | import fs from "node:fs"; |
| 3 | 3 | import path from "node:path"; |
| 4 | import { randomUUID } from "node:crypto"; | |
| 4 | 5 | |
| 5 | 6 | import { |
| 7 | type ProviderCheckpoint, | |
| 6 | 8 | type ProviderEvent, |
| 9 | type ProviderGetCheckpointDiffInput, | |
| 10 | type ProviderGetCheckpointDiffResult, | |
| 7 | 11 | type ProviderInterruptTurnInput, |
| 12 | type ProviderListCheckpointsInput, | |
| 13 | type ProviderListCheckpointsResult, | |
| 14 | type ProviderRevertToCheckpointInput, | |
| 15 | type ProviderRevertToCheckpointResult, | |
| 8 | 16 | type ProviderRespondToRequestInput, |
| 9 | 17 | type ProviderSendTurnInput, |
| 10 | 18 | type ProviderSession, |
| ... | ... | @@ -12,23 +20,155 @@ import { |
| 12 | 20 | type ProviderStopSessionInput, |
| 13 | 21 | type ProviderTurnStartResult, |
| 14 | 22 | providerInterruptTurnInputSchema, |
| 23 | providerGetCheckpointDiffInputSchema, | |
| 24 | providerListCheckpointsInputSchema, | |
| 25 | providerRevertToCheckpointInputSchema, | |
| 15 | 26 | providerRespondToRequestInputSchema, |
| 16 | 27 | providerSendTurnInputSchema, |
| 17 | 28 | providerSessionStartInputSchema, |
| 18 | 29 | providerStopSessionInputSchema, |
| 19 | 30 | } from "@t3tools/contracts"; |
| 31 | import type { CodexThreadTurnSnapshot } from "./codexAppServerManager"; | |
| 20 | 32 | import { CodexAppServerManager } from "./codexAppServerManager"; |
| 33 | import { FilesystemCheckpointStore } from "./filesystemCheckpointStore"; | |
| 21 | 34 | |
| 22 | 35 | export interface ProviderManagerEvents { |
| 23 | 36 | event: [event: ProviderEvent]; |
| 24 | 37 | } |
| 25 | 38 | |
| 39 | function asObject(value: unknown): Record<string, unknown> | undefined { | |
| 40 | if (!value || typeof value !== "object") { | |
| 41 | return undefined; | |
| 42 | } | |
| 43 | return value as Record<string, unknown>; | |
| 44 | } | |
| 45 | ||
| 46 | function asString(value: unknown): string | undefined { | |
| 47 | return typeof value === "string" ? value : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | function asArray(value: unknown): unknown[] { | |
| 51 | return Array.isArray(value) ? value : []; | |
| 52 | } | |
| 53 | ||
| 54 | function trimToPreview(value: string): string { | |
| 55 | const normalized = value.replace(/\s+/g, " ").trim(); | |
| 56 | if (normalized.length <= 120) { | |
| 57 | return normalized; | |
| 58 | } | |
| 59 | return `${normalized.slice(0, 117)}...`; | |
| 60 | } | |
| 61 | ||
| 62 | function summarizeUserMessageContent(content: unknown[]): string | undefined { | |
| 63 | const segments: string[] = []; | |
| 64 | ||
| 65 | for (const part of content) { | |
| 66 | const record = asObject(part); | |
| 67 | const type = asString(record?.type); | |
| 68 | if (!type) continue; | |
| 69 | ||
| 70 | if (type === "text") { | |
| 71 | const text = asString(record?.text); | |
| 72 | if (text && text.trim().length > 0) { | |
| 73 | segments.push(text.trim()); | |
| 74 | } | |
| 75 | continue; | |
| 76 | } | |
| 77 | ||
| 78 | if (type === "image") { | |
| 79 | segments.push("[Image attachment]"); | |
| 80 | continue; | |
| 81 | } | |
| 82 | ||
| 83 | if (type === "localImage") { | |
| 84 | segments.push("[Local image attachment]"); | |
| 85 | continue; | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | if (segments.length === 0) { | |
| 90 | return undefined; | |
| 91 | } | |
| 92 | return trimToPreview(segments.join(" ")); | |
| 93 | } | |
| 94 | ||
| 95 | function summarizeTurn(turn: CodexThreadTurnSnapshot): { | |
| 96 | messageCountDelta: number; | |
| 97 | preview?: string; | |
| 98 | } { | |
| 99 | let messageCountDelta = 0; | |
| 100 | let preview: string | undefined; | |
| 101 | ||
| 102 | for (const item of turn.items) { | |
| 103 | const record = asObject(item); | |
| 104 | const type = asString(record?.type); | |
| 105 | if (!type) continue; | |
| 106 | ||
| 107 | if (type === "userMessage") { | |
| 108 | messageCountDelta += 1; | |
| 109 | if (!preview) { | |
| 110 | const content = asArray(record?.content); | |
| 111 | preview = summarizeUserMessageContent(content); | |
| 112 | } | |
| 113 | continue; | |
| 114 | } | |
| 115 | ||
| 116 | if (type === "agentMessage") { | |
| 117 | messageCountDelta += 1; | |
| 118 | if (!preview) { | |
| 119 | const text = asString(record?.text); | |
| 120 | if (text && text.trim().length > 0) { | |
| 121 | preview = trimToPreview(text); | |
| 122 | } | |
| 123 | } | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | return { | |
| 128 | messageCountDelta, | |
| 129 | ...(preview ? { preview } : {}), | |
| 130 | }; | |
| 131 | } | |
| 132 | ||
| 133 | function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] { | |
| 134 | const checkpoints: ProviderCheckpoint[] = []; | |
| 135 | let messageCount = 0; | |
| 136 | const isEmpty = turns.length === 0; | |
| 137 | checkpoints.push({ | |
| 138 | id: "root", | |
| 139 | turnCount: 0, | |
| 140 | messageCount: 0, | |
| 141 | label: "Start of conversation", | |
| 142 | isCurrent: isEmpty, | |
| 143 | }); | |
| 144 | ||
| 145 | for (let index = 0; index < turns.length; index += 1) { | |
| 146 | const turn = turns[index]; | |
| 147 | if (!turn) continue; | |
| 148 | const turnSummary = summarizeTurn(turn); | |
| 149 | messageCount += turnSummary.messageCountDelta; | |
| 150 | checkpoints.push({ | |
| 151 | id: turn.id, | |
| 152 | turnCount: index + 1, | |
| 153 | messageCount, | |
| 154 | label: `Turn ${index + 1}`, | |
| 155 | ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), | |
| 156 | isCurrent: index === turns.length - 1, | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | return checkpoints; | |
| 161 | } | |
| 162 | ||
| 26 | 163 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 27 | 164 | private readonly codex = new CodexAppServerManager(); |
| 165 | private readonly filesystemCheckpointStore = new FilesystemCheckpointStore(); | |
| 28 | 166 | private readonly threadLogsDir: string; |
| 29 | 167 | private readonly threadLogStreams = new Map<string, fs.WriteStream>(); |
| 30 | 168 | private readonly sessionThreadIds = new Map<string, string>(); |
| 31 | 169 | private readonly pendingEventsBySession = new Map<string, ProviderEvent[]>(); |
| 170 | private readonly sessionCheckpointCwds = new Map<string, string>(); | |
| 171 | private readonly filesystemLocks = new Map<string, Promise<void>>(); | |
| 32 | 172 | private disposed = false; |
| 33 | 173 | private readonly onCodexEvent = (event: ProviderEvent) => { |
| 34 | 174 | if (this.disposed) { |
| ... | ... | @@ -37,6 +177,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 37 | 177 | |
| 38 | 178 | this.routeEventToThreadLog(event); |
| 39 | 179 | this.emit("event", event); |
| 180 | this.maybeCaptureFilesystemCheckpoint(event); | |
| 40 | 181 | }; |
| 41 | 182 | |
| 42 | 183 | constructor() { |
| ... | ... | @@ -60,6 +201,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 60 | 201 | this.sessionThreadIds.set(session.sessionId, session.threadId); |
| 61 | 202 | this.flushPendingSessionEvents(session.sessionId, session.threadId); |
| 62 | 203 | } |
| 204 | await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => { | |
| 205 | const message = | |
| 206 | error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; | |
| 207 | this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId); | |
| 208 | }); | |
| 63 | 209 | return session; |
| 64 | 210 | } |
| 65 | 211 | |
| ... | ... | @@ -95,12 +241,138 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 95 | 241 | this.codex.stopSession(input.sessionId); |
| 96 | 242 | this.sessionThreadIds.delete(input.sessionId); |
| 97 | 243 | this.pendingEventsBySession.delete(input.sessionId); |
| 244 | this.sessionCheckpointCwds.delete(input.sessionId); | |
| 245 | this.filesystemLocks.delete(input.sessionId); | |
| 98 | 246 | } |
| 99 | 247 | |
| 100 | 248 | listSessions(): ProviderSession[] { |
| 101 | 249 | return this.codex.listSessions(); |
| 102 | 250 | } |
| 103 | 251 | |
| 252 | async listCheckpoints(raw: ProviderListCheckpointsInput): Promise<ProviderListCheckpointsResult> { | |
| 253 | const input = providerListCheckpointsInputSchema.parse(raw); | |
| 254 | if (!this.codex.hasSession(input.sessionId)) { | |
| 255 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 256 | } | |
| 257 | ||
| 258 | const snapshot = await this.codex.readThread(input.sessionId); | |
| 259 | return { | |
| 260 | threadId: snapshot.threadId, | |
| 261 | checkpoints: buildCheckpoints(snapshot.turns), | |
| 262 | }; | |
| 263 | } | |
| 264 | ||
| 265 | async getCheckpointDiff( | |
| 266 | raw: ProviderGetCheckpointDiffInput, | |
| 267 | ): Promise<ProviderGetCheckpointDiffResult> { | |
| 268 | const input = providerGetCheckpointDiffInputSchema.parse(raw); | |
| 269 | if (!this.codex.hasSession(input.sessionId)) { | |
| 270 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 271 | } | |
| 272 | ||
| 273 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); | |
| 274 | if (!checkpointCwd) { | |
| 275 | throw new Error("Filesystem checkpoints are unavailable for this session."); | |
| 276 | } | |
| 277 | ||
| 278 | return this.withFilesystemLock(input.sessionId, async () => { | |
| 279 | const snapshot = await this.codex.readThread(input.sessionId); | |
| 280 | if (input.toTurnCount > snapshot.turns.length) { | |
| 281 | throw new Error( | |
| 282 | `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, | |
| 283 | ); | |
| 284 | } | |
| 285 | ||
| 286 | const diff = await this.filesystemCheckpointStore.diffCheckpoints({ | |
| 287 | cwd: checkpointCwd, | |
| 288 | threadId: snapshot.threadId, | |
| 289 | fromTurnCount: input.fromTurnCount, | |
| 290 | toTurnCount: input.toTurnCount, | |
| 291 | }); | |
| 292 | ||
| 293 | return { | |
| 294 | threadId: snapshot.threadId, | |
| 295 | fromTurnCount: input.fromTurnCount, | |
| 296 | toTurnCount: input.toTurnCount, | |
| 297 | diff, | |
| 298 | }; | |
| 299 | }); | |
| 300 | } | |
| 301 | ||
| 302 | async revertToCheckpoint( | |
| 303 | raw: ProviderRevertToCheckpointInput, | |
| 304 | ): Promise<ProviderRevertToCheckpointResult> { | |
| 305 | const input = providerRevertToCheckpointInputSchema.parse(raw); | |
| 306 | if (!this.codex.hasSession(input.sessionId)) { | |
| 307 | throw new Error(`Unknown provider session: ${input.sessionId}`); | |
| 308 | } | |
| 309 | ||
| 310 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); | |
| 311 | if (!checkpointCwd) { | |
| 312 | throw new Error("Filesystem checkpoints are unavailable for this session."); | |
| 313 | } | |
| 314 | return this.withFilesystemLock(input.sessionId, async () => { | |
| 315 | const beforeSnapshot = await this.codex.readThread(input.sessionId); | |
| 316 | const currentTurnCount = beforeSnapshot.turns.length; | |
| 317 | if (input.turnCount > currentTurnCount) { | |
| 318 | throw new Error( | |
| 319 | `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, | |
| 320 | ); | |
| 321 | } | |
| 322 | ||
| 323 | if (checkpointCwd && input.turnCount > 0) { | |
| 324 | const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({ | |
| 325 | cwd: checkpointCwd, | |
| 326 | threadId: beforeSnapshot.threadId, | |
| 327 | turnCount: input.turnCount, | |
| 328 | }); | |
| 329 | if (!hasCheckpoint) { | |
| 330 | throw new Error( | |
| 331 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | |
| 332 | ); | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ | |
| 337 | cwd: checkpointCwd, | |
| 338 | threadId: beforeSnapshot.threadId, | |
| 339 | turnCount: input.turnCount, | |
| 340 | }); | |
| 341 | if (!restored) { | |
| 342 | throw new Error( | |
| 343 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, | |
| 344 | ); | |
| 345 | } | |
| 346 | ||
| 347 | const requestedRollbackTurns = currentTurnCount - input.turnCount; | |
| 348 | const afterSnapshot = | |
| 349 | requestedRollbackTurns > 0 | |
| 350 | ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) | |
| 351 | : beforeSnapshot; | |
| 352 | ||
| 353 | await this.filesystemCheckpointStore.pruneAfterTurn({ | |
| 354 | cwd: checkpointCwd, | |
| 355 | threadId: afterSnapshot.threadId, | |
| 356 | maxTurnCount: afterSnapshot.turns.length, | |
| 357 | }); | |
| 358 | ||
| 359 | const checkpoints = buildCheckpoints(afterSnapshot.turns); | |
| 360 | const currentCheckpoint = | |
| 361 | checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? | |
| 362 | checkpoints[checkpoints.length - 1] ?? | |
| 363 | checkpoints[0]; | |
| 364 | const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); | |
| 365 | ||
| 366 | return { | |
| 367 | threadId: afterSnapshot.threadId, | |
| 368 | turnCount: currentCheckpoint?.turnCount ?? 0, | |
| 369 | messageCount: currentCheckpoint?.messageCount ?? 0, | |
| 370 | rolledBackTurns, | |
| 371 | checkpoints, | |
| 372 | }; | |
| 373 | }); | |
| 374 | } | |
| 375 | ||
| 104 | 376 | stopAll(): void { |
| 105 | 377 | this.codex.stopAll(); |
| 106 | 378 | } |
| ... | ... | @@ -118,6 +390,174 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 118 | 390 | this.threadLogStreams.clear(); |
| 119 | 391 | this.sessionThreadIds.clear(); |
| 120 | 392 | this.pendingEventsBySession.clear(); |
| 393 | this.sessionCheckpointCwds.clear(); | |
| 394 | this.filesystemLocks.clear(); | |
| 395 | } | |
| 396 | ||
| 397 | private async initializeFilesystemCheckpointing( | |
| 398 | session: ProviderSession, | |
| 399 | preferredCwd?: string, | |
| 400 | ): Promise<void> { | |
| 401 | const cwd = preferredCwd ?? session.cwd ?? process.cwd(); | |
| 402 | ||
| 403 | await this.withFilesystemLock(session.sessionId, async () => { | |
| 404 | const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd); | |
| 405 | if (!supportsGit) { | |
| 406 | this.sessionCheckpointCwds.delete(session.sessionId); | |
| 407 | return; | |
| 408 | } | |
| 409 | ||
| 410 | const snapshot = await this.codex.readThread(session.sessionId); | |
| 411 | await this.filesystemCheckpointStore.ensureRootCheckpoint({ | |
| 412 | cwd, | |
| 413 | threadId: snapshot.threadId, | |
| 414 | }); | |
| 415 | await this.filesystemCheckpointStore.captureCheckpoint({ | |
| 416 | cwd, | |
| 417 | threadId: snapshot.threadId, | |
| 418 | turnCount: snapshot.turns.length, | |
| 419 | }); | |
| 420 | if (this.codex.hasSession(session.sessionId)) { | |
| 421 | this.sessionCheckpointCwds.set(session.sessionId, cwd); | |
| 422 | } | |
| 423 | }); | |
| 424 | } | |
| 425 | ||
| 426 | private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise<string | null> { | |
| 427 | const existingCwd = this.sessionCheckpointCwds.get(sessionId); | |
| 428 | if (existingCwd) { | |
| 429 | return existingCwd; | |
| 430 | } | |
| 431 | ||
| 432 | const session = this.codex | |
| 433 | .listSessions() | |
| 434 | .find((candidate) => candidate.sessionId === sessionId); | |
| 435 | const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; | |
| 436 | ||
| 437 | await this.withFilesystemLock(sessionId, async () => { | |
| 438 | const currentCwd = this.sessionCheckpointCwds.get(sessionId); | |
| 439 | if (currentCwd) { | |
| 440 | return; | |
| 441 | } | |
| 442 | ||
| 443 | const cwdSupport = await Promise.all( | |
| 444 | candidateCwds.map(async (cwd) => ({ | |
| 445 | cwd, | |
| 446 | supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd), | |
| 447 | })), | |
| 448 | ); | |
| 449 | const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd; | |
| 450 | if (supportedCwd) { | |
| 451 | const snapshot = await this.codex.readThread(sessionId); | |
| 452 | await this.filesystemCheckpointStore.ensureRootCheckpoint({ | |
| 453 | cwd: supportedCwd, | |
| 454 | threadId: snapshot.threadId, | |
| 455 | }); | |
| 456 | await this.filesystemCheckpointStore.captureCheckpoint({ | |
| 457 | cwd: supportedCwd, | |
| 458 | threadId: snapshot.threadId, | |
| 459 | turnCount: snapshot.turns.length, | |
| 460 | }); | |
| 461 | if (this.codex.hasSession(sessionId)) { | |
| 462 | this.sessionCheckpointCwds.set(sessionId, supportedCwd); | |
| 463 | } | |
| 464 | return; | |
| 465 | } | |
| 466 | ||
| 467 | this.sessionCheckpointCwds.delete(sessionId); | |
| 468 | }); | |
| 469 | ||
| 470 | return this.sessionCheckpointCwds.get(sessionId) ?? null; | |
| 471 | } | |
| 472 | ||
| 473 | private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void { | |
| 474 | if (event.kind !== "notification" || event.method !== "turn/completed") { | |
| 475 | return; | |
| 476 | } | |
| 477 | ||
| 478 | const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); | |
| 479 | if (!checkpointCwd) { | |
| 480 | void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId) | |
| 481 | .then(async (initializedCwd) => { | |
| 482 | if (!initializedCwd || !this.codex.hasSession(event.sessionId)) { | |
| 483 | return; | |
| 484 | } | |
| 485 | const snapshot = await this.codex.readThread(event.sessionId); | |
| 486 | this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); | |
| 487 | }) | |
| 488 | .catch((error) => { | |
| 489 | const message = | |
| 490 | error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; | |
| 491 | this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); | |
| 492 | }); | |
| 493 | return; | |
| 494 | } | |
| 495 | ||
| 496 | void this.withFilesystemLock(event.sessionId, async () => { | |
| 497 | if (!this.codex.hasSession(event.sessionId)) { | |
| 498 | return; | |
| 499 | } | |
| 500 | ||
| 501 | const snapshot = await this.codex.readThread(event.sessionId); | |
| 502 | await this.filesystemCheckpointStore.captureCheckpoint({ | |
| 503 | cwd: checkpointCwd, | |
| 504 | threadId: snapshot.threadId, | |
| 505 | turnCount: snapshot.turns.length, | |
| 506 | }); | |
| 507 | this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); | |
| 508 | }).catch((error) => { | |
| 509 | const message = error instanceof Error ? error.message : "Failed to capture checkpoint."; | |
| 510 | this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); | |
| 511 | }); | |
| 512 | } | |
| 513 | ||
| 514 | private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void { | |
| 515 | this.emit("event", { | |
| 516 | id: randomUUID(), | |
| 517 | kind: "notification", | |
| 518 | provider: "codex", | |
| 519 | sessionId, | |
| 520 | createdAt: new Date().toISOString(), | |
| 521 | method: "checkpoint/captured", | |
| 522 | threadId, | |
| 523 | payload: { | |
| 524 | threadId, | |
| 525 | turnCount, | |
| 526 | }, | |
| 527 | }); | |
| 528 | } | |
| 529 | ||
| 530 | private emitFilesystemCheckpointError( | |
| 531 | sessionId: string, | |
| 532 | message: string, | |
| 533 | threadId?: string, | |
| 534 | ): void { | |
| 535 | this.emit("event", { | |
| 536 | id: randomUUID(), | |
| 537 | kind: "error", | |
| 538 | provider: "codex", | |
| 539 | sessionId, | |
| 540 | createdAt: new Date().toISOString(), | |
| 541 | method: "checkpoint/filesystemError", | |
| 542 | message, | |
| 543 | ...(threadId ? { threadId } : {}), | |
| 544 | }); | |
| 545 | } | |
| 546 | ||
| 547 | private withFilesystemLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> { | |
| 548 | const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve(); | |
| 549 | const next = previous.catch(() => undefined).then(fn); | |
| 550 | const completion = next.then( | |
| 551 | () => undefined, | |
| 552 | () => undefined, | |
| 553 | ); | |
| 554 | this.filesystemLocks.set(sessionId, completion); | |
| 555 | return next.finally(() => { | |
| 556 | const tracked = this.filesystemLocks.get(sessionId); | |
| 557 | if (tracked === completion) { | |
| 558 | this.filesystemLocks.delete(sessionId); | |
| 559 | } | |
| 560 | }); | |
| 121 | 561 | } |
| 122 | 562 | |
| 123 | 563 | private routeEventToThreadLog(event: ProviderEvent): void { |
apps/server/src/ptyAdapter.ts+2-1| ... | ... | @@ -20,6 +20,7 @@ export interface PtyProcess { |
| 20 | 20 | |
| 21 | 21 | export interface PtySpawnInput { |
| 22 | 22 | shell: string; |
| 23 | args?: string[]; | |
| 23 | 24 | cwd: string; |
| 24 | 25 | cols: number; |
| 25 | 26 | rows: number; |
| ... | ... | @@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess { |
| 118 | 119 | export class NodePtyAdapter implements PtyAdapter { |
| 119 | 120 | spawn(input: PtySpawnInput): PtyProcess { |
| 120 | 121 | ensureNodePtySpawnHelperExecutable(); |
| 121 | const ptyProcess = nodePty.spawn(input.shell, [], { | |
| 122 | const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { | |
| 122 | 123 | cwd: input.cwd, |
| 123 | 124 | cols: input.cols, |
| 124 | 125 | rows: input.rows, |
apps/server/src/terminalManager.test.ts+97| ... | ... | @@ -196,6 +196,65 @@ describe("TerminalManager", () => { |
| 196 | 196 | manager.dispose(); |
| 197 | 197 | }); |
| 198 | 198 | |
| 199 | it("resizes running terminal on open when a different size is requested", async () => { | |
| 200 | const { manager, ptyAdapter } = makeManager(); | |
| 201 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 202 | const process = ptyAdapter.processes[0]; | |
| 203 | expect(process).toBeDefined(); | |
| 204 | if (!process) return; | |
| 205 | ||
| 206 | await manager.open(openInput({ cols: 140, rows: 40 })); | |
| 207 | ||
| 208 | expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); | |
| 209 | ||
| 210 | manager.dispose(); | |
| 211 | }); | |
| 212 | ||
| 213 | it("preserves existing terminal size on open when size is omitted", async () => { | |
| 214 | const { manager, ptyAdapter } = makeManager(); | |
| 215 | await manager.open(openInput({ cols: 100, rows: 24 })); | |
| 216 | const ptyProcess = ptyAdapter.processes[0]; | |
| 217 | expect(ptyProcess).toBeDefined(); | |
| 218 | if (!ptyProcess) return; | |
| 219 | ||
| 220 | await manager.open({ | |
| 221 | threadId: "thread-1", | |
| 222 | cwd: globalThis.process.cwd(), | |
| 223 | }); | |
| 224 | ||
| 225 | expect(ptyProcess.resizeCalls).toEqual([]); | |
| 226 | ||
| 227 | ptyProcess.emitExit({ exitCode: 0, signal: 0 }); | |
| 228 | await manager.open({ | |
| 229 | threadId: "thread-1", | |
| 230 | cwd: globalThis.process.cwd(), | |
| 231 | }); | |
| 232 | ||
| 233 | const resumedSpawn = ptyAdapter.spawnInputs[1]; | |
| 234 | expect(resumedSpawn).toBeDefined(); | |
| 235 | if (!resumedSpawn) return; | |
| 236 | expect(resumedSpawn.cols).toBe(100); | |
| 237 | expect(resumedSpawn.rows).toBe(24); | |
| 238 | ||
| 239 | manager.dispose(); | |
| 240 | }); | |
| 241 | ||
| 242 | it("uses default dimensions when opening a new terminal without size hints", async () => { | |
| 243 | const { manager, ptyAdapter } = makeManager(); | |
| 244 | await manager.open({ | |
| 245 | threadId: "thread-1", | |
| 246 | cwd: process.cwd(), | |
| 247 | }); | |
| 248 | ||
| 249 | const spawned = ptyAdapter.spawnInputs[0]; | |
| 250 | expect(spawned).toBeDefined(); | |
| 251 | if (!spawned) return; | |
| 252 | expect(spawned.cols).toBe(120); | |
| 253 | expect(spawned.rows).toBe(30); | |
| 254 | ||
| 255 | manager.dispose(); | |
| 256 | }); | |
| 257 | ||
| 199 | 258 | it("supports multiple terminals per thread with isolated sessions", async () => { |
| 200 | 259 | const { manager, ptyAdapter } = makeManager(); |
| 201 | 260 | await manager.open(openInput({ terminalId: "default" })); |
| ... | ... | @@ -466,4 +525,42 @@ describe("TerminalManager", () => { |
| 466 | 525 | restoreEnv(); |
| 467 | 526 | } |
| 468 | 527 | }); |
| 528 | ||
| 529 | it("injects runtime env overrides into spawned terminals", async () => { | |
| 530 | const { manager, ptyAdapter } = makeManager(); | |
| 531 | await manager.open( | |
| 532 | openInput({ | |
| 533 | env: { | |
| 534 | T3CODE_PROJECT_ROOT: "/repo", | |
| 535 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 536 | CUSTOM_FLAG: "1", | |
| 537 | }, | |
| 538 | }), | |
| 539 | ); | |
| 540 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 541 | expect(spawnInput).toBeDefined(); | |
| 542 | if (!spawnInput) return; | |
| 543 | ||
| 544 | expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); | |
| 545 | expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); | |
| 546 | expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); | |
| 547 | ||
| 548 | manager.dispose(); | |
| 549 | }); | |
| 550 | ||
| 551 | it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { | |
| 552 | if (process.platform === "win32") return; | |
| 553 | const { manager, ptyAdapter } = makeManager(5, { | |
| 554 | shellResolver: () => "/bin/zsh", | |
| 555 | }); | |
| 556 | await manager.open(openInput()); | |
| 557 | const spawnInput = ptyAdapter.spawnInputs[0]; | |
| 558 | expect(spawnInput).toBeDefined(); | |
| 559 | if (!spawnInput) return; | |
| 560 | ||
| 561 | expect(spawnInput.shell).toBe("/bin/zsh"); | |
| 562 | expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); | |
| 563 | ||
| 564 | manager.dispose(); | |
| 565 | }); | |
| 469 | 566 | }); |
apps/server/src/terminalManager.ts+108-37| ... | ... | @@ -26,6 +26,8 @@ import { runProcess } from "./processRunner"; |
| 26 | 26 | const DEFAULT_HISTORY_LINE_LIMIT = 5_000; |
| 27 | 27 | const DEFAULT_PERSIST_DEBOUNCE_MS = 40; |
| 28 | 28 | const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; |
| 29 | const DEFAULT_OPEN_COLS = 120; | |
| 30 | const DEFAULT_OPEN_ROWS = 30; | |
| 29 | 31 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| 30 | 32 | |
| 31 | 33 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; |
| ... | ... | @@ -59,8 +61,16 @@ interface TerminalSessionState { |
| 59 | 61 | unsubscribeData: (() => void) | null; |
| 60 | 62 | unsubscribeExit: (() => void) | null; |
| 61 | 63 | hasRunningSubprocess: boolean; |
| 64 | runtimeEnv: Record<string, string> | null; | |
| 62 | 65 | } |
| 63 | 66 | |
| 67 | interface ShellCandidate { | |
| 68 | shell: string; | |
| 69 | args?: string[]; | |
| 70 | } | |
| 71 | ||
| 72 | type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number }; | |
| 73 | ||
| 64 | 74 | function defaultShellResolver(): string { |
| 65 | 75 | if (process.platform === "win32") { |
| 66 | 76 | return process.env.ComSpec ?? "cmd.exe"; |
| ... | ... | @@ -82,34 +92,54 @@ function normalizeShellCommand(value: string | undefined): string | null { |
| 82 | 92 | return firstToken.replace(/^['"]|['"]$/g, ""); |
| 83 | 93 | } |
| 84 | 94 | |
| 85 | function uniqueShells(shells: Array<string | null>): string[] { | |
| 95 | function shellCandidateFromCommand(command: string | null): ShellCandidate | null { | |
| 96 | if (!command || command.length === 0) return null; | |
| 97 | const shellName = path.basename(command).toLowerCase(); | |
| 98 | if (process.platform !== "win32" && shellName === "zsh") { | |
| 99 | return { shell: command, args: ["-o", "nopromptsp"] }; | |
| 100 | } | |
| 101 | return { shell: command }; | |
| 102 | } | |
| 103 | ||
| 104 | function formatShellCandidate(candidate: ShellCandidate): string { | |
| 105 | if (!candidate.args || candidate.args.length === 0) return candidate.shell; | |
| 106 | return `${candidate.shell} ${candidate.args.join(" ")}`; | |
| 107 | } | |
| 108 | ||
| 109 | function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] { | |
| 86 | 110 | const seen = new Set<string>(); |
| 87 | const ordered: string[] = []; | |
| 88 | for (const shell of shells) { | |
| 89 | if (!shell || shell.length === 0) continue; | |
| 90 | if (seen.has(shell)) continue; | |
| 91 | seen.add(shell); | |
| 92 | ordered.push(shell); | |
| 111 | const ordered: ShellCandidate[] = []; | |
| 112 | for (const candidate of candidates) { | |
| 113 | if (!candidate) continue; | |
| 114 | const key = formatShellCandidate(candidate); | |
| 115 | if (seen.has(key)) continue; | |
| 116 | seen.add(key); | |
| 117 | ordered.push(candidate); | |
| 93 | 118 | } |
| 94 | 119 | return ordered; |
| 95 | 120 | } |
| 96 | 121 | |
| 97 | function resolveShellCandidates(shellResolver: () => string): string[] { | |
| 98 | const requested = normalizeShellCommand(shellResolver()); | |
| 122 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { | |
| 123 | const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); | |
| 99 | 124 | |
| 100 | 125 | if (process.platform === "win32") { |
| 101 | return uniqueShells([requested, process.env.ComSpec ?? null, "powershell.exe", "cmd.exe"]); | |
| 126 | return uniqueShellCandidates([ | |
| 127 | requested, | |
| 128 | shellCandidateFromCommand(process.env.ComSpec ?? null), | |
| 129 | shellCandidateFromCommand("powershell.exe"), | |
| 130 | shellCandidateFromCommand("cmd.exe"), | |
| 131 | ]); | |
| 102 | 132 | } |
| 103 | 133 | |
| 104 | return uniqueShells([ | |
| 134 | return uniqueShellCandidates([ | |
| 105 | 135 | requested, |
| 106 | normalizeShellCommand(process.env.SHELL), | |
| 107 | "/bin/zsh", | |
| 108 | "/bin/bash", | |
| 109 | "/bin/sh", | |
| 110 | "zsh", | |
| 111 | "bash", | |
| 112 | "sh", | |
| 136 | shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), | |
| 137 | shellCandidateFromCommand("/bin/zsh"), | |
| 138 | shellCandidateFromCommand("/bin/bash"), | |
| 139 | shellCandidateFromCommand("/bin/sh"), | |
| 140 | shellCandidateFromCommand("zsh"), | |
| 141 | shellCandidateFromCommand("bash"), | |
| 142 | shellCandidateFromCommand("sh"), | |
| 113 | 143 | ]); |
| 114 | 144 | } |
| 115 | 145 | |
| ... | ... | @@ -241,16 +271,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { |
| 241 | 271 | return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); |
| 242 | 272 | } |
| 243 | 273 | |
| 244 | function createTerminalSpawnEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { | |
| 274 | function createTerminalSpawnEnv( | |
| 275 | baseEnv: NodeJS.ProcessEnv, | |
| 276 | runtimeEnv?: Record<string, string> | null, | |
| 277 | ): NodeJS.ProcessEnv { | |
| 245 | 278 | const spawnEnv: NodeJS.ProcessEnv = {}; |
| 246 | 279 | for (const [key, value] of Object.entries(baseEnv)) { |
| 247 | 280 | if (value === undefined) continue; |
| 248 | 281 | if (shouldExcludeTerminalEnvKey(key)) continue; |
| 249 | 282 | spawnEnv[key] = value; |
| 250 | 283 | } |
| 284 | if (runtimeEnv) { | |
| 285 | for (const [key, value] of Object.entries(runtimeEnv)) { | |
| 286 | spawnEnv[key] = value; | |
| 287 | } | |
| 288 | } | |
| 251 | 289 | return spawnEnv; |
| 252 | 290 | } |
| 253 | 291 | |
| 292 | function normalizedRuntimeEnv( | |
| 293 | env: Record<string, string> | undefined, | |
| 294 | ): Record<string, string> | null { | |
| 295 | if (!env) return null; | |
| 296 | const entries = Object.entries(env); | |
| 297 | if (entries.length === 0) return null; | |
| 298 | return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); | |
| 299 | } | |
| 300 | ||
| 254 | 301 | export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 255 | 302 | private readonly sessions = new Map<string, TerminalSessionState>(); |
| 256 | 303 | private readonly logsDir: string; |
| ... | ... | @@ -291,6 +338,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 291 | 338 | if (!existing) { |
| 292 | 339 | await this.flushPersistQueue(input.threadId, input.terminalId); |
| 293 | 340 | const history = await this.readHistory(input.threadId, input.terminalId); |
| 341 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 342 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 294 | 343 | const session: TerminalSessionState = { |
| 295 | 344 | threadId: input.threadId, |
| 296 | 345 | terminalId: input.terminalId, |
| ... | ... | @@ -301,37 +350,49 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 301 | 350 | exitCode: null, |
| 302 | 351 | exitSignal: null, |
| 303 | 352 | updatedAt: new Date().toISOString(), |
| 304 | cols: input.cols, | |
| 305 | rows: input.rows, | |
| 353 | cols, | |
| 354 | rows, | |
| 306 | 355 | process: null, |
| 307 | 356 | unsubscribeData: null, |
| 308 | 357 | unsubscribeExit: null, |
| 309 | 358 | hasRunningSubprocess: false, |
| 359 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 310 | 360 | }; |
| 311 | 361 | this.sessions.set(sessionKey, session); |
| 312 | this.startSession(session, input, "started"); | |
| 362 | this.startSession(session, { ...input, cols, rows }, "started"); | |
| 313 | 363 | return this.snapshot(session); |
| 314 | 364 | } |
| 315 | 365 | |
| 316 | if (existing.cwd !== input.cwd) { | |
| 366 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | |
| 367 | const currentRuntimeEnv = existing.runtimeEnv; | |
| 368 | const targetCols = input.cols ?? existing.cols; | |
| 369 | const targetRows = input.rows ?? existing.rows; | |
| 370 | const runtimeEnvChanged = | |
| 371 | JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); | |
| 372 | ||
| 373 | if (existing.cwd !== input.cwd || runtimeEnvChanged) { | |
| 317 | 374 | this.stopProcess(existing); |
| 318 | 375 | existing.cwd = input.cwd; |
| 376 | existing.runtimeEnv = nextRuntimeEnv; | |
| 319 | 377 | existing.history = ""; |
| 320 | 378 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 321 | 379 | } else if (existing.status === "exited" || existing.status === "error") { |
| 380 | existing.runtimeEnv = nextRuntimeEnv; | |
| 322 | 381 | existing.history = ""; |
| 323 | 382 | await this.persistHistory(existing.threadId, existing.terminalId, existing.history); |
| 383 | } else if (currentRuntimeEnv !== nextRuntimeEnv) { | |
| 384 | existing.runtimeEnv = nextRuntimeEnv; | |
| 324 | 385 | } |
| 325 | 386 | |
| 326 | 387 | if (!existing.process) { |
| 327 | this.startSession(existing, input, "started"); | |
| 388 | this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); | |
| 328 | 389 | return this.snapshot(existing); |
| 329 | 390 | } |
| 330 | 391 | |
| 331 | if (existing.cols !== input.cols || existing.rows !== input.rows) { | |
| 332 | existing.cols = input.cols; | |
| 333 | existing.rows = input.rows; | |
| 334 | existing.process.resize(input.cols, input.rows); | |
| 392 | if (existing.cols !== targetCols || existing.rows !== targetRows) { | |
| 393 | existing.cols = targetCols; | |
| 394 | existing.rows = targetRows; | |
| 395 | existing.process.resize(targetCols, targetRows); | |
| 335 | 396 | existing.updatedAt = new Date().toISOString(); |
| 336 | 397 | } |
| 337 | 398 | |
| ... | ... | @@ -388,6 +449,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 388 | 449 | const sessionKey = toSessionKey(input.threadId, input.terminalId); |
| 389 | 450 | let session = this.sessions.get(sessionKey); |
| 390 | 451 | if (!session) { |
| 452 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | |
| 453 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | |
| 391 | 454 | session = { |
| 392 | 455 | threadId: input.threadId, |
| 393 | 456 | terminalId: input.terminalId, |
| ... | ... | @@ -398,22 +461,27 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 398 | 461 | exitCode: null, |
| 399 | 462 | exitSignal: null, |
| 400 | 463 | updatedAt: new Date().toISOString(), |
| 401 | cols: input.cols, | |
| 402 | rows: input.rows, | |
| 464 | cols, | |
| 465 | rows, | |
| 403 | 466 | process: null, |
| 404 | 467 | unsubscribeData: null, |
| 405 | 468 | unsubscribeExit: null, |
| 406 | 469 | hasRunningSubprocess: false, |
| 470 | runtimeEnv: normalizedRuntimeEnv(input.env), | |
| 407 | 471 | }; |
| 408 | 472 | this.sessions.set(sessionKey, session); |
| 409 | 473 | } else { |
| 410 | 474 | this.stopProcess(session); |
| 411 | 475 | session.cwd = input.cwd; |
| 476 | session.runtimeEnv = normalizedRuntimeEnv(input.env); | |
| 412 | 477 | } |
| 413 | 478 | |
| 479 | const cols = input.cols ?? session.cols; | |
| 480 | const rows = input.rows ?? session.rows; | |
| 481 | ||
| 414 | 482 | session.history = ""; |
| 415 | 483 | await this.persistHistory(input.threadId, input.terminalId, session.history); |
| 416 | this.startSession(session, input, "restarted"); | |
| 484 | this.startSession(session, { ...input, cols, rows }, "restarted"); | |
| 417 | 485 | return this.snapshot(session); |
| 418 | 486 | }); |
| 419 | 487 | } |
| ... | ... | @@ -462,7 +530,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 462 | 530 | |
| 463 | 531 | private startSession( |
| 464 | 532 | session: TerminalSessionState, |
| 465 | input: TerminalOpenInput, | |
| 533 | input: TerminalStartInput, | |
| 466 | 534 | eventType: "started" | "restarted", |
| 467 | 535 | ): void { |
| 468 | 536 | this.stopProcess(session); |
| ... | ... | @@ -480,19 +548,20 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 480 | 548 | let startedShell: string | null = null; |
| 481 | 549 | try { |
| 482 | 550 | const shellCandidates = resolveShellCandidates(this.shellResolver); |
| 483 | const terminalEnv = createTerminalSpawnEnv(process.env); | |
| 551 | const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); | |
| 484 | 552 | let lastSpawnError: unknown = null; |
| 485 | 553 | |
| 486 | for (const shell of shellCandidates) { | |
| 554 | for (const candidate of shellCandidates) { | |
| 487 | 555 | try { |
| 488 | 556 | ptyProcess = this.ptyAdapter.spawn({ |
| 489 | shell, | |
| 557 | shell: candidate.shell, | |
| 558 | ...(candidate.args ? { args: candidate.args } : {}), | |
| 490 | 559 | cwd: session.cwd, |
| 491 | 560 | cols: session.cols, |
| 492 | 561 | rows: session.rows, |
| 493 | 562 | env: terminalEnv, |
| 494 | 563 | }); |
| 495 | startedShell = shell; | |
| 564 | startedShell = formatShellCandidate(candidate); | |
| 496 | 565 | break; |
| 497 | 566 | } catch (error) { |
| 498 | 567 | lastSpawnError = error; |
| ... | ... | @@ -506,7 +575,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 506 | 575 | const detail = |
| 507 | 576 | lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; |
| 508 | 577 | const tried = |
| 509 | shellCandidates.length > 0 ? ` Tried shells: ${shellCandidates.join(", ")}.` : ""; | |
| 578 | shellCandidates.length > 0 | |
| 579 | ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` | |
| 580 | : ""; | |
| 510 | 581 | throw new Error(`${detail}.${tried}`.trim()); |
| 511 | 582 | } |
| 512 | 583 |
apps/server/src/workspaceEntries.chunking.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { assert, beforeEach, describe, it, vi } from "vitest"; | |
| 2 | import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; | |
| 3 | ||
| 4 | const { runProcessMock } = vi.hoisted(() => ({ | |
| 5 | runProcessMock: vi.fn< | |
| 6 | ( | |
| 7 | command: string, | |
| 8 | args: readonly string[], | |
| 9 | options?: ProcessRunOptions, | |
| 10 | ) => Promise<ProcessRunResult> | |
| 11 | >(), | |
| 12 | })); | |
| 13 | ||
| 14 | vi.mock("./processRunner", () => ({ | |
| 15 | runProcess: runProcessMock, | |
| 16 | })); | |
| 17 | ||
| 18 | function processResult( | |
| 19 | overrides: Partial<ProcessRunResult> & Pick<ProcessRunResult, "stdout" | "code">, | |
| 20 | ): ProcessRunResult { | |
| 21 | return { | |
| 22 | stdout: overrides.stdout, | |
| 23 | code: overrides.code, | |
| 24 | stderr: overrides.stderr ?? "", | |
| 25 | signal: overrides.signal ?? null, | |
| 26 | timedOut: overrides.timedOut ?? false, | |
| 27 | stdoutTruncated: overrides.stdoutTruncated ?? false, | |
| 28 | stderrTruncated: overrides.stderrTruncated ?? false, | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 32 | describe("searchWorkspaceEntries git-ignore chunking", () => { | |
| 33 | beforeEach(() => { | |
| 34 | runProcessMock.mockReset(); | |
| 35 | vi.resetModules(); | |
| 36 | }); | |
| 37 | ||
| 38 | it("chunks git check-ignore stdin to avoid building giant strings", async () => { | |
| 39 | const ignoredPaths = Array.from( | |
| 40 | { length: 5000 }, | |
| 41 | (_, index) => `ignored/${index.toString().padStart(5, "0")}/${"x".repeat(80)}.ts`, | |
| 42 | ); | |
| 43 | const keptPaths = ["src/keep.ts", "docs/readme.md"]; | |
| 44 | const listedPaths = [...ignoredPaths, ...keptPaths]; | |
| 45 | let checkIgnoreCalls = 0; | |
| 46 | ||
| 47 | runProcessMock.mockImplementation(async (_command, args, options) => { | |
| 48 | if (args[0] === "rev-parse") { | |
| 49 | return processResult({ code: 0, stdout: "true\n" }); | |
| 50 | } | |
| 51 | ||
| 52 | if (args[0] === "ls-files") { | |
| 53 | return processResult({ code: 0, stdout: `${listedPaths.join("\0")}\0` }); | |
| 54 | } | |
| 55 | ||
| 56 | if (args[0] === "check-ignore") { | |
| 57 | checkIgnoreCalls += 1; | |
| 58 | const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0); | |
| 59 | const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/")); | |
| 60 | return processResult({ | |
| 61 | code: chunkIgnored.length > 0 ? 0 : 1, | |
| 62 | stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "", | |
| 63 | }); | |
| 64 | } | |
| 65 | ||
| 66 | throw new Error(`Unexpected command: git ${args.join(" ")}`); | |
| 67 | }); | |
| 68 | ||
| 69 | const { searchWorkspaceEntries } = await import("./workspaceEntries"); | |
| 70 | const result = await searchWorkspaceEntries({ | |
| 71 | cwd: "/virtual/workspace", | |
| 72 | query: "", | |
| 73 | limit: 100, | |
| 74 | }); | |
| 75 | ||
| 76 | assert.isAbove(checkIgnoreCalls, 1); | |
| 77 | assert.isFalse(result.entries.some((entry) => entry.path.startsWith("ignored/"))); | |
| 78 | assert.isTrue(result.entries.some((entry) => entry.path === "src/keep.ts")); | |
| 79 | }); | |
| 80 | }); |
apps/server/src/workspaceEntries.test.ts created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import fsPromises from "node:fs/promises"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { spawnSync } from "node:child_process"; | |
| 6 | ||
| 7 | import { afterEach, assert, describe, it, vi } from "vitest"; | |
| 8 | ||
| 9 | import { searchWorkspaceEntries } from "./workspaceEntries"; | |
| 10 | ||
| 11 | const tempDirs: string[] = []; | |
| 12 | ||
| 13 | function makeTempDir(prefix: string): string { | |
| 14 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 15 | tempDirs.push(dir); | |
| 16 | return dir; | |
| 17 | } | |
| 18 | ||
| 19 | function writeFile(cwd: string, relativePath: string, contents = ""): void { | |
| 20 | const absolutePath = path.join(cwd, relativePath); | |
| 21 | fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); | |
| 22 | fs.writeFileSync(absolutePath, contents, "utf8"); | |
| 23 | } | |
| 24 | ||
| 25 | function runGit(cwd: string, args: string[]): void { | |
| 26 | const result = spawnSync("git", args, { cwd, encoding: "utf8" }); | |
| 27 | if (result.status !== 0) { | |
| 28 | throw new Error(result.stderr || `git ${args.join(" ")} failed`); | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | describe("searchWorkspaceEntries", () => { | |
| 33 | afterEach(() => { | |
| 34 | vi.restoreAllMocks(); | |
| 35 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 36 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 37 | } | |
| 38 | }); | |
| 39 | ||
| 40 | it("returns files and directories relative to cwd", async () => { | |
| 41 | const cwd = makeTempDir("t3code-workspace-entries-"); | |
| 42 | writeFile(cwd, "src/components/Composer.tsx"); | |
| 43 | writeFile(cwd, "src/index.ts"); | |
| 44 | writeFile(cwd, "README.md"); | |
| 45 | writeFile(cwd, ".git/HEAD"); | |
| 46 | writeFile(cwd, "node_modules/pkg/index.js"); | |
| 47 | ||
| 48 | const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 49 | const paths = result.entries.map((entry) => entry.path); | |
| 50 | ||
| 51 | assert.include(paths, "src"); | |
| 52 | assert.include(paths, "src/components"); | |
| 53 | assert.include(paths, "src/components/Composer.tsx"); | |
| 54 | assert.include(paths, "README.md"); | |
| 55 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git"))); | |
| 56 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules"))); | |
| 57 | assert.isFalse(result.truncated); | |
| 58 | }); | |
| 59 | ||
| 60 | it("filters and ranks entries by query", async () => { | |
| 61 | const cwd = makeTempDir("t3code-workspace-query-"); | |
| 62 | writeFile(cwd, "src/components/Composer.tsx"); | |
| 63 | writeFile(cwd, "src/components/composePrompt.ts"); | |
| 64 | writeFile(cwd, "docs/composition.md"); | |
| 65 | ||
| 66 | const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); | |
| 67 | ||
| 68 | assert.isAbove(result.entries.length, 0); | |
| 69 | assert.isTrue(result.entries.some((entry) => entry.path === "src/components")); | |
| 70 | assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))); | |
| 71 | }); | |
| 72 | ||
| 73 | it("excludes gitignored paths for git repositories", async () => { | |
| 74 | const cwd = makeTempDir("t3code-workspace-gitignore-"); | |
| 75 | runGit(cwd, ["init"]); | |
| 76 | writeFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n"); | |
| 77 | writeFile(cwd, "src/keep.ts", "export {};"); | |
| 78 | writeFile(cwd, "ignored.txt", "ignore me"); | |
| 79 | writeFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 80 | writeFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}"); | |
| 81 | ||
| 82 | const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 83 | const paths = result.entries.map((entry) => entry.path); | |
| 84 | ||
| 85 | assert.include(paths, "src"); | |
| 86 | assert.include(paths, "src/keep.ts"); | |
| 87 | assert.notInclude(paths, "ignored.txt"); | |
| 88 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); | |
| 89 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith("convex/"))); | |
| 90 | }); | |
| 91 | ||
| 92 | it("excludes tracked paths that match ignore rules", async () => { | |
| 93 | const cwd = makeTempDir("t3code-workspace-tracked-gitignore-"); | |
| 94 | runGit(cwd, ["init"]); | |
| 95 | writeFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 96 | writeFile(cwd, "src/keep.ts", "export {};"); | |
| 97 | runGit(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]); | |
| 98 | writeFile(cwd, ".gitignore", ".convex/\n"); | |
| 99 | ||
| 100 | const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 101 | const paths = result.entries.map((entry) => entry.path); | |
| 102 | ||
| 103 | assert.include(paths, "src"); | |
| 104 | assert.include(paths, "src/keep.ts"); | |
| 105 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); | |
| 106 | }); | |
| 107 | ||
| 108 | it("excludes .convex in non-git workspaces", async () => { | |
| 109 | const cwd = makeTempDir("t3code-workspace-non-git-convex-"); | |
| 110 | writeFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 111 | writeFile(cwd, "src/keep.ts", "export {};"); | |
| 112 | ||
| 113 | const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 114 | const paths = result.entries.map((entry) => entry.path); | |
| 115 | ||
| 116 | assert.include(paths, "src"); | |
| 117 | assert.include(paths, "src/keep.ts"); | |
| 118 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); | |
| 119 | }); | |
| 120 | ||
| 121 | it("deduplicates concurrent index builds for the same cwd", async () => { | |
| 122 | const cwd = makeTempDir("t3code-workspace-concurrent-build-"); | |
| 123 | writeFile(cwd, "src/components/Composer.tsx"); | |
| 124 | ||
| 125 | let rootReadCount = 0; | |
| 126 | const originalReaddir = fsPromises.readdir.bind(fsPromises); | |
| 127 | vi.spyOn(fsPromises, "readdir").mockImplementation( | |
| 128 | (async (...args: Parameters<typeof fsPromises.readdir>) => { | |
| 129 | if (args[0] === cwd) { | |
| 130 | rootReadCount += 1; | |
| 131 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 132 | } | |
| 133 | return originalReaddir(...args); | |
| 134 | }) as typeof fsPromises.readdir, | |
| 135 | ); | |
| 136 | ||
| 137 | await Promise.all([ | |
| 138 | searchWorkspaceEntries({ cwd, query: "", limit: 100 }), | |
| 139 | searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), | |
| 140 | searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), | |
| 141 | ]); | |
| 142 | ||
| 143 | assert.equal(rootReadCount, 1); | |
| 144 | }); | |
| 145 | ||
| 146 | it("limits concurrent directory reads while walking the filesystem", async () => { | |
| 147 | const cwd = makeTempDir("t3code-workspace-read-concurrency-"); | |
| 148 | for (let index = 0; index < 80; index += 1) { | |
| 149 | writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"); | |
| 150 | } | |
| 151 | ||
| 152 | let activeReads = 0; | |
| 153 | let peakReads = 0; | |
| 154 | const originalReaddir = fsPromises.readdir.bind(fsPromises); | |
| 155 | vi.spyOn(fsPromises, "readdir").mockImplementation( | |
| 156 | (async (...args: Parameters<typeof fsPromises.readdir>) => { | |
| 157 | const target = args[0]; | |
| 158 | if (typeof target === "string" && target.startsWith(cwd)) { | |
| 159 | activeReads += 1; | |
| 160 | peakReads = Math.max(peakReads, activeReads); | |
| 161 | await new Promise((resolve) => setTimeout(resolve, 4)); | |
| 162 | try { | |
| 163 | return await originalReaddir(...args); | |
| 164 | } finally { | |
| 165 | activeReads -= 1; | |
| 166 | } | |
| 167 | } | |
| 168 | return originalReaddir(...args); | |
| 169 | }) as typeof fsPromises.readdir, | |
| 170 | ); | |
| 171 | ||
| 172 | await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); | |
| 173 | ||
| 174 | assert.isAtMost(peakReads, 32); | |
| 175 | }); | |
| 176 | }); |
apps/server/src/workspaceEntries.ts created+433| ... | ... | @@ -0,0 +1,433 @@ |
| 1 | import fs from "node:fs/promises"; | |
| 2 | import type { Dirent } from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | import { runProcess } from "./processRunner"; | |
| 5 | ||
| 6 | import { | |
| 7 | projectSearchEntriesInputSchema, | |
| 8 | type ProjectEntry, | |
| 9 | type ProjectSearchEntriesInput, | |
| 10 | type ProjectSearchEntriesResult, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | ||
| 13 | const WORKSPACE_CACHE_TTL_MS = 15_000; | |
| 14 | const WORKSPACE_CACHE_MAX_KEYS = 4; | |
| 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | |
| 16 | const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; | |
| 17 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; | |
| 18 | const IGNORED_DIRECTORY_NAMES = new Set([ | |
| 19 | ".git", | |
| 20 | ".convex", | |
| 21 | "node_modules", | |
| 22 | ".next", | |
| 23 | ".turbo", | |
| 24 | "dist", | |
| 25 | "build", | |
| 26 | "out", | |
| 27 | ".cache", | |
| 28 | ]); | |
| 29 | ||
| 30 | interface WorkspaceIndex { | |
| 31 | scannedAt: number; | |
| 32 | entries: ProjectEntry[]; | |
| 33 | truncated: boolean; | |
| 34 | } | |
| 35 | ||
| 36 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); | |
| 37 | const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); | |
| 38 | ||
| 39 | function toPosixPath(input: string): string { | |
| 40 | return input.split(path.sep).join("/"); | |
| 41 | } | |
| 42 | ||
| 43 | function parentPathOf(input: string): string | undefined { | |
| 44 | const separatorIndex = input.lastIndexOf("/"); | |
| 45 | if (separatorIndex === -1) { | |
| 46 | return undefined; | |
| 47 | } | |
| 48 | return input.slice(0, separatorIndex); | |
| 49 | } | |
| 50 | ||
| 51 | function basenameOf(input: string): string { | |
| 52 | const separatorIndex = input.lastIndexOf("/"); | |
| 53 | if (separatorIndex === -1) { | |
| 54 | return input; | |
| 55 | } | |
| 56 | return input.slice(separatorIndex + 1); | |
| 57 | } | |
| 58 | ||
| 59 | function normalizeQuery(input: string): string { | |
| 60 | return input | |
| 61 | .trim() | |
| 62 | .replace(/^[@./]+/, "") | |
| 63 | .toLowerCase(); | |
| 64 | } | |
| 65 | ||
| 66 | function scoreEntry(entry: ProjectEntry, query: string): number { | |
| 67 | if (!query) { | |
| 68 | return entry.kind === "directory" ? 0 : 1; | |
| 69 | } | |
| 70 | ||
| 71 | const normalizedPath = entry.path.toLowerCase(); | |
| 72 | const normalizedName = basenameOf(normalizedPath); | |
| 73 | ||
| 74 | if (normalizedName === query) return 0; | |
| 75 | if (normalizedPath === query) return 1; | |
| 76 | if (normalizedName.startsWith(query)) return 2; | |
| 77 | if (normalizedPath.startsWith(query)) return 3; | |
| 78 | if (normalizedPath.includes(`/${query}`)) return 4; | |
| 79 | return 5; | |
| 80 | } | |
| 81 | ||
| 82 | function isPathInIgnoredDirectory(relativePath: string): boolean { | |
| 83 | const firstSegment = relativePath.split("/")[0]; | |
| 84 | if (!firstSegment) return false; | |
| 85 | return IGNORED_DIRECTORY_NAMES.has(firstSegment); | |
| 86 | } | |
| 87 | ||
| 88 | function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { | |
| 89 | const parts = input.split("\0"); | |
| 90 | if (parts.length === 0) return []; | |
| 91 | ||
| 92 | // If output was truncated, the final token can be partial. | |
| 93 | if (truncated && parts[parts.length - 1]?.length) { | |
| 94 | parts.pop(); | |
| 95 | } | |
| 96 | ||
| 97 | return parts.filter((value) => value.length > 0); | |
| 98 | } | |
| 99 | ||
| 100 | function directoryAncestorsOf(relativePath: string): string[] { | |
| 101 | const segments = relativePath.split("/").filter((segment) => segment.length > 0); | |
| 102 | if (segments.length <= 1) return []; | |
| 103 | const directories: string[] = []; | |
| 104 | for (let index = 1; index < segments.length; index += 1) { | |
| 105 | directories.push(segments.slice(0, index).join("/")); | |
| 106 | } | |
| 107 | return directories; | |
| 108 | } | |
| 109 | ||
| 110 | async function mapWithConcurrency<TInput, TOutput>( | |
| 111 | items: readonly TInput[], | |
| 112 | concurrency: number, | |
| 113 | mapper: (item: TInput, index: number) => Promise<TOutput>, | |
| 114 | ): Promise<TOutput[]> { | |
| 115 | if (items.length === 0) { | |
| 116 | return []; | |
| 117 | } | |
| 118 | ||
| 119 | const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length)); | |
| 120 | const results = Array.from({ length: items.length }) as TOutput[]; | |
| 121 | let nextIndex = 0; | |
| 122 | ||
| 123 | const workers = Array.from({ length: boundedConcurrency }, async () => { | |
| 124 | while (nextIndex < items.length) { | |
| 125 | const currentIndex = nextIndex; | |
| 126 | nextIndex += 1; | |
| 127 | results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex); | |
| 128 | } | |
| 129 | }); | |
| 130 | ||
| 131 | await Promise.all(workers); | |
| 132 | return results; | |
| 133 | } | |
| 134 | ||
| 135 | async function isInsideGitWorkTree(cwd: string): Promise<boolean> { | |
| 136 | const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { | |
| 137 | cwd, | |
| 138 | allowNonZeroExit: true, | |
| 139 | timeoutMs: 5_000, | |
| 140 | maxBufferBytes: 4_096, | |
| 141 | }).catch(() => null); | |
| 142 | return Boolean( | |
| 143 | insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true", | |
| 144 | ); | |
| 145 | } | |
| 146 | ||
| 147 | async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise<string[]> { | |
| 148 | if (relativePaths.length === 0) { | |
| 149 | return relativePaths; | |
| 150 | } | |
| 151 | ||
| 152 | const ignoredPaths = new Set<string>(); | |
| 153 | let chunk: string[] = []; | |
| 154 | let chunkBytes = 0; | |
| 155 | ||
| 156 | const flushChunk = async (): Promise<boolean> => { | |
| 157 | if (chunk.length === 0) { | |
| 158 | return true; | |
| 159 | } | |
| 160 | ||
| 161 | const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], { | |
| 162 | cwd, | |
| 163 | allowNonZeroExit: true, | |
| 164 | timeoutMs: 20_000, | |
| 165 | maxBufferBytes: 16 * 1024 * 1024, | |
| 166 | outputMode: "truncate", | |
| 167 | stdin: `${chunk.join("\0")}\0`, | |
| 168 | }).catch(() => null); | |
| 169 | chunk = []; | |
| 170 | chunkBytes = 0; | |
| 171 | ||
| 172 | if (!checkIgnore) { | |
| 173 | return false; | |
| 174 | } | |
| 175 | ||
| 176 | // git-check-ignore exits with 1 when no paths match. | |
| 177 | if (checkIgnore.code !== 0 && checkIgnore.code !== 1) { | |
| 178 | return false; | |
| 179 | } | |
| 180 | ||
| 181 | const matchedIgnoredPaths = splitNullSeparatedPaths( | |
| 182 | checkIgnore.stdout, | |
| 183 | Boolean(checkIgnore.stdoutTruncated), | |
| 184 | ); | |
| 185 | for (const ignoredPath of matchedIgnoredPaths) { | |
| 186 | ignoredPaths.add(ignoredPath); | |
| 187 | } | |
| 188 | return true; | |
| 189 | }; | |
| 190 | ||
| 191 | for (const relativePath of relativePaths) { | |
| 192 | const relativePathBytes = Buffer.byteLength(relativePath) + 1; | |
| 193 | if ( | |
| 194 | chunk.length > 0 && | |
| 195 | chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES && | |
| 196 | !(await flushChunk()) | |
| 197 | ) { | |
| 198 | return relativePaths; | |
| 199 | } | |
| 200 | ||
| 201 | chunk.push(relativePath); | |
| 202 | chunkBytes += relativePathBytes; | |
| 203 | ||
| 204 | if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) { | |
| 205 | return relativePaths; | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | if (!(await flushChunk())) { | |
| 210 | return relativePaths; | |
| 211 | } | |
| 212 | ||
| 213 | if (ignoredPaths.size === 0) { | |
| 214 | return relativePaths; | |
| 215 | } | |
| 216 | ||
| 217 | return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); | |
| 218 | } | |
| 219 | ||
| 220 | async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> { | |
| 221 | if (!(await isInsideGitWorkTree(cwd))) { | |
| 222 | return null; | |
| 223 | } | |
| 224 | ||
| 225 | const listedFiles = await runProcess( | |
| 226 | "git", | |
| 227 | ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], | |
| 228 | { | |
| 229 | cwd, | |
| 230 | allowNonZeroExit: true, | |
| 231 | timeoutMs: 20_000, | |
| 232 | maxBufferBytes: 16 * 1024 * 1024, | |
| 233 | outputMode: "truncate", | |
| 234 | }, | |
| 235 | ).catch(() => null); | |
| 236 | if (!listedFiles || listedFiles.code !== 0) { | |
| 237 | return null; | |
| 238 | } | |
| 239 | ||
| 240 | const listedPaths = splitNullSeparatedPaths( | |
| 241 | listedFiles.stdout, | |
| 242 | Boolean(listedFiles.stdoutTruncated), | |
| 243 | ) | |
| 244 | .map((entry) => toPosixPath(entry)) | |
| 245 | .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); | |
| 246 | const filePaths = await filterGitIgnoredPaths(cwd, listedPaths); | |
| 247 | ||
| 248 | const directorySet = new Set<string>(); | |
| 249 | for (const filePath of filePaths) { | |
| 250 | for (const directoryPath of directoryAncestorsOf(filePath)) { | |
| 251 | if (!isPathInIgnoredDirectory(directoryPath)) { | |
| 252 | directorySet.add(directoryPath); | |
| 253 | } | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | const directoryEntries: ProjectEntry[] = [...directorySet] | |
| 258 | .toSorted((left, right) => left.localeCompare(right)) | |
| 259 | .map((directoryPath) => ({ | |
| 260 | path: directoryPath, | |
| 261 | kind: "directory", | |
| 262 | parentPath: parentPathOf(directoryPath), | |
| 263 | })); | |
| 264 | const fileEntries: ProjectEntry[] = [...new Set(filePaths)] | |
| 265 | .toSorted((left, right) => left.localeCompare(right)) | |
| 266 | .map((filePath) => ({ | |
| 267 | path: filePath, | |
| 268 | kind: "file", | |
| 269 | parentPath: parentPathOf(filePath), | |
| 270 | })); | |
| 271 | ||
| 272 | const entries = [...directoryEntries, ...fileEntries]; | |
| 273 | return { | |
| 274 | scannedAt: Date.now(), | |
| 275 | entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), | |
| 276 | truncated: Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, | |
| 277 | }; | |
| 278 | } | |
| 279 | ||
| 280 | async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | |
| 281 | const gitIndexed = await buildWorkspaceIndexFromGit(cwd); | |
| 282 | if (gitIndexed) { | |
| 283 | return gitIndexed; | |
| 284 | } | |
| 285 | const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); | |
| 286 | ||
| 287 | let pendingDirectories: string[] = [""]; | |
| 288 | const entries: ProjectEntry[] = []; | |
| 289 | let truncated = false; | |
| 290 | ||
| 291 | while (pendingDirectories.length > 0 && !truncated) { | |
| 292 | const currentDirectories = pendingDirectories; | |
| 293 | pendingDirectories = []; | |
| 294 | const directoryEntries = await mapWithConcurrency( | |
| 295 | currentDirectories, | |
| 296 | WORKSPACE_SCAN_READDIR_CONCURRENCY, | |
| 297 | async (relativeDir) => { | |
| 298 | const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; | |
| 299 | try { | |
| 300 | const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); | |
| 301 | return { relativeDir, dirents }; | |
| 302 | } catch (error) { | |
| 303 | if (!relativeDir) { | |
| 304 | throw new Error( | |
| 305 | `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`, | |
| 306 | { cause: error }, | |
| 307 | ); | |
| 308 | } | |
| 309 | return { relativeDir, dirents: null }; | |
| 310 | } | |
| 311 | }, | |
| 312 | ); | |
| 313 | ||
| 314 | const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { | |
| 315 | const { relativeDir, dirents } = directoryEntry; | |
| 316 | if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; | |
| 317 | ||
| 318 | dirents.sort((left, right) => left.name.localeCompare(right.name)); | |
| 319 | const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; | |
| 320 | for (const dirent of dirents) { | |
| 321 | if (!dirent.name || dirent.name === "." || dirent.name === "..") { | |
| 322 | continue; | |
| 323 | } | |
| 324 | if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { | |
| 325 | continue; | |
| 326 | } | |
| 327 | if (!dirent.isDirectory() && !dirent.isFile()) { | |
| 328 | continue; | |
| 329 | } | |
| 330 | ||
| 331 | const relativePath = toPosixPath( | |
| 332 | relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, | |
| 333 | ); | |
| 334 | if (isPathInIgnoredDirectory(relativePath)) { | |
| 335 | continue; | |
| 336 | } | |
| 337 | candidates.push({ dirent, relativePath }); | |
| 338 | } | |
| 339 | return candidates; | |
| 340 | }); | |
| 341 | ||
| 342 | const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => | |
| 343 | candidateEntries.map((entry) => entry.relativePath), | |
| 344 | ); | |
| 345 | const allowedPathSet = shouldFilterWithGitIgnore | |
| 346 | ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths)) | |
| 347 | : null; | |
| 348 | ||
| 349 | for (const candidateEntries of candidateEntriesByDirectory) { | |
| 350 | for (const candidate of candidateEntries) { | |
| 351 | if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { | |
| 352 | continue; | |
| 353 | } | |
| 354 | ||
| 355 | const entry: ProjectEntry = { | |
| 356 | path: candidate.relativePath, | |
| 357 | kind: candidate.dirent.isDirectory() ? "directory" : "file", | |
| 358 | parentPath: parentPathOf(candidate.relativePath), | |
| 359 | }; | |
| 360 | entries.push(entry); | |
| 361 | ||
| 362 | if (candidate.dirent.isDirectory()) { | |
| 363 | pendingDirectories.push(candidate.relativePath); | |
| 364 | } | |
| 365 | ||
| 366 | if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { | |
| 367 | truncated = true; | |
| 368 | break; | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | if (truncated) { | |
| 373 | break; | |
| 374 | } | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | return { | |
| 379 | scannedAt: Date.now(), | |
| 380 | entries, | |
| 381 | truncated, | |
| 382 | }; | |
| 383 | } | |
| 384 | ||
| 385 | async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | |
| 386 | const cached = workspaceIndexCache.get(cwd); | |
| 387 | if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) { | |
| 388 | return cached; | |
| 389 | } | |
| 390 | ||
| 391 | const inFlight = inFlightWorkspaceIndexBuilds.get(cwd); | |
| 392 | if (inFlight) { | |
| 393 | return inFlight; | |
| 394 | } | |
| 395 | ||
| 396 | const nextPromise = buildWorkspaceIndex(cwd) | |
| 397 | .then((next) => { | |
| 398 | workspaceIndexCache.set(cwd, next); | |
| 399 | while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { | |
| 400 | const oldestKey = workspaceIndexCache.keys().next().value; | |
| 401 | if (!oldestKey) break; | |
| 402 | workspaceIndexCache.delete(oldestKey); | |
| 403 | } | |
| 404 | return next; | |
| 405 | }) | |
| 406 | .finally(() => { | |
| 407 | inFlightWorkspaceIndexBuilds.delete(cwd); | |
| 408 | }); | |
| 409 | inFlightWorkspaceIndexBuilds.set(cwd, nextPromise); | |
| 410 | return nextPromise; | |
| 411 | } | |
| 412 | ||
| 413 | export async function searchWorkspaceEntries( | |
| 414 | input: ProjectSearchEntriesInput, | |
| 415 | ): Promise<ProjectSearchEntriesResult> { | |
| 416 | const parsed = projectSearchEntriesInputSchema.parse(input); | |
| 417 | const index = await getWorkspaceIndex(parsed.cwd); | |
| 418 | const normalizedQuery = normalizeQuery(parsed.query); | |
| 419 | const candidates = normalizedQuery | |
| 420 | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) | |
| 421 | : index.entries; | |
| 422 | ||
| 423 | const ranked = candidates.toSorted((left, right) => { | |
| 424 | const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); | |
| 425 | if (scoreDelta !== 0) return scoreDelta; | |
| 426 | return left.path.localeCompare(right.path); | |
| 427 | }); | |
| 428 | ||
| 429 | return { | |
| 430 | entries: ranked.slice(0, parsed.limit), | |
| 431 | truncated: index.truncated || ranked.length > parsed.limit, | |
| 432 | }; | |
| 433 | } |
apps/server/src/wsServer.test.ts+155| ... | ... | @@ -323,6 +323,8 @@ describe("WebSocket Server", () => { |
| 323 | 323 | }); |
| 324 | 324 | |
| 325 | 325 | it("responds to server.getConfig", async () => { |
| 326 | const fakeHome = makeTempDir("t3code-home-"); | |
| 327 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 326 | 328 | server = createTestServer({ cwd: "/my/workspace" }); |
| 327 | 329 | await server.start(); |
| 328 | 330 | const addr = server.httpServer.address(); |
| ... | ... | @@ -491,6 +493,59 @@ describe("WebSocket Server", () => { |
| 491 | 493 | expect(secondResponse.result).toEqual(firstResponse.result); |
| 492 | 494 | }); |
| 493 | 495 | |
| 496 | it("upserts keybinding rules and updates cached server config", async () => { | |
| 497 | const fakeHome = makeTempDir("t3code-home-upsert-keybinding-"); | |
| 498 | const configDir = path.join(fakeHome, ".t3"); | |
| 499 | fs.mkdirSync(configDir, { recursive: true }); | |
| 500 | const keybindingsPath = path.join(configDir, "keybindings.json"); | |
| 501 | fs.writeFileSync( | |
| 502 | keybindingsPath, | |
| 503 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), | |
| 504 | "utf8", | |
| 505 | ); | |
| 506 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | |
| 507 | ||
| 508 | server = createTestServer({ cwd: "/my/workspace" }); | |
| 509 | await server.start(); | |
| 510 | const addr = server.httpServer.address(); | |
| 511 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 512 | ||
| 513 | const ws = await connectWs(port); | |
| 514 | connections.push(ws); | |
| 515 | await waitForMessage(ws); | |
| 516 | ||
| 517 | const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { | |
| 518 | key: "mod+shift+r", | |
| 519 | command: "script.run-tests.run", | |
| 520 | }); | |
| 521 | expect(upsertResponse.error).toBeUndefined(); | |
| 522 | expect(upsertResponse.result).toEqual({ | |
| 523 | keybindings: mergeWithDefaultsForTest([ | |
| 524 | { key: "mod+j", command: "terminal.toggle" }, | |
| 525 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 526 | ]), | |
| 527 | }); | |
| 528 | ||
| 529 | const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{ | |
| 530 | key: string; | |
| 531 | command: string; | |
| 532 | }>; | |
| 533 | expect(persistedConfig).toEqual([ | |
| 534 | { key: "mod+j", command: "terminal.toggle" }, | |
| 535 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 536 | ]); | |
| 537 | ||
| 538 | const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | |
| 539 | expect(configResponse.error).toBeUndefined(); | |
| 540 | expect(configResponse.result).toEqual({ | |
| 541 | cwd: "/my/workspace", | |
| 542 | keybindings: mergeWithDefaultsForTest([ | |
| 543 | { key: "mod+j", command: "terminal.toggle" }, | |
| 544 | { key: "mod+shift+r", command: "script.run-tests.run" }, | |
| 545 | ]), | |
| 546 | }); | |
| 547 | }); | |
| 548 | ||
| 494 | 549 | it("warns and ignores unsupported keybindings config format", async () => { |
| 495 | 550 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 496 | 551 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); |
| ... | ... | @@ -591,6 +646,38 @@ describe("WebSocket Server", () => { |
| 591 | 646 | expect(response.result).toEqual([]); |
| 592 | 647 | }); |
| 593 | 648 | |
| 649 | it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { | |
| 650 | server = createTestServer({ cwd: "/test" }); | |
| 651 | await server.start(); | |
| 652 | const addr = server.httpServer.address(); | |
| 653 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 654 | ||
| 655 | const ws = await connectWs(port); | |
| 656 | connections.push(ws); | |
| 657 | await waitForMessage(ws); | |
| 658 | ||
| 659 | const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, { | |
| 660 | sessionId: "missing-session", | |
| 661 | }); | |
| 662 | expect(listResponse.result).toBeUndefined(); | |
| 663 | expect(listResponse.error?.message).toContain("Unknown provider session"); | |
| 664 | ||
| 665 | const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, { | |
| 666 | sessionId: "missing-session", | |
| 667 | turnCount: 0, | |
| 668 | }); | |
| 669 | expect(revertResponse.result).toBeUndefined(); | |
| 670 | expect(revertResponse.error?.message).toContain("Unknown provider session"); | |
| 671 | ||
| 672 | const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, { | |
| 673 | sessionId: "missing-session", | |
| 674 | fromTurnCount: 0, | |
| 675 | toTurnCount: 1, | |
| 676 | }); | |
| 677 | expect(diffResponse.result).toBeUndefined(); | |
| 678 | expect(diffResponse.error?.message).toContain("Unknown provider session"); | |
| 679 | }); | |
| 680 | ||
| 594 | 681 | it("routes terminal RPC methods and broadcasts terminal events", async () => { |
| 595 | 682 | const cwd = makeTempDir("t3code-ws-terminal-cwd-"); |
| 596 | 683 | const terminalManager = new MockTerminalManager(); |
| ... | ... | @@ -756,6 +843,42 @@ describe("WebSocket Server", () => { |
| 756 | 843 | expect(projectId).toBeTruthy(); |
| 757 | 844 | if (!projectId) return; |
| 758 | 845 | |
| 846 | const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, { | |
| 847 | id: projectId, | |
| 848 | scripts: [ | |
| 849 | { | |
| 850 | id: "setup", | |
| 851 | name: "Setup", | |
| 852 | command: "bun install", | |
| 853 | icon: "configure", | |
| 854 | runOnWorktreeCreate: true, | |
| 855 | }, | |
| 856 | ], | |
| 857 | }); | |
| 858 | expect(updatedScripts.error).toBeUndefined(); | |
| 859 | const scriptPayload = ( | |
| 860 | updatedScripts.result as { | |
| 861 | project: { | |
| 862 | scripts: Array<{ | |
| 863 | id: string; | |
| 864 | name: string; | |
| 865 | command: string; | |
| 866 | icon: string; | |
| 867 | runOnWorktreeCreate: boolean; | |
| 868 | }>; | |
| 869 | }; | |
| 870 | } | |
| 871 | ).project.scripts; | |
| 872 | expect(scriptPayload).toEqual([ | |
| 873 | { | |
| 874 | id: "setup", | |
| 875 | name: "Setup", | |
| 876 | command: "bun install", | |
| 877 | icon: "configure", | |
| 878 | runOnWorktreeCreate: true, | |
| 879 | }, | |
| 880 | ]); | |
| 881 | ||
| 759 | 882 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { |
| 760 | 883 | id: projectId, |
| 761 | 884 | }); |
| ... | ... | @@ -766,6 +889,38 @@ describe("WebSocket Server", () => { |
| 766 | 889 | expect(afterRemove.result).toEqual([]); |
| 767 | 890 | }); |
| 768 | 891 | |
| 892 | it("supports projects.searchEntries", async () => { | |
| 893 | const workspace = makeTempDir("t3code-ws-workspace-entries-"); | |
| 894 | fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); | |
| 895 | fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8"); | |
| 896 | fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); | |
| 897 | fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); | |
| 898 | fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); | |
| 899 | ||
| 900 | server = createTestServer({ cwd: "/test" }); | |
| 901 | await server.start(); | |
| 902 | const addr = server.httpServer.address(); | |
| 903 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 904 | ||
| 905 | const ws = await connectWs(port); | |
| 906 | connections.push(ws); | |
| 907 | await waitForMessage(ws); | |
| 908 | ||
| 909 | const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | |
| 910 | cwd: workspace, | |
| 911 | query: "comp", | |
| 912 | limit: 10, | |
| 913 | }); | |
| 914 | expect(response.error).toBeUndefined(); | |
| 915 | expect(response.result).toEqual({ | |
| 916 | entries: expect.arrayContaining([ | |
| 917 | expect.objectContaining({ path: "src/components", kind: "directory" }), | |
| 918 | expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), | |
| 919 | ]), | |
| 920 | truncated: false, | |
| 921 | }); | |
| 922 | }); | |
| 923 | ||
| 769 | 924 | it("supports git methods over websocket", async () => { |
| 770 | 925 | const repoCwd = makeTempDir("t3code-ws-git-project-"); |
| 771 | 926 |
apps/server/src/wsServer.ts+23-2| ... | ... | @@ -31,7 +31,8 @@ import { |
| 31 | 31 | removeGitWorktree, |
| 32 | 32 | } from "./git"; |
| 33 | 33 | import { TerminalManager } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; | |
| 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | |
| 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; | |
| 35 | 36 | |
| 36 | 37 | const MIME_TYPES: Record<string, string> = { |
| 37 | 38 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -91,7 +92,7 @@ export function createServer(options: ServerOptions) { |
| 91 | 92 | const logger = createLogger("ws"); |
| 92 | 93 | const logWebSocketEvents = |
| 93 | 94 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); |
| 94 | const keybindingsConfig = loadResolvedKeybindingsConfig(logger); | |
| 95 | let keybindingsConfig = loadResolvedKeybindingsConfig(logger); | |
| 95 | 96 | |
| 96 | 97 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 97 | 98 | if (!logWebSocketEvents) return; |
| ... | ... | @@ -309,6 +310,15 @@ export function createServer(options: ServerOptions) { |
| 309 | 310 | case WS_METHODS.providersListSessions: |
| 310 | 311 | return providerManager.listSessions(); |
| 311 | 312 | |
| 313 | case WS_METHODS.providersListCheckpoints: | |
| 314 | return providerManager.listCheckpoints(request.params as never); | |
| 315 | ||
| 316 | case WS_METHODS.providersGetCheckpointDiff: | |
| 317 | return providerManager.getCheckpointDiff(request.params as never); | |
| 318 | ||
| 319 | case WS_METHODS.providersRevertToCheckpoint: | |
| 320 | return providerManager.revertToCheckpoint(request.params as never); | |
| 321 | ||
| 312 | 322 | case WS_METHODS.projectsList: |
| 313 | 323 | return projectRegistry.list(); |
| 314 | 324 | |
| ... | ... | @@ -319,6 +329,11 @@ export function createServer(options: ServerOptions) { |
| 319 | 329 | projectRegistry.remove(request.params as never); |
| 320 | 330 | return undefined; |
| 321 | 331 | |
| 332 | case WS_METHODS.projectsSearchEntries: | |
| 333 | return searchWorkspaceEntries(request.params as never); | |
| 334 | case WS_METHODS.projectsUpdateScripts: | |
| 335 | return projectRegistry.updateScripts(request.params as never); | |
| 336 | ||
| 322 | 337 | case WS_METHODS.shellOpenInEditor: { |
| 323 | 338 | const params = request.params as { |
| 324 | 339 | cwd: string; |
| ... | ... | @@ -417,6 +432,12 @@ export function createServer(options: ServerOptions) { |
| 417 | 432 | keybindings: keybindingsConfig, |
| 418 | 433 | }; |
| 419 | 434 | |
| 435 | case WS_METHODS.serverUpsertKeybinding: | |
| 436 | keybindingsConfig = upsertKeybindingRule(logger, request.params); | |
| 437 | return { | |
| 438 | keybindings: keybindingsConfig, | |
| 439 | }; | |
| 440 | ||
| 420 | 441 | default: |
| 421 | 442 | throw new Error(`Unknown method: ${request.method}`); |
| 422 | 443 | } |
apps/web/index.html+3-1| ... | ... | @@ -3,13 +3,15 @@ |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> | |
| 7 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> | |
| 6 | 8 | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| 7 | 9 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| 8 | 10 | <link |
| 9 | 11 | href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" |
| 10 | 12 | rel="stylesheet" |
| 11 | 13 | /> |
| 12 | <title>T3 Code</title> | |
| 14 | <title>T3 Code (Alpha)</title> | |
| 13 | 15 | </head> |
| 14 | 16 | <body> |
| 15 | 17 | <div id="root"></div> |
apps/web/package.json+5| ... | ... | @@ -12,8 +12,12 @@ |
| 12 | 12 | }, |
| 13 | 13 | "dependencies": { |
| 14 | 14 | "@base-ui/react": "^1.2.0", |
| 15 | "@pierre/diffs": "^1.1.0-beta.16", | |
| 15 | 16 | "@t3tools/contracts": "workspace:*", |
| 17 | "@tanstack/react-pacer": "^0.19.4", | |
| 16 | 18 | "@tanstack/react-query": "^5.90.0", |
| 19 | "@tanstack/react-router": "^1.160.2", | |
| 20 | "@tanstack/react-virtual": "^3.13.18", | |
| 17 | 21 | "@xterm/addon-fit": "^0.11.0", |
| 18 | 22 | "@xterm/xterm": "^6.0.0", |
| 19 | 23 | "class-variance-authority": "^0.7.1", |
| ... | ... | @@ -29,6 +33,7 @@ |
| 29 | 33 | }, |
| 30 | 34 | "devDependencies": { |
| 31 | 35 | "@tailwindcss/vite": "^4.0.0", |
| 36 | "@tanstack/router-plugin": "^1.161.0", | |
| 32 | 37 | "@types/react": "^19.0.0", |
| 33 | 38 | "@types/react-dom": "^19.0.0", |
| 34 | 39 | "@vitejs/plugin-react": "^5.1.4", |
apps/web/public/apple-touch-icon.png created| Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ |
apps/web/public/favicon-16x16.png created| Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ |
apps/web/public/favicon-32x32.png created| Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ |
apps/web/public/favicon.ico created| Binary files /dev/null and b/apps/web/public/favicon.ico differ |
apps/web/public/favicon.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/> | |
| 3 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/> | |
| 4 | </svg> |
apps/web/src/App.tsx deleted-215| ... | ... | @@ -1,215 +0,0 @@ |
| 1 | import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; | |
| 2 | import { useEffect, useRef } from "react"; | |
| 3 | ||
| 4 | import ChatView from "./components/ChatView"; | |
| 5 | import DiffPanel from "./components/DiffPanel"; | |
| 6 | import Sidebar from "./components/Sidebar"; | |
| 7 | import { isElectron } from "./env"; | |
| 8 | import { DEFAULT_MODEL } from "./model-logic"; | |
| 9 | import { StoreProvider, useStore } from "./store"; | |
| 10 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; | |
| 11 | import { onServerWelcome } from "./wsNativeApi"; | |
| 12 | import { useNativeApi } from "./hooks/useNativeApi"; | |
| 13 | import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; | |
| 14 | import { invalidateGitQueries } from "./lib/gitReactQuery"; | |
| 15 | ||
| 16 | function EventRouter() { | |
| 17 | const api = useNativeApi(); | |
| 18 | const { dispatch } = useStore(); | |
| 19 | const queryClient = useQueryClient(); | |
| 20 | const activeAssistantItemRef = useRef<string | null>(null); | |
| 21 | ||
| 22 | useEffect(() => { | |
| 23 | if (!api) return; | |
| 24 | return api.providers.onEvent((event) => { | |
| 25 | if (event.method === "turn/completed") { | |
| 26 | void invalidateGitQueries(queryClient); | |
| 27 | } | |
| 28 | dispatch({ | |
| 29 | type: "APPLY_EVENT", | |
| 30 | event, | |
| 31 | activeAssistantItemRef, | |
| 32 | }); | |
| 33 | }); | |
| 34 | }, [api, dispatch, queryClient]); | |
| 35 | ||
| 36 | useEffect(() => { | |
| 37 | if (!api) return; | |
| 38 | return api.terminal.onEvent((event) => { | |
| 39 | dispatch({ | |
| 40 | type: "APPLY_TERMINAL_EVENT", | |
| 41 | event, | |
| 42 | }); | |
| 43 | }); | |
| 44 | }, [api, dispatch]); | |
| 45 | ||
| 46 | return null; | |
| 47 | } | |
| 48 | ||
| 49 | function AutoProjectBootstrap() { | |
| 50 | const { state, dispatch } = useStore(); | |
| 51 | const bootstrappedRef = useRef(false); | |
| 52 | ||
| 53 | useEffect(() => { | |
| 54 | // Browser mode bootstraps from server welcome. | |
| 55 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | |
| 56 | if (isElectron) return; | |
| 57 | ||
| 58 | return onServerWelcome((payload) => { | |
| 59 | if (bootstrappedRef.current) return; | |
| 60 | ||
| 61 | // Don't create duplicate projects for the same cwd | |
| 62 | const existing = state.projects.find((p) => p.cwd === payload.cwd); | |
| 63 | if (existing) { | |
| 64 | bootstrappedRef.current = true; | |
| 65 | // Ensure a thread is active | |
| 66 | const existingThread = state.threads.find((t) => t.projectId === existing.id); | |
| 67 | if (existingThread && !state.activeThreadId) { | |
| 68 | dispatch({ | |
| 69 | type: "SET_ACTIVE_THREAD", | |
| 70 | threadId: existingThread.id, | |
| 71 | }); | |
| 72 | } | |
| 73 | return; | |
| 74 | } | |
| 75 | ||
| 76 | bootstrappedRef.current = true; | |
| 77 | ||
| 78 | // Create project + thread from server cwd | |
| 79 | const projectId = crypto.randomUUID(); | |
| 80 | dispatch({ | |
| 81 | type: "ADD_PROJECT", | |
| 82 | project: { | |
| 83 | id: projectId, | |
| 84 | name: payload.projectName, | |
| 85 | cwd: payload.cwd, | |
| 86 | model: DEFAULT_MODEL, | |
| 87 | expanded: true, | |
| 88 | }, | |
| 89 | }); | |
| 90 | dispatch({ | |
| 91 | type: "ADD_THREAD", | |
| 92 | thread: { | |
| 93 | id: crypto.randomUUID(), | |
| 94 | codexThreadId: null, | |
| 95 | projectId, | |
| 96 | title: "New thread", | |
| 97 | model: DEFAULT_MODEL, | |
| 98 | terminalOpen: false, | |
| 99 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 100 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 101 | runningTerminalIds: [], | |
| 102 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 103 | terminalGroups: [ | |
| 104 | { | |
| 105 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 106 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 107 | }, | |
| 108 | ], | |
| 109 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 110 | session: null, | |
| 111 | messages: [], | |
| 112 | events: [], | |
| 113 | error: null, | |
| 114 | createdAt: new Date().toISOString(), | |
| 115 | branch: null, | |
| 116 | worktreePath: null, | |
| 117 | }, | |
| 118 | }); | |
| 119 | }); | |
| 120 | }, [state.projects, state.threads, state.activeThreadId, dispatch]); | |
| 121 | ||
| 122 | return null; | |
| 123 | } | |
| 124 | ||
| 125 | function DesktopProjectBootstrap() { | |
| 126 | const api = useNativeApi(); | |
| 127 | const { dispatch } = useStore(); | |
| 128 | const bootstrappedRef = useRef(false); | |
| 129 | ||
| 130 | useEffect(() => { | |
| 131 | if (!isElectron || !api || bootstrappedRef.current) return; | |
| 132 | ||
| 133 | let disposed = false; | |
| 134 | let retryDelayMs = 500; | |
| 135 | let retryTimer: ReturnType<typeof setTimeout> | null = null; | |
| 136 | ||
| 137 | const attemptBootstrap = async () => { | |
| 138 | try { | |
| 139 | const projects = await api.projects.list(); | |
| 140 | if (disposed) return; | |
| 141 | dispatch({ | |
| 142 | type: "SYNC_PROJECTS", | |
| 143 | projects: projects.map((project) => ({ | |
| 144 | id: project.id, | |
| 145 | name: project.name, | |
| 146 | cwd: project.cwd, | |
| 147 | model: DEFAULT_MODEL, | |
| 148 | expanded: true, | |
| 149 | })), | |
| 150 | }); | |
| 151 | bootstrappedRef.current = true; | |
| 152 | } catch { | |
| 153 | if (disposed) return; | |
| 154 | retryTimer = setTimeout(() => { | |
| 155 | retryTimer = null; | |
| 156 | void attemptBootstrap(); | |
| 157 | }, retryDelayMs); | |
| 158 | retryDelayMs = Math.min(retryDelayMs * 2, 5_000); | |
| 159 | } | |
| 160 | }; | |
| 161 | ||
| 162 | void attemptBootstrap(); | |
| 163 | ||
| 164 | return () => { | |
| 165 | disposed = true; | |
| 166 | if (retryTimer) { | |
| 167 | clearTimeout(retryTimer); | |
| 168 | } | |
| 169 | }; | |
| 170 | }, [api, dispatch]); | |
| 171 | ||
| 172 | return null; | |
| 173 | } | |
| 174 | ||
| 175 | function Layout() { | |
| 176 | const api = useNativeApi(); | |
| 177 | const { state } = useStore(); | |
| 178 | ||
| 179 | if (!api) { | |
| 180 | return ( | |
| 181 | <div className="flex h-screen flex-col bg-background text-foreground"> | |
| 182 | <div className="flex flex-1 items-center justify-center"> | |
| 183 | <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p> | |
| 184 | </div> | |
| 185 | </div> | |
| 186 | ); | |
| 187 | } | |
| 188 | ||
| 189 | return ( | |
| 190 | <div className="flex h-screen overflow-hidden bg-background text-foreground isolate"> | |
| 191 | <EventRouter /> | |
| 192 | <AutoProjectBootstrap /> | |
| 193 | <DesktopProjectBootstrap /> | |
| 194 | <Sidebar /> | |
| 195 | <ChatView /> | |
| 196 | {state.diffOpen && <DiffPanel />} | |
| 197 | </div> | |
| 198 | ); | |
| 199 | } | |
| 200 | ||
| 201 | const queryClient = new QueryClient(); | |
| 202 | ||
| 203 | export default function App() { | |
| 204 | return ( | |
| 205 | <QueryClientProvider client={queryClient}> | |
| 206 | <StoreProvider> | |
| 207 | <ToastProvider> | |
| 208 | <AnchoredToastProvider> | |
| 209 | <Layout /> | |
| 210 | </AnchoredToastProvider> | |
| 211 | </ToastProvider> | |
| 212 | </StoreProvider> | |
| 213 | </QueryClientProvider> | |
| 214 | ); | |
| 215 | } |
apps/web/src/appSettings.ts created+109| ... | ... | @@ -0,0 +1,109 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | |
| 2 | import { z } from "zod"; | |
| 3 | ||
| 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | |
| 5 | ||
| 6 | const appSettingsSchema = z.object({ | |
| 7 | codexBinaryPath: z.string().trim().max(4096).default(""), | |
| 8 | codexHomePath: z.string().trim().max(4096).default(""), | |
| 9 | confirmThreadDelete: z.boolean().default(true), | |
| 10 | }); | |
| 11 | ||
| 12 | export type AppSettings = z.infer<typeof appSettingsSchema>; | |
| 13 | ||
| 14 | const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); | |
| 15 | ||
| 16 | let listeners: Array<() => void> = []; | |
| 17 | let cachedRawSettings: string | null | undefined; | |
| 18 | let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; | |
| 19 | ||
| 20 | function emitChange(): void { | |
| 21 | for (const listener of listeners) { | |
| 22 | listener(); | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | function parsePersistedSettings(value: string | null): AppSettings { | |
| 27 | if (!value) { | |
| 28 | return DEFAULT_APP_SETTINGS; | |
| 29 | } | |
| 30 | ||
| 31 | try { | |
| 32 | const parsed = JSON.parse(value) as unknown; | |
| 33 | return appSettingsSchema.parse(parsed); | |
| 34 | } catch { | |
| 35 | return DEFAULT_APP_SETTINGS; | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | export function getAppSettingsSnapshot(): AppSettings { | |
| 40 | if (typeof window === "undefined") { | |
| 41 | return DEFAULT_APP_SETTINGS; | |
| 42 | } | |
| 43 | ||
| 44 | const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); | |
| 45 | if (raw === cachedRawSettings) { | |
| 46 | return cachedSnapshot; | |
| 47 | } | |
| 48 | ||
| 49 | cachedRawSettings = raw; | |
| 50 | cachedSnapshot = parsePersistedSettings(raw); | |
| 51 | return cachedSnapshot; | |
| 52 | } | |
| 53 | ||
| 54 | function persistSettings(next: AppSettings): void { | |
| 55 | if (typeof window === "undefined") return; | |
| 56 | ||
| 57 | const raw = JSON.stringify(next); | |
| 58 | try { | |
| 59 | if (raw !== cachedRawSettings) { | |
| 60 | window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); | |
| 61 | } | |
| 62 | } catch { | |
| 63 | // Best-effort persistence only. | |
| 64 | } | |
| 65 | ||
| 66 | cachedRawSettings = raw; | |
| 67 | cachedSnapshot = next; | |
| 68 | } | |
| 69 | ||
| 70 | function subscribe(listener: () => void): () => void { | |
| 71 | listeners.push(listener); | |
| 72 | ||
| 73 | const onStorage = (event: StorageEvent) => { | |
| 74 | if (event.key === APP_SETTINGS_STORAGE_KEY) { | |
| 75 | emitChange(); | |
| 76 | } | |
| 77 | }; | |
| 78 | ||
| 79 | window.addEventListener("storage", onStorage); | |
| 80 | return () => { | |
| 81 | listeners = listeners.filter((entry) => entry !== listener); | |
| 82 | window.removeEventListener("storage", onStorage); | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | export function useAppSettings() { | |
| 87 | const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); | |
| 88 | ||
| 89 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { | |
| 90 | const next = appSettingsSchema.parse({ | |
| 91 | ...getAppSettingsSnapshot(), | |
| 92 | ...patch, | |
| 93 | }); | |
| 94 | persistSettings(next); | |
| 95 | emitChange(); | |
| 96 | }, []); | |
| 97 | ||
| 98 | const resetSettings = useCallback(() => { | |
| 99 | persistSettings(DEFAULT_APP_SETTINGS); | |
| 100 | emitChange(); | |
| 101 | }, []); | |
| 102 | ||
| 103 | return { | |
| 104 | settings, | |
| 105 | updateSettings, | |
| 106 | resetSettings, | |
| 107 | defaults: DEFAULT_APP_SETTINGS, | |
| 108 | } as const; | |
| 109 | } |
apps/web/src/branding.ts created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | export const APP_BASE_NAME = "T3 Code"; | |
| 2 | export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; | |
| 3 | export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; |
apps/web/src/chat-scroll.test.ts+1-4| ... | ... | @@ -1,9 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | import { | |
| 4 | AUTO_SCROLL_BOTTOM_THRESHOLD_PX, | |
| 5 | isScrollContainerNearBottom, | |
| 6 | } from "./chat-scroll"; | |
| 3 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; | |
| 7 | 4 | |
| 8 | 5 | describe("isScrollContainerNearBottom", () => { |
| 9 | 6 | it("returns true when already at bottom", () => { |
apps/web/src/components/BranchToolbar.logic.test.ts created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; | |
| 4 | ||
| 5 | const branches: GitBranch[] = [ | |
| 6 | { | |
| 7 | name: "main", | |
| 8 | current: true, | |
| 9 | isDefault: true, | |
| 10 | worktreePath: null, | |
| 11 | }, | |
| 12 | { | |
| 13 | name: "feature/demo", | |
| 14 | current: false, | |
| 15 | isDefault: false, | |
| 16 | worktreePath: null, | |
| 17 | }, | |
| 18 | ]; | |
| 19 | ||
| 20 | describe("deriveSyncedLocalBranch", () => { | |
| 21 | it("syncs to git current branch in local mode", () => { | |
| 22 | const result = deriveSyncedLocalBranch({ | |
| 23 | activeThreadId: "thread-1", | |
| 24 | activeWorktreePath: null, | |
| 25 | envMode: "local", | |
| 26 | activeThreadBranch: "feature/demo", | |
| 27 | queryBranches: branches, | |
| 28 | }); | |
| 29 | ||
| 30 | expect(result).toBe("main"); | |
| 31 | }); | |
| 32 | ||
| 33 | it("does not sync when creating a new worktree", () => { | |
| 34 | const result = deriveSyncedLocalBranch({ | |
| 35 | activeThreadId: "thread-1", | |
| 36 | activeWorktreePath: null, | |
| 37 | envMode: "worktree", | |
| 38 | activeThreadBranch: "feature/demo", | |
| 39 | queryBranches: branches, | |
| 40 | }); | |
| 41 | ||
| 42 | expect(result).toBeNull(); | |
| 43 | }); | |
| 44 | ||
| 45 | it("does not sync when thread already targets a worktree path", () => { | |
| 46 | const result = deriveSyncedLocalBranch({ | |
| 47 | activeThreadId: "thread-1", | |
| 48 | activeWorktreePath: "/tmp/repo/worktrees/feature-demo", | |
| 49 | envMode: "local", | |
| 50 | activeThreadBranch: "feature/demo", | |
| 51 | queryBranches: branches, | |
| 52 | }); | |
| 53 | ||
| 54 | expect(result).toBeNull(); | |
| 55 | }); | |
| 56 | }); |
apps/web/src/components/BranchToolbar.logic.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | interface DeriveSyncedLocalBranchInput { | |
| 4 | activeThreadId: string | undefined; | |
| 5 | activeWorktreePath: string | null; | |
| 6 | envMode: "local" | "worktree"; | |
| 7 | activeThreadBranch: string | null; | |
| 8 | queryBranches: GitBranch[] | undefined; | |
| 9 | } | |
| 10 | ||
| 11 | export function deriveSyncedLocalBranch({ | |
| 12 | activeThreadId, | |
| 13 | activeWorktreePath, | |
| 14 | envMode, | |
| 15 | activeThreadBranch, | |
| 16 | queryBranches, | |
| 17 | }: DeriveSyncedLocalBranchInput): string | null { | |
| 18 | if (!activeThreadId || activeWorktreePath || envMode !== "local") { | |
| 19 | return null; | |
| 20 | } | |
| 21 | ||
| 22 | const currentBranch = queryBranches?.find((branch) => branch.current); | |
| 23 | if (!currentBranch || currentBranch.name === activeThreadBranch) { | |
| 24 | return null; | |
| 25 | } | |
| 26 | ||
| 27 | return currentBranch.name; | |
| 28 | } |
apps/web/src/components/BranchToolbar.tsx+28-19| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | gitCreateBranchAndCheckoutMutationOptions, |
| 10 | 10 | } from "../lib/gitReactQuery"; |
| 11 | 11 | import { useStore } from "../store"; |
| 12 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; | |
| 12 | 13 | import { Button } from "./ui/button"; |
| 13 | 14 | import { |
| 14 | 15 | Combobox, |
| ... | ... | @@ -22,6 +23,7 @@ import { |
| 22 | 23 | import { ChevronDownIcon } from "lucide-react"; |
| 23 | 24 | |
| 24 | 25 | interface BranchToolbarProps { |
| 26 | threadId: string; | |
| 25 | 27 | envMode: "local" | "worktree"; |
| 26 | 28 | onEnvModeChange: (mode: "local" | "worktree") => void; |
| 27 | 29 | envLocked: boolean; |
| ... | ... | @@ -29,6 +31,7 @@ interface BranchToolbarProps { |
| 29 | 31 | } |
| 30 | 32 | |
| 31 | 33 | export default function BranchToolbar({ |
| 34 | threadId, | |
| 32 | 35 | envMode, |
| 33 | 36 | onEnvModeChange, |
| 34 | 37 | envLocked, |
| ... | ... | @@ -41,7 +44,7 @@ export default function BranchToolbar({ |
| 41 | 44 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 42 | 45 | const [branchQuery, setBranchQuery] = useState(""); |
| 43 | 46 | |
| 44 | const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); | |
| 47 | const activeThread = state.threads.find((thread) => thread.id === threadId); | |
| 45 | 48 | const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); |
| 46 | 49 | const activeThreadId = activeThread?.id; |
| 47 | 50 | const activeThreadBranch = activeThread?.branch ?? null; |
| ... | ... | @@ -67,36 +70,35 @@ export default function BranchToolbar({ |
| 67 | 70 | : branchNames; |
| 68 | 71 | // ── Mutations ───────────────────────────────────────────────────────── |
| 69 | 72 | |
| 70 | const checkoutMutation = useMutation({ | |
| 71 | ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 72 | onError: (error) => { | |
| 73 | setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); | |
| 74 | setIsBranchMenuOpen(true); | |
| 75 | }, | |
| 76 | }); | |
| 73 | const checkoutMutation = useMutation( | |
| 74 | gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 75 | ); | |
| 77 | 76 | |
| 78 | const createBranchMutation = useMutation({ | |
| 79 | ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 80 | onError: (error) => | |
| 81 | setThreadError(error instanceof Error ? error.message : "Failed to create branch."), | |
| 82 | }); | |
| 77 | const createBranchMutation = useMutation( | |
| 78 | gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | |
| 79 | ); | |
| 83 | 80 | |
| 84 | 81 | // ── Effects ─────────────────────────────────────────────────────────── |
| 85 | 82 | |
| 86 | 83 | // Keep thread branch synced to git current branch for local threads. |
| 87 | 84 | const queryBranches = branchesQuery.data?.branches; |
| 88 | 85 | useEffect(() => { |
| 89 | if (!activeThreadId || activeWorktreePath) return; | |
| 90 | const current = queryBranches?.find((branch) => branch.current); | |
| 91 | if (!current) return; | |
| 92 | if (current.name === activeThreadBranch) return; | |
| 86 | const syncedBranch = deriveSyncedLocalBranch({ | |
| 87 | activeThreadId, | |
| 88 | activeWorktreePath, | |
| 89 | envMode, | |
| 90 | activeThreadBranch, | |
| 91 | queryBranches, | |
| 92 | }); | |
| 93 | if (!activeThreadId || !syncedBranch) return; | |
| 94 | ||
| 93 | 95 | dispatch({ |
| 94 | 96 | type: "SET_THREAD_BRANCH", |
| 95 | 97 | threadId: activeThreadId, |
| 96 | branch: current.name, | |
| 98 | branch: syncedBranch, | |
| 97 | 99 | worktreePath: null, |
| 98 | 100 | }); |
| 99 | }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]); | |
| 101 | }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch]); | |
| 100 | 102 | |
| 101 | 103 | useEffect(() => { |
| 102 | 104 | if (isBranchMenuOpen) return; |
| ... | ... | @@ -153,6 +155,10 @@ export default function BranchToolbar({ |
| 153 | 155 | setIsBranchMenuOpen(false); |
| 154 | 156 | onComposerFocusRequest?.(); |
| 155 | 157 | }, |
| 158 | onError: (error) => { | |
| 159 | setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); | |
| 160 | setIsBranchMenuOpen(true); | |
| 161 | }, | |
| 156 | 162 | }); |
| 157 | 163 | }; |
| 158 | 164 | |
| ... | ... | @@ -167,6 +173,9 @@ export default function BranchToolbar({ |
| 167 | 173 | setIsBranchMenuOpen(false); |
| 168 | 174 | onComposerFocusRequest?.(); |
| 169 | 175 | }, |
| 176 | onError: (error) => { | |
| 177 | setThreadError(error instanceof Error ? error.message : "Failed to create branch."); | |
| 178 | }, | |
| 170 | 179 | }); |
| 171 | 180 | }; |
| 172 | 181 |
apps/web/src/components/ChatView.tsx+1643-294| ... | ... | @@ -1,8 +1,10 @@ |
| 1 | 1 | import { |
| 2 | 2 | EDITORS, |
| 3 | 3 | type EditorId, |
| 4 | ModelSlug, | |
| 5 | 4 | type NativeApi, |
| 5 | type ProjectEntry, | |
| 6 | type ProjectScript, | |
| 7 | ModelSlug, | |
| 6 | 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 7 | 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 8 | 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| ... | ... | @@ -14,7 +16,6 @@ import { |
| 14 | 16 | type ClipboardEvent, |
| 15 | 17 | type DragEvent, |
| 16 | 18 | type FormEvent, |
| 17 | Fragment, | |
| 18 | 19 | type KeyboardEvent, |
| 19 | 20 | memo, |
| 20 | 21 | type RefObject, |
| ... | ... | @@ -25,12 +26,24 @@ import { |
| 25 | 26 | useRef, |
| 26 | 27 | useState, |
| 27 | 28 | } from "react"; |
| 28 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; | |
| 31 | import { useNavigate, useSearch } from "@tanstack/react-router"; | |
| 32 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; | |
| 29 | 33 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 30 | import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; | |
| 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | |
| 35 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | |
| 36 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | |
| 31 | 37 | |
| 32 | 38 | import { isElectron } from "../env"; |
| 39 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 40 | import { useAppSettings } from "../appSettings"; | |
| 33 | 41 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 42 | import { | |
| 43 | type ComposerTriggerKind, | |
| 44 | detectComposerTrigger, | |
| 45 | replaceTextRange, | |
| 46 | } from "../composer-logic"; | |
| 34 | 47 | import { |
| 35 | 48 | DEFAULT_MODEL, |
| 36 | 49 | DEFAULT_REASONING, |
| ... | ... | @@ -42,7 +55,9 @@ import { |
| 42 | 55 | import { |
| 43 | 56 | derivePendingApprovals, |
| 44 | 57 | derivePhase, |
| 58 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 45 | 59 | deriveTimelineEntries, |
| 60 | type TurnDiffSummary, | |
| 46 | 61 | type PendingApproval, |
| 47 | 62 | deriveWorkLogEntries, |
| 48 | 63 | formatDuration, |
| ... | ... | @@ -51,15 +66,21 @@ import { |
| 51 | 66 | } from "../session-logic"; |
| 52 | 67 | import { isScrollContainerNearBottom } from "../chat-scroll"; |
| 53 | 68 | import { useStore } from "../store"; |
| 54 | import type { ChatImageAttachment } from "../types"; | |
| 69 | import { truncateTitle } from "../truncateTitle"; | |
| 70 | import { | |
| 71 | DEFAULT_THREAD_TERMINAL_ID, | |
| 72 | MAX_THREAD_TERMINAL_COUNT, | |
| 73 | type ChatImageAttachment, | |
| 74 | type TurnDiffFileChange, | |
| 75 | } from "../types"; | |
| 76 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | |
| 77 | import { useTheme } from "../hooks/useTheme"; | |
| 78 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | |
| 55 | 79 | import BranchToolbar from "./BranchToolbar"; |
| 56 | 80 | import GitActionsControl from "./GitActionsControl"; |
| 57 | 81 | import { |
| 58 | 82 | isOpenFavoriteEditorShortcut, |
| 59 | isTerminalCloseShortcut, | |
| 60 | isTerminalNewShortcut, | |
| 61 | isTerminalSplitShortcut, | |
| 62 | isTerminalToggleShortcut, | |
| 83 | resolveShortcutCommand, | |
| 63 | 84 | shortcutLabelForCommand, |
| 64 | 85 | } from "../keybindings"; |
| 65 | 86 | import ChatMarkdown from "./ChatMarkdown"; |
| ... | ... | @@ -67,12 +88,17 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 67 | 88 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 68 | 89 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 69 | 90 | import { |
| 91 | BotIcon, | |
| 70 | 92 | ChevronDownIcon, |
| 71 | 93 | CircleAlertIcon, |
| 94 | FileIcon, | |
| 95 | FolderIcon, | |
| 96 | DiffIcon, | |
| 72 | 97 | FolderClosedIcon, |
| 73 | 98 | InfoIcon, |
| 74 | 99 | LockIcon, |
| 75 | 100 | LockOpenIcon, |
| 101 | Undo2Icon, | |
| 76 | 102 | XIcon, |
| 77 | 103 | } from "lucide-react"; |
| 78 | 104 | import { Button } from "./ui/button"; |
| ... | ... | @@ -83,6 +109,16 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" |
| 83 | 109 | import { CursorIcon, Icon } from "./Icons"; |
| 84 | 110 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 85 | 111 | import { Badge } from "./ui/badge"; |
| 112 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; | |
| 113 | import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; | |
| 114 | import { | |
| 115 | commandForProjectScript, | |
| 116 | nextProjectScriptId, | |
| 117 | projectScriptRuntimeEnv, | |
| 118 | projectScriptIdFromCommand, | |
| 119 | setupProjectScript, | |
| 120 | } from "~/projectScripts"; | |
| 121 | import { Toggle } from "./ui/toggle"; | |
| 86 | 122 | |
| 87 | 123 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 88 | 124 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -90,11 +126,40 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 90 | 126 | } |
| 91 | 127 | |
| 92 | 128 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 129 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | |
| 93 | 130 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 94 | 131 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 95 | 132 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 96 | 133 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 97 | 134 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 135 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | |
| 136 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | |
| 137 | const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ | |
| 138 | slug, | |
| 139 | name, | |
| 140 | searchSlug: slug.toLowerCase(), | |
| 141 | searchName: name.toLowerCase(), | |
| 142 | })); | |
| 143 | const SCRIPT_TERMINAL_COLS = 120; | |
| 144 | const SCRIPT_TERMINAL_ROWS = 30; | |
| 145 | ||
| 146 | function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | |
| 147 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 148 | if (!stored) return {}; | |
| 149 | ||
| 150 | try { | |
| 151 | const parsed: unknown = JSON.parse(stored); | |
| 152 | if (!parsed || typeof parsed !== "object") return {}; | |
| 153 | return Object.fromEntries( | |
| 154 | Object.entries(parsed).filter( | |
| 155 | (entry): entry is [string, string] => | |
| 156 | typeof entry[0] === "string" && typeof entry[1] === "string", | |
| 157 | ), | |
| 158 | ); | |
| 159 | } catch { | |
| 160 | return {}; | |
| 161 | } | |
| 162 | } | |
| 98 | 163 | |
| 99 | 164 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 100 | 165 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | ... | @@ -121,6 +186,56 @@ interface ExpandedImagePreview { |
| 121 | 186 | name: string; |
| 122 | 187 | } |
| 123 | 188 | |
| 189 | type TurnCheckpointFilesState = | |
| 190 | | { | |
| 191 | status: "pending" | "loading"; | |
| 192 | files: TurnDiffFileChange[]; | |
| 193 | errorMessage?: undefined; | |
| 194 | } | |
| 195 | | { | |
| 196 | status: "error"; | |
| 197 | files: TurnDiffFileChange[]; | |
| 198 | errorMessage: string; | |
| 199 | } | |
| 200 | | { | |
| 201 | status: "ready"; | |
| 202 | files: TurnDiffFileChange[]; | |
| 203 | errorMessage?: undefined; | |
| 204 | }; | |
| 205 | ||
| 206 | function checkpointErrorMessage(error: unknown): string { | |
| 207 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 208 | return error.message; | |
| 209 | } | |
| 210 | if (typeof error === "string" && error.trim().length > 0) { | |
| 211 | return error; | |
| 212 | } | |
| 213 | return "Checkpoint diff is unavailable."; | |
| 214 | } | |
| 215 | ||
| 216 | type ComposerCommandItem = | |
| 217 | | { | |
| 218 | id: string; | |
| 219 | type: "path"; | |
| 220 | path: string; | |
| 221 | pathKind: ProjectEntry["kind"]; | |
| 222 | label: string; | |
| 223 | description: string; | |
| 224 | } | |
| 225 | | { | |
| 226 | id: string; | |
| 227 | type: "slash-command"; | |
| 228 | label: string; | |
| 229 | description: string; | |
| 230 | } | |
| 231 | | { | |
| 232 | id: string; | |
| 233 | type: "model"; | |
| 234 | model: ModelSlug; | |
| 235 | label: string; | |
| 236 | description: string; | |
| 237 | }; | |
| 238 | ||
| 124 | 239 | function readFileAsDataUrl(file: File): Promise<string> { |
| 125 | 240 | return new Promise((resolve, reject) => { |
| 126 | 241 | const reader = new FileReader(); |
| ... | ... | @@ -138,19 +253,147 @@ function readFileAsDataUrl(file: File): Promise<string> { |
| 138 | 253 | }); |
| 139 | 254 | } |
| 140 | 255 | |
| 141 | export default function ChatView() { | |
| 256 | const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { | |
| 257 | pathValue: string; | |
| 258 | kind: "file" | "directory"; | |
| 259 | theme: "light" | "dark"; | |
| 260 | }) { | |
| 261 | const [failed, setFailed] = useState(false); | |
| 262 | const iconUrl = useMemo( | |
| 263 | () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), | |
| 264 | [props.kind, props.pathValue, props.theme], | |
| 265 | ); | |
| 266 | ||
| 267 | useEffect(() => { | |
| 268 | setFailed(false); | |
| 269 | }, [iconUrl]); | |
| 270 | ||
| 271 | if (failed) { | |
| 272 | return props.kind === "directory" ? ( | |
| 273 | <FolderIcon className="size-4 text-muted-foreground/80" /> | |
| 274 | ) : ( | |
| 275 | <FileIcon className="size-4 text-muted-foreground/80" /> | |
| 276 | ); | |
| 277 | } | |
| 278 | ||
| 279 | return ( | |
| 280 | <img | |
| 281 | src={iconUrl} | |
| 282 | alt="" | |
| 283 | aria-hidden="true" | |
| 284 | className="size-4 shrink-0" | |
| 285 | loading="lazy" | |
| 286 | onError={() => setFailed(true)} | |
| 287 | /> | |
| 288 | ); | |
| 289 | }); | |
| 290 | ||
| 291 | const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | |
| 292 | item: ComposerCommandItem; | |
| 293 | resolvedTheme: "light" | "dark"; | |
| 294 | onSelect: (item: ComposerCommandItem) => void; | |
| 295 | }) { | |
| 296 | return ( | |
| 297 | <CommandItem | |
| 298 | value={props.item.id} | |
| 299 | className="cursor-pointer gap-2" | |
| 300 | onMouseDown={(event) => { | |
| 301 | event.preventDefault(); | |
| 302 | }} | |
| 303 | onClick={() => { | |
| 304 | props.onSelect(props.item); | |
| 305 | }} | |
| 306 | > | |
| 307 | {props.item.type === "path" ? ( | |
| 308 | <VscodeEntryIcon | |
| 309 | pathValue={props.item.path} | |
| 310 | kind={props.item.pathKind} | |
| 311 | theme={props.resolvedTheme} | |
| 312 | /> | |
| 313 | ) : null} | |
| 314 | {props.item.type === "slash-command" ? ( | |
| 315 | <BotIcon className="size-4 text-muted-foreground/80" /> | |
| 316 | ) : null} | |
| 317 | {props.item.type === "model" ? ( | |
| 318 | <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | |
| 319 | model | |
| 320 | </Badge> | |
| 321 | ) : null} | |
| 322 | <span className="truncate">{props.item.label}</span> | |
| 323 | <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span> | |
| 324 | </CommandItem> | |
| 325 | ); | |
| 326 | }); | |
| 327 | ||
| 328 | const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | |
| 329 | items: ComposerCommandItem[]; | |
| 330 | resolvedTheme: "light" | "dark"; | |
| 331 | isLoading: boolean; | |
| 332 | triggerKind: ComposerTriggerKind | null; | |
| 333 | onHighlightedItemChange: (itemId: string | null) => void; | |
| 334 | onSelect: (item: ComposerCommandItem) => void; | |
| 335 | commandInputRef: RefObject<HTMLInputElement | null>; | |
| 336 | }) { | |
| 337 | return ( | |
| 338 | <Command | |
| 339 | mode="none" | |
| 340 | onItemHighlighted={(highlightedValue) => { | |
| 341 | props.onHighlightedItemChange( | |
| 342 | typeof highlightedValue === "string" ? highlightedValue : null, | |
| 343 | ); | |
| 344 | }} | |
| 345 | > | |
| 346 | <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"> | |
| 347 | <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0"> | |
| 348 | <CommandInput autoFocus={false} ref={props.commandInputRef} /> | |
| 349 | </div> | |
| 350 | <CommandList className="max-h-64"> | |
| 351 | {props.items.map((item) => ( | |
| 352 | <ComposerCommandMenuItem | |
| 353 | key={item.id} | |
| 354 | item={item} | |
| 355 | resolvedTheme={props.resolvedTheme} | |
| 356 | onSelect={props.onSelect} | |
| 357 | /> | |
| 358 | ))} | |
| 359 | </CommandList> | |
| 360 | {props.items.length === 0 && ( | |
| 361 | <p className="px-3 py-2 text-muted-foreground/70 text-xs"> | |
| 362 | {props.isLoading | |
| 363 | ? "Searching workspace files..." | |
| 364 | : props.triggerKind === "path" | |
| 365 | ? "No matching files or folders." | |
| 366 | : "No matching command."} | |
| 367 | </p> | |
| 368 | )} | |
| 369 | </div> | |
| 370 | </Command> | |
| 371 | ); | |
| 372 | }); | |
| 373 | ||
| 374 | interface ChatViewProps { | |
| 375 | threadId: string; | |
| 376 | } | |
| 377 | ||
| 378 | export default function ChatView({ threadId }: ChatViewProps) { | |
| 142 | 379 | const { state, dispatch } = useStore(); |
| 380 | const navigate = useNavigate(); | |
| 381 | const rawSearch = useSearch({ strict: false }); | |
| 143 | 382 | const api = useNativeApi(); |
| 383 | const { resolvedTheme } = useTheme(); | |
| 384 | const { settings: appSettings } = useAppSettings(); | |
| 144 | 385 | const queryClient = useQueryClient(); |
| 145 | 386 | const createWorktreeMutation = useMutation( |
| 146 | 387 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| 147 | 388 | ); |
| 148 | 389 | const [prompt, setPrompt] = useState(""); |
| 390 | const promptRef = useRef(prompt); | |
| 149 | 391 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 150 | 392 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 151 | 393 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| 152 | 394 | const [isSending, setIsSending] = useState(false); |
| 153 | 395 | const [isConnecting, setIsConnecting] = useState(false); |
| 396 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | |
| 154 | 397 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 155 | 398 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 156 | 399 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| ... | ... | @@ -158,23 +401,37 @@ export default function ChatView() { |
| 158 | 401 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 159 | 402 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 160 | 403 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 404 | const [composerCursor, setComposerCursor] = useState(0); | |
| 405 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | |
| 406 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< | |
| 407 | Record<string, string> | |
| 408 | >(() => readLastInvokedScriptByProjectFromStorage()); | |
| 161 | 409 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 162 | const messagesEndRef = useRef<HTMLDivElement>(null); | |
| 163 | 410 | const shouldAutoScrollRef = useRef(true); |
| 164 | 411 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 412 | const composerCommandInputRef = useRef<HTMLInputElement>(null); | |
| 165 | 413 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 166 | 414 | const dragDepthRef = useRef(0); |
| 167 | 415 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 416 | const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); | |
| 417 | const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>()); | |
| 168 | 418 | |
| 169 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | |
| 419 | const activeThread = state.threads.find((t) => t.id === threadId); | |
| 420 | const diffSearch = useMemo( | |
| 421 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | |
| 422 | [rawSearch], | |
| 423 | ); | |
| 424 | const diffOpen = diffSearch.diff === "1"; | |
| 170 | 425 | const activeThreadId = activeThread?.id ?? null; |
| 171 | const activeSessionId = activeThread?.session?.sessionId; | |
| 426 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 427 | const activeThreadRuntimeId = | |
| 428 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 172 | 429 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 173 | 430 | const selectedModel = resolveModelSlug( |
| 174 | 431 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 175 | 432 | ); |
| 176 | 433 | const phase = derivePhase(activeThread?.session ?? null); |
| 177 | const isWorking = phase === "running" || isSending || isConnecting; | |
| 434 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; | |
| 178 | 435 | const nowIso = new Date(nowTick).toISOString(); |
| 179 | 436 | const workLogEntries = useMemo( |
| 180 | 437 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), |
| ... | ... | @@ -202,6 +459,142 @@ export default function ChatView() { |
| 202 | 459 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), |
| 203 | 460 | [activeThread?.messages, workLogEntries], |
| 204 | 461 | ); |
| 462 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | |
| 463 | useTurnDiffSummaries(activeThread); | |
| 464 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { | |
| 465 | const byMessageId = new Map<string, TurnDiffSummary>(); | |
| 466 | for (const summary of turnDiffSummaries) { | |
| 467 | if (!summary.assistantMessageId) continue; | |
| 468 | byMessageId.set(summary.assistantMessageId, summary); | |
| 469 | } | |
| 470 | return byMessageId; | |
| 471 | }, [turnDiffSummaries]); | |
| 472 | const checkpointDiffInputsByTurn = useMemo( | |
| 473 | () => | |
| 474 | turnDiffSummaries | |
| 475 | .filter((summary) => summary.assistantMessageId) | |
| 476 | .map((summary) => { | |
| 477 | const checkpointTurnCount = | |
| 478 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 479 | return { | |
| 480 | turnId: summary.turnId, | |
| 481 | checkpointTurnCount: | |
| 482 | typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, | |
| 483 | }; | |
| 484 | }), | |
| 485 | [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], | |
| 486 | ); | |
| 487 | const checkpointDiffQueriesByTurn = useQueries({ | |
| 488 | queries: checkpointDiffInputsByTurn.map((input) => | |
| 489 | checkpointDiffQueryOptions(api, { | |
| 490 | sessionId: activeSessionId, | |
| 491 | threadRuntimeId: activeThreadRuntimeId, | |
| 492 | fromTurnCount: | |
| 493 | typeof input.checkpointTurnCount === "number" | |
| 494 | ? Math.max(0, input.checkpointTurnCount - 1) | |
| 495 | : null, | |
| 496 | toTurnCount: input.checkpointTurnCount, | |
| 497 | cacheScope: `turn:${input.turnId}`, | |
| 498 | }), | |
| 499 | ), | |
| 500 | }); | |
| 501 | const turnCheckpointFilesByTurnId = useMemo(() => { | |
| 502 | const byTurnId = new Map<string, TurnCheckpointFilesState>(); | |
| 503 | for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) { | |
| 504 | const input = checkpointDiffInputsByTurn[index]; | |
| 505 | const query = checkpointDiffQueriesByTurn[index]; | |
| 506 | if (!input || !query) continue; | |
| 507 | ||
| 508 | if (!activeSessionId || typeof input.checkpointTurnCount !== "number") { | |
| 509 | byTurnId.set(input.turnId, { status: "pending", files: [] }); | |
| 510 | continue; | |
| 511 | } | |
| 512 | ||
| 513 | const diff = query.data?.diff; | |
| 514 | if (typeof diff === "string") { | |
| 515 | byTurnId.set(input.turnId, { | |
| 516 | status: "ready", | |
| 517 | files: deriveTurnDiffFilesFromUnifiedDiff(diff), | |
| 518 | }); | |
| 519 | continue; | |
| 520 | } | |
| 521 | ||
| 522 | if (query.isError) { | |
| 523 | byTurnId.set(input.turnId, { | |
| 524 | status: "error", | |
| 525 | files: [], | |
| 526 | errorMessage: checkpointErrorMessage(query.error), | |
| 527 | }); | |
| 528 | continue; | |
| 529 | } | |
| 530 | ||
| 531 | byTurnId.set(input.turnId, { status: "loading", files: [] }); | |
| 532 | } | |
| 533 | return byTurnId; | |
| 534 | }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]); | |
| 535 | const revertTurnCountByUserMessageId = useMemo(() => { | |
| 536 | const byUserMessageId = new Map<string, number>(); | |
| 537 | for (let index = 0; index < timelineEntries.length; index += 1) { | |
| 538 | const entry = timelineEntries[index]; | |
| 539 | if (!entry || entry.kind !== "message" || entry.message.role !== "user") { | |
| 540 | continue; | |
| 541 | } | |
| 542 | ||
| 543 | for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { | |
| 544 | const nextEntry = timelineEntries[nextIndex]; | |
| 545 | if (!nextEntry || nextEntry.kind !== "message") { | |
| 546 | continue; | |
| 547 | } | |
| 548 | if (nextEntry.message.role === "user") { | |
| 549 | break; | |
| 550 | } | |
| 551 | const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); | |
| 552 | if (!summary) { | |
| 553 | continue; | |
| 554 | } | |
| 555 | const turnCount = | |
| 556 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 557 | if (typeof turnCount !== "number") { | |
| 558 | break; | |
| 559 | } | |
| 560 | byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); | |
| 561 | break; | |
| 562 | } | |
| 563 | } | |
| 564 | ||
| 565 | return byUserMessageId; | |
| 566 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); | |
| 567 | ||
| 568 | useEffect(() => { | |
| 569 | if (!activeThreadId || turnDiffSummaries.length === 0) { | |
| 570 | return; | |
| 571 | } | |
| 572 | ||
| 573 | const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>( | |
| 574 | (acc, summary) => { | |
| 575 | if (typeof summary.checkpointTurnCount === "number") { | |
| 576 | return acc; | |
| 577 | } | |
| 578 | const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; | |
| 579 | if (typeof inferredTurnCount !== "number") { | |
| 580 | return acc; | |
| 581 | } | |
| 582 | acc[summary.turnId] = inferredTurnCount; | |
| 583 | return acc; | |
| 584 | }, | |
| 585 | {}, | |
| 586 | ); | |
| 587 | if (Object.keys(inferredMissingTurnCounts).length === 0) { | |
| 588 | return; | |
| 589 | } | |
| 590 | ||
| 591 | dispatch({ | |
| 592 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 593 | threadId: activeThreadId, | |
| 594 | checkpointTurnCountByTurnId: inferredMissingTurnCounts, | |
| 595 | }); | |
| 596 | }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); | |
| 597 | ||
| 205 | 598 | const completionSummary = useMemo(() => { |
| 206 | 599 | if (!activeThread?.latestTurnStartedAt) return null; |
| 207 | 600 | if (!activeThread.latestTurnCompletedAt) return null; |
| ... | ... | @@ -257,23 +650,97 @@ export default function ChatView() { |
| 257 | 650 | completionSummary, |
| 258 | 651 | timelineEntries, |
| 259 | 652 | ]); |
| 260 | const runtimeSessionConfig = | |
| 261 | state.runtimeMode === "full-access" | |
| 262 | ? ({ | |
| 263 | approvalPolicy: "never", | |
| 264 | sandboxMode: "danger-full-access", | |
| 265 | } as const) | |
| 266 | : ({ | |
| 267 | approvalPolicy: "on-request", | |
| 268 | sandboxMode: "workspace-write", | |
| 269 | } as const); | |
| 653 | const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; | |
| 654 | const runtimeSandboxMode = | |
| 655 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 656 | const codexBinaryPath = appSettings.codexBinaryPath.trim(); | |
| 657 | const codexHomePath = appSettings.codexHomePath.trim(); | |
| 270 | 658 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 659 | const composerTrigger = useMemo( | |
| 660 | () => detectComposerTrigger(prompt, composerCursor), | |
| 661 | [prompt, composerCursor], | |
| 662 | ); | |
| 663 | const composerTriggerKind = composerTrigger?.kind ?? null; | |
| 664 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | |
| 665 | const isPathTrigger = composerTriggerKind === "path"; | |
| 666 | const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( | |
| 667 | pathTriggerQuery, | |
| 668 | { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, | |
| 669 | (debouncerState) => ({ isPending: debouncerState.isPending }), | |
| 670 | ); | |
| 671 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | |
| 271 | 672 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 272 | 673 | const keybindingsQuery = useQuery({ |
| 273 | 674 | ...serverConfigQueryOptions(api), |
| 274 | 675 | select: (config) => config.keybindings, |
| 275 | 676 | }); |
| 677 | const workspaceEntriesQuery = useQuery( | |
| 678 | projectSearchEntriesQueryOptions({ | |
| 679 | api, | |
| 680 | cwd: gitCwd, | |
| 681 | query: effectivePathQuery, | |
| 682 | enabled: isPathTrigger, | |
| 683 | limit: 80, | |
| 684 | }), | |
| 685 | ); | |
| 686 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; | |
| 687 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | |
| 688 | if (!composerTrigger) return []; | |
| 689 | if (composerTrigger.kind === "path") { | |
| 690 | return workspaceEntries.map((entry) => ({ | |
| 691 | id: `path:${entry.kind}:${entry.path}`, | |
| 692 | type: "path", | |
| 693 | path: entry.path, | |
| 694 | pathKind: entry.kind, | |
| 695 | label: basenameOfPath(entry.path), | |
| 696 | description: entry.parentPath ?? "", | |
| 697 | })); | |
| 698 | } | |
| 699 | ||
| 700 | if (composerTrigger.kind === "slash-command") { | |
| 701 | if (!"model".includes(composerTrigger.query.toLowerCase())) { | |
| 702 | return []; | |
| 703 | } | |
| 704 | return [ | |
| 705 | { | |
| 706 | id: "slash:model", | |
| 707 | type: "slash-command", | |
| 708 | label: "/model", | |
| 709 | description: "Switch response model for this thread", | |
| 710 | }, | |
| 711 | ]; | |
| 712 | } | |
| 713 | ||
| 714 | return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => { | |
| 715 | const query = composerTrigger.query.trim().toLowerCase(); | |
| 716 | if (!query) return true; | |
| 717 | return searchSlug.includes(query) || searchName.includes(query); | |
| 718 | }).map(({ slug, name }) => ({ | |
| 719 | id: `model:${slug}`, | |
| 720 | type: "model", | |
| 721 | model: slug, | |
| 722 | label: name, | |
| 723 | description: slug, | |
| 724 | })); | |
| 725 | }, [composerTrigger, workspaceEntries]); | |
| 726 | const composerMenuOpen = Boolean(composerTrigger); | |
| 727 | const activeComposerMenuItem = useMemo( | |
| 728 | () => | |
| 729 | composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? | |
| 730 | composerMenuItems[0] ?? | |
| 731 | null, | |
| 732 | [composerHighlightedItemId, composerMenuItems], | |
| 733 | ); | |
| 276 | 734 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; |
| 735 | const threadTerminalRuntimeEnv = useMemo(() => { | |
| 736 | if (!activeProject?.cwd) return {}; | |
| 737 | return projectScriptRuntimeEnv({ | |
| 738 | project: { | |
| 739 | cwd: activeProject.cwd, | |
| 740 | }, | |
| 741 | worktreePath: activeThread?.worktreePath ?? null, | |
| 742 | }); | |
| 743 | }, [activeProject?.cwd, activeThread?.worktreePath]); | |
| 277 | 744 | // Default true while loading to avoid toolbar flicker. |
| 278 | 745 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| 279 | 746 | const splitTerminalShortcutLabel = useMemo( |
| ... | ... | @@ -294,6 +761,8 @@ export default function ChatView() { |
| 294 | 761 | (activeThread.messages.length > 0 || |
| 295 | 762 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 296 | 763 | ); |
| 764 | const hasReachedTerminalLimit = | |
| 765 | (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; | |
| 297 | 766 | |
| 298 | 767 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 299 | 768 | for (const image of images) { |
| ... | ... | @@ -322,23 +791,23 @@ export default function ChatView() { |
| 322 | 791 | }); |
| 323 | 792 | }, [activeThread?.terminalOpen, activeThreadId, dispatch]); |
| 324 | 793 | const splitTerminal = useCallback(() => { |
| 325 | if (!activeThreadId) return; | |
| 794 | if (!activeThreadId || hasReachedTerminalLimit) return; | |
| 326 | 795 | dispatch({ |
| 327 | 796 | type: "SPLIT_THREAD_TERMINAL", |
| 328 | 797 | threadId: activeThreadId, |
| 329 | 798 | terminalId: `terminal-${crypto.randomUUID()}`, |
| 330 | 799 | }); |
| 331 | 800 | setTerminalFocusRequestId((value) => value + 1); |
| 332 | }, [activeThreadId, dispatch]); | |
| 801 | }, [activeThreadId, dispatch, hasReachedTerminalLimit]); | |
| 333 | 802 | const createNewTerminal = useCallback(() => { |
| 334 | if (!activeThreadId) return; | |
| 803 | if (!activeThreadId || hasReachedTerminalLimit) return; | |
| 335 | 804 | dispatch({ |
| 336 | 805 | type: "NEW_THREAD_TERMINAL", |
| 337 | 806 | threadId: activeThreadId, |
| 338 | 807 | terminalId: `terminal-${crypto.randomUUID()}`, |
| 339 | 808 | }); |
| 340 | 809 | setTerminalFocusRequestId((value) => value + 1); |
| 341 | }, [activeThreadId, dispatch]); | |
| 810 | }, [activeThreadId, dispatch, hasReachedTerminalLimit]); | |
| 342 | 811 | const activateTerminal = useCallback( |
| 343 | 812 | (terminalId: string) => { |
| 344 | 813 | if (!activeThreadId) return; |
| ... | ... | @@ -380,6 +849,225 @@ export default function ChatView() { |
| 380 | 849 | }, |
| 381 | 850 | [activeThread?.terminalIds.length, activeThreadId, api, dispatch], |
| 382 | 851 | ); |
| 852 | const runProjectScript = useCallback( | |
| 853 | async ( | |
| 854 | script: ProjectScript, | |
| 855 | options?: { | |
| 856 | cwd?: string; | |
| 857 | env?: Record<string, string>; | |
| 858 | worktreePath?: string | null; | |
| 859 | preferNewTerminal?: boolean; | |
| 860 | rememberAsLastInvoked?: boolean; | |
| 861 | }, | |
| 862 | ) => { | |
| 863 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | |
| 864 | if (options?.rememberAsLastInvoked !== false) { | |
| 865 | setLastInvokedScriptByProjectId((current) => { | |
| 866 | if (current[activeProject.id] === script.id) return current; | |
| 867 | return { ...current, [activeProject.id]: script.id }; | |
| 868 | }); | |
| 869 | } | |
| 870 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | |
| 871 | const baseTerminalId = | |
| 872 | activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; | |
| 873 | const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); | |
| 874 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; | |
| 875 | const shouldCreateNewTerminal = | |
| 876 | wantsNewTerminal && activeThread.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; | |
| 877 | const targetTerminalId = shouldCreateNewTerminal | |
| 878 | ? `terminal-${crypto.randomUUID()}` | |
| 879 | : baseTerminalId; | |
| 880 | ||
| 881 | dispatch({ | |
| 882 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 883 | threadId: activeThreadId, | |
| 884 | open: true, | |
| 885 | }); | |
| 886 | if (shouldCreateNewTerminal) { | |
| 887 | dispatch({ | |
| 888 | type: "NEW_THREAD_TERMINAL", | |
| 889 | threadId: activeThreadId, | |
| 890 | terminalId: targetTerminalId, | |
| 891 | }); | |
| 892 | } else { | |
| 893 | dispatch({ | |
| 894 | type: "SET_THREAD_ACTIVE_TERMINAL", | |
| 895 | threadId: activeThreadId, | |
| 896 | terminalId: targetTerminalId, | |
| 897 | }); | |
| 898 | } | |
| 899 | setTerminalFocusRequestId((value) => value + 1); | |
| 900 | ||
| 901 | const runtimeEnv = projectScriptRuntimeEnv({ | |
| 902 | project: { | |
| 903 | cwd: activeProject.cwd, | |
| 904 | }, | |
| 905 | worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, | |
| 906 | ...(options?.env ? { extraEnv: options.env } : {}), | |
| 907 | }); | |
| 908 | ||
| 909 | try { | |
| 910 | await api.terminal.open({ | |
| 911 | threadId: activeThreadId, | |
| 912 | terminalId: targetTerminalId, | |
| 913 | cwd: targetCwd, | |
| 914 | env: runtimeEnv, | |
| 915 | ...(shouldCreateNewTerminal | |
| 916 | ? { | |
| 917 | cols: SCRIPT_TERMINAL_COLS, | |
| 918 | rows: SCRIPT_TERMINAL_ROWS, | |
| 919 | } | |
| 920 | : {}), | |
| 921 | }); | |
| 922 | await api.terminal.write({ | |
| 923 | threadId: activeThreadId, | |
| 924 | terminalId: targetTerminalId, | |
| 925 | data: `${script.command}\r`, | |
| 926 | }); | |
| 927 | } catch (error) { | |
| 928 | dispatch({ | |
| 929 | type: "SET_ERROR", | |
| 930 | threadId: activeThreadId, | |
| 931 | error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`, | |
| 932 | }); | |
| 933 | } | |
| 934 | }, | |
| 935 | [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd], | |
| 936 | ); | |
| 937 | const persistProjectScripts = useCallback( | |
| 938 | async (input: { | |
| 939 | projectId: string; | |
| 940 | projectCwd: string; | |
| 941 | previousScripts: ProjectScript[]; | |
| 942 | nextScripts: ProjectScript[]; | |
| 943 | keybinding?: string | null; | |
| 944 | keybindingCommand: string; | |
| 945 | }) => { | |
| 946 | dispatch({ | |
| 947 | type: "SET_PROJECT_SCRIPTS", | |
| 948 | projectId: input.projectId, | |
| 949 | scripts: input.nextScripts, | |
| 950 | }); | |
| 951 | ||
| 952 | if (!isElectron || !api) return; | |
| 953 | ||
| 954 | let scriptsPersisted = false; | |
| 955 | try { | |
| 956 | const updated = await api.projects.updateScripts({ | |
| 957 | id: input.projectId, | |
| 958 | scripts: input.nextScripts, | |
| 959 | }); | |
| 960 | scriptsPersisted = true; | |
| 961 | ||
| 962 | if (input.keybinding) { | |
| 963 | const keybindingUpdate = await api.server.upsertKeybinding({ | |
| 964 | key: input.keybinding, | |
| 965 | command: input.keybindingCommand, | |
| 966 | }); | |
| 967 | queryClient.setQueryData( | |
| 968 | serverQueryKeys.config(), | |
| 969 | (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => | |
| 970 | current | |
| 971 | ? { ...current, keybindings: keybindingUpdate.keybindings } | |
| 972 | : { | |
| 973 | cwd: input.projectCwd, | |
| 974 | keybindings: keybindingUpdate.keybindings, | |
| 975 | }, | |
| 976 | ); | |
| 977 | } | |
| 978 | ||
| 979 | dispatch({ | |
| 980 | type: "SET_PROJECT_SCRIPTS", | |
| 981 | projectId: updated.project.id, | |
| 982 | scripts: updated.project.scripts, | |
| 983 | }); | |
| 984 | } catch (error) { | |
| 985 | if (scriptsPersisted) { | |
| 986 | await api.projects | |
| 987 | .updateScripts({ | |
| 988 | id: input.projectId, | |
| 989 | scripts: input.previousScripts, | |
| 990 | }) | |
| 991 | .catch(() => undefined); | |
| 992 | } | |
| 993 | dispatch({ | |
| 994 | type: "SET_PROJECT_SCRIPTS", | |
| 995 | projectId: input.projectId, | |
| 996 | scripts: input.previousScripts, | |
| 997 | }); | |
| 998 | throw error; | |
| 999 | } | |
| 1000 | }, | |
| 1001 | [api, dispatch, queryClient], | |
| 1002 | ); | |
| 1003 | const saveProjectScript = useCallback( | |
| 1004 | async (input: NewProjectScriptInput) => { | |
| 1005 | if (!activeProject) return; | |
| 1006 | const nextId = nextProjectScriptId( | |
| 1007 | input.name, | |
| 1008 | activeProject.scripts.map((script) => script.id), | |
| 1009 | ); | |
| 1010 | const nextScript: ProjectScript = { | |
| 1011 | id: nextId, | |
| 1012 | name: input.name, | |
| 1013 | command: input.command, | |
| 1014 | icon: input.icon, | |
| 1015 | runOnWorktreeCreate: input.runOnWorktreeCreate, | |
| 1016 | }; | |
| 1017 | const nextScripts = input.runOnWorktreeCreate | |
| 1018 | ? [ | |
| 1019 | ...activeProject.scripts.map((script) => | |
| 1020 | script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, | |
| 1021 | ), | |
| 1022 | nextScript, | |
| 1023 | ] | |
| 1024 | : [...activeProject.scripts, nextScript]; | |
| 1025 | ||
| 1026 | await persistProjectScripts({ | |
| 1027 | projectId: activeProject.id, | |
| 1028 | projectCwd: activeProject.cwd, | |
| 1029 | previousScripts: activeProject.scripts, | |
| 1030 | nextScripts, | |
| 1031 | keybinding: input.keybinding, | |
| 1032 | keybindingCommand: commandForProjectScript(nextId), | |
| 1033 | }); | |
| 1034 | }, | |
| 1035 | [activeProject, persistProjectScripts], | |
| 1036 | ); | |
| 1037 | const updateProjectScript = useCallback( | |
| 1038 | async (scriptId: string, input: NewProjectScriptInput) => { | |
| 1039 | if (!activeProject) return; | |
| 1040 | const existingScript = activeProject.scripts.find((script) => script.id === scriptId); | |
| 1041 | if (!existingScript) { | |
| 1042 | throw new Error("Script not found."); | |
| 1043 | } | |
| 1044 | ||
| 1045 | const updatedScript: ProjectScript = { | |
| 1046 | ...existingScript, | |
| 1047 | name: input.name, | |
| 1048 | command: input.command, | |
| 1049 | icon: input.icon, | |
| 1050 | runOnWorktreeCreate: input.runOnWorktreeCreate, | |
| 1051 | }; | |
| 1052 | const nextScripts = activeProject.scripts.map((script) => | |
| 1053 | script.id === scriptId | |
| 1054 | ? updatedScript | |
| 1055 | : input.runOnWorktreeCreate | |
| 1056 | ? { ...script, runOnWorktreeCreate: false } | |
| 1057 | : script, | |
| 1058 | ); | |
| 1059 | ||
| 1060 | await persistProjectScripts({ | |
| 1061 | projectId: activeProject.id, | |
| 1062 | projectCwd: activeProject.cwd, | |
| 1063 | previousScripts: activeProject.scripts, | |
| 1064 | nextScripts, | |
| 1065 | keybinding: input.keybinding, | |
| 1066 | keybindingCommand: commandForProjectScript(scriptId), | |
| 1067 | }); | |
| 1068 | }, | |
| 1069 | [activeProject, persistProjectScripts], | |
| 1070 | ); | |
| 383 | 1071 | |
| 384 | 1072 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 385 | 1073 | if (mode === state.runtimeMode) return; |
| ... | ... | @@ -404,6 +1092,21 @@ export default function ChatView() { |
| 404 | 1092 | } |
| 405 | 1093 | }; |
| 406 | 1094 | |
| 1095 | useEffect(() => { | |
| 1096 | try { | |
| 1097 | if (Object.keys(lastInvokedScriptByProjectId).length === 0) { | |
| 1098 | localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 1099 | return; | |
| 1100 | } | |
| 1101 | localStorage.setItem( | |
| 1102 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | |
| 1103 | JSON.stringify(lastInvokedScriptByProjectId), | |
| 1104 | ); | |
| 1105 | } catch { | |
| 1106 | // Ignore storage write failures (private mode, quota exceeded, etc.) | |
| 1107 | } | |
| 1108 | }, [lastInvokedScriptByProjectId]); | |
| 1109 | ||
| 407 | 1110 | // Auto-scroll on new messages |
| 408 | 1111 | const messageCount = activeThread?.messages.length ?? 0; |
| 409 | 1112 | const workLogCount = workLogEntries.length; |
| ... | ... | @@ -436,6 +1139,20 @@ export default function ChatView() { |
| 436 | 1139 | setExpandedWorkGroups({}); |
| 437 | 1140 | }, [activeThread?.id]); |
| 438 | 1141 | |
| 1142 | useEffect(() => { | |
| 1143 | if (!composerMenuOpen) { | |
| 1144 | setComposerHighlightedItemId(null); | |
| 1145 | return; | |
| 1146 | } | |
| 1147 | setComposerHighlightedItemId((existing) => | |
| 1148 | existing && composerMenuItems.some((item) => item.id === existing) ? existing : null, | |
| 1149 | ); | |
| 1150 | }, [composerMenuItems, composerMenuOpen]); | |
| 1151 | ||
| 1152 | useEffect(() => { | |
| 1153 | setIsRevertingCheckpoint(false); | |
| 1154 | }, [activeThread?.id]); | |
| 1155 | ||
| 439 | 1156 | useEffect(() => { |
| 440 | 1157 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| 441 | 1158 | const frame = window.requestAnimationFrame(() => { |
| ... | ... | @@ -455,6 +1172,11 @@ export default function ChatView() { |
| 455 | 1172 | revokePreviewUrls(existing); |
| 456 | 1173 | return []; |
| 457 | 1174 | }); |
| 1175 | setPrompt(""); | |
| 1176 | promptRef.current = ""; | |
| 1177 | setIsSending(false); | |
| 1178 | setComposerCursor(0); | |
| 1179 | setComposerHighlightedItemId(null); | |
| 458 | 1180 | dragDepthRef.current = 0; |
| 459 | 1181 | setIsDragOverComposer(false); |
| 460 | 1182 | setExpandedImage(null); |
| ... | ... | @@ -543,14 +1265,17 @@ export default function ChatView() { |
| 543 | 1265 | terminalOpen: Boolean(activeThread?.terminalOpen), |
| 544 | 1266 | }; |
| 545 | 1267 | |
| 546 | if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) { | |
| 1268 | const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext }); | |
| 1269 | if (!command) return; | |
| 1270 | ||
| 1271 | if (command === "terminal.toggle") { | |
| 547 | 1272 | event.preventDefault(); |
| 548 | 1273 | event.stopPropagation(); |
| 549 | 1274 | toggleTerminalVisibility(); |
| 550 | 1275 | return; |
| 551 | 1276 | } |
| 552 | 1277 | |
| 553 | if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { | |
| 1278 | if (command === "terminal.split") { | |
| 554 | 1279 | event.preventDefault(); |
| 555 | 1280 | event.stopPropagation(); |
| 556 | 1281 | if (!activeThread?.terminalOpen) { |
| ... | ... | @@ -564,7 +1289,7 @@ export default function ChatView() { |
| 564 | 1289 | return; |
| 565 | 1290 | } |
| 566 | 1291 | |
| 567 | if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { | |
| 1292 | if (command === "terminal.close") { | |
| 568 | 1293 | event.preventDefault(); |
| 569 | 1294 | event.stopPropagation(); |
| 570 | 1295 | if (!activeThread?.terminalOpen) return; |
| ... | ... | @@ -572,40 +1297,55 @@ export default function ChatView() { |
| 572 | 1297 | return; |
| 573 | 1298 | } |
| 574 | 1299 | |
| 575 | if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; | |
| 1300 | if (command === "terminal.new") { | |
| 1301 | event.preventDefault(); | |
| 1302 | event.stopPropagation(); | |
| 1303 | if (!activeThread?.terminalOpen) { | |
| 1304 | dispatch({ | |
| 1305 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 1306 | threadId: activeThreadId, | |
| 1307 | open: true, | |
| 1308 | }); | |
| 1309 | } | |
| 1310 | createNewTerminal(); | |
| 1311 | return; | |
| 1312 | } | |
| 1313 | ||
| 1314 | const scriptId = projectScriptIdFromCommand(command); | |
| 1315 | if (!scriptId || !activeProject) return; | |
| 1316 | const script = activeProject.scripts.find((entry) => entry.id === scriptId); | |
| 1317 | if (!script) return; | |
| 576 | 1318 | event.preventDefault(); |
| 577 | 1319 | event.stopPropagation(); |
| 578 | if (!activeThread?.terminalOpen) { | |
| 579 | dispatch({ | |
| 580 | type: "SET_THREAD_TERMINAL_OPEN", | |
| 581 | threadId: activeThreadId, | |
| 582 | open: true, | |
| 583 | }); | |
| 584 | } | |
| 585 | createNewTerminal(); | |
| 1320 | void runProjectScript(script); | |
| 586 | 1321 | }; |
| 587 | 1322 | window.addEventListener("keydown", handler); |
| 588 | 1323 | return () => window.removeEventListener("keydown", handler); |
| 589 | 1324 | }, [ |
| 1325 | activeProject, | |
| 590 | 1326 | activeThread?.terminalOpen, |
| 591 | 1327 | activeThread?.activeTerminalId, |
| 592 | 1328 | activeThreadId, |
| 593 | 1329 | closeTerminal, |
| 594 | 1330 | createNewTerminal, |
| 595 | 1331 | dispatch, |
| 1332 | runProjectScript, | |
| 596 | 1333 | splitTerminal, |
| 597 | 1334 | keybindings, |
| 598 | 1335 | toggleTerminalVisibility, |
| 599 | 1336 | ]); |
| 600 | 1337 | |
| 601 | const setThreadError = (threadId: string | null, error: string | null) => { | |
| 602 | if (!threadId) return; | |
| 603 | dispatch({ | |
| 604 | type: "SET_ERROR", | |
| 605 | threadId, | |
| 606 | error, | |
| 607 | }); | |
| 608 | }; | |
| 1338 | const setThreadError = useCallback( | |
| 1339 | (threadId: string | null, error: string | null) => { | |
| 1340 | if (!threadId) return; | |
| 1341 | dispatch({ | |
| 1342 | type: "SET_ERROR", | |
| 1343 | threadId, | |
| 1344 | error, | |
| 1345 | }); | |
| 1346 | }, | |
| 1347 | [dispatch], | |
| 1348 | ); | |
| 609 | 1349 | |
| 610 | 1350 | const addComposerImages = (files: File[]) => { |
| 611 | 1351 | if (!activeThreadId || files.length === 0) return; |
| ... | ... | @@ -710,62 +1450,240 @@ export default function ChatView() { |
| 710 | 1450 | focusComposer(); |
| 711 | 1451 | }; |
| 712 | 1452 | |
| 713 | const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | |
| 714 | if (!api || !activeThread || !activeProject) return null; | |
| 715 | if (activeThread.session && activeThread.session.status !== "closed") { | |
| 716 | const sessionThreadId = activeThread.session.threadId ?? null; | |
| 717 | const continuityState: SessionContinuityState = | |
| 718 | activeThread.codexThreadId === null | |
| 719 | ? "new" | |
| 720 | : sessionThreadId === activeThread.codexThreadId | |
| 721 | ? "resumed" | |
| 722 | : "fallback_new"; | |
| 723 | return { | |
| 724 | sessionId: activeThread.session.sessionId, | |
| 725 | resolvedThreadId: sessionThreadId, | |
| 726 | continuityState, | |
| 727 | } satisfies EnsuredSessionInfo; | |
| 728 | } | |
| 1453 | const syncCheckpointTurnCounts = useCallback( | |
| 1454 | async (input: { | |
| 1455 | threadId: string; | |
| 1456 | sessionId: string; | |
| 1457 | threadRuntimeId: string | null; | |
| 1458 | }): Promise<void> => { | |
| 1459 | if (!api || turnDiffSummaries.length === 0) { | |
| 1460 | return; | |
| 1461 | } | |
| 729 | 1462 | |
| 730 | const priorCodexThreadId = activeThread.codexThreadId; | |
| 731 | setIsConnecting(true); | |
| 732 | try { | |
| 733 | const session = await api.providers.startSession({ | |
| 734 | provider: "codex", | |
| 735 | cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, | |
| 736 | model: selectedModel || undefined, | |
| 737 | resumeThreadId: priorCodexThreadId ?? undefined, | |
| 738 | approvalPolicy: runtimeSessionConfig.approvalPolicy, | |
| 739 | sandboxMode: runtimeSessionConfig.sandboxMode, | |
| 740 | }); | |
| 741 | dispatch({ | |
| 742 | type: "UPDATE_SESSION", | |
| 743 | threadId: activeThread.id, | |
| 744 | session, | |
| 745 | }); | |
| 746 | const resolvedThreadId = session.threadId ?? null; | |
| 747 | const continuityState: SessionContinuityState = | |
| 748 | priorCodexThreadId === null | |
| 749 | ? "new" | |
| 750 | : resolvedThreadId === priorCodexThreadId | |
| 751 | ? "resumed" | |
| 752 | : "fallback_new"; | |
| 753 | return { | |
| 754 | sessionId: session.sessionId, | |
| 755 | resolvedThreadId, | |
| 756 | continuityState, | |
| 757 | }; | |
| 758 | } catch (err) { | |
| 759 | dispatch({ | |
| 760 | type: "SET_ERROR", | |
| 761 | threadId: activeThread.id, | |
| 762 | error: err instanceof Error ? err.message : "Failed to connect.", | |
| 763 | }); | |
| 764 | return null; | |
| 765 | } finally { | |
| 766 | setIsConnecting(false); | |
| 1463 | const turnIds = turnDiffSummaries.map((summary) => summary.turnId); | |
| 1464 | if (turnIds.length === 0) { | |
| 1465 | return; | |
| 1466 | } | |
| 1467 | ||
| 1468 | const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`; | |
| 1469 | const turnFingerprint = turnIds.join(","); | |
| 1470 | if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) { | |
| 1471 | return; | |
| 1472 | } | |
| 1473 | checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); | |
| 1474 | ||
| 1475 | try { | |
| 1476 | const result = await api.providers.listCheckpoints({ | |
| 1477 | sessionId: input.sessionId, | |
| 1478 | }); | |
| 1479 | const turnIdSet = new Set(turnIds); | |
| 1480 | const checkpointTurnCountByTurnId = Object.fromEntries( | |
| 1481 | result.checkpoints | |
| 1482 | .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id)) | |
| 1483 | .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const), | |
| 1484 | ); | |
| 1485 | if (Object.keys(checkpointTurnCountByTurnId).length === 0) { | |
| 1486 | return; | |
| 1487 | } | |
| 1488 | dispatch({ | |
| 1489 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 1490 | threadId: input.threadId, | |
| 1491 | checkpointTurnCountByTurnId, | |
| 1492 | }); | |
| 1493 | } catch { | |
| 1494 | checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); | |
| 1495 | } | |
| 1496 | }, | |
| 1497 | [api, dispatch, turnDiffSummaries], | |
| 1498 | ); | |
| 1499 | ||
| 1500 | const ensureSession = useCallback( | |
| 1501 | async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | |
| 1502 | if (!api || !activeThread || !activeProject) return null; | |
| 1503 | if (activeThread.session && activeThread.session.status !== "closed") { | |
| 1504 | const sessionThreadId = activeThread.session.threadId ?? null; | |
| 1505 | const continuityState: SessionContinuityState = | |
| 1506 | activeThread.codexThreadId === null | |
| 1507 | ? "new" | |
| 1508 | : sessionThreadId === activeThread.codexThreadId | |
| 1509 | ? "resumed" | |
| 1510 | : "fallback_new"; | |
| 1511 | await syncCheckpointTurnCounts({ | |
| 1512 | threadId: activeThread.id, | |
| 1513 | sessionId: activeThread.session.sessionId, | |
| 1514 | threadRuntimeId: sessionThreadId, | |
| 1515 | }); | |
| 1516 | return { | |
| 1517 | sessionId: activeThread.session.sessionId, | |
| 1518 | resolvedThreadId: sessionThreadId, | |
| 1519 | continuityState, | |
| 1520 | } satisfies EnsuredSessionInfo; | |
| 1521 | } | |
| 1522 | ||
| 1523 | const priorCodexThreadId = activeThread.codexThreadId; | |
| 1524 | setIsConnecting(true); | |
| 1525 | try { | |
| 1526 | const session = await api.providers.startSession({ | |
| 1527 | provider: "codex", | |
| 1528 | cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, | |
| 1529 | model: selectedModel || undefined, | |
| 1530 | resumeThreadId: priorCodexThreadId ?? undefined, | |
| 1531 | ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), | |
| 1532 | ...(codexHomePath.length > 0 ? { codexHomePath } : {}), | |
| 1533 | approvalPolicy: runtimeApprovalPolicy, | |
| 1534 | sandboxMode: runtimeSandboxMode, | |
| 1535 | }); | |
| 1536 | dispatch({ | |
| 1537 | type: "UPDATE_SESSION", | |
| 1538 | threadId: activeThread.id, | |
| 1539 | session, | |
| 1540 | }); | |
| 1541 | const resolvedThreadId = session.threadId ?? null; | |
| 1542 | const continuityState: SessionContinuityState = | |
| 1543 | priorCodexThreadId === null | |
| 1544 | ? "new" | |
| 1545 | : resolvedThreadId === priorCodexThreadId | |
| 1546 | ? "resumed" | |
| 1547 | : "fallback_new"; | |
| 1548 | await syncCheckpointTurnCounts({ | |
| 1549 | threadId: activeThread.id, | |
| 1550 | sessionId: session.sessionId, | |
| 1551 | threadRuntimeId: resolvedThreadId, | |
| 1552 | }); | |
| 1553 | return { | |
| 1554 | sessionId: session.sessionId, | |
| 1555 | resolvedThreadId, | |
| 1556 | continuityState, | |
| 1557 | }; | |
| 1558 | } catch (err) { | |
| 1559 | dispatch({ | |
| 1560 | type: "SET_ERROR", | |
| 1561 | threadId: activeThread.id, | |
| 1562 | error: err instanceof Error ? err.message : "Failed to connect.", | |
| 1563 | }); | |
| 1564 | return null; | |
| 1565 | } finally { | |
| 1566 | setIsConnecting(false); | |
| 1567 | } | |
| 1568 | }, | |
| 1569 | [ | |
| 1570 | activeProject, | |
| 1571 | activeThread, | |
| 1572 | api, | |
| 1573 | codexBinaryPath, | |
| 1574 | codexHomePath, | |
| 1575 | dispatch, | |
| 1576 | runtimeApprovalPolicy, | |
| 1577 | runtimeSandboxMode, | |
| 1578 | selectedModel, | |
| 1579 | syncCheckpointTurnCounts, | |
| 1580 | ], | |
| 1581 | ); | |
| 1582 | ||
| 1583 | useEffect(() => { | |
| 1584 | if (!api || !activeThreadId || activeSessionId) return; | |
| 1585 | if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; | |
| 1586 | const hasThreadIdentity = Boolean(activeThreadRuntimeId); | |
| 1587 | if (!hasThreadIdentity) return; | |
| 1588 | if (turnDiffSummaries.length === 0) return; | |
| 1589 | ||
| 1590 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; | |
| 1591 | if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { | |
| 1592 | return; | |
| 767 | 1593 | } |
| 768 | }; | |
| 1594 | checkpointHydrationSessionRequestRef.current.add(requestKey); | |
| 1595 | setThreadError(activeThreadId, null); | |
| 1596 | void ensureSession().then( | |
| 1597 | () => { | |
| 1598 | checkpointHydrationSessionRequestRef.current.delete(requestKey); | |
| 1599 | }, | |
| 1600 | () => { | |
| 1601 | // Keep the key on failure so this effect does not re-enter in a tight loop. | |
| 1602 | }, | |
| 1603 | ); | |
| 1604 | }, [ | |
| 1605 | activeThreadId, | |
| 1606 | activeThreadRuntimeId, | |
| 1607 | activeSessionId, | |
| 1608 | api, | |
| 1609 | ensureSession, | |
| 1610 | isConnecting, | |
| 1611 | isRevertingCheckpoint, | |
| 1612 | isSending, | |
| 1613 | phase, | |
| 1614 | setThreadError, | |
| 1615 | turnDiffSummaries, | |
| 1616 | ]); | |
| 1617 | ||
| 1618 | const onRevertToTurnCount = useCallback( | |
| 1619 | async (turnCount: number) => { | |
| 1620 | if (!api || !activeThread || isRevertingCheckpoint) { | |
| 1621 | return; | |
| 1622 | } | |
| 1623 | if (phase === "running" || isSending || isConnecting) { | |
| 1624 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); | |
| 1625 | return; | |
| 1626 | } | |
| 1627 | const confirmed = await api.dialogs.confirm( | |
| 1628 | [ | |
| 1629 | `Revert this thread to checkpoint ${turnCount}?`, | |
| 1630 | "This will discard newer messages and turn diffs in this thread.", | |
| 1631 | "This action cannot be undone.", | |
| 1632 | ].join("\n"), | |
| 1633 | ); | |
| 1634 | if (!confirmed) { | |
| 1635 | return; | |
| 1636 | } | |
| 1637 | ||
| 1638 | setIsRevertingCheckpoint(true); | |
| 1639 | setThreadError(activeThread.id, null); | |
| 1640 | try { | |
| 1641 | const sessionInfo = await ensureSession(); | |
| 1642 | if (!sessionInfo) { | |
| 1643 | return; | |
| 1644 | } | |
| 1645 | const result = await api.providers.revertToCheckpoint({ | |
| 1646 | sessionId: sessionInfo.sessionId, | |
| 1647 | turnCount, | |
| 1648 | }); | |
| 1649 | dispatch({ | |
| 1650 | type: "REVERT_TO_CHECKPOINT", | |
| 1651 | threadId: activeThread.id, | |
| 1652 | sessionId: sessionInfo.sessionId, | |
| 1653 | threadRuntimeId: result.threadId, | |
| 1654 | turnCount: result.turnCount, | |
| 1655 | messageCount: result.messageCount, | |
| 1656 | }); | |
| 1657 | dispatch({ | |
| 1658 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 1659 | threadId: activeThread.id, | |
| 1660 | checkpointTurnCountByTurnId: Object.fromEntries( | |
| 1661 | result.checkpoints | |
| 1662 | .filter((entry) => entry.turnCount > 0) | |
| 1663 | .map((entry) => [entry.id, entry.turnCount] as const), | |
| 1664 | ), | |
| 1665 | }); | |
| 1666 | } catch (err) { | |
| 1667 | setThreadError( | |
| 1668 | activeThread.id, | |
| 1669 | err instanceof Error ? err.message : "Failed to revert thread state.", | |
| 1670 | ); | |
| 1671 | } finally { | |
| 1672 | setIsRevertingCheckpoint(false); | |
| 1673 | } | |
| 1674 | }, | |
| 1675 | [ | |
| 1676 | activeThread, | |
| 1677 | api, | |
| 1678 | dispatch, | |
| 1679 | ensureSession, | |
| 1680 | isConnecting, | |
| 1681 | isRevertingCheckpoint, | |
| 1682 | isSending, | |
| 1683 | phase, | |
| 1684 | setThreadError, | |
| 1685 | ], | |
| 1686 | ); | |
| 769 | 1687 | |
| 770 | 1688 | const onSend = async (e: FormEvent) => { |
| 771 | 1689 | e.preventDefault(); |
| ... | ... | @@ -797,6 +1715,14 @@ export default function ChatView() { |
| 797 | 1715 | branch: result.worktree.branch, |
| 798 | 1716 | worktreePath: result.worktree.path, |
| 799 | 1717 | }); |
| 1718 | const setupScript = setupProjectScript(activeProject.scripts); | |
| 1719 | if (setupScript) { | |
| 1720 | void runProjectScript(setupScript, { | |
| 1721 | cwd: result.worktree.path, | |
| 1722 | worktreePath: result.worktree.path, | |
| 1723 | rememberAsLastInvoked: false, | |
| 1724 | }); | |
| 1725 | } | |
| 800 | 1726 | } catch (err) { |
| 801 | 1727 | dispatch({ |
| 802 | 1728 | type: "SET_ERROR", |
| ... | ... | @@ -814,7 +1740,7 @@ export default function ChatView() { |
| 814 | 1740 | (composerImagesSnapshot.length > 0 |
| 815 | 1741 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` |
| 816 | 1742 | : "New thread"); |
| 817 | const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed; | |
| 1743 | const title = truncateTitle(titleSeed); | |
| 818 | 1744 | dispatch({ |
| 819 | 1745 | type: "SET_THREAD_TITLE", |
| 820 | 1746 | threadId: activeThread.id, |
| ... | ... | @@ -839,8 +1765,11 @@ export default function ChatView() { |
| 839 | 1765 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), |
| 840 | 1766 | }); |
| 841 | 1767 | const previousMessages = activeThread.messages; |
| 1768 | promptRef.current = ""; | |
| 842 | 1769 | setPrompt(""); |
| 843 | 1770 | setComposerImages([]); |
| 1771 | setComposerCursor(0); | |
| 1772 | setComposerHighlightedItemId(null); | |
| 844 | 1773 | |
| 845 | 1774 | const sessionInfo = await ensureSession(sessionCwd); |
| 846 | 1775 | if (!sessionInfo) return; |
| ... | ... | @@ -947,7 +1876,86 @@ export default function ChatView() { |
| 947 | 1876 | [scheduleComposerFocus], |
| 948 | 1877 | ); |
| 949 | 1878 | |
| 1879 | const applyPromptReplacement = useCallback( | |
| 1880 | (rangeStart: number, rangeEnd: number, replacement: string) => { | |
| 1881 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); | |
| 1882 | promptRef.current = next.text; | |
| 1883 | setPrompt(next.text); | |
| 1884 | window.requestAnimationFrame(() => { | |
| 1885 | const textarea = textareaRef.current; | |
| 1886 | if (!textarea) return; | |
| 1887 | textarea.focus(); | |
| 1888 | textarea.setSelectionRange(next.cursor, next.cursor); | |
| 1889 | setComposerCursor(next.cursor); | |
| 1890 | }); | |
| 1891 | }, | |
| 1892 | [], | |
| 1893 | ); | |
| 1894 | ||
| 1895 | const onSelectComposerItem = useCallback( | |
| 1896 | (item: ComposerCommandItem) => { | |
| 1897 | if (!composerTrigger) return; | |
| 1898 | if (item.type === "path") { | |
| 1899 | applyPromptReplacement( | |
| 1900 | composerTrigger.rangeStart, | |
| 1901 | composerTrigger.rangeEnd, | |
| 1902 | `@${item.path} `, | |
| 1903 | ); | |
| 1904 | return; | |
| 1905 | } | |
| 1906 | if (item.type === "slash-command") { | |
| 1907 | applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model "); | |
| 1908 | return; | |
| 1909 | } | |
| 1910 | onModelSelect(item.model); | |
| 1911 | applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); | |
| 1912 | }, | |
| 1913 | [applyPromptReplacement, composerTrigger, onModelSelect], | |
| 1914 | ); | |
| 1915 | const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { | |
| 1916 | setComposerHighlightedItemId(itemId); | |
| 1917 | }, []); | |
| 1918 | const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => { | |
| 1919 | const commandInput = composerCommandInputRef.current; | |
| 1920 | if (!commandInput) return; | |
| 1921 | commandInput.dispatchEvent( | |
| 1922 | new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), | |
| 1923 | ); | |
| 1924 | }, []); | |
| 1925 | const isComposerMenuLoading = | |
| 1926 | composerTriggerKind === "path" && | |
| 1927 | ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || | |
| 1928 | workspaceEntriesQuery.isLoading || | |
| 1929 | workspaceEntriesQuery.isFetching); | |
| 1930 | ||
| 1931 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { | |
| 1932 | promptRef.current = nextPrompt; | |
| 1933 | setPrompt(nextPrompt); | |
| 1934 | setComposerCursor(nextCursor); | |
| 1935 | }, []); | |
| 1936 | ||
| 950 | 1937 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { |
| 1938 | if (composerMenuOpen && composerMenuItems.length > 0) { | |
| 1939 | if (e.key === "ArrowDown") { | |
| 1940 | e.preventDefault(); | |
| 1941 | nudgeComposerMenuHighlight("ArrowDown"); | |
| 1942 | return; | |
| 1943 | } | |
| 1944 | if (e.key === "ArrowUp") { | |
| 1945 | e.preventDefault(); | |
| 1946 | nudgeComposerMenuHighlight("ArrowUp"); | |
| 1947 | return; | |
| 1948 | } | |
| 1949 | if (e.key === "Tab" || e.key === "Enter") { | |
| 1950 | const selectedItem = activeComposerMenuItem ?? composerMenuItems[0]; | |
| 1951 | if (selectedItem) { | |
| 1952 | e.preventDefault(); | |
| 1953 | onSelectComposerItem(selectedItem); | |
| 1954 | return; | |
| 1955 | } | |
| 1956 | } | |
| 1957 | } | |
| 1958 | ||
| 951 | 1959 | if (e.key === "Enter" && !e.shiftKey) { |
| 952 | 1960 | e.preventDefault(); |
| 953 | 1961 | void onSend(e as unknown as FormEvent); |
| ... | ... | @@ -963,13 +1971,45 @@ export default function ChatView() { |
| 963 | 1971 | setExpandedImage(image); |
| 964 | 1972 | }, []); |
| 965 | 1973 | const onToggleDiff = useCallback(() => { |
| 966 | dispatch({ type: "TOGGLE_DIFF" }); | |
| 967 | }, [dispatch]); | |
| 1974 | void navigate({ | |
| 1975 | to: "/$threadId", | |
| 1976 | params: { threadId }, | |
| 1977 | search: (previous) => { | |
| 1978 | const rest = stripDiffSearchParams(previous); | |
| 1979 | return diffOpen ? rest : { ...rest, diff: "1" }; | |
| 1980 | }, | |
| 1981 | }); | |
| 1982 | }, [diffOpen, navigate, threadId]); | |
| 1983 | const onOpenTurnDiff = useCallback( | |
| 1984 | (turnId: string, filePath?: string) => { | |
| 1985 | void navigate({ | |
| 1986 | to: "/$threadId", | |
| 1987 | params: { threadId }, | |
| 1988 | search: (previous) => { | |
| 1989 | const rest = stripDiffSearchParams(previous); | |
| 1990 | return filePath | |
| 1991 | ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } | |
| 1992 | : { ...rest, diff: "1", diffTurnId: turnId }; | |
| 1993 | }, | |
| 1994 | }); | |
| 1995 | }, | |
| 1996 | [navigate, threadId], | |
| 1997 | ); | |
| 1998 | const onRevertUserMessage = useCallback( | |
| 1999 | (messageId: string) => { | |
| 2000 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); | |
| 2001 | if (typeof targetTurnCount !== "number") { | |
| 2002 | return; | |
| 2003 | } | |
| 2004 | void onRevertToTurnCount(targetTurnCount); | |
| 2005 | }, | |
| 2006 | [onRevertToTurnCount, revertTurnCountByUserMessageId], | |
| 2007 | ); | |
| 968 | 2008 | |
| 969 | 2009 | // Empty state: no active thread |
| 970 | 2010 | if (!activeThread) { |
| 971 | 2011 | return ( |
| 972 | <div className="flex min-h-0 flex-1 flex-col bg-background text-muted-foreground/40"> | |
| 2012 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> | |
| 973 | 2013 | {isElectron && ( |
| 974 | 2014 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> |
| 975 | 2015 | <span className="text-xs text-muted-foreground/50">No active thread</span> |
| ... | ... | @@ -985,18 +2025,28 @@ export default function ChatView() { |
| 985 | 2025 | } |
| 986 | 2026 | |
| 987 | 2027 | return ( |
| 988 | <div className="flex min-h-0 flex-1 flex-col bg-background"> | |
| 2028 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background"> | |
| 989 | 2029 | {/* Top bar */} |
| 990 | 2030 | <header |
| 991 | 2031 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} |
| 992 | 2032 | > |
| 993 | 2033 | <ChatHeader |
| 2034 | activeThreadId={activeThread.id} | |
| 994 | 2035 | activeThreadTitle={activeThread.title} |
| 995 | 2036 | activeProjectName={activeProject?.name} |
| 2037 | activeProjectScripts={activeProject?.scripts} | |
| 2038 | preferredScriptId={ | |
| 2039 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null | |
| 2040 | } | |
| 996 | 2041 | keybindings={keybindings} |
| 997 | 2042 | api={api} |
| 998 | 2043 | gitCwd={gitCwd} |
| 999 | diffOpen={state.diffOpen} | |
| 2044 | diffOpen={diffOpen} | |
| 2045 | onRunProjectScript={(script) => { | |
| 2046 | void runProjectScript(script); | |
| 2047 | }} | |
| 2048 | onAddProjectScript={saveProjectScript} | |
| 2049 | onUpdateProjectScript={updateProjectScript} | |
| 1000 | 2050 | onToggleDiff={onToggleDiff} |
| 1001 | 2051 | /> |
| 1002 | 2052 | </header> |
| ... | ... | @@ -1018,15 +2068,21 @@ export default function ChatView() { |
| 1018 | 2068 | <MessagesTimeline |
| 1019 | 2069 | hasMessages={activeThread.messages.length > 0} |
| 1020 | 2070 | isWorking={isWorking} |
| 2071 | scrollContainerRef={messagesScrollRef} | |
| 1021 | 2072 | timelineEntries={timelineEntries} |
| 1022 | 2073 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| 1023 | 2074 | completionSummary={completionSummary} |
| 1024 | 2075 | assistantCompletionByItemId={assistantCompletionByItemId} |
| 2076 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} | |
| 2077 | turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId} | |
| 1025 | 2078 | nowIso={nowIso} |
| 1026 | 2079 | expandedWorkGroups={expandedWorkGroups} |
| 1027 | 2080 | onToggleWorkGroup={onToggleWorkGroup} |
| 2081 | onOpenTurnDiff={onOpenTurnDiff} | |
| 2082 | revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} | |
| 2083 | onRevertUserMessage={onRevertUserMessage} | |
| 2084 | isRevertingCheckpoint={isRevertingCheckpoint} | |
| 1028 | 2085 | onImageExpand={onExpandTimelineImage} |
| 1029 | messagesEndRef={messagesEndRef} | |
| 1030 | 2086 | /> |
| 1031 | 2087 | </div> |
| 1032 | 2088 | |
| ... | ... | @@ -1043,7 +2099,21 @@ export default function ChatView() { |
| 1043 | 2099 | onDrop={onComposerDrop} |
| 1044 | 2100 | > |
| 1045 | 2101 | {/* Textarea area */} |
| 1046 | <div className="px-4 pt-4 pb-2"> | |
| 2102 | <div className="relative px-4 pt-4 pb-2"> | |
| 2103 | {composerMenuOpen && ( | |
| 2104 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> | |
| 2105 | <ComposerCommandMenu | |
| 2106 | items={composerMenuItems} | |
| 2107 | resolvedTheme={resolvedTheme} | |
| 2108 | isLoading={isComposerMenuLoading} | |
| 2109 | triggerKind={composerTriggerKind} | |
| 2110 | onHighlightedItemChange={onComposerMenuItemHighlighted} | |
| 2111 | onSelect={onSelectComposerItem} | |
| 2112 | commandInputRef={composerCommandInputRef} | |
| 2113 | /> | |
| 2114 | </div> | |
| 2115 | )} | |
| 2116 | ||
| 1047 | 2117 | {composerImages.length > 0 && ( |
| 1048 | 2118 | <div className="mb-3 flex flex-wrap gap-2"> |
| 1049 | 2119 | {composerImages.map((image) => ( |
| ... | ... | @@ -1083,13 +2153,33 @@ export default function ChatView() { |
| 1083 | 2153 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" |
| 1084 | 2154 | rows={2} |
| 1085 | 2155 | value={prompt} |
| 1086 | onChange={(e) => setPrompt(e.target.value)} | |
| 2156 | onChange={(event) => | |
| 2157 | onPromptChange( | |
| 2158 | event.target.value, | |
| 2159 | event.target.selectionStart ?? event.target.value.length, | |
| 2160 | ) | |
| 2161 | } | |
| 1087 | 2162 | onKeyDown={onKeyDown} |
| 2163 | onKeyUp={(event) => | |
| 2164 | setComposerCursor( | |
| 2165 | event.currentTarget.selectionStart ?? event.currentTarget.value.length, | |
| 2166 | ) | |
| 2167 | } | |
| 2168 | onClick={(event) => | |
| 2169 | setComposerCursor( | |
| 2170 | event.currentTarget.selectionStart ?? event.currentTarget.value.length, | |
| 2171 | ) | |
| 2172 | } | |
| 2173 | onSelect={(event) => | |
| 2174 | setComposerCursor( | |
| 2175 | event.currentTarget.selectionStart ?? event.currentTarget.value.length, | |
| 2176 | ) | |
| 2177 | } | |
| 1088 | 2178 | onPaste={onComposerPaste} |
| 1089 | 2179 | placeholder={ |
| 1090 | 2180 | phase === "disconnected" |
| 1091 | 2181 | ? "Ask for follow-up changes or attach images" |
| 1092 | : "Ask anything, or attach images..." | |
| 2182 | : "Ask anything, @tag files/folders, or use /model" | |
| 1093 | 2183 | } |
| 1094 | 2184 | disabled={isSending || isConnecting} |
| 1095 | 2185 | /> |
| ... | ... | @@ -1209,6 +2299,7 @@ export default function ChatView() { |
| 1209 | 2299 | |
| 1210 | 2300 | {isGitRepo && ( |
| 1211 | 2301 | <BranchToolbar |
| 2302 | threadId={activeThread.id} | |
| 1212 | 2303 | envMode={envMode} |
| 1213 | 2304 | onEnvModeChange={onEnvModeChange} |
| 1214 | 2305 | envLocked={envLocked} |
| ... | ... | @@ -1222,6 +2313,7 @@ export default function ChatView() { |
| 1222 | 2313 | api={api} |
| 1223 | 2314 | threadId={activeThread.id} |
| 1224 | 2315 | cwd={gitCwd ?? activeProject.cwd} |
| 2316 | runtimeEnv={threadTerminalRuntimeEnv} | |
| 1225 | 2317 | height={activeThread.terminalHeight} |
| 1226 | 2318 | terminalIds={activeThread.terminalIds} |
| 1227 | 2319 | activeTerminalId={activeThread.activeTerminalId} |
| ... | ... | @@ -1284,44 +2376,70 @@ export default function ChatView() { |
| 1284 | 2376 | } |
| 1285 | 2377 | |
| 1286 | 2378 | interface ChatHeaderProps { |
| 2379 | activeThreadId: string; | |
| 1287 | 2380 | activeThreadTitle: string; |
| 1288 | 2381 | activeProjectName: string | undefined; |
| 2382 | activeProjectScripts: ProjectScript[] | undefined; | |
| 2383 | preferredScriptId: string | null; | |
| 1289 | 2384 | keybindings: ResolvedKeybindingsConfig; |
| 1290 | 2385 | api: NativeApi | undefined; |
| 1291 | 2386 | gitCwd: string | null; |
| 1292 | 2387 | diffOpen: boolean; |
| 2388 | onRunProjectScript: (script: ProjectScript) => void; | |
| 2389 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | |
| 2390 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | |
| 1293 | 2391 | onToggleDiff: () => void; |
| 1294 | 2392 | } |
| 1295 | 2393 | |
| 1296 | 2394 | const ChatHeader = memo(function ChatHeader({ |
| 2395 | activeThreadId, | |
| 1297 | 2396 | activeThreadTitle, |
| 1298 | 2397 | activeProjectName, |
| 2398 | activeProjectScripts, | |
| 2399 | preferredScriptId, | |
| 1299 | 2400 | keybindings, |
| 1300 | 2401 | api, |
| 1301 | 2402 | gitCwd, |
| 1302 | 2403 | diffOpen, |
| 2404 | onRunProjectScript, | |
| 2405 | onAddProjectScript, | |
| 2406 | onUpdateProjectScript, | |
| 1303 | 2407 | onToggleDiff, |
| 1304 | 2408 | }: ChatHeaderProps) { |
| 1305 | 2409 | return ( |
| 1306 | 2410 | <> |
| 1307 | <div className="flex items-center gap-3"> | |
| 1308 | <h2 className="text-sm font-medium text-foreground">{activeThreadTitle}</h2> | |
| 2411 | <div className="flex min-w-0 flex-1 items-center gap-3"> | |
| 2412 | <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}> | |
| 2413 | {activeThreadTitle} | |
| 2414 | </h2> | |
| 1309 | 2415 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} |
| 1310 | 2416 | </div> |
| 1311 | <div className="flex items-center gap-3"> | |
| 1312 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | |
| 1313 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} | |
| 1314 | <Button | |
| 2417 | <div className="shrink-0 flex items-center gap-3"> | |
| 2418 | {activeProjectScripts && ( | |
| 2419 | <ProjectScriptsControl | |
| 2420 | scripts={activeProjectScripts} | |
| 2421 | keybindings={keybindings} | |
| 2422 | preferredScriptId={preferredScriptId} | |
| 2423 | onRunScript={onRunProjectScript} | |
| 2424 | onAddScript={onAddProjectScript} | |
| 2425 | onUpdateScript={onUpdateProjectScript} | |
| 2426 | /> | |
| 2427 | )} | |
| 2428 | {activeProjectName && ( | |
| 2429 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> | |
| 2430 | )} | |
| 2431 | {activeProjectName && ( | |
| 2432 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | |
| 2433 | )} | |
| 2434 | <Toggle | |
| 2435 | pressed={diffOpen} | |
| 2436 | onPressedChange={onToggleDiff} | |
| 2437 | aria-label="Toggle diff panel" | |
| 2438 | variant="outline" | |
| 1315 | 2439 | size="xs" |
| 1316 | variant="ghost" | |
| 1317 | className={cn( | |
| 1318 | "text-muted-foreground/70 hover:text-foreground/80", | |
| 1319 | diffOpen && "bg-accent text-accent-foreground", | |
| 1320 | )} | |
| 1321 | onClick={onToggleDiff} | |
| 1322 | 2440 | > |
| 1323 | Diff | |
| 1324 | </Button> | |
| 2441 | <DiffIcon className="size-3" /> | |
| 2442 | </Toggle> | |
| 1325 | 2443 | </div> |
| 1326 | 2444 | </> |
| 1327 | 2445 | ); |
| ... | ... | @@ -1416,214 +2534,443 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| 1416 | 2534 | interface MessagesTimelineProps { |
| 1417 | 2535 | hasMessages: boolean; |
| 1418 | 2536 | isWorking: boolean; |
| 2537 | scrollContainerRef: RefObject<HTMLDivElement | null>; | |
| 1419 | 2538 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 1420 | 2539 | completionDividerBeforeEntryId: string | null; |
| 1421 | 2540 | completionSummary: string | null; |
| 1422 | 2541 | assistantCompletionByItemId: Map<string, string>; |
| 2542 | turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>; | |
| 2543 | turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>; | |
| 1423 | 2544 | nowIso: string; |
| 1424 | 2545 | expandedWorkGroups: Record<string, boolean>; |
| 1425 | 2546 | onToggleWorkGroup: (groupId: string) => void; |
| 2547 | onOpenTurnDiff: (turnId: string, filePath?: string) => void; | |
| 2548 | revertTurnCountByUserMessageId: Map<string, number>; | |
| 2549 | onRevertUserMessage: (messageId: string) => void; | |
| 2550 | isRevertingCheckpoint: boolean; | |
| 1426 | 2551 | onImageExpand: (image: ExpandedImagePreview) => void; |
| 1427 | messagesEndRef: RefObject<HTMLDivElement | null>; | |
| 1428 | 2552 | } |
| 1429 | 2553 | |
| 2554 | type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number]; | |
| 2555 | type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"]; | |
| 2556 | type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"]; | |
| 2557 | type TimelineRow = | |
| 2558 | | { | |
| 2559 | kind: "work"; | |
| 2560 | id: string; | |
| 2561 | groupedEntries: TimelineWorkEntry[]; | |
| 2562 | } | |
| 2563 | | { | |
| 2564 | kind: "message"; | |
| 2565 | id: string; | |
| 2566 | message: TimelineMessage; | |
| 2567 | showCompletionDivider: boolean; | |
| 2568 | } | |
| 2569 | | { kind: "working"; id: string }; | |
| 2570 | ||
| 1430 | 2571 | const MessagesTimeline = memo(function MessagesTimeline({ |
| 1431 | 2572 | hasMessages, |
| 1432 | 2573 | isWorking, |
| 2574 | scrollContainerRef, | |
| 1433 | 2575 | timelineEntries, |
| 1434 | 2576 | completionDividerBeforeEntryId, |
| 1435 | 2577 | completionSummary, |
| 1436 | 2578 | assistantCompletionByItemId, |
| 2579 | turnDiffSummaryByAssistantMessageId, | |
| 2580 | turnCheckpointFilesByTurnId, | |
| 1437 | 2581 | nowIso, |
| 1438 | 2582 | expandedWorkGroups, |
| 1439 | 2583 | onToggleWorkGroup, |
| 2584 | onOpenTurnDiff, | |
| 2585 | revertTurnCountByUserMessageId, | |
| 2586 | onRevertUserMessage, | |
| 2587 | isRevertingCheckpoint, | |
| 1440 | 2588 | onImageExpand, |
| 1441 | messagesEndRef, | |
| 1442 | 2589 | }: MessagesTimelineProps) { |
| 2590 | const rows = useMemo<TimelineRow[]>(() => { | |
| 2591 | const nextRows: TimelineRow[] = []; | |
| 2592 | ||
| 2593 | for (let index = 0; index < timelineEntries.length; index += 1) { | |
| 2594 | const timelineEntry = timelineEntries[index]; | |
| 2595 | if (!timelineEntry) { | |
| 2596 | continue; | |
| 2597 | } | |
| 2598 | ||
| 2599 | if (timelineEntry.kind === "work") { | |
| 2600 | const groupedEntries = [timelineEntry.entry]; | |
| 2601 | let cursor = index + 1; | |
| 2602 | while (cursor < timelineEntries.length) { | |
| 2603 | const nextEntry = timelineEntries[cursor]; | |
| 2604 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 2605 | groupedEntries.push(nextEntry.entry); | |
| 2606 | cursor += 1; | |
| 2607 | } | |
| 2608 | nextRows.push({ | |
| 2609 | kind: "work", | |
| 2610 | id: timelineEntry.id, | |
| 2611 | groupedEntries, | |
| 2612 | }); | |
| 2613 | index = cursor - 1; | |
| 2614 | continue; | |
| 2615 | } | |
| 2616 | ||
| 2617 | nextRows.push({ | |
| 2618 | kind: "message", | |
| 2619 | id: timelineEntry.id, | |
| 2620 | message: timelineEntry.message, | |
| 2621 | showCompletionDivider: | |
| 2622 | timelineEntry.message.role === "assistant" && | |
| 2623 | completionDividerBeforeEntryId === timelineEntry.id, | |
| 2624 | }); | |
| 2625 | } | |
| 2626 | ||
| 2627 | if (isWorking) { | |
| 2628 | nextRows.push({ kind: "working", id: "working-indicator-row" }); | |
| 2629 | } | |
| 2630 | ||
| 2631 | return nextRows; | |
| 2632 | }, [timelineEntries, completionDividerBeforeEntryId, isWorking]); | |
| 2633 | ||
| 2634 | const rowVirtualizer = useVirtualizer({ | |
| 2635 | count: rows.length, | |
| 2636 | getScrollElement: () => scrollContainerRef.current, | |
| 2637 | estimateSize: (index: number) => { | |
| 2638 | const row = rows[index]; | |
| 2639 | if (!row) return 96; | |
| 2640 | if (row.kind === "work") return 112; | |
| 2641 | if (row.kind === "working") return 40; | |
| 2642 | return row.message.role === "assistant" ? 220 : 170; | |
| 2643 | }, | |
| 2644 | measureElement: (element: HTMLElement) => element.getBoundingClientRect().height, | |
| 2645 | overscan: 8, | |
| 2646 | }); | |
| 2647 | ||
| 2648 | const virtualRows = rowVirtualizer.getVirtualItems(); | |
| 2649 | ||
| 1443 | 2650 | if (!hasMessages && !isWorking) { |
| 1444 | 2651 | return ( |
| 1445 | 2652 | <div className="flex h-full items-center justify-center"> |
| 1446 | <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p> | |
| 2653 | <p className="text-sm text-muted-foreground/30"> | |
| 2654 | Send a message to start the conversation. | |
| 2655 | </p> | |
| 1447 | 2656 | </div> |
| 1448 | 2657 | ); |
| 1449 | 2658 | } |
| 1450 | 2659 | |
| 1451 | 2660 | return ( |
| 1452 | <div className="mx-auto max-w-3xl space-y-4"> | |
| 1453 | {timelineEntries.map((timelineEntry, index) => { | |
| 1454 | if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { | |
| 1455 | return null; | |
| 1456 | } | |
| 1457 | ||
| 1458 | const showCompletionDivider = | |
| 1459 | timelineEntry.kind === "message" && | |
| 1460 | timelineEntry.message.role === "assistant" && | |
| 1461 | completionDividerBeforeEntryId === timelineEntry.id; | |
| 1462 | ||
| 1463 | if (timelineEntry.kind === "work") { | |
| 1464 | const groupedEntries = [timelineEntry.entry]; | |
| 1465 | let cursor = index + 1; | |
| 1466 | while (cursor < timelineEntries.length) { | |
| 1467 | const nextEntry = timelineEntries[cursor]; | |
| 1468 | if (!nextEntry || nextEntry.kind !== "work") break; | |
| 1469 | groupedEntries.push(nextEntry.entry); | |
| 1470 | cursor += 1; | |
| 1471 | } | |
| 2661 | <div | |
| 2662 | className="relative mx-auto max-w-3xl" | |
| 2663 | style={{ height: `${rowVirtualizer.getTotalSize()}px` }} | |
| 2664 | > | |
| 2665 | {virtualRows.map((virtualRow: VirtualItem) => { | |
| 2666 | const row = rows[virtualRow.index]; | |
| 2667 | if (!row) return null; | |
| 1472 | 2668 | |
| 1473 | const groupId = timelineEntry.id; | |
| 1474 | const isExpanded = expandedWorkGroups[groupId] ?? false; | |
| 1475 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 1476 | const visibleEntries = | |
| 1477 | hasOverflow && !isExpanded | |
| 1478 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 1479 | : groupedEntries; | |
| 1480 | const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 1481 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 1482 | const groupLabel = onlyToolEntries | |
| 1483 | ? groupedEntries.length === 1 | |
| 1484 | ? "Tool call" | |
| 1485 | : `Tool calls (${groupedEntries.length})` | |
| 1486 | : groupedEntries.length === 1 | |
| 1487 | ? "Work event" | |
| 1488 | : `Work log (${groupedEntries.length})`; | |
| 1489 | ||
| 1490 | return ( | |
| 1491 | <Fragment key={timelineEntry.id}> | |
| 1492 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 1493 | <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 1494 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 1495 | {groupLabel} | |
| 1496 | </p> | |
| 1497 | {hasOverflow && ( | |
| 1498 | <button | |
| 1499 | type="button" | |
| 1500 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 1501 | onClick={() => onToggleWorkGroup(groupId)} | |
| 1502 | > | |
| 1503 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 1504 | </button> | |
| 1505 | )} | |
| 1506 | </div> | |
| 1507 | <div className="space-y-1"> | |
| 1508 | {visibleEntries.map((workEntry) => ( | |
| 1509 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> | |
| 1510 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 1511 | <p | |
| 1512 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | |
| 1513 | > | |
| 1514 | {workEntry.detail ? ( | |
| 1515 | <> | |
| 1516 | {workEntry.label} | |
| 1517 | <span | |
| 1518 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | |
| 1519 | title={workEntry.detail} | |
| 2669 | return ( | |
| 2670 | <div | |
| 2671 | key={row.id} | |
| 2672 | data-index={virtualRow.index} | |
| 2673 | ref={rowVirtualizer.measureElement} | |
| 2674 | className="absolute left-0 top-0 w-full" | |
| 2675 | style={{ transform: `translateY(${virtualRow.start}px)` }} | |
| 2676 | > | |
| 2677 | <div className="pb-4"> | |
| 2678 | {row.kind === "work" && | |
| 2679 | (() => { | |
| 2680 | const groupId = row.id; | |
| 2681 | const groupedEntries = row.groupedEntries; | |
| 2682 | const isExpanded = expandedWorkGroups[groupId] ?? false; | |
| 2683 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | |
| 2684 | const visibleEntries = | |
| 2685 | hasOverflow && !isExpanded | |
| 2686 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | |
| 2687 | : groupedEntries; | |
| 2688 | const hiddenCount = groupedEntries.length - visibleEntries.length; | |
| 2689 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | |
| 2690 | const groupLabel = onlyToolEntries | |
| 2691 | ? groupedEntries.length === 1 | |
| 2692 | ? "Tool call" | |
| 2693 | : `Tool calls (${groupedEntries.length})` | |
| 2694 | : groupedEntries.length === 1 | |
| 2695 | ? "Work event" | |
| 2696 | : `Work log (${groupedEntries.length})`; | |
| 2697 | ||
| 2698 | return ( | |
| 2699 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | |
| 2700 | <div className="mb-1.5 flex items-center justify-between gap-3"> | |
| 2701 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 2702 | {groupLabel} | |
| 2703 | </p> | |
| 2704 | {hasOverflow && ( | |
| 2705 | <button | |
| 2706 | type="button" | |
| 2707 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | |
| 2708 | onClick={() => onToggleWorkGroup(groupId)} | |
| 2709 | > | |
| 2710 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | |
| 2711 | </button> | |
| 2712 | )} | |
| 2713 | </div> | |
| 2714 | <div className="space-y-1"> | |
| 2715 | {visibleEntries.map((workEntry) => ( | |
| 2716 | <div | |
| 2717 | key={`work-row:${workEntry.id}`} | |
| 2718 | className="flex items-start gap-2 py-0.5" | |
| 2719 | > | |
| 2720 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 2721 | <p | |
| 2722 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | |
| 1520 | 2723 | > |
| 1521 | {workEntry.detail} | |
| 1522 | </span> | |
| 1523 | </> | |
| 1524 | ) : ( | |
| 1525 | workEntry.label | |
| 2724 | {workEntry.detail ? ( | |
| 2725 | <> | |
| 2726 | {workEntry.label} | |
| 2727 | <span | |
| 2728 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | |
| 2729 | title={workEntry.detail} | |
| 2730 | > | |
| 2731 | {workEntry.detail} | |
| 2732 | </span> | |
| 2733 | </> | |
| 2734 | ) : ( | |
| 2735 | workEntry.label | |
| 2736 | )} | |
| 2737 | </p> | |
| 2738 | </div> | |
| 2739 | ))} | |
| 2740 | </div> | |
| 2741 | </div> | |
| 2742 | ); | |
| 2743 | })()} | |
| 2744 | ||
| 2745 | {row.kind === "message" && | |
| 2746 | row.message.role === "user" && | |
| 2747 | (() => { | |
| 2748 | const userImages = row.message.attachments ?? []; | |
| 2749 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); | |
| 2750 | return ( | |
| 2751 | <div className="flex justify-end"> | |
| 2752 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 2753 | {userImages.length > 0 && ( | |
| 2754 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 2755 | {userImages.map( | |
| 2756 | (image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | |
| 2757 | <div | |
| 2758 | key={image.id} | |
| 2759 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 2760 | > | |
| 2761 | {image.previewUrl ? ( | |
| 2762 | <img | |
| 2763 | src={image.previewUrl} | |
| 2764 | alt={image.name} | |
| 2765 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | |
| 2766 | onClick={() => | |
| 2767 | onImageExpand({ src: image.previewUrl!, name: image.name }) | |
| 2768 | } | |
| 2769 | /> | |
| 2770 | ) : ( | |
| 2771 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 2772 | {image.name} | |
| 2773 | </div> | |
| 2774 | )} | |
| 2775 | </div> | |
| 2776 | ), | |
| 2777 | )} | |
| 2778 | </div> | |
| 1526 | 2779 | )} |
| 1527 | </p> | |
| 2780 | {row.message.text && ( | |
| 2781 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 2782 | {row.message.text} | |
| 2783 | </pre> | |
| 2784 | )} | |
| 2785 | <div className="mt-1.5 flex items-center justify-end gap-2"> | |
| 2786 | {canRevertAgentWork && ( | |
| 2787 | <Button | |
| 2788 | type="button" | |
| 2789 | size="xs" | |
| 2790 | variant="outline" | |
| 2791 | disabled={isRevertingCheckpoint || isWorking} | |
| 2792 | onClick={() => onRevertUserMessage(row.message.id)} | |
| 2793 | > | |
| 2794 | <Undo2Icon className="size-3" /> | |
| 2795 | </Button> | |
| 2796 | )} | |
| 2797 | <p className="text-right text-[10px] text-muted-foreground/30"> | |
| 2798 | {formatTimestamp(row.message.createdAt)} | |
| 2799 | </p> | |
| 2800 | </div> | |
| 2801 | </div> | |
| 1528 | 2802 | </div> |
| 1529 | ))} | |
| 1530 | </div> | |
| 1531 | </div> | |
| 1532 | </Fragment> | |
| 1533 | ); | |
| 1534 | } | |
| 1535 | ||
| 1536 | if (timelineEntry.message.role === "user") { | |
| 1537 | const userImages = timelineEntry.message.attachments ?? []; | |
| 1538 | return ( | |
| 1539 | <Fragment key={timelineEntry.id}> | |
| 1540 | <div className="flex justify-end"> | |
| 1541 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | |
| 1542 | {userImages.length > 0 && ( | |
| 1543 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | |
| 1544 | {userImages.map((image) => ( | |
| 1545 | <div | |
| 1546 | key={image.id} | |
| 1547 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | |
| 1548 | > | |
| 1549 | {image.previewUrl ? ( | |
| 1550 | <img | |
| 1551 | src={image.previewUrl} | |
| 1552 | alt={image.name} | |
| 1553 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | |
| 1554 | onClick={() => | |
| 1555 | onImageExpand({ src: image.previewUrl!, name: image.name }) | |
| 2803 | ); | |
| 2804 | })()} | |
| 2805 | ||
| 2806 | {row.kind === "message" && | |
| 2807 | row.message.role === "assistant" && | |
| 2808 | (() => { | |
| 2809 | const messageText = | |
| 2810 | row.message.text || (row.message.streaming ? "" : "(empty response)"); | |
| 2811 | return ( | |
| 2812 | <> | |
| 2813 | {row.showCompletionDivider && ( | |
| 2814 | <div className="my-3 flex items-center gap-3"> | |
| 2815 | <span className="h-px flex-1 bg-border" /> | |
| 2816 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 2817 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | |
| 2818 | </span> | |
| 2819 | <span className="h-px flex-1 bg-border" /> | |
| 2820 | </div> | |
| 2821 | )} | |
| 2822 | <div className="px-1 py-0.5"> | |
| 2823 | <ChatMarkdown text={messageText} /> | |
| 2824 | {(() => { | |
| 2825 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( | |
| 2826 | row.message.id, | |
| 2827 | ); | |
| 2828 | if (!turnSummary) return null; | |
| 2829 | const checkpointFilesState = turnCheckpointFilesByTurnId.get( | |
| 2830 | turnSummary.turnId, | |
| 2831 | ); | |
| 2832 | const checkpointFiles = checkpointFilesState?.files ?? []; | |
| 2833 | const summaryStat = checkpointFiles.reduce( | |
| 2834 | (acc, file) => { | |
| 2835 | if ( | |
| 2836 | typeof file.additions !== "number" || | |
| 2837 | typeof file.deletions !== "number" | |
| 2838 | ) { | |
| 2839 | return acc; | |
| 1556 | 2840 | } |
| 1557 | /> | |
| 1558 | ) : ( | |
| 1559 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | |
| 1560 | {image.name} | |
| 2841 | return { | |
| 2842 | additions: acc.additions + file.additions, | |
| 2843 | deletions: acc.deletions + file.deletions, | |
| 2844 | }; | |
| 2845 | }, | |
| 2846 | { additions: 0, deletions: 0 }, | |
| 2847 | ); | |
| 2848 | const changedFileCountLabel = | |
| 2849 | checkpointFilesState?.status === "ready" | |
| 2850 | ? String(checkpointFiles.length) | |
| 2851 | : "..."; | |
| 2852 | return ( | |
| 2853 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | |
| 2854 | <div className="mb-1.5 flex items-center justify-between gap-2"> | |
| 2855 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | |
| 2856 | <span>Changed files ({changedFileCountLabel})</span> | |
| 2857 | {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( | |
| 2858 | <> | |
| 2859 | <span className="mx-1">•</span> | |
| 2860 | <span className="text-success">+{summaryStat.additions}</span> | |
| 2861 | <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 2862 | <span className="text-destructive"> | |
| 2863 | -{summaryStat.deletions} | |
| 2864 | </span> | |
| 2865 | </> | |
| 2866 | )} | |
| 2867 | </p> | |
| 2868 | <Button | |
| 2869 | type="button" | |
| 2870 | size="xs" | |
| 2871 | variant="outline" | |
| 2872 | onClick={() => | |
| 2873 | onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | |
| 2874 | } | |
| 2875 | > | |
| 2876 | View diff | |
| 2877 | </Button> | |
| 2878 | </div> | |
| 2879 | {!checkpointFilesState || | |
| 2880 | checkpointFilesState.status === "pending" ? ( | |
| 2881 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2882 | Waiting for checkpoint metadata... | |
| 2883 | </p> | |
| 2884 | ) : checkpointFilesState?.status === "loading" ? ( | |
| 2885 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2886 | Loading checkpoint diff... | |
| 2887 | </p> | |
| 2888 | ) : checkpointFilesState?.status === "error" ? ( | |
| 2889 | <p | |
| 2890 | className="truncate text-[11px] text-muted-foreground/70" | |
| 2891 | title={checkpointFilesState.errorMessage} | |
| 2892 | > | |
| 2893 | {checkpointFilesState.errorMessage} | |
| 2894 | </p> | |
| 2895 | ) : checkpointFiles.length > 0 ? ( | |
| 2896 | <div className="flex flex-wrap gap-1.5"> | |
| 2897 | {checkpointFiles.map((file) => ( | |
| 2898 | <button | |
| 2899 | key={`${turnSummary.turnId}:${file.path}`} | |
| 2900 | type="button" | |
| 2901 | className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | |
| 2902 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2903 | > | |
| 2904 | {(() => { | |
| 2905 | const stat = | |
| 2906 | typeof file.additions === "number" && | |
| 2907 | typeof file.deletions === "number" | |
| 2908 | ? { | |
| 2909 | additions: file.additions, | |
| 2910 | deletions: file.deletions, | |
| 2911 | } | |
| 2912 | : null; | |
| 2913 | if (!stat) { | |
| 2914 | return file.path; | |
| 2915 | } | |
| 2916 | return ( | |
| 2917 | <> | |
| 2918 | <span>{file.path}</span> | |
| 2919 | <span className="ml-1 text-muted-foreground/70">(</span> | |
| 2920 | <span className="text-success">+{stat.additions}</span> | |
| 2921 | <span className="mx-0.5 text-muted-foreground/70"> | |
| 2922 | / | |
| 2923 | </span> | |
| 2924 | <span className="text-destructive"> | |
| 2925 | -{stat.deletions} | |
| 2926 | </span> | |
| 2927 | <span className="text-muted-foreground/70">)</span> | |
| 2928 | </> | |
| 2929 | ); | |
| 2930 | })()} | |
| 2931 | </button> | |
| 2932 | ))} | |
| 2933 | </div> | |
| 2934 | ) : ( | |
| 2935 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2936 | No changed files. | |
| 2937 | </p> | |
| 2938 | )} | |
| 1561 | 2939 | </div> |
| 2940 | ); | |
| 2941 | })()} | |
| 2942 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | |
| 2943 | {formatMessageMeta( | |
| 2944 | row.message.createdAt, | |
| 2945 | row.message.streaming | |
| 2946 | ? formatElapsed(row.message.createdAt, nowIso) | |
| 2947 | : formatElapsed( | |
| 2948 | row.message.createdAt, | |
| 2949 | assistantCompletionByItemId.get(row.message.id), | |
| 2950 | ), | |
| 1562 | 2951 | )} |
| 1563 | </div> | |
| 1564 | ))} | |
| 1565 | </div> | |
| 1566 | )} | |
| 1567 | {timelineEntry.message.text && ( | |
| 1568 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 1569 | {timelineEntry.message.text} | |
| 1570 | </pre> | |
| 1571 | )} | |
| 1572 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | |
| 1573 | {formatTimestamp(timelineEntry.message.createdAt)} | |
| 1574 | </p> | |
| 2952 | </p> | |
| 2953 | </div> | |
| 2954 | </> | |
| 2955 | ); | |
| 2956 | })()} | |
| 2957 | ||
| 2958 | {row.kind === "working" && ( | |
| 2959 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | |
| 2960 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 2961 | <div className="flex items-center pt-1"> | |
| 2962 | <span className="inline-flex items-center gap-[3px]"> | |
| 2963 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 2964 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 2965 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 2966 | </span> | |
| 2967 | </div> | |
| 1575 | 2968 | </div> |
| 1576 | </div> | |
| 1577 | </Fragment> | |
| 1578 | ); | |
| 1579 | } | |
| 1580 | ||
| 1581 | return ( | |
| 1582 | <Fragment key={timelineEntry.id}> | |
| 1583 | {showCompletionDivider && ( | |
| 1584 | <div className="my-3 flex items-center gap-3"> | |
| 1585 | <span className="h-px flex-1 bg-border" /> | |
| 1586 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | |
| 1587 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | |
| 1588 | </span> | |
| 1589 | <span className="h-px flex-1 bg-border" /> | |
| 1590 | </div> | |
| 1591 | )} | |
| 1592 | <div className="px-1 py-0.5"> | |
| 1593 | <ChatMarkdown | |
| 1594 | text={ | |
| 1595 | timelineEntry.message.text || | |
| 1596 | (timelineEntry.message.streaming ? "" : "(empty response)") | |
| 1597 | } | |
| 1598 | /> | |
| 1599 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | |
| 1600 | {formatMessageMeta( | |
| 1601 | timelineEntry.message.createdAt, | |
| 1602 | timelineEntry.message.streaming | |
| 1603 | ? formatElapsed(timelineEntry.message.createdAt, nowIso) | |
| 1604 | : formatElapsed( | |
| 1605 | timelineEntry.message.createdAt, | |
| 1606 | assistantCompletionByItemId.get(timelineEntry.message.id), | |
| 1607 | ), | |
| 1608 | )} | |
| 1609 | </p> | |
| 2969 | )} | |
| 1610 | 2970 | </div> |
| 1611 | </Fragment> | |
| 2971 | </div> | |
| 1612 | 2972 | ); |
| 1613 | 2973 | })} |
| 1614 | {isWorking && ( | |
| 1615 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | |
| 1616 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | |
| 1617 | <div className="flex items-center pt-1"> | |
| 1618 | <span className="inline-flex items-center gap-[3px]"> | |
| 1619 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 1620 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | |
| 1621 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | |
| 1622 | </span> | |
| 1623 | </div> | |
| 1624 | </div> | |
| 1625 | )} | |
| 1626 | <div ref={messagesEndRef} /> | |
| 1627 | 2974 | </div> |
| 1628 | 2975 | ); |
| 1629 | 2976 | }); |
| ... | ... | @@ -1678,8 +3025,10 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { |
| 1678 | 3025 | |
| 1679 | 3026 | const OpenInPicker = memo(function OpenInPicker({ |
| 1680 | 3027 | keybindings, |
| 3028 | activeThreadId, | |
| 1681 | 3029 | }: { |
| 1682 | 3030 | keybindings: ResolvedKeybindingsConfig; |
| 3031 | activeThreadId: string | null; | |
| 1683 | 3032 | }) { |
| 1684 | 3033 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 1685 | 3034 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| ... | ... | @@ -1706,7 +3055,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 1706 | 3055 | |
| 1707 | 3056 | const api = useNativeApi(); |
| 1708 | 3057 | const { state } = useStore(); |
| 1709 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | |
| 3058 | const activeThread = state.threads.find((t) => t.id === activeThreadId); | |
| 1710 | 3059 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 1711 | 3060 | |
| 1712 | 3061 | const openInEditor = useCallback( |
apps/web/src/components/DiffPanel.tsx+462-44| ... | ... | @@ -1,58 +1,476 @@ |
| 1 | import { XIcon } from "lucide-react"; | |
| 1 | import { parsePatchFiles } from "@pierre/diffs"; | |
| 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; | |
| 3 | import { useQuery } from "@tanstack/react-query"; | |
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | |
| 5 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; | |
| 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | |
| 8 | import { cn } from "~/lib/utils"; | |
| 9 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 2 | 10 | import { isElectron } from "../env"; |
| 11 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 12 | import { useTheme } from "../hooks/useTheme"; | |
| 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; | |
| 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | |
| 3 | 15 | import { useStore } from "../store"; |
| 4 | import { Button } from "./ui/button"; | |
| 16 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | |
| 5 | 17 | |
| 6 | export default function DiffPanel() { | |
| 7 | const { dispatch } = useStore(); | |
| 18 | type DiffRenderMode = "stacked" | "split"; | |
| 19 | type DiffThemeType = "light" | "dark" | "system"; | |
| 20 | ||
| 21 | type RenderablePatch = | |
| 22 | | { | |
| 23 | kind: "files"; | |
| 24 | files: FileDiffMetadata[]; | |
| 25 | } | |
| 26 | | { | |
| 27 | kind: "raw"; | |
| 28 | text: string; | |
| 29 | reason: string; | |
| 30 | }; | |
| 31 | ||
| 32 | function getRenderablePatch(patch: string | undefined): RenderablePatch | null { | |
| 33 | if (!patch) return null; | |
| 34 | const normalizedPatch = patch.trim(); | |
| 35 | if (normalizedPatch.length === 0) return null; | |
| 36 | ||
| 37 | try { | |
| 38 | const parsedPatches = parsePatchFiles(normalizedPatch, buildPatchCacheKey(normalizedPatch)); | |
| 39 | const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); | |
| 40 | if (files.length > 0) { | |
| 41 | return { kind: "files", files }; | |
| 42 | } | |
| 43 | ||
| 44 | return { | |
| 45 | kind: "raw", | |
| 46 | text: normalizedPatch, | |
| 47 | reason: "Unsupported diff format. Showing raw patch.", | |
| 48 | }; | |
| 49 | } catch { | |
| 50 | return { | |
| 51 | kind: "raw", | |
| 52 | text: normalizedPatch, | |
| 53 | reason: "Failed to parse patch. Showing raw patch.", | |
| 54 | }; | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { | |
| 59 | const raw = fileDiff.name ?? fileDiff.prevName ?? ""; | |
| 60 | if (raw.startsWith("a/") || raw.startsWith("b/")) { | |
| 61 | return raw.slice(2); | |
| 62 | } | |
| 63 | return raw; | |
| 64 | } | |
| 65 | ||
| 66 | function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { | |
| 67 | return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; | |
| 68 | } | |
| 69 | ||
| 70 | function formatTurnChipTimestamp(isoDate: string): string { | |
| 71 | return new Intl.DateTimeFormat(undefined, { | |
| 72 | hour: "numeric", | |
| 73 | minute: "2-digit", | |
| 74 | }).format(new Date(isoDate)); | |
| 75 | } | |
| 76 | ||
| 77 | interface DiffPanelProps { | |
| 78 | mode?: "inline" | "sheet"; | |
| 79 | } | |
| 80 | ||
| 81 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | |
| 82 | ||
| 83 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | |
| 84 | const api = useNativeApi(); | |
| 85 | const navigate = useNavigate(); | |
| 86 | const { resolvedTheme } = useTheme(); | |
| 87 | const { state } = useStore(); | |
| 88 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); | |
| 89 | const patchViewportRef = useRef<HTMLDivElement>(null); | |
| 90 | const turnStripRef = useRef<HTMLDivElement>(null); | |
| 91 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); | |
| 92 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); | |
| 93 | const params = useParams({ strict: false }); | |
| 94 | const rawSearch = useSearch({ strict: false }); | |
| 95 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | |
| 96 | const diffSearch = useMemo( | |
| 97 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | |
| 98 | [rawSearch], | |
| 99 | ); | |
| 100 | const activeThreadId = routeThreadId; | |
| 101 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); | |
| 102 | const activeThreadRuntimeId = | |
| 103 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 104 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 105 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | |
| 106 | useTurnDiffSummaries(activeThread); | |
| 107 | ||
| 108 | const selectedTurnId = diffSearch.diffTurnId ?? null; | |
| 109 | const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; | |
| 110 | const selectedTurn = | |
| 111 | selectedTurnId === null | |
| 112 | ? undefined | |
| 113 | : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | |
| 114 | turnDiffSummaries[0]); | |
| 115 | const selectedCheckpointTurnCount = | |
| 116 | selectedTurn && | |
| 117 | (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); | |
| 118 | const selectedCheckpointRange = useMemo( | |
| 119 | () => | |
| 120 | typeof selectedCheckpointTurnCount === "number" | |
| 121 | ? { | |
| 122 | fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), | |
| 123 | toTurnCount: selectedCheckpointTurnCount, | |
| 124 | } | |
| 125 | : null, | |
| 126 | [selectedCheckpointTurnCount], | |
| 127 | ); | |
| 128 | const conversationCheckpointTurnCount = useMemo(() => { | |
| 129 | const turnCounts = turnDiffSummaries | |
| 130 | .map( | |
| 131 | (summary) => | |
| 132 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], | |
| 133 | ) | |
| 134 | .filter((value): value is number => typeof value === "number"); | |
| 135 | if (turnCounts.length === 0) { | |
| 136 | return undefined; | |
| 137 | } | |
| 138 | const latest = Math.max(...turnCounts); | |
| 139 | return latest > 0 ? latest : undefined; | |
| 140 | }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); | |
| 141 | const conversationCheckpointRange = useMemo( | |
| 142 | () => | |
| 143 | !selectedTurn && typeof conversationCheckpointTurnCount === "number" | |
| 144 | ? { | |
| 145 | fromTurnCount: 0, | |
| 146 | toTurnCount: conversationCheckpointTurnCount, | |
| 147 | } | |
| 148 | : null, | |
| 149 | [conversationCheckpointTurnCount, selectedTurn], | |
| 150 | ); | |
| 151 | const activeCheckpointRange = selectedTurn | |
| 152 | ? selectedCheckpointRange | |
| 153 | : conversationCheckpointRange; | |
| 154 | const conversationCacheScope = useMemo(() => { | |
| 155 | if (selectedTurn || turnDiffSummaries.length === 0) { | |
| 156 | return null; | |
| 157 | } | |
| 158 | return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`; | |
| 159 | }, [selectedTurn, turnDiffSummaries]); | |
| 160 | const activeCheckpointDiffQuery = useQuery( | |
| 161 | checkpointDiffQueryOptions(api, { | |
| 162 | sessionId: activeSessionId, | |
| 163 | threadRuntimeId: activeThreadRuntimeId, | |
| 164 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, | |
| 165 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, | |
| 166 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, | |
| 167 | }), | |
| 168 | ); | |
| 169 | const selectedTurnCheckpointDiff = selectedTurn | |
| 170 | ? activeCheckpointDiffQuery.data?.diff | |
| 171 | : undefined; | |
| 172 | const conversationCheckpointDiff = selectedTurn | |
| 173 | ? undefined | |
| 174 | : activeCheckpointDiffQuery.data?.diff; | |
| 175 | const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; | |
| 176 | const checkpointDiffError = | |
| 177 | activeCheckpointDiffQuery.error instanceof Error | |
| 178 | ? activeCheckpointDiffQuery.error.message | |
| 179 | : activeCheckpointDiffQuery.error | |
| 180 | ? "Failed to load checkpoint diff." | |
| 181 | : null; | |
| 182 | ||
| 183 | const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; | |
| 184 | const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); | |
| 185 | const renderableFiles = useMemo(() => { | |
| 186 | if (!renderablePatch || renderablePatch.kind !== "files") { | |
| 187 | return []; | |
| 188 | } | |
| 189 | return renderablePatch.files.toSorted((left, right) => | |
| 190 | resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { | |
| 191 | numeric: true, | |
| 192 | sensitivity: "base", | |
| 193 | }), | |
| 194 | ); | |
| 195 | }, [renderablePatch]); | |
| 196 | ||
| 197 | useEffect(() => { | |
| 198 | if (!selectedFilePath || !patchViewportRef.current) { | |
| 199 | return; | |
| 200 | } | |
| 201 | const target = Array.from( | |
| 202 | patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"), | |
| 203 | ).find((element) => element.dataset.diffFilePath === selectedFilePath); | |
| 204 | target?.scrollIntoView({ block: "nearest" }); | |
| 205 | }, [selectedFilePath, renderableFiles]); | |
| 206 | ||
| 207 | const selectTurn = (turnId: string) => { | |
| 208 | if (!activeThread) return; | |
| 209 | void navigate({ | |
| 210 | to: "/$threadId", | |
| 211 | params: { threadId: activeThread.id }, | |
| 212 | search: (previous) => { | |
| 213 | const rest = stripDiffSearchParams(previous); | |
| 214 | return { ...rest, diff: "1", diffTurnId: turnId }; | |
| 215 | }, | |
| 216 | }); | |
| 217 | }; | |
| 218 | const selectWholeConversation = () => { | |
| 219 | if (!activeThread) return; | |
| 220 | void navigate({ | |
| 221 | to: "/$threadId", | |
| 222 | params: { threadId: activeThread.id }, | |
| 223 | search: (previous) => { | |
| 224 | const rest = stripDiffSearchParams(previous); | |
| 225 | return { ...rest, diff: "1" }; | |
| 226 | }, | |
| 227 | }); | |
| 228 | }; | |
| 229 | const updateTurnStripScrollState = useCallback(() => { | |
| 230 | const element = turnStripRef.current; | |
| 231 | if (!element) { | |
| 232 | setCanScrollTurnStripLeft(false); | |
| 233 | setCanScrollTurnStripRight(false); | |
| 234 | return; | |
| 235 | } | |
| 236 | ||
| 237 | const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); | |
| 238 | setCanScrollTurnStripLeft(element.scrollLeft > 4); | |
| 239 | setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); | |
| 240 | }, []); | |
| 241 | const scrollTurnStripBy = useCallback((offset: number) => { | |
| 242 | const element = turnStripRef.current; | |
| 243 | if (!element) return; | |
| 244 | element.scrollBy({ left: offset, behavior: "smooth" }); | |
| 245 | }, []); | |
| 246 | const onTurnStripWheel = useCallback((event: WheelEvent) => { | |
| 247 | const element = turnStripRef.current; | |
| 248 | if (!element) return; | |
| 249 | if (element.scrollWidth <= element.clientWidth + 1) return; | |
| 250 | if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; | |
| 251 | ||
| 252 | event.preventDefault(); | |
| 253 | element.scrollBy({ left: event.deltaY, behavior: "auto" }); | |
| 254 | }, []); | |
| 255 | ||
| 256 | useEffect(() => { | |
| 257 | const element = turnStripRef.current; | |
| 258 | if (!element) return; | |
| 259 | ||
| 260 | updateTurnStripScrollState(); | |
| 261 | const onScroll = () => updateTurnStripScrollState(); | |
| 262 | ||
| 263 | element.addEventListener("scroll", onScroll, { passive: true }); | |
| 264 | element.addEventListener("wheel", onTurnStripWheel, { passive: false }); | |
| 265 | ||
| 266 | const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); | |
| 267 | resizeObserver.observe(element); | |
| 268 | ||
| 269 | return () => { | |
| 270 | element.removeEventListener("scroll", onScroll); | |
| 271 | element.removeEventListener("wheel", onTurnStripWheel); | |
| 272 | resizeObserver.disconnect(); | |
| 273 | }; | |
| 274 | }, [updateTurnStripScrollState, onTurnStripWheel]); | |
| 275 | ||
| 276 | useEffect(() => { | |
| 277 | updateTurnStripScrollState(); | |
| 278 | }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); | |
| 279 | ||
| 280 | useEffect(() => { | |
| 281 | const element = turnStripRef.current; | |
| 282 | if (!element) return; | |
| 283 | ||
| 284 | const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']"); | |
| 285 | selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); | |
| 286 | }, [selectedTurn?.turnId, selectedTurnId]); | |
| 287 | ||
| 288 | const shouldUseDragRegion = isElectron && mode === "inline"; | |
| 8 | 289 | |
| 9 | 290 | return ( |
| 10 | <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card"> | |
| 11 | {/* Header */} | |
| 291 | <aside | |
| 292 | className={cn( | |
| 293 | "flex h-full min-w-0 flex-col bg-card", | |
| 294 | mode === "inline" | |
| 295 | ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border" | |
| 296 | : "w-full", | |
| 297 | )} | |
| 298 | > | |
| 12 | 299 | <div |
| 13 | className={`flex items-center justify-between border-b border-border px-4 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} | |
| 300 | className={cn( | |
| 301 | "flex items-center justify-between gap-2 border-b border-border px-4", | |
| 302 | shouldUseDragRegion ? "drag-region h-[52px]" : "py-3", | |
| 303 | )} | |
| 14 | 304 | > |
| 15 | <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3> | |
| 16 | <Button | |
| 17 | type="button" | |
| 18 | size="icon-xs" | |
| 19 | variant="ghost" | |
| 20 | onClick={() => dispatch({ type: "TOGGLE_DIFF" })} | |
| 305 | <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> | |
| 306 | {canScrollTurnStripLeft && ( | |
| 307 | <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" /> | |
| 308 | )} | |
| 309 | {canScrollTurnStripRight && ( | |
| 310 | <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" /> | |
| 311 | )} | |
| 312 | <button | |
| 313 | type="button" | |
| 314 | className={cn( | |
| 315 | "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", | |
| 316 | canScrollTurnStripLeft | |
| 317 | ? "border-border/70 hover:border-border hover:text-foreground" | |
| 318 | : "cursor-not-allowed border-border/40 text-muted-foreground/40", | |
| 319 | )} | |
| 320 | onClick={() => scrollTurnStripBy(-180)} | |
| 321 | disabled={!canScrollTurnStripLeft} | |
| 322 | aria-label="Scroll turn list left" | |
| 323 | > | |
| 324 | <ChevronLeftIcon className="size-3.5" /> | |
| 325 | </button> | |
| 326 | <button | |
| 327 | type="button" | |
| 328 | className={cn( | |
| 329 | "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", | |
| 330 | canScrollTurnStripRight | |
| 331 | ? "border-border/70 hover:border-border hover:text-foreground" | |
| 332 | : "cursor-not-allowed border-border/40 text-muted-foreground/40", | |
| 333 | )} | |
| 334 | onClick={() => scrollTurnStripBy(180)} | |
| 335 | disabled={!canScrollTurnStripRight} | |
| 336 | aria-label="Scroll turn list right" | |
| 337 | > | |
| 338 | <ChevronRightIcon className="size-3.5" /> | |
| 339 | </button> | |
| 340 | <div | |
| 341 | ref={turnStripRef} | |
| 342 | className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5" | |
| 343 | > | |
| 344 | <button | |
| 345 | type="button" | |
| 346 | className="shrink-0 rounded-md" | |
| 347 | onClick={selectWholeConversation} | |
| 348 | data-turn-chip-selected={selectedTurnId === null} | |
| 349 | > | |
| 350 | <div | |
| 351 | className={cn( | |
| 352 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 353 | selectedTurnId === null | |
| 354 | ? "border-border bg-accent text-accent-foreground" | |
| 355 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 356 | )} | |
| 357 | > | |
| 358 | <div className="text-[10px] leading-tight font-medium">All turns</div> | |
| 359 | </div> | |
| 360 | </button> | |
| 361 | {turnDiffSummaries.map((summary, index) => ( | |
| 362 | <button | |
| 363 | key={summary.turnId} | |
| 364 | type="button" | |
| 365 | className="shrink-0 rounded-md" | |
| 366 | onClick={() => selectTurn(summary.turnId)} | |
| 367 | title={summary.turnId} | |
| 368 | data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} | |
| 369 | > | |
| 370 | <div | |
| 371 | className={cn( | |
| 372 | "rounded-md border px-2 py-1 text-left transition-colors", | |
| 373 | summary.turnId === selectedTurn?.turnId | |
| 374 | ? "border-border bg-accent text-accent-foreground" | |
| 375 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | |
| 376 | )} | |
| 377 | > | |
| 378 | <div className="flex items-center gap-1"> | |
| 379 | <span className="text-[10px] leading-tight font-medium"> | |
| 380 | Turn {turnDiffSummaries.length - index} | |
| 381 | </span> | |
| 382 | <span className="text-[9px] leading-tight opacity-70"> | |
| 383 | {formatTurnChipTimestamp(summary.completedAt)} | |
| 384 | </span> | |
| 385 | </div> | |
| 386 | </div> | |
| 387 | </button> | |
| 388 | ))} | |
| 389 | </div> | |
| 390 | </div> | |
| 391 | <ToggleGroup | |
| 392 | className="shrink-0 [-webkit-app-region:no-drag]" | |
| 393 | variant="outline" | |
| 394 | size="xs" | |
| 395 | value={[diffRenderMode]} | |
| 396 | onValueChange={(value) => { | |
| 397 | const next = value[0]; | |
| 398 | if (next === "stacked" || next === "split") { | |
| 399 | setDiffRenderMode(next); | |
| 400 | } | |
| 401 | }} | |
| 21 | 402 | > |
| 22 | <XIcon /> | |
| 23 | </Button> | |
| 403 | <Toggle aria-label="Stacked diff view" value="stacked"> | |
| 404 | <Rows3Icon className="size-3" /> | |
| 405 | </Toggle> | |
| 406 | <Toggle aria-label="Split diff view" value="split"> | |
| 407 | <Columns2Icon className="size-3" /> | |
| 408 | </Toggle> | |
| 409 | </ToggleGroup> | |
| 24 | 410 | </div> |
| 25 | 411 | |
| 26 | {/* Placeholder content */} | |
| 27 | <div className="flex-1 overflow-y-auto p-4"> | |
| 28 | <div className="space-y-3"> | |
| 29 | <div className="rounded-lg border border-border/80 bg-muted p-3"> | |
| 30 | <div className="mb-2 flex items-center gap-2"> | |
| 31 | <span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400"> | |
| 32 | M | |
| 33 | </span> | |
| 34 | <span className="font-mono text-xs text-muted-foreground/80">src/App.tsx</span> | |
| 35 | </div> | |
| 36 | <div className="space-y-0.5 font-mono text-[11px]"> | |
| 37 | <div className="text-red-400/60">- const oldValue = "hello";</div> | |
| 38 | <div className="text-emerald-400/60">+ const newValue = "world";</div> | |
| 39 | </div> | |
| 40 | </div> | |
| 41 | ||
| 42 | <div className="rounded-lg border border-border/80 bg-muted p-3"> | |
| 43 | <div className="flex items-center gap-2"> | |
| 44 | <span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-medium text-blue-400"> | |
| 45 | A | |
| 46 | </span> | |
| 47 | <span className="font-mono text-xs text-muted-foreground/80">src/utils.ts</span> | |
| 48 | </div> | |
| 49 | </div> | |
| 412 | {!activeThread ? ( | |
| 413 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> | |
| 414 | Select a thread to inspect turn diffs. | |
| 50 | 415 | </div> |
| 51 | ||
| 52 | <p className="mt-6 text-center text-[10px] text-muted-foreground/30"> | |
| 53 | Diff integration coming soon | |
| 54 | </p> | |
| 55 | </div> | |
| 416 | ) : turnDiffSummaries.length === 0 ? ( | |
| 417 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> | |
| 418 | No completed turns yet. | |
| 419 | </div> | |
| 420 | ) : ( | |
| 421 | <> | |
| 422 | <div ref={patchViewportRef} className="min-h-0 min-w-0 flex-1 overflow-hidden"> | |
| 423 | {checkpointDiffError && !renderablePatch && ( | |
| 424 | <div className="px-3"> | |
| 425 | <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p> | |
| 426 | </div> | |
| 427 | )} | |
| 428 | {!renderablePatch ? ( | |
| 429 | <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70"> | |
| 430 | <p> | |
| 431 | {isLoadingCheckpointDiff | |
| 432 | ? "Loading checkpoint diff..." | |
| 433 | : "No patch available for this selection."} | |
| 434 | </p> | |
| 435 | </div> | |
| 436 | ) : renderablePatch.kind === "files" ? ( | |
| 437 | <Virtualizer | |
| 438 | className="h-full min-h-0 overflow-auto px-3 py-2" | |
| 439 | config={{ | |
| 440 | overscrollSize: 600, | |
| 441 | intersectionObserverMargin: 1200, | |
| 442 | }} | |
| 443 | > | |
| 444 | {renderableFiles.map((fileDiff) => { | |
| 445 | const filePath = resolveFileDiffPath(fileDiff); | |
| 446 | const fileKey = buildFileDiffRenderKey(fileDiff); | |
| 447 | return ( | |
| 448 | <div key={fileKey} data-diff-file-path={filePath} className="rounded-md"> | |
| 449 | <FileDiff | |
| 450 | fileDiff={fileDiff} | |
| 451 | options={{ | |
| 452 | diffStyle: diffRenderMode === "split" ? "split" : "unified", | |
| 453 | lineDiffType: "none", | |
| 454 | themeType: resolvedTheme as DiffThemeType, | |
| 455 | }} | |
| 456 | /> | |
| 457 | </div> | |
| 458 | ); | |
| 459 | })} | |
| 460 | </Virtualizer> | |
| 461 | ) : ( | |
| 462 | <div className="h-full overflow-auto px-3 py-2"> | |
| 463 | <div className="space-y-2"> | |
| 464 | <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p> | |
| 465 | <pre className="max-h-[72vh] overflow-auto rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90"> | |
| 466 | {renderablePatch.text} | |
| 467 | </pre> | |
| 468 | </div> | |
| 469 | </div> | |
| 470 | )} | |
| 471 | </div> | |
| 472 | </> | |
| 473 | )} | |
| 56 | 474 | </aside> |
| 57 | 475 | ); |
| 58 | 476 | } |
apps/web/src/components/DiffWorkerPoolProvider.tsx created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import { WorkerPoolContextProvider } from "@pierre/diffs/react"; | |
| 2 | import { useMemo, type ReactNode } from "react"; | |
| 3 | ||
| 4 | export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { | |
| 5 | const workerPoolSize = useMemo(() => { | |
| 6 | const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); | |
| 7 | return Math.max(2, Math.min(6, Math.floor(cores / 2))); | |
| 8 | }, []); | |
| 9 | ||
| 10 | return ( | |
| 11 | <WorkerPoolContextProvider | |
| 12 | poolOptions={{ | |
| 13 | workerFactory: () => | |
| 14 | new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }), | |
| 15 | poolSize: workerPoolSize, | |
| 16 | totalASTLRUCacheSize: 240, | |
| 17 | }} | |
| 18 | highlighterOptions={{ | |
| 19 | tokenizeMaxLineLength: 1_000, | |
| 20 | }} | |
| 21 | > | |
| 22 | {children} | |
| 23 | </WorkerPoolContextProvider> | |
| 24 | ); | |
| 25 | } | |
| 26 |
apps/web/src/components/GitActionsControl.logic.ts+3-3| ... | ... | @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { |
| 67 | 67 | return [...commitStages, pushStage, "Creating PR..."]; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | const withDescription = (title: string, description: string | undefined) => | |
| 71 | description ? { title, description } : { title }; | |
| 72 | ||
| 70 | 73 | export function summarizeGitResult(result: GitRunStackedActionResult): { |
| 71 | 74 | title: string; |
| 72 | 75 | description?: string; |
| 73 | 76 | } { |
| 74 | const withDescription = (title: string, description: string | undefined) => | |
| 75 | description ? { title, description } : { title }; | |
| 76 | ||
| 77 | 77 | if (result.pr.status === "created" || result.pr.status === "opened_existing") { |
| 78 | 78 | const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; |
| 79 | 79 | const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; |
apps/web/src/components/GitActionsControl.tsx+13-13| ... | ... | @@ -38,12 +38,12 @@ import { |
| 38 | 38 | gitStatusQueryOptions, |
| 39 | 39 | invalidateGitQueries, |
| 40 | 40 | } from "~/lib/gitReactQuery"; |
| 41 | import { useStore } from "~/store"; | |
| 42 | 41 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; |
| 43 | 42 | |
| 44 | 43 | interface GitActionsControlProps { |
| 45 | 44 | api: NativeApi | undefined; |
| 46 | 45 | gitCwd: string | null; |
| 46 | activeThreadId: string | null; | |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | function getMenuActionDisabledReason( |
| ... | ... | @@ -136,9 +136,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 136 | 136 | return <InfoIcon className={iconClassName} />; |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { | |
| 140 | const { state } = useStore(); | |
| 141 | const activeThreadId = state.activeThreadId; | |
| 139 | export default function GitActionsControl({ | |
| 140 | api, | |
| 141 | gitCwd, | |
| 142 | activeThreadId, | |
| 143 | }: GitActionsControlProps) { | |
| 142 | 144 | const threadToastData = useMemo( |
| 143 | 145 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 144 | 146 | [activeThreadId], |
| ... | ... | @@ -226,18 +228,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 226 | 228 | }); |
| 227 | 229 | return; |
| 228 | 230 | } |
| 229 | const promise = api.shell.openExternal(prUrl); | |
| 230 | toastManager.promise(promise, { | |
| 231 | loading: { title: "Opening PR...", data: threadToastData }, | |
| 232 | success: { title: "PR opened", data: threadToastData }, | |
| 233 | error: (err) => ({ | |
| 231 | void api.shell.openExternal(prUrl).catch((err) => { | |
| 232 | toastManager.add({ | |
| 233 | type: "error", | |
| 234 | 234 | title: "Unable to open PR link", |
| 235 | 235 | description: err instanceof Error ? err.message : "An error occurred.", |
| 236 | 236 | data: threadToastData, |
| 237 | }), | |
| 237 | }); | |
| 238 | 238 | }); |
| 239 | void promise.catch(() => undefined); | |
| 240 | }, [api, gitStatusForActions?.pr, threadToastData]); | |
| 239 | }, [api, gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); | |
| 241 | 240 | |
| 242 | 241 | const runGitActionWithToast = useCallback( |
| 243 | 242 | async ({ |
| ... | ... | @@ -632,7 +631,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 632 | 631 | : gitStatusForActions.aheadCount === 0 && |
| 633 | 632 | gitStatusForActions.behindCount === 0 |
| 634 | 633 | ? "Up to date" |
| 635 | : gitStatusForActions.aheadCount > 0 && gitStatusForActions.behindCount > 0 | |
| 634 | : gitStatusForActions.aheadCount > 0 && | |
| 635 | gitStatusForActions.behindCount > 0 | |
| 636 | 636 | ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})` |
| 637 | 637 | : gitStatusForActions.aheadCount > 0 |
| 638 | 638 | ? `Ahead by ${gitStatusForActions.aheadCount}` |
apps/web/src/components/ProjectScriptsControl.tsx created+464| ... | ... | @@ -0,0 +1,464 @@ |
| 1 | import type { | |
| 2 | ProjectScript, | |
| 3 | ProjectScriptIcon, | |
| 4 | ResolvedKeybindingsConfig, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | BugIcon, | |
| 8 | ChevronDownIcon, | |
| 9 | FlaskConicalIcon, | |
| 10 | HammerIcon, | |
| 11 | ListChecksIcon, | |
| 12 | PlayIcon, | |
| 13 | PlusIcon, | |
| 14 | SettingsIcon, | |
| 15 | WrenchIcon, | |
| 16 | } from "lucide-react"; | |
| 17 | import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; | |
| 18 | ||
| 19 | import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; | |
| 20 | import { shortcutLabelForCommand } from "~/keybindings"; | |
| 21 | import { isMacPlatform } from "~/lib/utils"; | |
| 22 | import { Button } from "./ui/button"; | |
| 23 | import { | |
| 24 | Dialog, | |
| 25 | DialogDescription, | |
| 26 | DialogFooter, | |
| 27 | DialogHeader, | |
| 28 | DialogPanel, | |
| 29 | DialogPopup, | |
| 30 | DialogTitle, | |
| 31 | } from "./ui/dialog"; | |
| 32 | import { Group, GroupSeparator } from "./ui/group"; | |
| 33 | import { Input } from "./ui/input"; | |
| 34 | import { Label } from "./ui/label"; | |
| 35 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; | |
| 36 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | |
| 37 | import { Switch } from "./ui/switch"; | |
| 38 | import { Textarea } from "./ui/textarea"; | |
| 39 | ||
| 40 | const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ | |
| 41 | { id: "play", label: "Play" }, | |
| 42 | { id: "test", label: "Test" }, | |
| 43 | { id: "lint", label: "Lint" }, | |
| 44 | { id: "configure", label: "Configure" }, | |
| 45 | { id: "build", label: "Build" }, | |
| 46 | { id: "debug", label: "Debug" }, | |
| 47 | ]; | |
| 48 | ||
| 49 | function ScriptIcon({ | |
| 50 | icon, | |
| 51 | className = "size-3.5", | |
| 52 | }: { | |
| 53 | icon: ProjectScriptIcon; | |
| 54 | className?: string; | |
| 55 | }) { | |
| 56 | if (icon === "test") return <FlaskConicalIcon className={className} />; | |
| 57 | if (icon === "lint") return <ListChecksIcon className={className} />; | |
| 58 | if (icon === "configure") return <WrenchIcon className={className} />; | |
| 59 | if (icon === "build") return <HammerIcon className={className} />; | |
| 60 | if (icon === "debug") return <BugIcon className={className} />; | |
| 61 | return <PlayIcon className={className} />; | |
| 62 | } | |
| 63 | ||
| 64 | export interface NewProjectScriptInput { | |
| 65 | name: string; | |
| 66 | command: string; | |
| 67 | icon: ProjectScriptIcon; | |
| 68 | runOnWorktreeCreate: boolean; | |
| 69 | keybinding: string | null; | |
| 70 | } | |
| 71 | ||
| 72 | interface ProjectScriptsControlProps { | |
| 73 | scripts: ProjectScript[]; | |
| 74 | keybindings: ResolvedKeybindingsConfig; | |
| 75 | preferredScriptId?: string | null; | |
| 76 | onRunScript: (script: ProjectScript) => void; | |
| 77 | onAddScript: (input: NewProjectScriptInput) => Promise<void> | void; | |
| 78 | onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void; | |
| 79 | } | |
| 80 | ||
| 81 | function normalizeShortcutKeyToken(key: string): string | null { | |
| 82 | const normalized = key.toLowerCase(); | |
| 83 | if ( | |
| 84 | normalized === "meta" || | |
| 85 | normalized === "control" || | |
| 86 | normalized === "ctrl" || | |
| 87 | normalized === "shift" || | |
| 88 | normalized === "alt" || | |
| 89 | normalized === "option" | |
| 90 | ) { | |
| 91 | return null; | |
| 92 | } | |
| 93 | if (normalized === " ") return "space"; | |
| 94 | if (normalized === "escape") return "esc"; | |
| 95 | if (normalized === "arrowup") return "arrowup"; | |
| 96 | if (normalized === "arrowdown") return "arrowdown"; | |
| 97 | if (normalized === "arrowleft") return "arrowleft"; | |
| 98 | if (normalized === "arrowright") return "arrowright"; | |
| 99 | if (normalized.length === 1) return normalized; | |
| 100 | if (normalized.startsWith("f") && normalized.length <= 3) return normalized; | |
| 101 | if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { | |
| 102 | return normalized; | |
| 103 | } | |
| 104 | if (normalized === "delete" || normalized === "home" || normalized === "end") { | |
| 105 | return normalized; | |
| 106 | } | |
| 107 | if (normalized === "pageup" || normalized === "pagedown") return normalized; | |
| 108 | return null; | |
| 109 | } | |
| 110 | ||
| 111 | function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null { | |
| 112 | const keyToken = normalizeShortcutKeyToken(event.key); | |
| 113 | if (!keyToken) return null; | |
| 114 | ||
| 115 | const parts: string[] = []; | |
| 116 | if (isMacPlatform(navigator.platform)) { | |
| 117 | if (event.metaKey) parts.push("mod"); | |
| 118 | if (event.ctrlKey) parts.push("ctrl"); | |
| 119 | } else { | |
| 120 | if (event.ctrlKey) parts.push("mod"); | |
| 121 | if (event.metaKey) parts.push("meta"); | |
| 122 | } | |
| 123 | if (event.altKey) parts.push("alt"); | |
| 124 | if (event.shiftKey) parts.push("shift"); | |
| 125 | if (parts.length === 0) { | |
| 126 | return null; | |
| 127 | } | |
| 128 | parts.push(keyToken); | |
| 129 | return parts.join("+"); | |
| 130 | } | |
| 131 | ||
| 132 | function keybindingValueForCommand( | |
| 133 | keybindings: ResolvedKeybindingsConfig, | |
| 134 | command: string, | |
| 135 | ): string | null { | |
| 136 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 137 | const binding = keybindings[index]; | |
| 138 | if (!binding || binding.command !== command) continue; | |
| 139 | ||
| 140 | const parts: string[] = []; | |
| 141 | if (binding.shortcut.modKey) parts.push("mod"); | |
| 142 | if (binding.shortcut.ctrlKey) parts.push("ctrl"); | |
| 143 | if (binding.shortcut.metaKey) parts.push("meta"); | |
| 144 | if (binding.shortcut.altKey) parts.push("alt"); | |
| 145 | if (binding.shortcut.shiftKey) parts.push("shift"); | |
| 146 | const keyToken = | |
| 147 | binding.shortcut.key === " " | |
| 148 | ? "space" | |
| 149 | : binding.shortcut.key === "escape" | |
| 150 | ? "esc" | |
| 151 | : binding.shortcut.key; | |
| 152 | parts.push(keyToken); | |
| 153 | return parts.join("+"); | |
| 154 | } | |
| 155 | return null; | |
| 156 | } | |
| 157 | ||
| 158 | export default function ProjectScriptsControl({ | |
| 159 | scripts, | |
| 160 | keybindings, | |
| 161 | preferredScriptId = null, | |
| 162 | onRunScript, | |
| 163 | onAddScript, | |
| 164 | onUpdateScript, | |
| 165 | }: ProjectScriptsControlProps) { | |
| 166 | const addScriptFormId = React.useId(); | |
| 167 | const [editingScriptId, setEditingScriptId] = useState<string | null>(null); | |
| 168 | const [dialogOpen, setDialogOpen] = useState(false); | |
| 169 | const [name, setName] = useState(""); | |
| 170 | const [command, setCommand] = useState(""); | |
| 171 | const [icon, setIcon] = useState<ProjectScriptIcon>("play"); | |
| 172 | const [iconPickerOpen, setIconPickerOpen] = useState(false); | |
| 173 | const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); | |
| 174 | const [keybinding, setKeybinding] = useState(""); | |
| 175 | const [validationError, setValidationError] = useState<string | null>(null); | |
| 176 | ||
| 177 | const primaryScript = useMemo(() => { | |
| 178 | if (preferredScriptId) { | |
| 179 | const preferred = scripts.find((script) => script.id === preferredScriptId); | |
| 180 | if (preferred) return preferred; | |
| 181 | } | |
| 182 | return primaryProjectScript(scripts); | |
| 183 | }, [preferredScriptId, scripts]); | |
| 184 | const isEditing = editingScriptId !== null; | |
| 185 | const dropdownItemClassName = | |
| 186 | "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; | |
| 187 | ||
| 188 | const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => { | |
| 189 | if (event.key === "Tab") return; | |
| 190 | event.preventDefault(); | |
| 191 | if (event.key === "Backspace" || event.key === "Delete") { | |
| 192 | setKeybinding(""); | |
| 193 | return; | |
| 194 | } | |
| 195 | const next = keybindingFromEvent(event); | |
| 196 | if (!next) return; | |
| 197 | setKeybinding(next); | |
| 198 | }; | |
| 199 | ||
| 200 | const submitAddScript = async (event: FormEvent) => { | |
| 201 | event.preventDefault(); | |
| 202 | const trimmedName = name.trim(); | |
| 203 | const trimmedCommand = command.trim(); | |
| 204 | if (trimmedName.length === 0) { | |
| 205 | setValidationError("Name is required."); | |
| 206 | return; | |
| 207 | } | |
| 208 | if (trimmedCommand.length === 0) { | |
| 209 | setValidationError("Command is required."); | |
| 210 | return; | |
| 211 | } | |
| 212 | ||
| 213 | setValidationError(null); | |
| 214 | try { | |
| 215 | const payload = { | |
| 216 | name: trimmedName, | |
| 217 | command: trimmedCommand, | |
| 218 | icon, | |
| 219 | runOnWorktreeCreate, | |
| 220 | keybinding: keybinding.trim() || null, | |
| 221 | } satisfies NewProjectScriptInput; | |
| 222 | if (editingScriptId) { | |
| 223 | await onUpdateScript(editingScriptId, payload); | |
| 224 | } else { | |
| 225 | await onAddScript(payload); | |
| 226 | } | |
| 227 | setDialogOpen(false); | |
| 228 | setIconPickerOpen(false); | |
| 229 | } catch (error) { | |
| 230 | setValidationError(error instanceof Error ? error.message : "Failed to save action."); | |
| 231 | } | |
| 232 | }; | |
| 233 | ||
| 234 | const openAddDialog = () => { | |
| 235 | setEditingScriptId(null); | |
| 236 | setName(""); | |
| 237 | setCommand(""); | |
| 238 | setIcon("play"); | |
| 239 | setIconPickerOpen(false); | |
| 240 | setRunOnWorktreeCreate(false); | |
| 241 | setKeybinding(""); | |
| 242 | setValidationError(null); | |
| 243 | setDialogOpen(true); | |
| 244 | }; | |
| 245 | ||
| 246 | const openEditDialog = (script: ProjectScript) => { | |
| 247 | setEditingScriptId(script.id); | |
| 248 | setName(script.name); | |
| 249 | setCommand(script.command); | |
| 250 | setIcon(script.icon); | |
| 251 | setIconPickerOpen(false); | |
| 252 | setRunOnWorktreeCreate(script.runOnWorktreeCreate); | |
| 253 | setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); | |
| 254 | setValidationError(null); | |
| 255 | setDialogOpen(true); | |
| 256 | }; | |
| 257 | ||
| 258 | return ( | |
| 259 | <> | |
| 260 | {primaryScript ? ( | |
| 261 | <Group aria-label="Project scripts"> | |
| 262 | <Button | |
| 263 | size="xs" | |
| 264 | variant="outline" | |
| 265 | onClick={() => onRunScript(primaryScript)} | |
| 266 | title={`Run ${primaryScript.name}`} | |
| 267 | > | |
| 268 | <ScriptIcon icon={primaryScript.icon} /> | |
| 269 | <span className="ml-0.5">{primaryScript.name}</span> | |
| 270 | </Button> | |
| 271 | <GroupSeparator /> | |
| 272 | <Menu highlightItemOnHover={false}> | |
| 273 | <MenuTrigger | |
| 274 | render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />} | |
| 275 | > | |
| 276 | <ChevronDownIcon className="size-4" /> | |
| 277 | </MenuTrigger> | |
| 278 | <MenuPopup align="end"> | |
| 279 | {scripts.map((script) => { | |
| 280 | const shortcutLabel = shortcutLabelForCommand( | |
| 281 | keybindings, | |
| 282 | commandForProjectScript(script.id), | |
| 283 | ); | |
| 284 | return ( | |
| 285 | <MenuItem | |
| 286 | key={script.id} | |
| 287 | className={`group ${dropdownItemClassName}`} | |
| 288 | onClick={() => onRunScript(script)} | |
| 289 | > | |
| 290 | <ScriptIcon icon={script.icon} className="size-4" /> | |
| 291 | <span className="truncate"> | |
| 292 | {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} | |
| 293 | </span> | |
| 294 | <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end"> | |
| 295 | {shortcutLabel && ( | |
| 296 | <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"> | |
| 297 | {shortcutLabel} | |
| 298 | </MenuShortcut> | |
| 299 | )} | |
| 300 | <Button | |
| 301 | type="button" | |
| 302 | variant="ghost" | |
| 303 | size="icon-xs" | |
| 304 | className="absolute right-0 top-1/2 size-6 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto" | |
| 305 | aria-label={`Edit ${script.name}`} | |
| 306 | onPointerDown={(event) => { | |
| 307 | event.preventDefault(); | |
| 308 | event.stopPropagation(); | |
| 309 | }} | |
| 310 | onClick={(event) => { | |
| 311 | event.preventDefault(); | |
| 312 | event.stopPropagation(); | |
| 313 | openEditDialog(script); | |
| 314 | }} | |
| 315 | > | |
| 316 | <SettingsIcon className="size-3.5" /> | |
| 317 | </Button> | |
| 318 | </span> | |
| 319 | </MenuItem> | |
| 320 | ); | |
| 321 | })} | |
| 322 | <MenuItem className={dropdownItemClassName} onClick={openAddDialog}> | |
| 323 | <PlusIcon className="size-4" /> | |
| 324 | Add action | |
| 325 | </MenuItem> | |
| 326 | </MenuPopup> | |
| 327 | </Menu> | |
| 328 | </Group> | |
| 329 | ) : ( | |
| 330 | <Button size="xs" variant="outline" onClick={openAddDialog}> | |
| 331 | <PlusIcon className="size-3.5" /> | |
| 332 | Add action | |
| 333 | </Button> | |
| 334 | )} | |
| 335 | ||
| 336 | <Dialog | |
| 337 | onOpenChange={(open) => { | |
| 338 | setDialogOpen(open); | |
| 339 | if (!open) { | |
| 340 | setIconPickerOpen(false); | |
| 341 | } | |
| 342 | }} | |
| 343 | onOpenChangeComplete={(open) => { | |
| 344 | if (open) return; | |
| 345 | setEditingScriptId(null); | |
| 346 | setName(""); | |
| 347 | setCommand(""); | |
| 348 | setIcon("play"); | |
| 349 | setRunOnWorktreeCreate(false); | |
| 350 | setKeybinding(""); | |
| 351 | setValidationError(null); | |
| 352 | }} | |
| 353 | open={dialogOpen} | |
| 354 | > | |
| 355 | <DialogPopup> | |
| 356 | <DialogHeader> | |
| 357 | <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle> | |
| 358 | <DialogDescription> | |
| 359 | Actions are project-scoped commands you can run from the top bar or keybindings. | |
| 360 | </DialogDescription> | |
| 361 | </DialogHeader> | |
| 362 | <DialogPanel> | |
| 363 | <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}> | |
| 364 | <div className="space-y-1.5"> | |
| 365 | <Label htmlFor="script-name">Name</Label> | |
| 366 | <div className="flex items-center gap-2"> | |
| 367 | <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}> | |
| 368 | <PopoverTrigger | |
| 369 | render={ | |
| 370 | <Button | |
| 371 | type="button" | |
| 372 | variant="outline" | |
| 373 | className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)]" | |
| 374 | aria-label="Choose icon" | |
| 375 | /> | |
| 376 | } | |
| 377 | > | |
| 378 | <ScriptIcon icon={icon} className="size-4.5" /> | |
| 379 | </PopoverTrigger> | |
| 380 | <PopoverPopup align="start"> | |
| 381 | <div className="grid grid-cols-3 gap-2"> | |
| 382 | {SCRIPT_ICONS.map((entry) => { | |
| 383 | const isSelected = entry.id === icon; | |
| 384 | return ( | |
| 385 | <button | |
| 386 | key={entry.id} | |
| 387 | type="button" | |
| 388 | className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs ${ | |
| 389 | isSelected | |
| 390 | ? "border-primary/70 bg-primary/10" | |
| 391 | : "border-border/70 hover:bg-accent/60" | |
| 392 | }`} | |
| 393 | onClick={() => { | |
| 394 | setIcon(entry.id); | |
| 395 | setIconPickerOpen(false); | |
| 396 | }} | |
| 397 | > | |
| 398 | <ScriptIcon icon={entry.id} className="size-4" /> | |
| 399 | <span>{entry.label}</span> | |
| 400 | </button> | |
| 401 | ); | |
| 402 | })} | |
| 403 | </div> | |
| 404 | </PopoverPopup> | |
| 405 | </Popover> | |
| 406 | <Input | |
| 407 | id="script-name" | |
| 408 | placeholder="Test" | |
| 409 | value={name} | |
| 410 | onChange={(event) => setName(event.target.value)} | |
| 411 | /> | |
| 412 | </div> | |
| 413 | </div> | |
| 414 | <div className="space-y-1.5"> | |
| 415 | <Label htmlFor="script-keybinding">Keybinding</Label> | |
| 416 | <Input | |
| 417 | id="script-keybinding" | |
| 418 | placeholder="Press shortcut" | |
| 419 | value={keybinding} | |
| 420 | readOnly | |
| 421 | onKeyDown={captureKeybinding} | |
| 422 | /> | |
| 423 | <p className="text-xs text-muted-foreground"> | |
| 424 | Press a shortcut. Use <code>Backspace</code> to clear. | |
| 425 | </p> | |
| 426 | </div> | |
| 427 | <div className="space-y-1.5"> | |
| 428 | <Label htmlFor="script-command">Command</Label> | |
| 429 | <Textarea | |
| 430 | id="script-command" | |
| 431 | placeholder="bun test" | |
| 432 | value={command} | |
| 433 | onChange={(event) => setCommand(event.target.value)} | |
| 434 | /> | |
| 435 | </div> | |
| 436 | <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm"> | |
| 437 | <span>Run automatically on worktree creation</span> | |
| 438 | <Switch | |
| 439 | checked={runOnWorktreeCreate} | |
| 440 | onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} | |
| 441 | /> | |
| 442 | </label> | |
| 443 | {validationError && <p className="text-sm text-destructive">{validationError}</p>} | |
| 444 | </form> | |
| 445 | </DialogPanel> | |
| 446 | <DialogFooter> | |
| 447 | <Button | |
| 448 | type="button" | |
| 449 | variant="outline" | |
| 450 | onClick={() => { | |
| 451 | setDialogOpen(false); | |
| 452 | }} | |
| 453 | > | |
| 454 | Cancel | |
| 455 | </Button> | |
| 456 | <Button form={addScriptFormId} type="submit"> | |
| 457 | {isEditing ? "Save changes" : "Save action"} | |
| 458 | </Button> | |
| 459 | </DialogFooter> | |
| 460 | </DialogPopup> | |
| 461 | </Dialog> | |
| 462 | </> | |
| 463 | ); | |
| 464 | } |
apps/web/src/components/Sidebar.tsx+93-70| ... | ... | @@ -9,25 +9,22 @@ import { useCallback, useEffect, useMemo, useState } from "react"; |
| 9 | 9 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 10 | 10 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 11 | 11 | import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts"; |
| 12 | import { useNavigate, useParams } from "@tanstack/react-router"; | |
| 13 | import { useAppSettings } from "../appSettings"; | |
| 12 | 14 | import { isElectron } from "../env"; |
| 13 | import { useTheme } from "../hooks/useTheme"; | |
| 15 | import { APP_STAGE_LABEL } from "../branding"; | |
| 14 | 16 | import { DEFAULT_MODEL } from "../model-logic"; |
| 15 | 17 | import { derivePendingApprovals } from "../session-logic"; |
| 16 | 18 | import { useStore } from "../store"; |
| 17 | 19 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 18 | import { | |
| 19 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 20 | DEFAULT_THREAD_TERMINAL_ID, | |
| 21 | type Project, | |
| 22 | type Thread, | |
| 23 | } from "../types"; | |
| 20 | import { type Project, type Thread } from "../types"; | |
| 24 | 21 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 25 | 22 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 26 | 23 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 27 | 24 | import { toastManager } from "./ui/toast"; |
| 28 | 25 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 26 | import { createThread } from "../threadFactory"; | |
| 29 | 27 | |
| 30 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | |
| 31 | 28 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 32 | 29 | |
| 33 | 30 | function formatRelativeTime(iso: string): string { |
| ... | ... | @@ -157,6 +154,10 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null |
| 157 | 154 | export default function Sidebar() { |
| 158 | 155 | const { state, dispatch } = useStore(); |
| 159 | 156 | const api = useNativeApi(); |
| 157 | const navigate = useNavigate(); | |
| 158 | const { settings: appSettings } = useAppSettings(); | |
| 159 | const params = useParams({ strict: false }); | |
| 160 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | |
| 160 | 161 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ |
| 161 | 162 | ...serverConfigQueryOptions(api), |
| 162 | 163 | select: (config) => config.keybindings, |
| ... | ... | @@ -165,7 +166,6 @@ export default function Sidebar() { |
| 165 | 166 | const removeWorktreeMutation = useMutation( |
| 166 | 167 | gitRemoveWorktreeMutationOptions({ api, queryClient }), |
| 167 | 168 | ); |
| 168 | const { theme, setTheme } = useTheme(); | |
| 169 | 169 | const [addingProject, setAddingProject] = useState(false); |
| 170 | 170 | const [newCwd, setNewCwd] = useState(""); |
| 171 | 171 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| ... | ... | @@ -242,37 +242,21 @@ export default function Sidebar() { |
| 242 | 242 | worktreePath?: string | null; |
| 243 | 243 | }, |
| 244 | 244 | ) => { |
| 245 | const thread = createThread(projectId, { | |
| 246 | model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, | |
| 247 | branch: options?.branch ?? null, | |
| 248 | worktreePath: options?.worktreePath ?? null, | |
| 249 | }); | |
| 245 | 250 | dispatch({ |
| 246 | 251 | type: "ADD_THREAD", |
| 247 | thread: { | |
| 248 | id: crypto.randomUUID(), | |
| 249 | codexThreadId: null, | |
| 250 | projectId, | |
| 251 | title: "New thread", | |
| 252 | model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, | |
| 253 | terminalOpen: false, | |
| 254 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 255 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 256 | runningTerminalIds: [], | |
| 257 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 258 | terminalGroups: [ | |
| 259 | { | |
| 260 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 261 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 262 | }, | |
| 263 | ], | |
| 264 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 265 | session: null, | |
| 266 | messages: [], | |
| 267 | events: [], | |
| 268 | error: null, | |
| 269 | createdAt: new Date().toISOString(), | |
| 270 | branch: options?.branch ?? null, | |
| 271 | worktreePath: options?.worktreePath ?? null, | |
| 272 | }, | |
| 252 | thread, | |
| 253 | }); | |
| 254 | void navigate({ | |
| 255 | to: "/$threadId", | |
| 256 | params: { threadId: thread.id }, | |
| 273 | 257 | }); |
| 274 | 258 | }, |
| 275 | [dispatch, state.projects], | |
| 259 | [dispatch, navigate, state.projects], | |
| 276 | 260 | ); |
| 277 | 261 | |
| 278 | 262 | const focusMostRecentThreadForProject = useCallback( |
| ... | ... | @@ -286,12 +270,12 @@ export default function Sidebar() { |
| 286 | 270 | })[0]; |
| 287 | 271 | if (!latestThread) return; |
| 288 | 272 | |
| 289 | dispatch({ | |
| 290 | type: "SET_ACTIVE_THREAD", | |
| 291 | threadId: latestThread.id, | |
| 273 | void navigate({ | |
| 274 | to: "/$threadId", | |
| 275 | params: { threadId: latestThread.id }, | |
| 292 | 276 | }); |
| 293 | 277 | }, |
| 294 | [dispatch, state.threads], | |
| 278 | [navigate, state.threads], | |
| 295 | 279 | ); |
| 296 | 280 | |
| 297 | 281 | const addProjectFromPath = useCallback( |
| ... | ... | @@ -309,6 +293,7 @@ export default function Sidebar() { |
| 309 | 293 | cwd: result.project.cwd, |
| 310 | 294 | model: DEFAULT_MODEL, |
| 311 | 295 | expanded: true, |
| 296 | scripts: result.project.scripts, | |
| 312 | 297 | }; |
| 313 | 298 | const existingById = state.projects.find((p) => p.id === project.id); |
| 314 | 299 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); |
| ... | ... | @@ -336,6 +321,7 @@ export default function Sidebar() { |
| 336 | 321 | cwd, |
| 337 | 322 | model: DEFAULT_MODEL, |
| 338 | 323 | expanded: true, |
| 324 | scripts: [], | |
| 339 | 325 | }; |
| 340 | 326 | dispatch({ type: "ADD_PROJECT", project }); |
| 341 | 327 | handleNewThread(project.id); |
| ... | ... | @@ -380,6 +366,17 @@ export default function Sidebar() { |
| 380 | 366 | |
| 381 | 367 | const thread = state.threads.find((t) => t.id === threadId); |
| 382 | 368 | if (!thread) return; |
| 369 | if (appSettings.confirmThreadDelete) { | |
| 370 | const confirmed = await api.dialogs.confirm( | |
| 371 | [ | |
| 372 | `Delete thread \"${thread.title}\"?`, | |
| 373 | "This permanently clears conversation history for this thread.", | |
| 374 | ].join("\n"), | |
| 375 | ); | |
| 376 | if (!confirmed) { | |
| 377 | return; | |
| 378 | } | |
| 379 | } | |
| 383 | 380 | const threadProject = state.projects.find((project) => project.id === thread.projectId); |
| 384 | 381 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); |
| 385 | 382 | const displayWorktreePath = orphanedWorktreePath |
| ... | ... | @@ -417,7 +414,20 @@ export default function Sidebar() { |
| 417 | 414 | // Terminal may already be closed |
| 418 | 415 | } |
| 419 | 416 | |
| 417 | const shouldNavigateToFallback = routeThreadId === threadId; | |
| 418 | const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; | |
| 420 | 419 | dispatch({ type: "DELETE_THREAD", threadId }); |
| 420 | if (shouldNavigateToFallback) { | |
| 421 | if (fallbackThreadId) { | |
| 422 | void navigate({ | |
| 423 | to: "/$threadId", | |
| 424 | params: { threadId: fallbackThreadId }, | |
| 425 | replace: true, | |
| 426 | }); | |
| 427 | } else { | |
| 428 | void navigate({ to: "/", replace: true }); | |
| 429 | } | |
| 430 | } | |
| 421 | 431 | |
| 422 | 432 | if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { |
| 423 | 433 | return; |
| ... | ... | @@ -427,12 +437,33 @@ export default function Sidebar() { |
| 427 | 437 | await removeWorktreeMutation.mutateAsync({ |
| 428 | 438 | cwd: threadProject.cwd, |
| 429 | 439 | path: orphanedWorktreePath, |
| 440 | force: true, | |
| 441 | }); | |
| 442 | } catch (error) { | |
| 443 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; | |
| 444 | console.error("Failed to remove orphaned worktree after thread deletion", { | |
| 445 | threadId, | |
| 446 | projectCwd: threadProject.cwd, | |
| 447 | worktreePath: orphanedWorktreePath, | |
| 448 | error, | |
| 449 | }); | |
| 450 | toastManager.add({ | |
| 451 | type: "error", | |
| 452 | title: "Thread deleted, but worktree removal failed", | |
| 453 | description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, | |
| 430 | 454 | }); |
| 431 | } catch { | |
| 432 | // Worktree deletion is best-effort and should not block thread deletion. | |
| 433 | 455 | } |
| 434 | 456 | }, |
| 435 | [api, dispatch, removeWorktreeMutation, state.projects, state.threads], | |
| 457 | [ | |
| 458 | api, | |
| 459 | appSettings.confirmThreadDelete, | |
| 460 | dispatch, | |
| 461 | navigate, | |
| 462 | removeWorktreeMutation, | |
| 463 | routeThreadId, | |
| 464 | state.projects, | |
| 465 | state.threads, | |
| 466 | ], | |
| 436 | 467 | ); |
| 437 | 468 | |
| 438 | 469 | const handleProjectContextMenu = useCallback( |
| ... | ... | @@ -482,7 +513,9 @@ export default function Sidebar() { |
| 482 | 513 | |
| 483 | 514 | useEffect(() => { |
| 484 | 515 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 485 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | |
| 516 | const activeThread = routeThreadId | |
| 517 | ? state.threads.find((thread) => thread.id === routeThreadId) | |
| 518 | : undefined; | |
| 486 | 519 | if (isChatNewLocalShortcut(event, keybindings)) { |
| 487 | 520 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 488 | 521 | if (!projectId) return; |
| ... | ... | @@ -505,7 +538,7 @@ export default function Sidebar() { |
| 505 | 538 | return () => { |
| 506 | 539 | window.removeEventListener("keydown", onWindowKeyDown); |
| 507 | 540 | }; |
| 508 | }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); | |
| 541 | }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); | |
| 509 | 542 | |
| 510 | 543 | return ( |
| 511 | 544 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> |
| ... | ... | @@ -513,24 +546,14 @@ export default function Sidebar() { |
| 513 | 546 | <div |
| 514 | 547 | className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} |
| 515 | 548 | > |
| 516 | <span className="flex-1 text-sm font-semibold tracking-tight text-foreground"> | |
| 517 | T3 <span className="font-normal text-muted-foreground">Code</span> | |
| 518 | </span> | |
| 519 | <button | |
| 520 | type="button" | |
| 521 | className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground" | |
| 522 | onClick={() => setTheme(THEME_CYCLE[theme])} | |
| 523 | aria-label={`Theme: ${theme}`} | |
| 524 | title={`Theme: ${theme}`} | |
| 525 | > | |
| 526 | {theme === "system" ? ( | |
| 527 | <MonitorIcon className="size-3.5" /> | |
| 528 | ) : theme === "light" ? ( | |
| 529 | <SunIcon className="size-3.5" /> | |
| 530 | ) : ( | |
| 531 | <MoonIcon className="size-3.5" /> | |
| 532 | )} | |
| 533 | </button> | |
| 549 | <div className="flex min-w-0 flex-1 items-center gap-2"> | |
| 550 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> | |
| 551 | T3 <span className="font-normal text-muted-foreground">Code</span> | |
| 552 | </span> | |
| 553 | <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60"> | |
| 554 | {APP_STAGE_LABEL} | |
| 555 | </span> | |
| 556 | </div> | |
| 534 | 557 | </div> |
| 535 | 558 | |
| 536 | 559 | {/* New thread (global) */} |
| ... | ... | @@ -590,7 +613,7 @@ export default function Sidebar() { |
| 590 | 613 | {project.expanded && ( |
| 591 | 614 | <div className="ml-2 border-l border-border/80 pl-2"> |
| 592 | 615 | {threads.map((thread) => { |
| 593 | const isActive = state.activeThreadId === thread.id; | |
| 616 | const isActive = routeThreadId === thread.id; | |
| 594 | 617 | const threadStatus = threadStatusPill( |
| 595 | 618 | thread, |
| 596 | 619 | pendingApprovalByThreadId.get(thread.id) === true, |
| ... | ... | @@ -606,12 +629,12 @@ export default function Sidebar() { |
| 606 | 629 | ? "bg-accent text-foreground" |
| 607 | 630 | : "text-muted-foreground hover:bg-secondary" |
| 608 | 631 | }`} |
| 609 | onClick={() => | |
| 610 | dispatch({ | |
| 611 | type: "SET_ACTIVE_THREAD", | |
| 612 | threadId: thread.id, | |
| 613 | }) | |
| 614 | } | |
| 632 | onClick={() => { | |
| 633 | void navigate({ | |
| 634 | to: "/$threadId", | |
| 635 | params: { threadId: thread.id }, | |
| 636 | }); | |
| 637 | }} | |
| 615 | 638 | onContextMenu={(e) => { |
| 616 | 639 | e.preventDefault(); |
| 617 | 640 | void handleThreadContextMenu(thread.id, { |
apps/web/src/components/ThreadTerminalDrawer.tsx+55-22| ... | ... | @@ -22,6 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb |
| 22 | 22 | import { |
| 23 | 23 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 24 | 24 | DEFAULT_THREAD_TERMINAL_ID, |
| 25 | MAX_THREAD_TERMINAL_COUNT, | |
| 25 | 26 | type ThreadTerminalGroup, |
| 26 | 27 | } from "../types"; |
| 27 | 28 | |
| ... | ... | @@ -110,6 +111,7 @@ interface TerminalViewportProps { |
| 110 | 111 | threadId: string; |
| 111 | 112 | terminalId: string; |
| 112 | 113 | cwd: string; |
| 114 | runtimeEnv?: Record<string, string>; | |
| 113 | 115 | focusRequestId: number; |
| 114 | 116 | autoFocus: boolean; |
| 115 | 117 | resizeEpoch: number; |
| ... | ... | @@ -121,6 +123,7 @@ function TerminalViewport({ |
| 121 | 123 | threadId, |
| 122 | 124 | terminalId, |
| 123 | 125 | cwd, |
| 126 | runtimeEnv, | |
| 124 | 127 | focusRequestId, |
| 125 | 128 | autoFocus, |
| 126 | 129 | resizeEpoch, |
| ... | ... | @@ -273,6 +276,7 @@ function TerminalViewport({ |
| 273 | 276 | cwd, |
| 274 | 277 | cols: activeTerminal.cols, |
| 275 | 278 | rows: activeTerminal.rows, |
| 279 | ...(runtimeEnv ? { env: runtimeEnv } : {}), | |
| 276 | 280 | }); |
| 277 | 281 | if (disposed) return; |
| 278 | 282 | activeTerminal.write("\u001bc"); |
| ... | ... | @@ -368,7 +372,7 @@ function TerminalViewport({ |
| 368 | 372 | fitAddonRef.current = null; |
| 369 | 373 | terminal.dispose(); |
| 370 | 374 | }; |
| 371 | }, [api, cwd, terminalId, threadId]); | |
| 375 | }, [api, cwd, runtimeEnv, terminalId, threadId]); | |
| 372 | 376 | |
| 373 | 377 | useEffect(() => { |
| 374 | 378 | if (!autoFocus) return; |
| ... | ... | @@ -413,6 +417,7 @@ interface ThreadTerminalDrawerProps { |
| 413 | 417 | api: NativeApi; |
| 414 | 418 | threadId: string; |
| 415 | 419 | cwd: string; |
| 420 | runtimeEnv?: Record<string, string>; | |
| 416 | 421 | height: number; |
| 417 | 422 | terminalIds: string[]; |
| 418 | 423 | activeTerminalId: string; |
| ... | ... | @@ -462,6 +467,7 @@ export default function ThreadTerminalDrawer({ |
| 462 | 467 | api, |
| 463 | 468 | threadId, |
| 464 | 469 | cwd, |
| 470 | runtimeEnv, | |
| 465 | 471 | height, |
| 466 | 472 | terminalIds, |
| 467 | 473 | activeTerminalId, |
| ... | ... | @@ -581,6 +587,7 @@ export default function ThreadTerminalDrawer({ |
| 581 | 587 | const showGroupHeaders = |
| 582 | 588 | resolvedTerminalGroups.length > 1 || |
| 583 | 589 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); |
| 590 | const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT; | |
| 584 | 591 | const terminalLabelById = useMemo( |
| 585 | 592 | () => |
| 586 | 593 | new Map( |
| ... | ... | @@ -588,9 +595,23 @@ export default function ThreadTerminalDrawer({ |
| 588 | 595 | ), |
| 589 | 596 | [normalizedTerminalIds], |
| 590 | 597 | ); |
| 598 | const splitTerminalActionLabel = hasReachedTerminalLimit | |
| 599 | ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` | |
| 600 | : (splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"); | |
| 601 | const newTerminalActionLabel = hasReachedTerminalLimit | |
| 602 | ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` | |
| 603 | : (newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"); | |
| 591 | 604 | const closeTerminalActionLabel = closeShortcutLabel |
| 592 | 605 | ? `Close Terminal (${closeShortcutLabel})` |
| 593 | 606 | : "Close Terminal"; |
| 607 | const onSplitTerminalAction = useCallback(() => { | |
| 608 | if (hasReachedTerminalLimit) return; | |
| 609 | onSplitTerminal(); | |
| 610 | }, [hasReachedTerminalLimit, onSplitTerminal]); | |
| 611 | const onNewTerminalAction = useCallback(() => { | |
| 612 | if (hasReachedTerminalLimit) return; | |
| 613 | onNewTerminal(); | |
| 614 | }, [hasReachedTerminalLimit, onNewTerminal]); | |
| 594 | 615 | |
| 595 | 616 | useEffect(() => { |
| 596 | 617 | onHeightChangeRef.current = onHeightChange; |
| ... | ... | @@ -685,7 +706,7 @@ export default function ThreadTerminalDrawer({ |
| 685 | 706 | |
| 686 | 707 | return ( |
| 687 | 708 | <aside |
| 688 | className="thread-terminal-drawer relative flex shrink-0 flex-col border-t border-border/80 bg-background" | |
| 709 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" | |
| 689 | 710 | style={{ height: `${drawerHeight}px` }} |
| 690 | 711 | > |
| 691 | 712 | <div |
| ... | ... | @@ -700,19 +721,25 @@ export default function ThreadTerminalDrawer({ |
| 700 | 721 | <div className="pointer-events-none absolute right-2 top-2 z-20"> |
| 701 | 722 | <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70"> |
| 702 | 723 | <TerminalActionButton |
| 703 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | |
| 704 | onClick={onSplitTerminal} | |
| 705 | label={ | |
| 706 | splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal" | |
| 707 | } | |
| 724 | className={`p-1 text-foreground/90 transition-colors ${ | |
| 725 | hasReachedTerminalLimit | |
| 726 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 727 | : "hover:bg-accent" | |
| 728 | }`} | |
| 729 | onClick={onSplitTerminalAction} | |
| 730 | label={splitTerminalActionLabel} | |
| 708 | 731 | > |
| 709 | 732 | <SquareSplitHorizontal className="size-3.25" /> |
| 710 | 733 | </TerminalActionButton> |
| 711 | 734 | <div className="h-4 w-px bg-border/80" /> |
| 712 | 735 | <TerminalActionButton |
| 713 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | |
| 714 | onClick={onNewTerminal} | |
| 715 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} | |
| 736 | className={`p-1 text-foreground/90 transition-colors ${ | |
| 737 | hasReachedTerminalLimit | |
| 738 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 739 | : "hover:bg-accent" | |
| 740 | }`} | |
| 741 | onClick={onNewTerminalAction} | |
| 742 | label={newTerminalActionLabel} | |
| 716 | 743 | > |
| 717 | 744 | <Plus className="size-3.25" /> |
| 718 | 745 | </TerminalActionButton> |
| ... | ... | @@ -733,9 +760,9 @@ export default function ThreadTerminalDrawer({ |
| 733 | 760 | <div className="min-w-0 flex-1"> |
| 734 | 761 | {isSplitView ? ( |
| 735 | 762 | <div |
| 736 | className="grid h-full w-full gap-0 overflow-x-auto" | |
| 763 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" | |
| 737 | 764 | style={{ |
| 738 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`, | |
| 765 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 739 | 766 | }} |
| 740 | 767 | > |
| 741 | 768 | {visibleTerminalIds.map((terminalId) => ( |
| ... | ... | @@ -756,6 +783,7 @@ export default function ThreadTerminalDrawer({ |
| 756 | 783 | threadId={threadId} |
| 757 | 784 | terminalId={terminalId} |
| 758 | 785 | cwd={cwd} |
| 786 | {...(runtimeEnv ? { runtimeEnv } : {})} | |
| 759 | 787 | focusRequestId={focusRequestId} |
| 760 | 788 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 761 | 789 | resizeEpoch={resizeEpoch} |
| ... | ... | @@ -773,6 +801,7 @@ export default function ThreadTerminalDrawer({ |
| 773 | 801 | threadId={threadId} |
| 774 | 802 | terminalId={resolvedActiveTerminalId} |
| 775 | 803 | cwd={cwd} |
| 804 | {...(runtimeEnv ? { runtimeEnv } : {})} | |
| 776 | 805 | focusRequestId={focusRequestId} |
| 777 | 806 | autoFocus |
| 778 | 807 | resizeEpoch={resizeEpoch} |
| ... | ... | @@ -787,20 +816,24 @@ export default function ThreadTerminalDrawer({ |
| 787 | 816 | <div className="flex h-[22px] items-stretch justify-end border-b border-border/70"> |
| 788 | 817 | <div className="inline-flex h-full items-stretch"> |
| 789 | 818 | <TerminalActionButton |
| 790 | className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" | |
| 791 | onClick={onSplitTerminal} | |
| 792 | label={ | |
| 793 | splitShortcutLabel | |
| 794 | ? `Split Terminal (${splitShortcutLabel})` | |
| 795 | : "Split Terminal" | |
| 796 | } | |
| 819 | className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${ | |
| 820 | hasReachedTerminalLimit | |
| 821 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 822 | : "hover:bg-accent/70" | |
| 823 | }`} | |
| 824 | onClick={onSplitTerminalAction} | |
| 825 | label={splitTerminalActionLabel} | |
| 797 | 826 | > |
| 798 | 827 | <SquareSplitHorizontal className="size-3.25" /> |
| 799 | 828 | </TerminalActionButton> |
| 800 | 829 | <TerminalActionButton |
| 801 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | |
| 802 | onClick={onNewTerminal} | |
| 803 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} | |
| 830 | className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${ | |
| 831 | hasReachedTerminalLimit | |
| 832 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 833 | : "hover:bg-accent/70" | |
| 834 | }`} | |
| 835 | onClick={onNewTerminalAction} | |
| 836 | label={newTerminalActionLabel} | |
| 804 | 837 | > |
| 805 | 838 | <Plus className="size-3.25" /> |
| 806 | 839 | </TerminalActionButton> |
apps/web/src/components/ui/sheet.tsx+3-1| ... | ... | @@ -60,16 +60,18 @@ function SheetPopup({ |
| 60 | 60 | className, |
| 61 | 61 | children, |
| 62 | 62 | showCloseButton = true, |
| 63 | keepMounted = false, | |
| 63 | 64 | side = "right", |
| 64 | 65 | variant = "default", |
| 65 | 66 | ...props |
| 66 | 67 | }: SheetPrimitive.Popup.Props & { |
| 67 | 68 | showCloseButton?: boolean; |
| 69 | keepMounted?: boolean; | |
| 68 | 70 | side?: "right" | "left" | "top" | "bottom"; |
| 69 | 71 | variant?: "default" | "inset"; |
| 70 | 72 | }) { |
| 71 | 73 | return ( |
| 72 | <SheetPortal> | |
| 74 | <SheetPortal keepMounted={keepMounted}> | |
| 73 | 75 | <SheetBackdrop /> |
| 74 | 76 | <SheetViewport side={side} variant={variant}> |
| 75 | 77 | <SheetPrimitive.Popup |
apps/web/src/components/ui/toast.tsx+10-5| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | 4 | import { useEffect } from "react"; |
| 5 | import { useParams } from "@tanstack/react-router"; | |
| 5 | 6 | import { |
| 6 | 7 | CircleAlertIcon, |
| 7 | 8 | CircleCheckIcon, |
| ... | ... | @@ -12,7 +13,6 @@ import { |
| 12 | 13 | |
| 13 | 14 | import { cn } from "~/lib/utils"; |
| 14 | 15 | import { buttonVariants } from "~/components/ui/button"; |
| 15 | import { useStore } from "~/store"; | |
| 16 | 16 | import { shouldHideCollapsedToastContent } from "./toast.logic"; |
| 17 | 17 | |
| 18 | 18 | type ThreadToastData = { |
| ... | ... | @@ -55,6 +55,13 @@ function shouldRenderForActiveThread( |
| 55 | 55 | return toastThreadId === activeThreadId; |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | function useActiveThreadIdFromRoute(): string | null { | |
| 59 | return useParams({ | |
| 60 | strict: false, | |
| 61 | select: (params) => params.threadId ?? null, | |
| 62 | }); | |
| 63 | } | |
| 64 | ||
| 58 | 65 | function ThreadToastVisibleAutoDismiss({ |
| 59 | 66 | toastId, |
| 60 | 67 | dismissAfterVisibleMs, |
| ... | ... | @@ -143,9 +150,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv |
| 143 | 150 | } |
| 144 | 151 | |
| 145 | 152 | function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 146 | const { state } = useStore(); | |
| 147 | 153 | const { toasts } = Toast.useToastManager<ThreadToastData>(); |
| 148 | const activeThreadId = state.activeThreadId; | |
| 154 | const activeThreadId = useActiveThreadIdFromRoute(); | |
| 149 | 155 | const isTop = position.startsWith("top"); |
| 150 | 156 | const visibleToasts = toasts.filter((toast) => |
| 151 | 157 | shouldRenderForActiveThread(toast.data, activeThreadId), |
| ... | ... | @@ -292,9 +298,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { |
| 292 | 298 | } |
| 293 | 299 | |
| 294 | 300 | function AnchoredToasts() { |
| 295 | const { state } = useStore(); | |
| 296 | 301 | const { toasts } = Toast.useToastManager<ThreadToastData>(); |
| 297 | const activeThreadId = state.activeThreadId; | |
| 302 | const activeThreadId = useActiveThreadIdFromRoute(); | |
| 298 | 303 | |
| 299 | 304 | return ( |
| 300 | 305 | <Toast.Portal data-slot="toast-portal-anchored"> |
apps/web/src/components/ui/toggle-group.tsx created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import type { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; | |
| 4 | import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"; | |
| 5 | import type { VariantProps } from "class-variance-authority"; | |
| 6 | import * as React from "react"; | |
| 7 | ||
| 8 | import { cn } from "~/lib/utils"; | |
| 9 | import { Separator } from "~/components/ui/separator"; | |
| 10 | import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle"; | |
| 11 | ||
| 12 | const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({ | |
| 13 | size: "default", | |
| 14 | variant: "default", | |
| 15 | }); | |
| 16 | ||
| 17 | function ToggleGroup({ | |
| 18 | className, | |
| 19 | variant = "default", | |
| 20 | size = "default", | |
| 21 | orientation = "horizontal", | |
| 22 | children, | |
| 23 | ...props | |
| 24 | }: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants>) { | |
| 25 | return ( | |
| 26 | <ToggleGroupPrimitive | |
| 27 | className={cn( | |
| 28 | "flex w-fit *:focus-visible:z-10 dark:*:[[data-slot=separator]:has(+[data-slot=toggle]:hover)]:before:bg-input/64 dark:*:[[data-slot=separator]:has(+[data-slot=toggle][data-pressed])]:before:bg-input dark:*:[[data-slot=toggle]:hover+[data-slot=separator]]:before:bg-input/64 dark:*:[[data-slot=toggle][data-pressed]+[data-slot=separator]]:before:bg-input", | |
| 29 | orientation === "horizontal" | |
| 30 | ? "*:pointer-coarse:after:min-w-auto" | |
| 31 | : "*:pointer-coarse:after:min-h-auto", | |
| 32 | variant === "default" | |
| 33 | ? "gap-0.5" | |
| 34 | : orientation === "horizontal" | |
| 35 | ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none" | |
| 36 | : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block", | |
| 37 | className, | |
| 38 | )} | |
| 39 | data-size={size} | |
| 40 | data-slot="toggle-group" | |
| 41 | data-variant={variant} | |
| 42 | orientation={orientation} | |
| 43 | {...props} | |
| 44 | > | |
| 45 | <ToggleGroupContext.Provider value={{ size, variant }}> | |
| 46 | {children} | |
| 47 | </ToggleGroupContext.Provider> | |
| 48 | </ToggleGroupPrimitive> | |
| 49 | ); | |
| 50 | } | |
| 51 | ||
| 52 | function Toggle({ | |
| 53 | className, | |
| 54 | children, | |
| 55 | variant, | |
| 56 | size, | |
| 57 | ...props | |
| 58 | }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { | |
| 59 | const context = React.useContext(ToggleGroupContext); | |
| 60 | ||
| 61 | const resolvedVariant = variant ?? context.variant; | |
| 62 | const resolvedSize = size ?? context.size; | |
| 63 | ||
| 64 | return ( | |
| 65 | <ToggleComponent | |
| 66 | className={className} | |
| 67 | data-size={resolvedSize} | |
| 68 | data-variant={resolvedVariant} | |
| 69 | size={resolvedSize} | |
| 70 | variant={resolvedVariant} | |
| 71 | {...props} | |
| 72 | > | |
| 73 | {children} | |
| 74 | </ToggleComponent> | |
| 75 | ); | |
| 76 | } | |
| 77 | ||
| 78 | function ToggleGroupSeparator({ | |
| 79 | className, | |
| 80 | orientation = "vertical", | |
| 81 | ...props | |
| 82 | }: { | |
| 83 | className?: string; | |
| 84 | } & React.ComponentProps<typeof Separator>) { | |
| 85 | return ( | |
| 86 | <Separator | |
| 87 | className={cn( | |
| 88 | "pointer-events-none relative bg-input before:absolute before:inset-0 dark:before:bg-input/32", | |
| 89 | className, | |
| 90 | )} | |
| 91 | orientation={orientation} | |
| 92 | {...props} | |
| 93 | /> | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | export { ToggleGroup, Toggle, Toggle as ToggleGroupItem, ToggleGroupSeparator }; |
apps/web/src/components/ui/toggle.tsx created+46| ... | ... | @@ -0,0 +1,46 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; | |
| 4 | import { cva, type VariantProps } from "class-variance-authority"; | |
| 5 | ||
| 6 | import { cn } from "~/lib/utils"; | |
| 7 | ||
| 8 | const toggleVariants = cva( | |
| 9 | "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base text-foreground outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-pressed:bg-input/64 data-pressed:text-accent-foreground sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", | |
| 10 | { | |
| 11 | defaultVariants: { | |
| 12 | size: "default", | |
| 13 | variant: "default", | |
| 14 | }, | |
| 15 | variants: { | |
| 16 | size: { | |
| 17 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", | |
| 18 | lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", | |
| 19 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", | |
| 20 | xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", | |
| 21 | }, | |
| 22 | variant: { | |
| 23 | default: "border-transparent", | |
| 24 | outline: | |
| 25 | "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", | |
| 26 | }, | |
| 27 | }, | |
| 28 | }, | |
| 29 | ); | |
| 30 | ||
| 31 | function Toggle({ | |
| 32 | className, | |
| 33 | variant, | |
| 34 | size, | |
| 35 | ...props | |
| 36 | }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { | |
| 37 | return ( | |
| 38 | <TogglePrimitive | |
| 39 | className={cn(toggleVariants({ className, size, variant }))} | |
| 40 | data-slot="toggle" | |
| 41 | {...props} | |
| 42 | /> | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | export { Toggle, toggleVariants }; |
apps/web/src/composer-logic.test.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { detectComposerTrigger, replaceTextRange } from "./composer-logic"; | |
| 4 | ||
| 5 | describe("detectComposerTrigger", () => { | |
| 6 | it("detects @path trigger at cursor", () => { | |
| 7 | const text = "Please check @src/com"; | |
| 8 | const trigger = detectComposerTrigger(text, text.length); | |
| 9 | ||
| 10 | expect(trigger).toEqual({ | |
| 11 | kind: "path", | |
| 12 | query: "src/com", | |
| 13 | rangeStart: "Please check ".length, | |
| 14 | rangeEnd: text.length, | |
| 15 | }); | |
| 16 | }); | |
| 17 | ||
| 18 | it("detects slash command token while typing command name", () => { | |
| 19 | const text = "/mo"; | |
| 20 | const trigger = detectComposerTrigger(text, text.length); | |
| 21 | ||
| 22 | expect(trigger).toEqual({ | |
| 23 | kind: "slash-command", | |
| 24 | query: "mo", | |
| 25 | rangeStart: 0, | |
| 26 | rangeEnd: text.length, | |
| 27 | }); | |
| 28 | }); | |
| 29 | ||
| 30 | it("detects slash model query after /model", () => { | |
| 31 | const text = "/model spark"; | |
| 32 | const trigger = detectComposerTrigger(text, text.length); | |
| 33 | ||
| 34 | expect(trigger).toEqual({ | |
| 35 | kind: "slash-model", | |
| 36 | query: "spark", | |
| 37 | rangeStart: 0, | |
| 38 | rangeEnd: text.length, | |
| 39 | }); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("replaceTextRange", () => { | |
| 44 | it("replaces a text range and returns new cursor", () => { | |
| 45 | const replaced = replaceTextRange("hello @src", 6, 10, ""); | |
| 46 | expect(replaced).toEqual({ | |
| 47 | text: "hello ", | |
| 48 | cursor: 6, | |
| 49 | }); | |
| 50 | }); | |
| 51 | }); |
apps/web/src/composer-logic.ts created+90| ... | ... | @@ -0,0 +1,90 @@ |
| 1 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; | |
| 2 | ||
| 3 | export interface ComposerTrigger { | |
| 4 | kind: ComposerTriggerKind; | |
| 5 | query: string; | |
| 6 | rangeStart: number; | |
| 7 | rangeEnd: number; | |
| 8 | } | |
| 9 | ||
| 10 | function clampCursor(text: string, cursor: number): number { | |
| 11 | if (!Number.isFinite(cursor)) return text.length; | |
| 12 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); | |
| 13 | } | |
| 14 | ||
| 15 | function isWhitespace(char: string): boolean { | |
| 16 | return char === " " || char === "\n" || char === "\t" || char === "\r"; | |
| 17 | } | |
| 18 | ||
| 19 | function tokenStartForCursor(text: string, cursor: number): number { | |
| 20 | let index = cursor - 1; | |
| 21 | while (index >= 0 && !isWhitespace(text[index] ?? "")) { | |
| 22 | index -= 1; | |
| 23 | } | |
| 24 | return index + 1; | |
| 25 | } | |
| 26 | ||
| 27 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { | |
| 28 | const cursor = clampCursor(text, cursorInput); | |
| 29 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | |
| 30 | const linePrefix = text.slice(lineStart, cursor); | |
| 31 | ||
| 32 | if (linePrefix.startsWith("/")) { | |
| 33 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); | |
| 34 | if (commandMatch) { | |
| 35 | const commandQuery = commandMatch[1] ?? ""; | |
| 36 | if (commandQuery.toLowerCase() === "model") { | |
| 37 | return { | |
| 38 | kind: "slash-model", | |
| 39 | query: "", | |
| 40 | rangeStart: lineStart, | |
| 41 | rangeEnd: cursor, | |
| 42 | }; | |
| 43 | } | |
| 44 | if ("model".startsWith(commandQuery.toLowerCase())) { | |
| 45 | return { | |
| 46 | kind: "slash-command", | |
| 47 | query: commandQuery, | |
| 48 | rangeStart: lineStart, | |
| 49 | rangeEnd: cursor, | |
| 50 | }; | |
| 51 | } | |
| 52 | return null; | |
| 53 | } | |
| 54 | ||
| 55 | const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | |
| 56 | if (modelMatch) { | |
| 57 | return { | |
| 58 | kind: "slash-model", | |
| 59 | query: (modelMatch[1] ?? "").trim(), | |
| 60 | rangeStart: lineStart, | |
| 61 | rangeEnd: cursor, | |
| 62 | }; | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | const tokenStart = tokenStartForCursor(text, cursor); | |
| 67 | const token = text.slice(tokenStart, cursor); | |
| 68 | if (!token.startsWith("@")) { | |
| 69 | return null; | |
| 70 | } | |
| 71 | ||
| 72 | return { | |
| 73 | kind: "path", | |
| 74 | query: token.slice(1), | |
| 75 | rangeStart: tokenStart, | |
| 76 | rangeEnd: cursor, | |
| 77 | }; | |
| 78 | } | |
| 79 | ||
| 80 | export function replaceTextRange( | |
| 81 | text: string, | |
| 82 | rangeStart: number, | |
| 83 | rangeEnd: number, | |
| 84 | replacement: string, | |
| 85 | ): { text: string; cursor: number } { | |
| 86 | const safeStart = Math.max(0, Math.min(text.length, rangeStart)); | |
| 87 | const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); | |
| 88 | const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; | |
| 89 | return { text: nextText, cursor: safeStart + replacement.length }; | |
| 90 | } |
apps/web/src/diffRouteSearch.test.ts created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { parseDiffRouteSearch } from "./diffRouteSearch"; | |
| 4 | ||
| 5 | describe("parseDiffRouteSearch", () => { | |
| 6 | it("parses valid diff search values", () => { | |
| 7 | const parsed = parseDiffRouteSearch({ | |
| 8 | diff: "1", | |
| 9 | diffTurnId: "turn-1", | |
| 10 | diffFilePath: "src/app.ts", | |
| 11 | }); | |
| 12 | ||
| 13 | expect(parsed).toEqual({ | |
| 14 | diff: "1", | |
| 15 | diffTurnId: "turn-1", | |
| 16 | diffFilePath: "src/app.ts", | |
| 17 | }); | |
| 18 | }); | |
| 19 | ||
| 20 | it("drops turn and file values when diff is closed", () => { | |
| 21 | const parsed = parseDiffRouteSearch({ | |
| 22 | diff: "0", | |
| 23 | diffTurnId: "turn-1", | |
| 24 | diffFilePath: "src/app.ts", | |
| 25 | }); | |
| 26 | ||
| 27 | expect(parsed).toEqual({}); | |
| 28 | }); | |
| 29 | ||
| 30 | it("drops file value when turn is not selected", () => { | |
| 31 | const parsed = parseDiffRouteSearch({ | |
| 32 | diff: "1", | |
| 33 | diffFilePath: "src/app.ts", | |
| 34 | }); | |
| 35 | ||
| 36 | expect(parsed).toEqual({ | |
| 37 | diff: "1", | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | it("normalizes whitespace-only values", () => { | |
| 42 | const parsed = parseDiffRouteSearch({ | |
| 43 | diff: "1", | |
| 44 | diffTurnId: " ", | |
| 45 | diffFilePath: " ", | |
| 46 | }); | |
| 47 | ||
| 48 | expect(parsed).toEqual({ | |
| 49 | diff: "1", | |
| 50 | }); | |
| 51 | }); | |
| 52 | }); | |
| 53 |
apps/web/src/diffRouteSearch.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | export interface DiffRouteSearch { | |
| 2 | diff?: "1"; | |
| 3 | diffTurnId?: string; | |
| 4 | diffFilePath?: string; | |
| 5 | } | |
| 6 | ||
| 7 | function normalizeSearchString(value: unknown): string | undefined { | |
| 8 | if (typeof value !== "string") { | |
| 9 | return undefined; | |
| 10 | } | |
| 11 | const normalized = value.trim(); | |
| 12 | return normalized.length > 0 ? normalized : undefined; | |
| 13 | } | |
| 14 | ||
| 15 | export function stripDiffSearchParams<T extends Record<string, unknown>>( | |
| 16 | params: T, | |
| 17 | ): Omit<T, "diff" | "diffTurnId" | "diffFilePath"> { | |
| 18 | const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; | |
| 19 | return rest as Omit<T, "diff" | "diffTurnId" | "diffFilePath">; | |
| 20 | } | |
| 21 | ||
| 22 | export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch { | |
| 23 | const diff = search.diff === "1" ? "1" : undefined; | |
| 24 | const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined; | |
| 25 | const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; | |
| 26 | ||
| 27 | return { | |
| 28 | ...(diff ? { diff } : {}), | |
| 29 | ...(diffTurnId ? { diffTurnId } : {}), | |
| 30 | ...(diffFilePath ? { diffFilePath } : {}), | |
| 31 | }; | |
| 32 | } |
apps/web/src/hooks/useMediaQuery.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { useEffect, useState } from "react"; | |
| 2 | ||
| 3 | function getMediaQueryMatch(query: string): boolean { | |
| 4 | if (typeof window === "undefined") { | |
| 5 | return false; | |
| 6 | } | |
| 7 | return window.matchMedia(query).matches; | |
| 8 | } | |
| 9 | ||
| 10 | export function useMediaQuery(query: string): boolean { | |
| 11 | const [matches, setMatches] = useState(() => getMediaQueryMatch(query)); | |
| 12 | ||
| 13 | useEffect(() => { | |
| 14 | const mediaQueryList = window.matchMedia(query); | |
| 15 | const handleChange = () => { | |
| 16 | setMatches(mediaQueryList.matches); | |
| 17 | }; | |
| 18 | ||
| 19 | setMatches(mediaQueryList.matches); | |
| 20 | mediaQueryList.addEventListener("change", handleChange); | |
| 21 | return () => { | |
| 22 | mediaQueryList.removeEventListener("change", handleChange); | |
| 23 | }; | |
| 24 | }, [query]); | |
| 25 | ||
| 26 | return matches; | |
| 27 | } |
apps/web/src/hooks/useTheme.ts+1| ... | ... | @@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 28 | 28 | document.documentElement.classList.toggle("dark", isDark); |
| 29 | 29 | if (suppressTransitions) { |
| 30 | 30 | // Force a reflow so the no-transitions class takes effect before removal |
| 31 | // oxlint-disable-next-line no-unused-expressions | |
| 31 | 32 | document.documentElement.offsetHeight; |
| 32 | 33 | requestAnimationFrame(() => { |
| 33 | 34 | document.documentElement.classList.remove("no-transitions"); |
apps/web/src/hooks/useTurnDiffSummaries.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import type { ProviderEvent } from "@t3tools/contracts"; | |
| 2 | import { useMemo } from "react"; | |
| 3 | import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic"; | |
| 4 | import type { Thread, TurnDiffSummary } from "../types"; | |
| 5 | ||
| 6 | const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>(); | |
| 7 | ||
| 8 | function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] { | |
| 9 | const cached = derivedTurnDiffSummaryCache.get(events); | |
| 10 | if (cached) { | |
| 11 | return cached; | |
| 12 | } | |
| 13 | const derived = deriveTurnDiffSummaries(events); | |
| 14 | derivedTurnDiffSummaryCache.set(events, derived); | |
| 15 | return derived; | |
| 16 | } | |
| 17 | ||
| 18 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { | |
| 19 | const turnDiffSummaries = useMemo(() => { | |
| 20 | if (!activeThread) { | |
| 21 | return []; | |
| 22 | } | |
| 23 | if (activeThread.turnDiffSummaries.length > 0) { | |
| 24 | return activeThread.turnDiffSummaries; | |
| 25 | } | |
| 26 | return deriveTurnDiffSummariesCached(activeThread.events); | |
| 27 | }, [activeThread]); | |
| 28 | ||
| 29 | const inferredCheckpointTurnCountByTurnId = useMemo( | |
| 30 | () => inferCheckpointTurnCountByTurnId(turnDiffSummaries), | |
| 31 | [turnDiffSummaries], | |
| 32 | ); | |
| 33 | ||
| 34 | return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId }; | |
| 35 | } |
apps/web/src/index.css+10| ... | ... | @@ -190,6 +190,16 @@ input { |
| 190 | 190 | background: rgba(255, 255, 255, 0.18); |
| 191 | 191 | } |
| 192 | 192 | |
| 193 | .turn-chip-strip { | |
| 194 | scrollbar-width: none; | |
| 195 | -ms-overflow-style: none; | |
| 196 | overscroll-behavior-x: contain; | |
| 197 | } | |
| 198 | ||
| 199 | .turn-chip-strip::-webkit-scrollbar { | |
| 200 | display: none; | |
| 201 | } | |
| 202 | ||
| 193 | 203 | /* Terminal drawer scrollbar parity with chat */ |
| 194 | 204 | .thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical { |
| 195 | 205 | width: 6px !important; |
apps/web/src/keybindings.test.ts+43-20| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | isTerminalNewShortcut, |
| 17 | 17 | isTerminalSplitShortcut, |
| 18 | 18 | isTerminalToggleShortcut, |
| 19 | resolveShortcutCommand, | |
| 19 | 20 | shortcutLabelForCommand, |
| 20 | 21 | terminalNavigationShortcutData, |
| 21 | 22 | type ShortcutEventLike, |
| ... | ... | @@ -98,7 +99,9 @@ const DEFAULT_BINDINGS = compile([ |
| 98 | 99 | describe("isTerminalToggleShortcut", () => { |
| 99 | 100 | it("matches Cmd+J on macOS", () => { |
| 100 | 101 | assert.isTrue( |
| 101 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" }), | |
| 102 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { | |
| 103 | platform: "MacIntel", | |
| 104 | }), | |
| 102 | 105 | ); |
| 103 | 106 | }); |
| 104 | 107 | |
| ... | ... | @@ -219,7 +222,10 @@ describe("shortcutLabelForCommand", () => { |
| 219 | 222 | whenAst: whenNot(whenIdentifier("terminalFocus")), |
| 220 | 223 | }, |
| 221 | 224 | ]); |
| 222 | assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); | |
| 225 | assert.strictEqual( | |
| 226 | shortcutLabelForCommand(bindings, "terminal.split", "Linux"), | |
| 227 | "Ctrl+Shift+\\", | |
| 228 | ); | |
| 223 | 229 | }); |
| 224 | 230 | |
| 225 | 231 | it("returns labels for non-terminal commands", () => { |
| ... | ... | @@ -247,22 +253,14 @@ describe("chat/editor shortcuts", () => { |
| 247 | 253 | |
| 248 | 254 | it("matches chat.newLocal shortcut", () => { |
| 249 | 255 | assert.isTrue( |
| 250 | isChatNewLocalShortcut( | |
| 251 | event({ key: "n", metaKey: true, shiftKey: true }), | |
| 252 | DEFAULT_BINDINGS, | |
| 253 | { | |
| 254 | platform: "MacIntel", | |
| 255 | }, | |
| 256 | ), | |
| 256 | isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 257 | platform: "MacIntel", | |
| 258 | }), | |
| 257 | 259 | ); |
| 258 | 260 | assert.isTrue( |
| 259 | isChatNewLocalShortcut( | |
| 260 | event({ key: "n", ctrlKey: true, shiftKey: true }), | |
| 261 | DEFAULT_BINDINGS, | |
| 262 | { | |
| 263 | platform: "Linux", | |
| 264 | }, | |
| 265 | ), | |
| 261 | isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 262 | platform: "Linux", | |
| 263 | }), | |
| 266 | 264 | ); |
| 267 | 265 | }); |
| 268 | 266 | |
| ... | ... | @@ -342,13 +340,34 @@ describe("cross-command precedence", () => { |
| 342 | 340 | }); |
| 343 | 341 | }); |
| 344 | 342 | |
| 343 | describe("resolveShortcutCommand", () => { | |
| 344 | it("returns dynamic script commands", () => { | |
| 345 | const keybindings = compile([ | |
| 346 | { shortcut: modShortcut("r"), command: "script.setup.run" }, | |
| 347 | ]); | |
| 348 | ||
| 349 | assert.strictEqual( | |
| 350 | resolveShortcutCommand(event({ key: "r", ctrlKey: true }), keybindings, { | |
| 351 | platform: "Linux", | |
| 352 | }), | |
| 353 | "script.setup.run", | |
| 354 | ); | |
| 355 | }); | |
| 356 | }); | |
| 357 | ||
| 345 | 358 | describe("formatShortcutLabel", () => { |
| 346 | 359 | it("formats labels for macOS", () => { |
| 347 | assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D"); | |
| 360 | assert.strictEqual( | |
| 361 | formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), | |
| 362 | "⇧⌘D", | |
| 363 | ); | |
| 348 | 364 | }); |
| 349 | 365 | |
| 350 | 366 | it("formats labels for non-macOS", () => { |
| 351 | assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D"); | |
| 367 | assert.strictEqual( | |
| 368 | formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), | |
| 369 | "Ctrl+Shift+D", | |
| 370 | ); | |
| 352 | 371 | }); |
| 353 | 372 | |
| 354 | 373 | it("formats labels for plus key", () => { |
| ... | ... | @@ -368,7 +387,9 @@ describe("isTerminalClearShortcut", () => { |
| 368 | 387 | }); |
| 369 | 388 | |
| 370 | 389 | it("ignores non-keydown events", () => { |
| 371 | assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux")); | |
| 390 | assert.isFalse( | |
| 391 | isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux"), | |
| 392 | ); | |
| 372 | 393 | }); |
| 373 | 394 | }); |
| 374 | 395 | |
| ... | ... | @@ -413,7 +434,9 @@ describe("terminalNavigationShortcutData", () => { |
| 413 | 434 | "MacIntel", |
| 414 | 435 | ), |
| 415 | 436 | ); |
| 416 | assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); | |
| 437 | assert.isNull( | |
| 438 | terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux"), | |
| 439 | ); | |
| 417 | 440 | assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); |
| 418 | 441 | }); |
| 419 | 442 |
apps/web/src/keybindings.ts+2-2| ... | ... | @@ -100,11 +100,11 @@ function matchesCommandShortcut( |
| 100 | 100 | return resolveShortcutCommand(event, keybindings, options) === command; |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | function resolveShortcutCommand( | |
| 103 | export function resolveShortcutCommand( | |
| 104 | 104 | event: ShortcutEventLike, |
| 105 | 105 | keybindings: ResolvedKeybindingsConfig, |
| 106 | 106 | options?: ShortcutMatchOptions, |
| 107 | ): KeybindingCommand | null { | |
| 107 | ): string | null { | |
| 108 | 108 | const platform = resolvePlatform(options); |
| 109 | 109 | const context = resolveContext(options); |
| 110 | 110 |
apps/web/src/lib/diffRendering.test.ts created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { buildPatchCacheKey } from "./diffRendering"; | |
| 3 | ||
| 4 | describe("buildPatchCacheKey", () => { | |
| 5 | it("returns a stable cache key for identical content", () => { | |
| 6 | const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')"; | |
| 7 | ||
| 8 | expect(buildPatchCacheKey(patch)).toBe(buildPatchCacheKey(patch)); | |
| 9 | }); | |
| 10 | ||
| 11 | it("normalizes outer whitespace before hashing", () => { | |
| 12 | const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')"; | |
| 13 | ||
| 14 | expect(buildPatchCacheKey(`\n${patch}\n`)).toBe(buildPatchCacheKey(patch)); | |
| 15 | }); | |
| 16 | ||
| 17 | it("changes when diff content changes", () => { | |
| 18 | const before = "diff --git a/a.ts b/a.ts\n+console.log('hello')"; | |
| 19 | const after = "diff --git a/a.ts b/a.ts\n+console.log('hello world')"; | |
| 20 | ||
| 21 | expect(buildPatchCacheKey(before)).not.toBe(buildPatchCacheKey(after)); | |
| 22 | }); | |
| 23 | }); |
apps/web/src/lib/diffRendering.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | const FNV_OFFSET_BASIS_32 = 0x811c9dc5; | |
| 2 | const FNV_PRIME_32 = 0x01000193; | |
| 3 | const SECONDARY_HASH_SEED = 0x9e3779b9; | |
| 4 | const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b; | |
| 5 | ||
| 6 | function fnv1a32(input: string, seed: number, multiplier: number): number { | |
| 7 | let hash = seed >>> 0; | |
| 8 | for (let index = 0; index < input.length; index += 1) { | |
| 9 | hash ^= input.charCodeAt(index); | |
| 10 | hash = Math.imul(hash, multiplier) >>> 0; | |
| 11 | } | |
| 12 | return hash >>> 0; | |
| 13 | } | |
| 14 | ||
| 15 | export function buildPatchCacheKey(patch: string, scope = "diff-panel"): string { | |
| 16 | const normalizedPatch = patch.trim(); | |
| 17 | const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36); | |
| 18 | const secondary = fnv1a32( | |
| 19 | normalizedPatch, | |
| 20 | SECONDARY_HASH_SEED, | |
| 21 | SECONDARY_HASH_MULTIPLIER, | |
| 22 | ).toString(36); | |
| 23 | return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; | |
| 24 | } |
apps/web/src/lib/gitReactQuery.ts+15-2| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | 1 | import type { GitStackedAction, NativeApi } from "@t3tools/contracts"; |
| 2 | 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; |
| 3 | 3 | |
| 4 | const GIT_STATUS_STALE_TIME_MS = 5_000; | |
| 5 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; | |
| 6 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; | |
| 7 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; | |
| 8 | ||
| 4 | 9 | export const gitQueryKeys = { |
| 5 | 10 | all: ["git"] as const, |
| 6 | 11 | status: (cwd: string | null) => ["git", "status", cwd] as const, |
| ... | ... | @@ -21,6 +26,10 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | |
| 21 | 26 | return api.git.status({ cwd }); |
| 22 | 27 | }, |
| 23 | 28 | enabled: !!api && !!cwd, |
| 29 | staleTime: GIT_STATUS_STALE_TIME_MS, | |
| 30 | refetchOnWindowFocus: "always", | |
| 31 | refetchOnReconnect: "always", | |
| 32 | refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, | |
| 24 | 33 | }); |
| 25 | 34 | } |
| 26 | 35 | |
| ... | ... | @@ -34,6 +43,10 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string |
| 34 | 43 | return api.git.listBranches({ cwd }); |
| 35 | 44 | }, |
| 36 | 45 | enabled: !!api && !!cwd, |
| 46 | staleTime: GIT_BRANCHES_STALE_TIME_MS, | |
| 47 | refetchOnWindowFocus: true, | |
| 48 | refetchOnReconnect: true, | |
| 49 | refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS, | |
| 37 | 50 | }); |
| 38 | 51 | } |
| 39 | 52 | |
| ... | ... | @@ -160,11 +173,11 @@ export function gitRemoveWorktreeMutationOptions(input: { |
| 160 | 173 | queryClient: QueryClient; |
| 161 | 174 | }) { |
| 162 | 175 | return mutationOptions({ |
| 163 | mutationFn: async ({ cwd, path }: { cwd: string; path: string }) => { | |
| 176 | mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { | |
| 164 | 177 | if (!input.api) { |
| 165 | 178 | throw new Error("Git worktree removal is unavailable."); |
| 166 | 179 | } |
| 167 | return input.api.git.removeWorktree({ cwd, path }); | |
| 180 | return input.api.git.removeWorktree({ cwd, path, force }); | |
| 168 | 181 | }, |
| 169 | 182 | onSettled: async () => { |
| 170 | 183 | await invalidateGitQueries(input.queryClient); |
apps/web/src/lib/projectReactQuery.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 2 | import { queryOptions } from "@tanstack/react-query"; | |
| 3 | ||
| 4 | export const projectQueryKeys = { | |
| 5 | all: ["projects"] as const, | |
| 6 | searchEntries: (cwd: string | null, query: string, limit: number) => | |
| 7 | ["projects", "search-entries", cwd, query, limit] as const, | |
| 8 | }; | |
| 9 | ||
| 10 | const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; | |
| 11 | const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000; | |
| 12 | const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { | |
| 13 | entries: [], | |
| 14 | truncated: false, | |
| 15 | }; | |
| 16 | ||
| 17 | export function projectSearchEntriesQueryOptions(input: { | |
| 18 | api: NativeApi | undefined; | |
| 19 | cwd: string | null; | |
| 20 | query: string; | |
| 21 | enabled?: boolean; | |
| 22 | limit?: number; | |
| 23 | staleTime?: number; | |
| 24 | }) { | |
| 25 | const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; | |
| 26 | return queryOptions({ | |
| 27 | queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), | |
| 28 | queryFn: async () => { | |
| 29 | if (!input.api || !input.cwd) { | |
| 30 | throw new Error("Workspace entry search is unavailable."); | |
| 31 | } | |
| 32 | return input.api.projects.searchEntries({ | |
| 33 | cwd: input.cwd, | |
| 34 | query: input.query, | |
| 35 | limit, | |
| 36 | }); | |
| 37 | }, | |
| 38 | enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd), | |
| 39 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, | |
| 40 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, | |
| 41 | }); | |
| 42 | } |
apps/web/src/lib/providerReactQuery.test.ts created+111| ... | ... | @@ -0,0 +1,111 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | import { QueryClient } from "@tanstack/react-query"; | |
| 3 | import { describe, expect, it, vi } from "vitest"; | |
| 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; | |
| 5 | ||
| 6 | describe("providerQueryKeys.checkpointDiff", () => { | |
| 7 | it("includes cacheScope so reused turn counts do not collide", () => { | |
| 8 | const baseInput = { | |
| 9 | sessionId: "session-id", | |
| 10 | threadRuntimeId: "thread-id", | |
| 11 | fromTurnCount: 1, | |
| 12 | toTurnCount: 2, | |
| 13 | } as const; | |
| 14 | ||
| 15 | expect( | |
| 16 | providerQueryKeys.checkpointDiff({ | |
| 17 | ...baseInput, | |
| 18 | cacheScope: "turn:old-turn", | |
| 19 | }), | |
| 20 | ).not.toEqual( | |
| 21 | providerQueryKeys.checkpointDiff({ | |
| 22 | ...baseInput, | |
| 23 | cacheScope: "turn:new-turn", | |
| 24 | }), | |
| 25 | ); | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | describe("checkpointDiffQueryOptions", () => { | |
| 30 | it("forwards checkpoint range to the provider API", async () => { | |
| 31 | const getCheckpointDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 32 | const api = { | |
| 33 | providers: { | |
| 34 | getCheckpointDiff, | |
| 35 | }, | |
| 36 | } as unknown as NativeApi; | |
| 37 | ||
| 38 | const options = checkpointDiffQueryOptions(api, { | |
| 39 | sessionId: "session-id", | |
| 40 | threadRuntimeId: "thread-id", | |
| 41 | fromTurnCount: 3, | |
| 42 | toTurnCount: 4, | |
| 43 | cacheScope: "turn:abc", | |
| 44 | }); | |
| 45 | ||
| 46 | const queryClient = new QueryClient(); | |
| 47 | await queryClient.fetchQuery(options); | |
| 48 | ||
| 49 | expect(getCheckpointDiff).toHaveBeenCalledWith({ | |
| 50 | sessionId: "session-id", | |
| 51 | fromTurnCount: 3, | |
| 52 | toTurnCount: 4, | |
| 53 | }); | |
| 54 | }); | |
| 55 | ||
| 56 | it("retries checkpoint-not-ready errors longer than generic failures", () => { | |
| 57 | const options = checkpointDiffQueryOptions(undefined, { | |
| 58 | sessionId: "session-id", | |
| 59 | threadRuntimeId: "thread-id", | |
| 60 | fromTurnCount: 1, | |
| 61 | toTurnCount: 2, | |
| 62 | cacheScope: "turn:abc", | |
| 63 | }); | |
| 64 | const retry = options.retry; | |
| 65 | expect(typeof retry).toBe("function"); | |
| 66 | if (typeof retry !== "function") { | |
| 67 | throw new Error("Expected retry to be a function."); | |
| 68 | } | |
| 69 | ||
| 70 | expect(retry(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(true); | |
| 71 | expect( | |
| 72 | retry( | |
| 73 | 11, | |
| 74 | new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."), | |
| 75 | ), | |
| 76 | ).toBe(true); | |
| 77 | expect( | |
| 78 | retry( | |
| 79 | 12, | |
| 80 | new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."), | |
| 81 | ), | |
| 82 | ).toBe(false); | |
| 83 | expect(retry(2, new Error("Something else failed."))).toBe(true); | |
| 84 | expect(retry(3, new Error("Something else failed."))).toBe(false); | |
| 85 | }); | |
| 86 | ||
| 87 | it("backs off longer for checkpoint-not-ready errors", () => { | |
| 88 | const options = checkpointDiffQueryOptions(undefined, { | |
| 89 | sessionId: "session-id", | |
| 90 | threadRuntimeId: "thread-id", | |
| 91 | fromTurnCount: 1, | |
| 92 | toTurnCount: 2, | |
| 93 | cacheScope: "turn:abc", | |
| 94 | }); | |
| 95 | const retryDelay = options.retryDelay; | |
| 96 | expect(typeof retryDelay).toBe("function"); | |
| 97 | if (typeof retryDelay !== "function") { | |
| 98 | throw new Error("Expected retryDelay to be a function."); | |
| 99 | } | |
| 100 | ||
| 101 | const checkpointDelay = retryDelay( | |
| 102 | 4, | |
| 103 | new Error("Checkpoint turn count 2 exceeds current turn count 1."), | |
| 104 | ); | |
| 105 | const genericDelay = retryDelay(4, new Error("Network failure")); | |
| 106 | ||
| 107 | expect(typeof checkpointDelay).toBe("number"); | |
| 108 | expect(typeof genericDelay).toBe("number"); | |
| 109 | expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true); | |
| 110 | }); | |
| 111 | }); |
apps/web/src/lib/providerReactQuery.ts created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | |
| 2 | import { queryOptions } from "@tanstack/react-query"; | |
| 3 | ||
| 4 | export interface CheckpointDiffQueryInput { | |
| 5 | sessionId: string | null; | |
| 6 | threadRuntimeId: string | null; | |
| 7 | fromTurnCount: number | null; | |
| 8 | toTurnCount: number | null; | |
| 9 | cacheScope?: string | null; | |
| 10 | } | |
| 11 | ||
| 12 | export const providerQueryKeys = { | |
| 13 | all: ["providers"] as const, | |
| 14 | checkpointDiff: (input: CheckpointDiffQueryInput) => | |
| 15 | [ | |
| 16 | "providers", | |
| 17 | "checkpointDiff", | |
| 18 | input.sessionId, | |
| 19 | input.threadRuntimeId, | |
| 20 | input.fromTurnCount, | |
| 21 | input.toTurnCount, | |
| 22 | input.cacheScope ?? null, | |
| 23 | ] as const, | |
| 24 | }; | |
| 25 | ||
| 26 | function asCheckpointErrorMessage(error: unknown): string { | |
| 27 | if (error instanceof Error) { | |
| 28 | return error.message; | |
| 29 | } | |
| 30 | if (typeof error === "string") { | |
| 31 | return error; | |
| 32 | } | |
| 33 | return ""; | |
| 34 | } | |
| 35 | ||
| 36 | function isCheckpointTemporarilyUnavailable(error: unknown): boolean { | |
| 37 | const message = asCheckpointErrorMessage(error).toLowerCase(); | |
| 38 | return ( | |
| 39 | message.includes("exceeds current turn count") || | |
| 40 | message.includes("checkpoint is unavailable for turn") || | |
| 41 | message.includes("filesystem checkpoint is unavailable") | |
| 42 | ); | |
| 43 | } | |
| 44 | ||
| 45 | export function checkpointDiffQueryOptions( | |
| 46 | api: NativeApi | undefined, | |
| 47 | input: CheckpointDiffQueryInput, | |
| 48 | ) { | |
| 49 | const hasValidRange = | |
| 50 | typeof input.fromTurnCount === "number" && | |
| 51 | typeof input.toTurnCount === "number" && | |
| 52 | Number.isInteger(input.fromTurnCount) && | |
| 53 | Number.isInteger(input.toTurnCount) && | |
| 54 | input.fromTurnCount >= 0 && | |
| 55 | input.toTurnCount >= 0 && | |
| 56 | input.fromTurnCount <= input.toTurnCount; | |
| 57 | ||
| 58 | return queryOptions({ | |
| 59 | queryKey: providerQueryKeys.checkpointDiff(input), | |
| 60 | queryFn: async () => { | |
| 61 | if (!api || !input.sessionId || !hasValidRange) { | |
| 62 | throw new Error("Checkpoint diff is unavailable."); | |
| 63 | } | |
| 64 | const { fromTurnCount, toTurnCount } = input; | |
| 65 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { | |
| 66 | throw new Error("Checkpoint diff range is invalid."); | |
| 67 | } | |
| 68 | return api.providers.getCheckpointDiff({ | |
| 69 | sessionId: input.sessionId, | |
| 70 | fromTurnCount, | |
| 71 | toTurnCount, | |
| 72 | }); | |
| 73 | }, | |
| 74 | enabled: !!api && !!input.sessionId && hasValidRange, | |
| 75 | staleTime: Infinity, | |
| 76 | retry: (failureCount, error) => { | |
| 77 | if (isCheckpointTemporarilyUnavailable(error)) { | |
| 78 | return failureCount < 12; | |
| 79 | } | |
| 80 | return failureCount < 3; | |
| 81 | }, | |
| 82 | retryDelay: (attempt, error) => | |
| 83 | isCheckpointTemporarilyUnavailable(error) | |
| 84 | ? Math.min(5_000, 250 * 2 ** (attempt - 1)) | |
| 85 | : Math.min(1_000, 100 * 2 ** (attempt - 1)), | |
| 86 | }); | |
| 87 | } |
apps/web/src/main.tsx+34-3| ... | ... | @@ -1,13 +1,44 @@ |
| 1 | 1 | import React from "react"; |
| 2 | 2 | import ReactDOM from "react-dom/client"; |
| 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | |
| 4 | import { RouterProvider } from "@tanstack/react-router"; | |
| 5 | import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; | |
| 6 | import { StoreProvider } from "./store"; | |
| 7 | ||
| 3 | 8 | import "@xterm/xterm/css/xterm.css"; |
| 4 | 9 | import "highlight.js/styles/github-dark.css"; |
| 5 | ||
| 6 | import App from "./App"; | |
| 7 | 10 | import "./index.css"; |
| 8 | 11 | |
| 12 | import { APP_DISPLAY_NAME } from "./branding"; | |
| 13 | import { isElectron } from "./env"; | |
| 14 | import { routeTree } from "./routeTree.gen"; | |
| 15 | ||
| 16 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | |
| 17 | ||
| 18 | const queryClient = new QueryClient(); | |
| 19 | document.title = APP_DISPLAY_NAME; | |
| 20 | ||
| 21 | const router = createRouter({ | |
| 22 | routeTree, | |
| 23 | history, | |
| 24 | context: { | |
| 25 | queryClient, | |
| 26 | }, | |
| 27 | Wrap: ({ children }) => ( | |
| 28 | <QueryClientProvider client={queryClient}> | |
| 29 | <StoreProvider>{children}</StoreProvider> | |
| 30 | </QueryClientProvider> | |
| 31 | ), | |
| 32 | }); | |
| 33 | ||
| 34 | declare module "@tanstack/react-router" { | |
| 35 | interface Register { | |
| 36 | router: typeof router; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 9 | 40 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
| 10 | 41 | <React.StrictMode> |
| 11 | <App /> | |
| 42 | <RouterProvider router={router} /> | |
| 12 | 43 | </React.StrictMode>, |
| 13 | 44 | ); |
apps/web/src/persistenceSchema.test.ts+139-2| ... | ... | @@ -20,6 +20,7 @@ describe("hydratePersistedState", () => { |
| 20 | 20 | cwd: "/tmp/project", |
| 21 | 21 | model: "gpt-5.2-codex", |
| 22 | 22 | expanded: true, |
| 23 | scripts: [], | |
| 23 | 24 | }, |
| 24 | 25 | ], |
| 25 | 26 | threads: [ |
| ... | ... | @@ -69,6 +70,7 @@ describe("hydratePersistedState", () => { |
| 69 | 70 | cwd: "/tmp/project", |
| 70 | 71 | model: "gpt-5.3-codex", |
| 71 | 72 | expanded: false, |
| 73 | scripts: [], | |
| 72 | 74 | }, |
| 73 | 75 | ], |
| 74 | 76 | threads: [ |
| ... | ... | @@ -104,7 +106,6 @@ describe("hydratePersistedState", () => { |
| 104 | 106 | { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, |
| 105 | 107 | ]); |
| 106 | 108 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); |
| 107 | expect(hydrated?.activeThreadId).toBe("t-1"); | |
| 108 | 109 | expect(hydrated?.runtimeMode).toBe("full-access"); |
| 109 | 110 | }); |
| 110 | 111 | |
| ... | ... | @@ -119,6 +120,7 @@ describe("hydratePersistedState", () => { |
| 119 | 120 | cwd: "/tmp/project", |
| 120 | 121 | model: "gpt-5.3-codex", |
| 121 | 122 | expanded: false, |
| 123 | scripts: [], | |
| 122 | 124 | }, |
| 123 | 125 | ], |
| 124 | 126 | threads: [], |
| ... | ... | @@ -140,6 +142,7 @@ describe("hydratePersistedState", () => { |
| 140 | 142 | cwd: "/tmp/project", |
| 141 | 143 | model: "gpt-5.3-codex", |
| 142 | 144 | expanded: true, |
| 145 | scripts: [], | |
| 143 | 146 | }, |
| 144 | 147 | ], |
| 145 | 148 | threads: [ |
| ... | ... | @@ -187,6 +190,7 @@ describe("hydratePersistedState", () => { |
| 187 | 190 | cwd: "/tmp/project", |
| 188 | 191 | model: "gpt-5.3-codex", |
| 189 | 192 | expanded: true, |
| 193 | scripts: [], | |
| 190 | 194 | }, |
| 191 | 195 | ], |
| 192 | 196 | threads: [ |
| ... | ... | @@ -231,6 +235,7 @@ describe("hydratePersistedState", () => { |
| 231 | 235 | cwd: "/tmp/project", |
| 232 | 236 | model: "gpt-5.3-codex", |
| 233 | 237 | expanded: true, |
| 238 | scripts: [], | |
| 234 | 239 | }, |
| 235 | 240 | ], |
| 236 | 241 | threads: [ |
| ... | ... | @@ -257,6 +262,106 @@ describe("hydratePersistedState", () => { |
| 257 | 262 | ]); |
| 258 | 263 | expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); |
| 259 | 264 | }); |
| 265 | ||
| 266 | it("hydrates persisted turn diff summaries as metadata-only entries", () => { | |
| 267 | const payload = JSON.stringify({ | |
| 268 | version: 7, | |
| 269 | runtimeMode: "full-access", | |
| 270 | projects: [ | |
| 271 | { | |
| 272 | id: "p-1", | |
| 273 | name: "Project", | |
| 274 | cwd: "/tmp/project", | |
| 275 | model: "gpt-5.3-codex", | |
| 276 | expanded: true, | |
| 277 | }, | |
| 278 | ], | |
| 279 | threads: [ | |
| 280 | { | |
| 281 | id: "t-1", | |
| 282 | codexThreadId: "thr_1", | |
| 283 | projectId: "p-1", | |
| 284 | title: "Thread", | |
| 285 | model: "gpt-5.3-codex", | |
| 286 | messages: [], | |
| 287 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 288 | turnDiffSummaries: [ | |
| 289 | { | |
| 290 | turnId: "turn-1", | |
| 291 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 292 | checkpointTurnCount: 1, | |
| 293 | files: [ | |
| 294 | { | |
| 295 | path: "src/app.ts", | |
| 296 | kind: "modified", | |
| 297 | additions: 3, | |
| 298 | deletions: 1, | |
| 299 | }, | |
| 300 | ], | |
| 301 | }, | |
| 302 | ], | |
| 303 | }, | |
| 304 | ], | |
| 305 | activeThreadId: "t-1", | |
| 306 | }); | |
| 307 | ||
| 308 | const hydrated = hydratePersistedState(payload, false); | |
| 309 | expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([ | |
| 310 | { | |
| 311 | turnId: "turn-1", | |
| 312 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 313 | checkpointTurnCount: 1, | |
| 314 | files: [ | |
| 315 | { | |
| 316 | path: "src/app.ts", | |
| 317 | kind: "modified", | |
| 318 | additions: 3, | |
| 319 | deletions: 1, | |
| 320 | }, | |
| 321 | ], | |
| 322 | }, | |
| 323 | ]); | |
| 324 | }); | |
| 325 | ||
| 326 | it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => { | |
| 327 | const payload = JSON.stringify({ | |
| 328 | version: 7, | |
| 329 | runtimeMode: "full-access", | |
| 330 | projects: [ | |
| 331 | { | |
| 332 | id: "p-1", | |
| 333 | name: "Project", | |
| 334 | cwd: "/tmp/project", | |
| 335 | model: "gpt-5.3-codex", | |
| 336 | expanded: true, | |
| 337 | }, | |
| 338 | ], | |
| 339 | threads: [ | |
| 340 | { | |
| 341 | id: "t-1", | |
| 342 | codexThreadId: "thr_1", | |
| 343 | projectId: "p-1", | |
| 344 | title: "Thread", | |
| 345 | model: "gpt-5.3-codex", | |
| 346 | messages: [], | |
| 347 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 348 | turnDiffSummaries: [ | |
| 349 | { | |
| 350 | turnId: "turn-1", | |
| 351 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 352 | files: [{ path: 123 }], | |
| 353 | }, | |
| 354 | ], | |
| 355 | }, | |
| 356 | ], | |
| 357 | activeThreadId: "t-1", | |
| 358 | }); | |
| 359 | ||
| 360 | const hydrated = hydratePersistedState(payload, false); | |
| 361 | expect(hydrated).not.toBeNull(); | |
| 362 | expect(hydrated?.threads[0]?.id).toBe("t-1"); | |
| 363 | expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]); | |
| 364 | }); | |
| 260 | 365 | }); |
| 261 | 366 | |
| 262 | 367 | describe("toPersistedState", () => { |
| ... | ... | @@ -303,6 +408,22 @@ describe("toPersistedState", () => { |
| 303 | 408 | lastVisitedAt: "2026-02-08T10:02:00.000Z", |
| 304 | 409 | branch: null, |
| 305 | 410 | worktreePath: null, |
| 411 | turnDiffSummaries: [ | |
| 412 | { | |
| 413 | turnId: "turn-1", | |
| 414 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 415 | status: "completed", | |
| 416 | checkpointTurnCount: 1, | |
| 417 | files: [ | |
| 418 | { | |
| 419 | path: "src/app.ts", | |
| 420 | kind: "modified", | |
| 421 | additions: 3, | |
| 422 | deletions: 1, | |
| 423 | }, | |
| 424 | ], | |
| 425 | }, | |
| 426 | ], | |
| 306 | 427 | }; |
| 307 | 428 | |
| 308 | 429 | const persisted = toPersistedState({ |
| ... | ... | @@ -313,10 +434,10 @@ describe("toPersistedState", () => { |
| 313 | 434 | cwd: "/tmp/project", |
| 314 | 435 | model: "gpt-5.3-codex", |
| 315 | 436 | expanded: true, |
| 437 | scripts: [], | |
| 316 | 438 | }, |
| 317 | 439 | ], |
| 318 | 440 | threads: [thread], |
| 319 | activeThreadId: "t-1", | |
| 320 | 441 | runtimeMode: "full-access", |
| 321 | 442 | }); |
| 322 | 443 | |
| ... | ... | @@ -359,6 +480,22 @@ describe("toPersistedState", () => { |
| 359 | 480 | lastVisitedAt: thread.lastVisitedAt, |
| 360 | 481 | branch: null, |
| 361 | 482 | worktreePath: null, |
| 483 | turnDiffSummaries: [ | |
| 484 | { | |
| 485 | turnId: "turn-1", | |
| 486 | completedAt: "2026-02-08T10:05:00.000Z", | |
| 487 | status: "completed", | |
| 488 | checkpointTurnCount: 1, | |
| 489 | files: [ | |
| 490 | { | |
| 491 | path: "src/app.ts", | |
| 492 | kind: "modified", | |
| 493 | additions: 3, | |
| 494 | deletions: 1, | |
| 495 | }, | |
| 496 | ], | |
| 497 | }, | |
| 498 | ], | |
| 362 | 499 | }); |
| 363 | 500 | const persistedThread = persisted.threads[0]; |
| 364 | 501 | expect(persistedThread).toBeDefined(); |
apps/web/src/persistenceSchema.ts+51-9| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | import { projectScriptSchema } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 4 | 5 | import { |
| ... | ... | @@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({ |
| 19 | 20 | cwd: z.string().min(1), |
| 20 | 21 | model: z.string().min(1), |
| 21 | 22 | expanded: z.boolean(), |
| 23 | scripts: z.array(projectScriptSchema).default([]), | |
| 22 | 24 | }); |
| 23 | 25 | |
| 24 | 26 | const persistedMessageSchema = z.object({ |
| ... | ... | @@ -45,6 +47,22 @@ const persistedTerminalGroupSchema = z.object({ |
| 45 | 47 | terminalIds: z.array(z.string().trim().min(1)), |
| 46 | 48 | }); |
| 47 | 49 | |
| 50 | const persistedTurnDiffFileChangeSchema = z.object({ | |
| 51 | path: z.string().min(1), | |
| 52 | kind: z.string().min(1).optional(), | |
| 53 | additions: z.number().int().min(0).optional(), | |
| 54 | deletions: z.number().int().min(0).optional(), | |
| 55 | }); | |
| 56 | ||
| 57 | const persistedTurnDiffSummarySchema = z.object({ | |
| 58 | turnId: z.string().min(1), | |
| 59 | completedAt: z.string().min(1), | |
| 60 | status: z.string().min(1).optional(), | |
| 61 | files: z.array(persistedTurnDiffFileChangeSchema), | |
| 62 | assistantMessageId: z.string().min(1).optional(), | |
| 63 | checkpointTurnCount: z.number().int().min(0).optional(), | |
| 64 | }); | |
| 65 | ||
| 48 | 66 | const persistedThreadSchema = z.object({ |
| 49 | 67 | id: z.string().min(1), |
| 50 | 68 | codexThreadId: z.string().min(1).nullable().default(null), |
| ... | ... | @@ -65,12 +83,13 @@ const persistedThreadSchema = z.object({ |
| 65 | 83 | lastVisitedAt: z.string().min(1).optional(), |
| 66 | 84 | branch: z.string().min(1).nullable().optional(), |
| 67 | 85 | worktreePath: z.string().min(1).nullable().optional(), |
| 86 | turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]), | |
| 68 | 87 | }); |
| 69 | 88 | |
| 70 | 89 | const persistedStateBodySchema = z.object({ |
| 71 | 90 | projects: z.array(persistedProjectSchema), |
| 72 | 91 | threads: z.array(persistedThreadSchema), |
| 73 | activeThreadId: z.string().min(1).nullable(), | |
| 92 | activeThreadId: z.string().min(1).nullable().optional(), | |
| 74 | 93 | }); |
| 75 | 94 | |
| 76 | 95 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); |
| ... | ... | @@ -121,7 +140,6 @@ const persistedStateSchema = z.union([ |
| 121 | 140 | export interface PersistedStoreSnapshot { |
| 122 | 141 | projects: Project[]; |
| 123 | 142 | threads: Thread[]; |
| 124 | activeThreadId: string | null; | |
| 125 | 143 | runtimeMode: RuntimeMode; |
| 126 | 144 | } |
| 127 | 145 | |
| ... | ... | @@ -140,6 +158,7 @@ function hydrateProject( |
| 140 | 158 | return { |
| 141 | 159 | ...project, |
| 142 | 160 | model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), |
| 161 | scripts: project.scripts, | |
| 143 | 162 | }; |
| 144 | 163 | } |
| 145 | 164 | |
| ... | ... | @@ -270,6 +289,21 @@ function hydrateThread( |
| 270 | 289 | lastVisitedAt: thread.lastVisitedAt, |
| 271 | 290 | branch: thread.branch ?? null, |
| 272 | 291 | worktreePath: thread.worktreePath ?? null, |
| 292 | turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ | |
| 293 | turnId: summary.turnId, | |
| 294 | completedAt: summary.completedAt, | |
| 295 | ...(summary.status ? { status: summary.status } : {}), | |
| 296 | files: summary.files.map((file) => ({ | |
| 297 | path: file.path, | |
| 298 | ...(file.kind ? { kind: file.kind } : {}), | |
| 299 | ...(typeof file.additions === "number" ? { additions: file.additions } : {}), | |
| 300 | ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), | |
| 301 | })), | |
| 302 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), | |
| 303 | ...(typeof summary.checkpointTurnCount === "number" | |
| 304 | ? { checkpointTurnCount: summary.checkpointTurnCount } | |
| 305 | : {}), | |
| 306 | })), | |
| 273 | 307 | }; |
| 274 | 308 | } |
| 275 | 309 | |
| ... | ... | @@ -296,15 +330,9 @@ export function hydratePersistedState( |
| 296 | 330 | const threads = parsedState.data.threads |
| 297 | 331 | .map((thread) => hydrateThread(thread, isLegacyPayload)) |
| 298 | 332 | .filter((thread) => projectIds.has(thread.projectId)); |
| 299 | const hasActiveThread = Boolean( | |
| 300 | parsedState.data.activeThreadId && | |
| 301 | threads.some((thread) => thread.id === parsedState.data.activeThreadId), | |
| 302 | ); | |
| 303 | ||
| 304 | 333 | return { |
| 305 | 334 | projects, |
| 306 | 335 | threads, |
| 307 | activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null), | |
| 308 | 336 | runtimeMode: |
| 309 | 337 | "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, |
| 310 | 338 | }; |
| ... | ... | @@ -350,8 +378,22 @@ export function toPersistedState( |
| 350 | 378 | ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), |
| 351 | 379 | branch: thread.branch, |
| 352 | 380 | worktreePath: thread.worktreePath, |
| 381 | turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ | |
| 382 | turnId: summary.turnId, | |
| 383 | completedAt: summary.completedAt, | |
| 384 | ...(summary.status ? { status: summary.status } : {}), | |
| 385 | files: summary.files.map((file) => ({ | |
| 386 | path: file.path, | |
| 387 | ...(file.kind ? { kind: file.kind } : {}), | |
| 388 | ...(typeof file.additions === "number" ? { additions: file.additions } : {}), | |
| 389 | ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), | |
| 390 | })), | |
| 391 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), | |
| 392 | ...(typeof summary.checkpointTurnCount === "number" | |
| 393 | ? { checkpointTurnCount: summary.checkpointTurnCount } | |
| 394 | : {}), | |
| 395 | })), | |
| 353 | 396 | })), |
| 354 | activeThreadId: state.activeThreadId, | |
| 355 | 397 | runtimeMode: state.runtimeMode, |
| 356 | 398 | }; |
| 357 | 399 | } |
apps/web/src/projectScripts.test.ts created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | commandForProjectScript, | |
| 5 | nextProjectScriptId, | |
| 6 | primaryProjectScript, | |
| 7 | projectScriptRuntimeEnv, | |
| 8 | projectScriptIdFromCommand, | |
| 9 | setupProjectScript, | |
| 10 | } from "./projectScripts"; | |
| 11 | ||
| 12 | describe("projectScripts helpers", () => { | |
| 13 | it("builds and parses script run commands", () => { | |
| 14 | const command = commandForProjectScript("lint"); | |
| 15 | expect(command).toBe("script.lint.run"); | |
| 16 | expect(projectScriptIdFromCommand(command)).toBe("lint"); | |
| 17 | expect(projectScriptIdFromCommand("terminal.toggle")).toBeNull(); | |
| 18 | }); | |
| 19 | ||
| 20 | it("slugifies and dedupes project script ids", () => { | |
| 21 | expect(nextProjectScriptId("Run Tests", [])).toBe("run-tests"); | |
| 22 | expect(nextProjectScriptId("Run Tests", ["run-tests"])).toBe("run-tests-2"); | |
| 23 | expect(nextProjectScriptId("!!!", [])).toBe("script"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("resolves primary and setup scripts", () => { | |
| 27 | const scripts = [ | |
| 28 | { | |
| 29 | id: "setup", | |
| 30 | name: "Setup", | |
| 31 | command: "bun install", | |
| 32 | icon: "configure" as const, | |
| 33 | runOnWorktreeCreate: true, | |
| 34 | }, | |
| 35 | { | |
| 36 | id: "test", | |
| 37 | name: "Test", | |
| 38 | command: "bun test", | |
| 39 | icon: "test" as const, | |
| 40 | runOnWorktreeCreate: false, | |
| 41 | }, | |
| 42 | ]; | |
| 43 | ||
| 44 | expect(primaryProjectScript(scripts)?.id).toBe("test"); | |
| 45 | expect(setupProjectScript(scripts)?.id).toBe("setup"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("builds default runtime env for scripts", () => { | |
| 49 | const env = projectScriptRuntimeEnv({ | |
| 50 | project: { cwd: "/repo" }, | |
| 51 | worktreePath: "/repo/worktree-a", | |
| 52 | }); | |
| 53 | ||
| 54 | expect(env).toMatchObject({ | |
| 55 | T3CODE_PROJECT_ROOT: "/repo", | |
| 56 | T3CODE_WORKTREE_PATH: "/repo/worktree-a", | |
| 57 | }); | |
| 58 | }); | |
| 59 | ||
| 60 | it("allows overriding runtime env values", () => { | |
| 61 | const env = projectScriptRuntimeEnv({ | |
| 62 | project: { cwd: "/repo" }, | |
| 63 | extraEnv: { | |
| 64 | T3CODE_PROJECT_ROOT: "/custom-root", | |
| 65 | CUSTOM_FLAG: "1", | |
| 66 | }, | |
| 67 | }); | |
| 68 | ||
| 69 | expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); | |
| 70 | expect(env.CUSTOM_FLAG).toBe("1"); | |
| 71 | expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); | |
| 72 | }); | |
| 73 | }); |
apps/web/src/projectScripts.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import type { ProjectScript } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | const SCRIPT_RUN_COMMAND_PATTERN = /^script\.([a-z0-9][a-z0-9-]*)\.run$/; | |
| 4 | const MAX_SCRIPT_ID_LENGTH = 64; | |
| 5 | ||
| 6 | function normalizeScriptId(value: string): string { | |
| 7 | const cleaned = value | |
| 8 | .trim() | |
| 9 | .toLowerCase() | |
| 10 | .replace(/[^a-z0-9]+/g, "-") | |
| 11 | .replace(/^-+|-+$/g, ""); | |
| 12 | if (cleaned.length === 0) { | |
| 13 | return "script"; | |
| 14 | } | |
| 15 | if (cleaned.length <= MAX_SCRIPT_ID_LENGTH) { | |
| 16 | return cleaned; | |
| 17 | } | |
| 18 | return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script"; | |
| 19 | } | |
| 20 | ||
| 21 | export function commandForProjectScript(scriptId: string): string { | |
| 22 | return `script.${scriptId}.run`; | |
| 23 | } | |
| 24 | ||
| 25 | export function projectScriptIdFromCommand(command: string): string | null { | |
| 26 | const match = SCRIPT_RUN_COMMAND_PATTERN.exec(command.trim()); | |
| 27 | return match?.[1] ?? null; | |
| 28 | } | |
| 29 | ||
| 30 | export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string { | |
| 31 | const taken = new Set(Array.from(existingIds)); | |
| 32 | const baseId = normalizeScriptId(name); | |
| 33 | if (!taken.has(baseId)) return baseId; | |
| 34 | ||
| 35 | let suffix = 2; | |
| 36 | while (suffix < 10_000) { | |
| 37 | const candidate = `${baseId}-${suffix}`; | |
| 38 | const safeCandidate = | |
| 39 | candidate.length <= MAX_SCRIPT_ID_LENGTH | |
| 40 | ? candidate | |
| 41 | : `${baseId.slice(0, Math.max(1, MAX_SCRIPT_ID_LENGTH - String(suffix).length - 1))}-${suffix}`; | |
| 42 | if (!taken.has(safeCandidate)) { | |
| 43 | return safeCandidate; | |
| 44 | } | |
| 45 | suffix += 1; | |
| 46 | } | |
| 47 | ||
| 48 | // This last-resort fallback only triggers after exhausting thousands of suffixes. | |
| 49 | return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); | |
| 50 | } | |
| 51 | ||
| 52 | interface ProjectScriptRuntimeEnvInput { | |
| 53 | project: { | |
| 54 | cwd: string; | |
| 55 | }; | |
| 56 | worktreePath?: string | null; | |
| 57 | extraEnv?: Record<string, string>; | |
| 58 | } | |
| 59 | ||
| 60 | export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> { | |
| 61 | const env: Record<string, string> = { | |
| 62 | T3CODE_PROJECT_ROOT: input.project.cwd, | |
| 63 | }; | |
| 64 | if (input.worktreePath) { | |
| 65 | env.T3CODE_WORKTREE_PATH = input.worktreePath; | |
| 66 | } | |
| 67 | if (input.extraEnv) { | |
| 68 | return { ...env, ...input.extraEnv }; | |
| 69 | } | |
| 70 | return env; | |
| 71 | } | |
| 72 | ||
| 73 | export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { | |
| 74 | const regular = scripts.find((script) => !script.runOnWorktreeCreate); | |
| 75 | return regular ?? scripts[0] ?? null; | |
| 76 | } | |
| 77 | ||
| 78 | export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null { | |
| 79 | return scripts.find((script) => script.runOnWorktreeCreate) ?? null; | |
| 80 | } |
apps/web/src/routeTree.gen.ts created+118| ... | ... | @@ -0,0 +1,118 @@ |
| 1 | /* eslint-disable */ | |
| 2 | ||
| 3 | // @ts-nocheck | |
| 4 | ||
| 5 | // noinspection JSUnusedGlobalSymbols | |
| 6 | ||
| 7 | // This file was automatically generated by TanStack Router. | |
| 8 | // You should NOT make any changes in this file as it will be overwritten. | |
| 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. | |
| 10 | ||
| 11 | import { Route as rootRouteImport } from './routes/__root' | |
| 12 | import { Route as ChatRouteImport } from './routes/_chat' | |
| 13 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | |
| 14 | import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' | |
| 15 | import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' | |
| 16 | ||
| 17 | const ChatRoute = ChatRouteImport.update({ | |
| 18 | id: '/_chat', | |
| 19 | getParentRoute: () => rootRouteImport, | |
| 20 | } as any) | |
| 21 | const ChatIndexRoute = ChatIndexRouteImport.update({ | |
| 22 | id: '/', | |
| 23 | path: '/', | |
| 24 | getParentRoute: () => ChatRoute, | |
| 25 | } as any) | |
| 26 | const ChatSettingsRoute = ChatSettingsRouteImport.update({ | |
| 27 | id: '/settings', | |
| 28 | path: '/settings', | |
| 29 | getParentRoute: () => ChatRoute, | |
| 30 | } as any) | |
| 31 | const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ | |
| 32 | id: '/$threadId', | |
| 33 | path: '/$threadId', | |
| 34 | getParentRoute: () => ChatRoute, | |
| 35 | } as any) | |
| 36 | ||
| 37 | export interface FileRoutesByFullPath { | |
| 38 | '/': typeof ChatIndexRoute | |
| 39 | '/$threadId': typeof ChatThreadIdRoute | |
| 40 | '/settings': typeof ChatSettingsRoute | |
| 41 | } | |
| 42 | export interface FileRoutesByTo { | |
| 43 | '/$threadId': typeof ChatThreadIdRoute | |
| 44 | '/settings': typeof ChatSettingsRoute | |
| 45 | '/': typeof ChatIndexRoute | |
| 46 | } | |
| 47 | export interface FileRoutesById { | |
| 48 | __root__: typeof rootRouteImport | |
| 49 | '/_chat': typeof ChatRouteWithChildren | |
| 50 | '/_chat/$threadId': typeof ChatThreadIdRoute | |
| 51 | '/_chat/settings': typeof ChatSettingsRoute | |
| 52 | '/_chat/': typeof ChatIndexRoute | |
| 53 | } | |
| 54 | export interface FileRouteTypes { | |
| 55 | fileRoutesByFullPath: FileRoutesByFullPath | |
| 56 | fullPaths: '/' | '/$threadId' | '/settings' | |
| 57 | fileRoutesByTo: FileRoutesByTo | |
| 58 | to: '/$threadId' | '/settings' | '/' | |
| 59 | id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/' | |
| 60 | fileRoutesById: FileRoutesById | |
| 61 | } | |
| 62 | export interface RootRouteChildren { | |
| 63 | ChatRoute: typeof ChatRouteWithChildren | |
| 64 | } | |
| 65 | ||
| 66 | declare module '@tanstack/react-router' { | |
| 67 | interface FileRoutesByPath { | |
| 68 | '/_chat': { | |
| 69 | id: '/_chat' | |
| 70 | path: '' | |
| 71 | fullPath: '/' | |
| 72 | preLoaderRoute: typeof ChatRouteImport | |
| 73 | parentRoute: typeof rootRouteImport | |
| 74 | } | |
| 75 | '/_chat/': { | |
| 76 | id: '/_chat/' | |
| 77 | path: '/' | |
| 78 | fullPath: '/' | |
| 79 | preLoaderRoute: typeof ChatIndexRouteImport | |
| 80 | parentRoute: typeof ChatRoute | |
| 81 | } | |
| 82 | '/_chat/settings': { | |
| 83 | id: '/_chat/settings' | |
| 84 | path: '/settings' | |
| 85 | fullPath: '/settings' | |
| 86 | preLoaderRoute: typeof ChatSettingsRouteImport | |
| 87 | parentRoute: typeof ChatRoute | |
| 88 | } | |
| 89 | '/_chat/$threadId': { | |
| 90 | id: '/_chat/$threadId' | |
| 91 | path: '/$threadId' | |
| 92 | fullPath: '/$threadId' | |
| 93 | preLoaderRoute: typeof ChatThreadIdRouteImport | |
| 94 | parentRoute: typeof ChatRoute | |
| 95 | } | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | interface ChatRouteChildren { | |
| 100 | ChatThreadIdRoute: typeof ChatThreadIdRoute | |
| 101 | ChatSettingsRoute: typeof ChatSettingsRoute | |
| 102 | ChatIndexRoute: typeof ChatIndexRoute | |
| 103 | } | |
| 104 | ||
| 105 | const ChatRouteChildren: ChatRouteChildren = { | |
| 106 | ChatThreadIdRoute: ChatThreadIdRoute, | |
| 107 | ChatSettingsRoute: ChatSettingsRoute, | |
| 108 | ChatIndexRoute: ChatIndexRoute, | |
| 109 | } | |
| 110 | ||
| 111 | const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) | |
| 112 | ||
| 113 | const rootRouteChildren: RootRouteChildren = { | |
| 114 | ChatRoute: ChatRouteWithChildren, | |
| 115 | } | |
| 116 | export const routeTree = rootRouteImport | |
| 117 | ._addFileChildren(rootRouteChildren) | |
| 118 | ._addFileTypes<FileRouteTypes>() |
apps/web/src/routes/__root.tsx created+198| ... | ... | @@ -0,0 +1,198 @@ |
| 1 | import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router"; | |
| 2 | import { useEffect, useRef } from "react"; | |
| 3 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; | |
| 4 | ||
| 5 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; | |
| 6 | import { isElectron } from "../env"; | |
| 7 | import { useNativeApi } from "../hooks/useNativeApi"; | |
| 8 | import { invalidateGitQueries } from "../lib/gitReactQuery"; | |
| 9 | import { DEFAULT_MODEL } from "../model-logic"; | |
| 10 | import { useStore } from "../store"; | |
| 11 | import { onServerWelcome } from "../wsNativeApi"; | |
| 12 | import { APP_DISPLAY_NAME } from "../branding"; | |
| 13 | ||
| 14 | export const Route = createRootRouteWithContext<{ | |
| 15 | queryClient: QueryClient; | |
| 16 | }>()({ | |
| 17 | component: RootRouteView, | |
| 18 | }); | |
| 19 | ||
| 20 | function RootRouteView() { | |
| 21 | const api = useNativeApi(); | |
| 22 | ||
| 23 | if (!api) { | |
| 24 | return ( | |
| 25 | <div className="flex h-screen flex-col bg-background text-foreground"> | |
| 26 | <div className="flex flex-1 items-center justify-center"> | |
| 27 | <p className="text-sm text-muted-foreground"> | |
| 28 | Connecting to {APP_DISPLAY_NAME} server... | |
| 29 | </p> | |
| 30 | </div> | |
| 31 | </div> | |
| 32 | ); | |
| 33 | } | |
| 34 | ||
| 35 | return ( | |
| 36 | <ToastProvider> | |
| 37 | <AnchoredToastProvider> | |
| 38 | <EventRouter /> | |
| 39 | <AutoProjectBootstrap /> | |
| 40 | <DesktopProjectBootstrap /> | |
| 41 | <Outlet /> | |
| 42 | </AnchoredToastProvider> | |
| 43 | </ToastProvider> | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | function EventRouter() { | |
| 48 | const api = useNativeApi(); | |
| 49 | const { dispatch } = useStore(); | |
| 50 | const queryClient = useQueryClient(); | |
| 51 | const activeAssistantItemRef = useRef<string | null>(null); | |
| 52 | const activeThreadId = useParams({ | |
| 53 | strict: false, | |
| 54 | select: (params) => params.threadId, | |
| 55 | }); | |
| 56 | ||
| 57 | useEffect(() => { | |
| 58 | if (!api) return; | |
| 59 | return api.providers.onEvent((event) => { | |
| 60 | if (event.method === "turn/completed") { | |
| 61 | void invalidateGitQueries(queryClient); | |
| 62 | } | |
| 63 | if (event.method === "checkpoint/captured") { | |
| 64 | const payload = event.payload as { turnCount?: number } | undefined; | |
| 65 | const turnCount = payload?.turnCount; | |
| 66 | void queryClient.invalidateQueries({ | |
| 67 | queryKey: ["providers", "checkpointDiff"] as const, | |
| 68 | predicate: (query) => { | |
| 69 | if (typeof turnCount !== "number") return true; | |
| 70 | return query.queryKey[5] === turnCount; | |
| 71 | }, | |
| 72 | }); | |
| 73 | } | |
| 74 | if (!activeThreadId) return; | |
| 75 | dispatch({ | |
| 76 | type: "APPLY_EVENT", | |
| 77 | event, | |
| 78 | activeAssistantItemRef, | |
| 79 | activeThreadId, | |
| 80 | }); | |
| 81 | }); | |
| 82 | }, [activeThreadId, api, dispatch, queryClient]); | |
| 83 | ||
| 84 | useEffect(() => { | |
| 85 | if (!activeThreadId) return; | |
| 86 | dispatch({ | |
| 87 | type: "MARK_THREAD_VISITED", | |
| 88 | threadId: activeThreadId, | |
| 89 | visitedAt: new Date().toISOString(), | |
| 90 | }); | |
| 91 | }, [activeThreadId, dispatch]); | |
| 92 | ||
| 93 | useEffect(() => { | |
| 94 | if (!api) return; | |
| 95 | return api.terminal.onEvent((event) => { | |
| 96 | dispatch({ | |
| 97 | type: "APPLY_TERMINAL_EVENT", | |
| 98 | event, | |
| 99 | }); | |
| 100 | }); | |
| 101 | }, [api, dispatch]); | |
| 102 | ||
| 103 | return null; | |
| 104 | } | |
| 105 | ||
| 106 | function AutoProjectBootstrap() { | |
| 107 | const { state, dispatch } = useStore(); | |
| 108 | const bootstrappedRef = useRef(false); | |
| 109 | ||
| 110 | useEffect(() => { | |
| 111 | // Browser mode bootstraps from server welcome. | |
| 112 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | |
| 113 | if (isElectron) return; | |
| 114 | ||
| 115 | return onServerWelcome((payload) => { | |
| 116 | if (bootstrappedRef.current) return; | |
| 117 | ||
| 118 | // Don't create duplicate projects for the same cwd | |
| 119 | const existing = state.projects.find((project) => project.cwd === payload.cwd); | |
| 120 | if (existing) { | |
| 121 | bootstrappedRef.current = true; | |
| 122 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 123 | return; | |
| 124 | } | |
| 125 | ||
| 126 | bootstrappedRef.current = true; | |
| 127 | ||
| 128 | // Create project + thread from server cwd | |
| 129 | const projectId = crypto.randomUUID(); | |
| 130 | dispatch({ | |
| 131 | type: "ADD_PROJECT", | |
| 132 | project: { | |
| 133 | id: projectId, | |
| 134 | name: payload.projectName, | |
| 135 | cwd: payload.cwd, | |
| 136 | model: DEFAULT_MODEL, | |
| 137 | expanded: true, | |
| 138 | scripts: [], | |
| 139 | }, | |
| 140 | }); | |
| 141 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 142 | }); | |
| 143 | }, [state.projects, dispatch]); | |
| 144 | ||
| 145 | return null; | |
| 146 | } | |
| 147 | ||
| 148 | function DesktopProjectBootstrap() { | |
| 149 | const api = useNativeApi(); | |
| 150 | const { dispatch } = useStore(); | |
| 151 | const bootstrappedRef = useRef(false); | |
| 152 | ||
| 153 | useEffect(() => { | |
| 154 | if (!isElectron || !api || bootstrappedRef.current) return; | |
| 155 | ||
| 156 | let disposed = false; | |
| 157 | let retryDelayMs = 500; | |
| 158 | let retryTimer: ReturnType<typeof setTimeout> | null = null; | |
| 159 | ||
| 160 | const attemptBootstrap = async () => { | |
| 161 | try { | |
| 162 | const projects = await api.projects.list(); | |
| 163 | if (disposed) return; | |
| 164 | dispatch({ | |
| 165 | type: "SYNC_PROJECTS", | |
| 166 | projects: projects.map((project) => ({ | |
| 167 | id: project.id, | |
| 168 | name: project.name, | |
| 169 | cwd: project.cwd, | |
| 170 | model: DEFAULT_MODEL, | |
| 171 | expanded: true, | |
| 172 | scripts: project.scripts, | |
| 173 | })), | |
| 174 | }); | |
| 175 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | |
| 176 | bootstrappedRef.current = true; | |
| 177 | } catch { | |
| 178 | if (disposed) return; | |
| 179 | retryTimer = setTimeout(() => { | |
| 180 | retryTimer = null; | |
| 181 | void attemptBootstrap(); | |
| 182 | }, retryDelayMs); | |
| 183 | retryDelayMs = Math.min(retryDelayMs * 2, 5_000); | |
| 184 | } | |
| 185 | }; | |
| 186 | ||
| 187 | void attemptBootstrap(); | |
| 188 | ||
| 189 | return () => { | |
| 190 | disposed = true; | |
| 191 | if (retryTimer) { | |
| 192 | clearTimeout(retryTimer); | |
| 193 | } | |
| 194 | }; | |
| 195 | }, [api, dispatch]); | |
| 196 | ||
| 197 | return null; | |
| 198 | } |
apps/web/src/routes/_chat.$threadId.tsx created+114| ... | ... | @@ -0,0 +1,114 @@ |
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | |
| 3 | ||
| 4 | import ChatView from "../components/ChatView"; | |
| 5 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 6 | import { useMediaQuery } from "../hooks/useMediaQuery"; | |
| 7 | import { useStore } from "../store"; | |
| 8 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | |
| 9 | ||
| 10 | const DiffPanel = lazy(() => import("../components/DiffPanel")); | |
| 11 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; | |
| 12 | ||
| 13 | const DiffPanelWrapper = (props: { | |
| 14 | children: ReactNode; | |
| 15 | sheet: boolean; | |
| 16 | diffOpen: boolean; | |
| 17 | onCloseDiff: () => void; | |
| 18 | }) => { | |
| 19 | if (props.sheet) { | |
| 20 | return ( | |
| 21 | <Sheet | |
| 22 | open={props.diffOpen} | |
| 23 | onOpenChange={(open) => { | |
| 24 | if (!open) { | |
| 25 | props.onCloseDiff(); | |
| 26 | } | |
| 27 | }} | |
| 28 | > | |
| 29 | <SheetPopup | |
| 30 | side="right" | |
| 31 | showCloseButton={false} | |
| 32 | keepMounted | |
| 33 | className="w-[min(88vw,820px)] max-w-[820px] p-0" | |
| 34 | > | |
| 35 | {props.children} | |
| 36 | </SheetPopup> | |
| 37 | </Sheet> | |
| 38 | ); | |
| 39 | } | |
| 40 | ||
| 41 | return ( | |
| 42 | <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}> | |
| 43 | {props.children} | |
| 44 | </aside> | |
| 45 | ); | |
| 46 | }; | |
| 47 | ||
| 48 | function ChatThreadRouteView() { | |
| 49 | const { state } = useStore(); | |
| 50 | const navigate = useNavigate(); | |
| 51 | const { threadId } = Route.useParams(); | |
| 52 | const search = Route.useSearch(); | |
| 53 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; | |
| 54 | const diffOpen = search.diff === "1"; | |
| 55 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | |
| 56 | const closeDiff = useCallback(() => { | |
| 57 | void navigate({ | |
| 58 | to: "/$threadId", | |
| 59 | params: { threadId }, | |
| 60 | search: (previous) => { | |
| 61 | return stripDiffSearchParams(previous); | |
| 62 | }, | |
| 63 | }); | |
| 64 | }, [navigate, threadId]); | |
| 65 | ||
| 66 | useEffect(() => { | |
| 67 | if (!threadId) { | |
| 68 | void navigate({ to: "/", replace: true }); | |
| 69 | return; | |
| 70 | } | |
| 71 | ||
| 72 | if (!state.threadsHydrated) { | |
| 73 | return; | |
| 74 | } | |
| 75 | ||
| 76 | if (!threadExists) { | |
| 77 | void navigate({ to: "/", replace: true }); | |
| 78 | return; | |
| 79 | } | |
| 80 | }, [navigate, state.threadsHydrated, threadExists, threadId]); | |
| 81 | ||
| 82 | if (!threadId || !state.threadsHydrated || !threadExists) { | |
| 83 | return null; | |
| 84 | } | |
| 85 | ||
| 86 | const diffLoadingFallback = | |
| 87 | !diffOpen || shouldUseDiffSheet ? ( | |
| 88 | <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70"> | |
| 89 | Loading diff viewer... | |
| 90 | </div> | |
| 91 | ) : ( | |
| 92 | <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70"> | |
| 93 | Loading diff viewer... | |
| 94 | </aside> | |
| 95 | ); | |
| 96 | ||
| 97 | return ( | |
| 98 | <> | |
| 99 | <ChatView threadId={threadId} /> | |
| 100 | <Activity mode={diffOpen ? "visible" : "hidden"}> | |
| 101 | <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}> | |
| 102 | <Suspense fallback={diffLoadingFallback}> | |
| 103 | <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} /> | |
| 104 | </Suspense> | |
| 105 | </DiffPanelWrapper> | |
| 106 | </Activity> | |
| 107 | </> | |
| 108 | ); | |
| 109 | } | |
| 110 | ||
| 111 | export const Route = createFileRoute("/_chat/$threadId")({ | |
| 112 | validateSearch: (search) => parseDiffRouteSearch(search), | |
| 113 | component: ChatThreadRouteView, | |
| 114 | }); |
apps/web/src/routes/_chat.index.tsx created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { isElectron } from "../env"; | |
| 4 | ||
| 5 | function ChatIndexRouteView() { | |
| 6 | return ( | |
| 7 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> | |
| 8 | {isElectron && ( | |
| 9 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | |
| 10 | <span className="text-xs text-muted-foreground/50">No active thread</span> | |
| 11 | </div> | |
| 12 | )} | |
| 13 | ||
| 14 | <div className="flex flex-1 items-center justify-center"> | |
| 15 | <div className="text-center"> | |
| 16 | <p className="text-sm">Select a thread or create a new one to get started.</p> | |
| 17 | </div> | |
| 18 | </div> | |
| 19 | </div> | |
| 20 | ); | |
| 21 | } | |
| 22 | ||
| 23 | export const Route = createFileRoute("/_chat/")({ | |
| 24 | component: ChatIndexRouteView, | |
| 25 | }); |
apps/web/src/routes/_chat.settings.tsx created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { useAppSettings } from "../appSettings"; | |
| 4 | import { isElectron } from "../env"; | |
| 5 | import { useTheme } from "../hooks/useTheme"; | |
| 6 | import { useStore } from "../store"; | |
| 7 | import { Button } from "../components/ui/button"; | |
| 8 | import { Input } from "../components/ui/input"; | |
| 9 | import { Switch } from "../components/ui/switch"; | |
| 10 | ||
| 11 | const THEME_OPTIONS = [ | |
| 12 | { | |
| 13 | value: "system", | |
| 14 | label: "System", | |
| 15 | description: "Match your OS appearance setting.", | |
| 16 | }, | |
| 17 | { | |
| 18 | value: "light", | |
| 19 | label: "Light", | |
| 20 | description: "Always use the light theme.", | |
| 21 | }, | |
| 22 | { | |
| 23 | value: "dark", | |
| 24 | label: "Dark", | |
| 25 | description: "Always use the dark theme.", | |
| 26 | }, | |
| 27 | ] as const; | |
| 28 | ||
| 29 | const RUNTIME_MODE_OPTIONS = [ | |
| 30 | { | |
| 31 | value: "full-access", | |
| 32 | label: "Full access", | |
| 33 | description: "Allow commands to run without confirmation prompts.", | |
| 34 | }, | |
| 35 | { | |
| 36 | value: "approval-required", | |
| 37 | label: "Supervised", | |
| 38 | description: "Require approval prompts before command execution.", | |
| 39 | }, | |
| 40 | ] as const; | |
| 41 | ||
| 42 | function SettingsRouteView() { | |
| 43 | const { theme, setTheme, resolvedTheme } = useTheme(); | |
| 44 | const { state, dispatch } = useStore(); | |
| 45 | const { settings, defaults, updateSettings } = useAppSettings(); | |
| 46 | ||
| 47 | const codexBinaryPath = settings.codexBinaryPath; | |
| 48 | const codexHomePath = settings.codexHomePath; | |
| 49 | ||
| 50 | return ( | |
| 51 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | |
| 52 | {isElectron && ( | |
| 53 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | |
| 54 | <span className="text-xs font-medium tracking-wide text-muted-foreground/70">Settings</span> | |
| 55 | </div> | |
| 56 | )} | |
| 57 | ||
| 58 | <div className="flex-1 overflow-y-auto p-6"> | |
| 59 | <div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> | |
| 60 | <header className="space-y-1"> | |
| 61 | <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1> | |
| 62 | <p className="text-sm text-muted-foreground"> | |
| 63 | Configure app-level preferences for this device. | |
| 64 | </p> | |
| 65 | </header> | |
| 66 | ||
| 67 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 68 | <div className="mb-4"> | |
| 69 | <h2 className="text-sm font-medium text-foreground">Appearance</h2> | |
| 70 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 71 | Choose how T3 Code handles light and dark mode. | |
| 72 | </p> | |
| 73 | </div> | |
| 74 | ||
| 75 | <div className="space-y-2" role="radiogroup" aria-label="Theme preference"> | |
| 76 | {THEME_OPTIONS.map((option) => { | |
| 77 | const selected = theme === option.value; | |
| 78 | return ( | |
| 79 | <button | |
| 80 | key={option.value} | |
| 81 | type="button" | |
| 82 | role="radio" | |
| 83 | aria-checked={selected} | |
| 84 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 85 | selected | |
| 86 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 87 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 88 | }`} | |
| 89 | onClick={() => setTheme(option.value)} | |
| 90 | > | |
| 91 | <span className="flex flex-col"> | |
| 92 | <span className="text-sm font-medium">{option.label}</span> | |
| 93 | <span className="text-xs">{option.description}</span> | |
| 94 | </span> | |
| 95 | {selected ? ( | |
| 96 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 97 | Selected | |
| 98 | </span> | |
| 99 | ) : null} | |
| 100 | </button> | |
| 101 | ); | |
| 102 | })} | |
| 103 | </div> | |
| 104 | ||
| 105 | <p className="mt-4 text-xs text-muted-foreground"> | |
| 106 | Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span> | |
| 107 | </p> | |
| 108 | </section> | |
| 109 | ||
| 110 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 111 | <div className="mb-4"> | |
| 112 | <h2 className="text-sm font-medium text-foreground">Codex App Server</h2> | |
| 113 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 114 | These overrides apply to new sessions and let you use a non-default Codex install. | |
| 115 | </p> | |
| 116 | </div> | |
| 117 | ||
| 118 | <div className="space-y-4"> | |
| 119 | <label className="block space-y-1"> | |
| 120 | <span className="text-xs font-medium text-foreground">Codex binary path</span> | |
| 121 | <Input | |
| 122 | value={codexBinaryPath} | |
| 123 | onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })} | |
| 124 | placeholder="codex" | |
| 125 | spellCheck={false} | |
| 126 | /> | |
| 127 | <span className="text-xs text-muted-foreground"> | |
| 128 | Leave blank to use <code>codex</code> from your PATH. | |
| 129 | </span> | |
| 130 | </label> | |
| 131 | ||
| 132 | <label className="block space-y-1"> | |
| 133 | <span className="text-xs font-medium text-foreground">CODEX_HOME path</span> | |
| 134 | <Input | |
| 135 | value={codexHomePath} | |
| 136 | onChange={(event) => updateSettings({ codexHomePath: event.target.value })} | |
| 137 | placeholder="/Users/you/.codex" | |
| 138 | spellCheck={false} | |
| 139 | /> | |
| 140 | <span className="text-xs text-muted-foreground"> | |
| 141 | Optional custom Codex home/config directory. | |
| 142 | </span> | |
| 143 | </label> | |
| 144 | ||
| 145 | <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> | |
| 146 | <p> | |
| 147 | Binary source: <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span> | |
| 148 | </p> | |
| 149 | <Button | |
| 150 | size="xs" | |
| 151 | variant="outline" | |
| 152 | onClick={() => | |
| 153 | updateSettings({ | |
| 154 | codexBinaryPath: defaults.codexBinaryPath, | |
| 155 | codexHomePath: defaults.codexHomePath, | |
| 156 | }) | |
| 157 | } | |
| 158 | > | |
| 159 | Reset codex overrides | |
| 160 | </Button> | |
| 161 | </div> | |
| 162 | </div> | |
| 163 | </section> | |
| 164 | ||
| 165 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 166 | <div className="mb-4"> | |
| 167 | <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2> | |
| 168 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 169 | Select the default execution policy for this client. | |
| 170 | </p> | |
| 171 | </div> | |
| 172 | ||
| 173 | <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference"> | |
| 174 | {RUNTIME_MODE_OPTIONS.map((option) => { | |
| 175 | const selected = state.runtimeMode === option.value; | |
| 176 | return ( | |
| 177 | <button | |
| 178 | key={option.value} | |
| 179 | type="button" | |
| 180 | role="radio" | |
| 181 | aria-checked={selected} | |
| 182 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 183 | selected | |
| 184 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 185 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 186 | }`} | |
| 187 | onClick={() => { | |
| 188 | dispatch({ | |
| 189 | type: "SET_RUNTIME_MODE", | |
| 190 | mode: option.value, | |
| 191 | }); | |
| 192 | }} | |
| 193 | > | |
| 194 | <span className="flex flex-col"> | |
| 195 | <span className="text-sm font-medium">{option.label}</span> | |
| 196 | <span className="text-xs">{option.description}</span> | |
| 197 | </span> | |
| 198 | {selected ? ( | |
| 199 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 200 | Selected | |
| 201 | </span> | |
| 202 | ) : null} | |
| 203 | </button> | |
| 204 | ); | |
| 205 | })} | |
| 206 | </div> | |
| 207 | </section> | |
| 208 | ||
| 209 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 210 | <div className="mb-4"> | |
| 211 | <h2 className="text-sm font-medium text-foreground">Safety</h2> | |
| 212 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 213 | Additional guardrails for destructive local actions. | |
| 214 | </p> | |
| 215 | </div> | |
| 216 | ||
| 217 | <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2"> | |
| 218 | <div> | |
| 219 | <p className="text-sm font-medium text-foreground">Confirm thread deletion</p> | |
| 220 | <p className="text-xs text-muted-foreground"> | |
| 221 | Ask for confirmation before deleting a thread and its chat history. | |
| 222 | </p> | |
| 223 | </div> | |
| 224 | <Switch | |
| 225 | checked={settings.confirmThreadDelete} | |
| 226 | onCheckedChange={(checked) => | |
| 227 | updateSettings({ | |
| 228 | confirmThreadDelete: Boolean(checked), | |
| 229 | }) | |
| 230 | } | |
| 231 | aria-label="Confirm thread deletion" | |
| 232 | /> | |
| 233 | </div> | |
| 234 | ||
| 235 | {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( | |
| 236 | <div className="mt-3 flex justify-end"> | |
| 237 | <Button | |
| 238 | size="xs" | |
| 239 | variant="outline" | |
| 240 | onClick={() => | |
| 241 | updateSettings({ | |
| 242 | confirmThreadDelete: defaults.confirmThreadDelete, | |
| 243 | }) | |
| 244 | } | |
| 245 | > | |
| 246 | Restore default | |
| 247 | </Button> | |
| 248 | </div> | |
| 249 | ) : null} | |
| 250 | </section> | |
| 251 | </div> | |
| 252 | </div> | |
| 253 | </div> | |
| 254 | ); | |
| 255 | } | |
| 256 | ||
| 257 | export const Route = createFileRoute("/_chat/settings")({ | |
| 258 | component: SettingsRouteView, | |
| 259 | }); |
apps/web/src/routes/_chat.tsx created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { useEffect } from "react"; | |
| 3 | ||
| 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | |
| 5 | import Sidebar from "../components/Sidebar"; | |
| 6 | ||
| 7 | function ChatRouteLayout() { | |
| 8 | const navigate = useNavigate(); | |
| 9 | ||
| 10 | useEffect(() => { | |
| 11 | const onMenuAction = window.desktopBridge?.onMenuAction; | |
| 12 | if (typeof onMenuAction !== "function") { | |
| 13 | return; | |
| 14 | } | |
| 15 | ||
| 16 | const unsubscribe = onMenuAction((action) => { | |
| 17 | if (action !== "open-settings") return; | |
| 18 | void navigate({ to: "/settings" }); | |
| 19 | }); | |
| 20 | ||
| 21 | return () => { | |
| 22 | unsubscribe?.(); | |
| 23 | }; | |
| 24 | }, [navigate]); | |
| 25 | ||
| 26 | return ( | |
| 27 | <div className="flex h-screen overflow-hidden bg-background text-foreground isolate"> | |
| 28 | <Sidebar /> | |
| 29 | <DiffWorkerPoolProvider> | |
| 30 | <Outlet /> | |
| 31 | </DiffWorkerPoolProvider> | |
| 32 | </div> | |
| 33 | ); | |
| 34 | } | |
| 35 | ||
| 36 | export const Route = createFileRoute("/_chat")({ | |
| 37 | component: ChatRouteLayout, | |
| 38 | }); | |
| 39 |
apps/web/src/session-logic.test.ts+235| ... | ... | @@ -5,6 +5,8 @@ import { |
| 5 | 5 | type WorkLogEntry, |
| 6 | 6 | applyEventToMessages, |
| 7 | 7 | derivePendingApprovals, |
| 8 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 9 | deriveTurnDiffSummaries, | |
| 8 | 10 | deriveTimelineEntries, |
| 9 | 11 | deriveWorkLogEntries, |
| 10 | 12 | evolveSession, |
| ... | ... | @@ -393,6 +395,239 @@ describe("deriveWorkLogEntries", () => { |
| 393 | 395 | }); |
| 394 | 396 | }); |
| 395 | 397 | |
| 398 | describe("deriveTurnDiffSummaries", () => { | |
| 399 | it("tracks completed turns and links them to assistant message completion", () => { | |
| 400 | const summaries = deriveTurnDiffSummaries([ | |
| 401 | makeEvent({ | |
| 402 | id: "evt-turn-start", | |
| 403 | method: "turn/started", | |
| 404 | turnId: "turn-1", | |
| 405 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 406 | }), | |
| 407 | makeEvent({ | |
| 408 | id: "evt-file-change", | |
| 409 | method: "item/completed", | |
| 410 | turnId: "turn-1", | |
| 411 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 412 | payload: { | |
| 413 | item: { | |
| 414 | id: "item-file-change", | |
| 415 | type: "fileChange", | |
| 416 | status: "completed", | |
| 417 | changes: [ | |
| 418 | { | |
| 419 | path: "src/a.ts", | |
| 420 | kind: "modified", | |
| 421 | diff: "diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n", | |
| 422 | }, | |
| 423 | ], | |
| 424 | }, | |
| 425 | }, | |
| 426 | }), | |
| 427 | makeEvent({ | |
| 428 | id: "evt-turn-diff-updated", | |
| 429 | method: "turn/diff/updated", | |
| 430 | turnId: "turn-1", | |
| 431 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 432 | payload: { | |
| 433 | threadId: "thr-1", | |
| 434 | turnId: "turn-1", | |
| 435 | diff: [ | |
| 436 | "diff --git a/src/a.ts b/src/a.ts", | |
| 437 | "@@ -1 +1 @@", | |
| 438 | "-old", | |
| 439 | "+new", | |
| 440 | "diff --git a/src/b.ts b/src/b.ts", | |
| 441 | "@@ -0,0 +1 @@", | |
| 442 | "+created", | |
| 443 | ].join("\n"), | |
| 444 | }, | |
| 445 | }), | |
| 446 | makeEvent({ | |
| 447 | id: "evt-agent-msg", | |
| 448 | method: "item/completed", | |
| 449 | turnId: "turn-1", | |
| 450 | createdAt: "2026-02-08T10:00:03.000Z", | |
| 451 | payload: { | |
| 452 | item: { | |
| 453 | id: "msg-1", | |
| 454 | type: "agentMessage", | |
| 455 | text: "Done", | |
| 456 | }, | |
| 457 | }, | |
| 458 | }), | |
| 459 | makeEvent({ | |
| 460 | id: "evt-turn-completed", | |
| 461 | method: "turn/completed", | |
| 462 | turnId: "turn-1", | |
| 463 | createdAt: "2026-02-08T10:00:04.000Z", | |
| 464 | payload: { | |
| 465 | turn: { | |
| 466 | id: "turn-1", | |
| 467 | status: "completed", | |
| 468 | }, | |
| 469 | }, | |
| 470 | }), | |
| 471 | makeEvent({ | |
| 472 | id: "evt-turn-2-file-change", | |
| 473 | method: "item/completed", | |
| 474 | turnId: "turn-2", | |
| 475 | createdAt: "2026-02-08T10:00:05.000Z", | |
| 476 | payload: { | |
| 477 | item: { | |
| 478 | id: "item-file-change-2", | |
| 479 | type: "fileChange", | |
| 480 | status: "completed", | |
| 481 | changes: [ | |
| 482 | { | |
| 483 | path: "src/unfinished.ts", | |
| 484 | kind: "modified", | |
| 485 | diff: "diff --git a/src/unfinished.ts b/src/unfinished.ts", | |
| 486 | }, | |
| 487 | ], | |
| 488 | }, | |
| 489 | }, | |
| 490 | }), | |
| 491 | ]); | |
| 492 | ||
| 493 | expect(summaries).toHaveLength(1); | |
| 494 | expect(summaries[0]?.turnId).toBe("turn-1"); | |
| 495 | expect(summaries[0]?.assistantMessageId).toBe("msg-1"); | |
| 496 | expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); | |
| 497 | expect(summaries[0]?.files[0]?.kind).toBe("modified"); | |
| 498 | expect(summaries[0]?.files[0]?.additions).toBe(1); | |
| 499 | expect(summaries[0]?.files[0]?.deletions).toBe(1); | |
| 500 | expect(summaries[0]).not.toHaveProperty("unifiedDiff"); | |
| 501 | }); | |
| 502 | ||
| 503 | it("includes completed turns even when no fileChange tool events were emitted", () => { | |
| 504 | const summaries = deriveTurnDiffSummaries([ | |
| 505 | makeEvent({ | |
| 506 | id: "evt-turn-start", | |
| 507 | method: "turn/started", | |
| 508 | turnId: "turn-1", | |
| 509 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 510 | }), | |
| 511 | makeEvent({ | |
| 512 | id: "evt-turn-completed", | |
| 513 | method: "turn/completed", | |
| 514 | turnId: "turn-1", | |
| 515 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 516 | payload: { | |
| 517 | turn: { | |
| 518 | id: "turn-1", | |
| 519 | status: "completed", | |
| 520 | }, | |
| 521 | }, | |
| 522 | }), | |
| 523 | ]); | |
| 524 | ||
| 525 | expect(summaries).toHaveLength(1); | |
| 526 | expect(summaries[0]?.turnId).toBe("turn-1"); | |
| 527 | expect(summaries[0]?.files).toEqual([]); | |
| 528 | }); | |
| 529 | ||
| 530 | it("keeps newest diff data when older events provide stale values", () => { | |
| 531 | const summaries = deriveTurnDiffSummaries([ | |
| 532 | makeEvent({ | |
| 533 | id: "evt-turn-diff-old", | |
| 534 | method: "turn/diff/updated", | |
| 535 | turnId: "turn-1", | |
| 536 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 537 | payload: { | |
| 538 | diff: [ | |
| 539 | "diff --git a/src/example.ts b/src/example.ts", | |
| 540 | "@@ -1 +1,2 @@", | |
| 541 | "-old", | |
| 542 | "+older", | |
| 543 | "+extra", | |
| 544 | ].join("\n"), | |
| 545 | }, | |
| 546 | }), | |
| 547 | makeEvent({ | |
| 548 | id: "evt-turn-diff-new", | |
| 549 | method: "turn/diff/updated", | |
| 550 | turnId: "turn-1", | |
| 551 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 552 | payload: { | |
| 553 | diff: [ | |
| 554 | "diff --git a/src/example.ts b/src/example.ts", | |
| 555 | "@@ -1 +1 @@", | |
| 556 | "-old", | |
| 557 | "+newest", | |
| 558 | ].join("\n"), | |
| 559 | }, | |
| 560 | }), | |
| 561 | makeEvent({ | |
| 562 | id: "evt-turn-completed", | |
| 563 | method: "turn/completed", | |
| 564 | turnId: "turn-1", | |
| 565 | createdAt: "2026-02-08T10:00:03.000Z", | |
| 566 | payload: { | |
| 567 | turn: { | |
| 568 | id: "turn-1", | |
| 569 | status: "completed", | |
| 570 | }, | |
| 571 | }, | |
| 572 | }), | |
| 573 | ]); | |
| 574 | ||
| 575 | expect(summaries).toHaveLength(1); | |
| 576 | expect(summaries[0]?.files[0]?.path).toBe("src/example.ts"); | |
| 577 | expect(summaries[0]?.files[0]?.additions).toBe(1); | |
| 578 | expect(summaries[0]?.files[0]?.deletions).toBe(1); | |
| 579 | }); | |
| 580 | }); | |
| 581 | ||
| 582 | describe("deriveTurnDiffFilesFromUnifiedDiff", () => { | |
| 583 | it("splits a multi-file patch into sorted per-file entries", () => { | |
| 584 | const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 585 | [ | |
| 586 | "diff --git a/src/b.ts b/src/b.ts", | |
| 587 | "@@ -1 +1 @@", | |
| 588 | "-old-b", | |
| 589 | "+new-b", | |
| 590 | "diff --git a/src/a.ts b/src/a.ts", | |
| 591 | "@@ -1 +1 @@", | |
| 592 | "-old-a", | |
| 593 | "+new-a", | |
| 594 | ].join("\n"), | |
| 595 | ); | |
| 596 | ||
| 597 | expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); | |
| 598 | expect(files[0]?.additions).toBe(1); | |
| 599 | expect(files[0]?.deletions).toBe(1); | |
| 600 | }); | |
| 601 | ||
| 602 | it("parses git headers when file paths include ` b/`", () => { | |
| 603 | const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 604 | [ | |
| 605 | "diff --git a/subdir b/example.ts b/subdir b/example.ts", | |
| 606 | "--- a/subdir b/example.ts", | |
| 607 | "+++ b/subdir b/example.ts", | |
| 608 | "@@ -1 +1 @@", | |
| 609 | "-old", | |
| 610 | "+new", | |
| 611 | ].join("\n"), | |
| 612 | ); | |
| 613 | ||
| 614 | expect(files).toHaveLength(1); | |
| 615 | expect(files[0]?.path).toBe("subdir b/example.ts"); | |
| 616 | }); | |
| 617 | ||
| 618 | it("parses git header fallback for binary diffs with ` b/` in path", () => { | |
| 619 | const files = deriveTurnDiffFilesFromUnifiedDiff( | |
| 620 | [ | |
| 621 | "diff --git a/subdir b/image.png b/subdir b/image.png", | |
| 622 | "Binary files a/subdir b/image.png and b/subdir b/image.png differ", | |
| 623 | ].join("\n"), | |
| 624 | ); | |
| 625 | ||
| 626 | expect(files).toHaveLength(1); | |
| 627 | expect(files[0]?.path).toBe("subdir b/image.png"); | |
| 628 | }); | |
| 629 | }); | |
| 630 | ||
| 396 | 631 | describe("derivePendingApprovals", () => { |
| 397 | 632 | it("returns pending command/file-change approvals", () => { |
| 398 | 633 | const approvals = derivePendingApprovals([ |
apps/web/src/session-logic.ts+277-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; |
| 2 | import type { ChatMessage, SessionPhase } from "./types"; | |
| 2 | import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; | |
| 3 | 3 | import { createWsNativeApi } from "./wsNativeApi"; |
| 4 | 4 | |
| 5 | 5 | export const PROVIDER_OPTIONS: Array<{ |
| ... | ... | @@ -33,10 +33,18 @@ export function asObject(value: unknown): Record<string, unknown> | undefined { |
| 33 | 33 | return value as Record<string, unknown>; |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | function asArray(value: unknown): unknown[] | undefined { | |
| 37 | return Array.isArray(value) ? value : undefined; | |
| 38 | } | |
| 39 | ||
| 36 | 40 | export function asString(value: unknown): string | undefined { |
| 37 | 41 | return typeof value === "string" ? value : undefined; |
| 38 | 42 | } |
| 39 | 43 | |
| 44 | function asNumber(value: unknown): number | undefined { | |
| 45 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; | |
| 46 | } | |
| 47 | ||
| 40 | 48 | export function formatTimestamp(isoDate: string): string { |
| 41 | 49 | return new Intl.DateTimeFormat(undefined, { |
| 42 | 50 | hour: "numeric", |
| ... | ... | @@ -99,6 +107,8 @@ export type TimelineEntry = |
| 99 | 107 | entry: WorkLogEntry; |
| 100 | 108 | }; |
| 101 | 109 | |
| 110 | export type { TurnDiffFileChange, TurnDiffSummary } from "./types"; | |
| 111 | ||
| 102 | 112 | function normalizeDetail(value: string | undefined): string | undefined { |
| 103 | 113 | if (!value) return undefined; |
| 104 | 114 | const trimmed = value.trim(); |
| ... | ... | @@ -438,6 +448,272 @@ export function deriveWorkLogEntries( |
| 438 | 448 | return entries; |
| 439 | 449 | } |
| 440 | 450 | |
| 451 | interface MutableTurnDiffSummary { | |
| 452 | turnId: string; | |
| 453 | completedAt: string | undefined; | |
| 454 | status: string | undefined; | |
| 455 | assistantMessageId: string | undefined; | |
| 456 | hasTurnDiffUpdate: boolean; | |
| 457 | filesByPath: Map<string, TurnDiffFileChange>; | |
| 458 | } | |
| 459 | ||
| 460 | function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { | |
| 461 | if (event.method !== "item/completed") { | |
| 462 | return []; | |
| 463 | } | |
| 464 | ||
| 465 | const item = asObject(asObject(event.payload)?.item); | |
| 466 | const itemType = normalizeItemType(asString(item?.type)); | |
| 467 | if (itemType !== "file change") { | |
| 468 | return []; | |
| 469 | } | |
| 470 | ||
| 471 | const changes = asArray(item?.changes) ?? []; | |
| 472 | const parsed: TurnDiffFileChange[] = []; | |
| 473 | for (const rawChange of changes) { | |
| 474 | const change = asObject(rawChange); | |
| 475 | if (!change) continue; | |
| 476 | ||
| 477 | const diff = normalizeDetail(asString(change.diff)); | |
| 478 | const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); | |
| 479 | if (!path) continue; | |
| 480 | ||
| 481 | const additions = asNumber(change.additions); | |
| 482 | const deletions = asNumber(change.deletions); | |
| 483 | const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null; | |
| 484 | const kind = normalizeDetail(asString(change.kind)); | |
| 485 | parsed.push({ | |
| 486 | path, | |
| 487 | ...(kind ? { kind } : {}), | |
| 488 | ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), | |
| 489 | ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), | |
| 490 | }); | |
| 491 | } | |
| 492 | ||
| 493 | return parsed; | |
| 494 | } | |
| 495 | ||
| 496 | function mergeTurnDiffFileChange( | |
| 497 | byPath: Map<string, TurnDiffFileChange>, | |
| 498 | incoming: TurnDiffFileChange, | |
| 499 | ): void { | |
| 500 | const existing = byPath.get(incoming.path); | |
| 501 | if (!existing) { | |
| 502 | byPath.set(incoming.path, incoming); | |
| 503 | return; | |
| 504 | } | |
| 505 | ||
| 506 | if (existing.kind === undefined && incoming.kind !== undefined) { | |
| 507 | existing.kind = incoming.kind; | |
| 508 | } | |
| 509 | if (existing.additions === undefined && incoming.additions !== undefined) { | |
| 510 | existing.additions = incoming.additions; | |
| 511 | } | |
| 512 | if (existing.deletions === undefined && incoming.deletions !== undefined) { | |
| 513 | existing.deletions = incoming.deletions; | |
| 514 | } | |
| 515 | } | |
| 516 | ||
| 517 | function parsePathFromDiff(diff: string): string | undefined { | |
| 518 | const normalized = diff.replace(/\r\n/g, "\n"); | |
| 519 | const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); | |
| 520 | if (bPath && bPath[1]) return bPath[1]; | |
| 521 | const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); | |
| 522 | if (gitHeader && gitHeader[1]) return gitHeader[1]; | |
| 523 | const direct = normalized.match(/^\+\+\+ (.+)$/m); | |
| 524 | if (!direct || !direct[1] || direct[1] === "/dev/null") { | |
| 525 | return undefined; | |
| 526 | } | |
| 527 | return direct[1]; | |
| 528 | } | |
| 529 | ||
| 530 | export function splitUnifiedDiffByFile(diff: string): Map<string, string> { | |
| 531 | const normalized = diff.replace(/\r\n/g, "\n"); | |
| 532 | const byPath = new Map<string, string>(); | |
| 533 | const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; | |
| 534 | ||
| 535 | if (headerMatches.length === 0) { | |
| 536 | const path = parsePathFromDiff(normalized); | |
| 537 | if (path) { | |
| 538 | byPath.set(path, normalized.trim()); | |
| 539 | } | |
| 540 | return byPath; | |
| 541 | } | |
| 542 | ||
| 543 | for (let index = 0; index < headerMatches.length; index += 1) { | |
| 544 | const match = headerMatches[index]; | |
| 545 | if (!match) continue; | |
| 546 | const start = match.index ?? 0; | |
| 547 | const nextStart = headerMatches[index + 1]?.index ?? normalized.length; | |
| 548 | const segment = normalized.slice(start, nextStart).trim(); | |
| 549 | const path = parsePathFromDiff(segment); | |
| 550 | if (!path || segment.length === 0) continue; | |
| 551 | byPath.set(path, segment); | |
| 552 | } | |
| 553 | ||
| 554 | return byPath; | |
| 555 | } | |
| 556 | ||
| 557 | export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { | |
| 558 | const fileDiffsByPath = splitUnifiedDiffByFile(diff); | |
| 559 | return Array.from(fileDiffsByPath.entries()) | |
| 560 | .map(([path, fileDiff]) => { | |
| 561 | const stat = countDiffStat(fileDiff); | |
| 562 | return { | |
| 563 | path, | |
| 564 | additions: stat.additions, | |
| 565 | deletions: stat.deletions, | |
| 566 | }; | |
| 567 | }) | |
| 568 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 569 | } | |
| 570 | ||
| 571 | export function inferCheckpointTurnCountByTurnId( | |
| 572 | summaries: TurnDiffSummary[], | |
| 573 | ): Record<string, number> { | |
| 574 | if (summaries.length === 0) { | |
| 575 | return {}; | |
| 576 | } | |
| 577 | ||
| 578 | const sorted = [...summaries].toSorted((a, b) => { | |
| 579 | const aTime = Date.parse(a.completedAt); | |
| 580 | const bTime = Date.parse(b.completedAt); | |
| 581 | if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 582 | return a.completedAt.localeCompare(b.completedAt); | |
| 583 | } | |
| 584 | return aTime - bTime; | |
| 585 | }); | |
| 586 | ||
| 587 | const next: Record<string, number> = {}; | |
| 588 | let fallbackTurnCount = 1; | |
| 589 | for (const summary of sorted) { | |
| 590 | if (typeof summary.checkpointTurnCount === "number") { | |
| 591 | next[summary.turnId] = summary.checkpointTurnCount; | |
| 592 | fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1); | |
| 593 | continue; | |
| 594 | } | |
| 595 | next[summary.turnId] = fallbackTurnCount; | |
| 596 | fallbackTurnCount += 1; | |
| 597 | } | |
| 598 | ||
| 599 | return next; | |
| 600 | } | |
| 601 | ||
| 602 | export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } { | |
| 603 | if (!patch) { | |
| 604 | return { additions: 0, deletions: 0 }; | |
| 605 | } | |
| 606 | ||
| 607 | let additions = 0; | |
| 608 | let deletions = 0; | |
| 609 | for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { | |
| 610 | if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; | |
| 611 | if (line.startsWith("+")) { | |
| 612 | additions += 1; | |
| 613 | continue; | |
| 614 | } | |
| 615 | if (line.startsWith("-")) { | |
| 616 | deletions += 1; | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | return { additions, deletions }; | |
| 621 | } | |
| 622 | ||
| 623 | export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { | |
| 624 | const firstEventAt = Date.parse(events[0]?.createdAt ?? ""); | |
| 625 | const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? ""); | |
| 626 | const ordered = | |
| 627 | events.length < 2 || | |
| 628 | Number.isNaN(firstEventAt) || | |
| 629 | Number.isNaN(lastEventAt) || | |
| 630 | firstEventAt >= lastEventAt | |
| 631 | ? events | |
| 632 | : [...events].toReversed(); | |
| 633 | const byTurnId = new Map<string, MutableTurnDiffSummary>(); | |
| 634 | ||
| 635 | const ensureSummary = (turnId: string): MutableTurnDiffSummary => { | |
| 636 | const existing = byTurnId.get(turnId); | |
| 637 | if (existing) return existing; | |
| 638 | const next: MutableTurnDiffSummary = { | |
| 639 | turnId, | |
| 640 | completedAt: undefined, | |
| 641 | status: undefined, | |
| 642 | assistantMessageId: undefined, | |
| 643 | hasTurnDiffUpdate: false, | |
| 644 | filesByPath: new Map<string, TurnDiffFileChange>(), | |
| 645 | }; | |
| 646 | byTurnId.set(turnId, next); | |
| 647 | return next; | |
| 648 | }; | |
| 649 | ||
| 650 | for (const event of ordered) { | |
| 651 | const turnId = eventTurnId(event); | |
| 652 | if (!turnId) continue; | |
| 653 | const summary = ensureSummary(turnId); | |
| 654 | ||
| 655 | if (event.method === "turn/completed") { | |
| 656 | if (summary.completedAt === undefined) { | |
| 657 | summary.completedAt = event.createdAt; | |
| 658 | } | |
| 659 | const turn = asObject(asObject(event.payload)?.turn); | |
| 660 | if (summary.status === undefined) { | |
| 661 | summary.status = normalizeDetail(asString(turn?.status)); | |
| 662 | } | |
| 663 | } | |
| 664 | ||
| 665 | if (event.method === "item/completed") { | |
| 666 | const item = asObject(asObject(event.payload)?.item); | |
| 667 | if (asString(item?.type) === "agentMessage") { | |
| 668 | const itemId = normalizeDetail(asString(item?.id)); | |
| 669 | if (itemId && summary.assistantMessageId === undefined) { | |
| 670 | summary.assistantMessageId = itemId; | |
| 671 | } | |
| 672 | } | |
| 673 | } | |
| 674 | ||
| 675 | if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) { | |
| 676 | summary.hasTurnDiffUpdate = true; | |
| 677 | const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); | |
| 678 | if (diff) { | |
| 679 | for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { | |
| 680 | mergeTurnDiffFileChange(summary.filesByPath, file); | |
| 681 | } | |
| 682 | } | |
| 683 | } | |
| 684 | ||
| 685 | for (const file of parseFileChangeEntriesFromEvent(event)) { | |
| 686 | mergeTurnDiffFileChange(summary.filesByPath, file); | |
| 687 | } | |
| 688 | } | |
| 689 | ||
| 690 | const summaries: TurnDiffSummary[] = []; | |
| 691 | for (const summary of byTurnId.values()) { | |
| 692 | if (!summary.completedAt) continue; | |
| 693 | ||
| 694 | summaries.push({ | |
| 695 | turnId: summary.turnId, | |
| 696 | completedAt: summary.completedAt, | |
| 697 | ...(summary.status ? { status: summary.status } : {}), | |
| 698 | files: Array.from(summary.filesByPath.values()).toSorted((a, b) => | |
| 699 | a.path.localeCompare(b.path), | |
| 700 | ), | |
| 701 | ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), | |
| 702 | }); | |
| 703 | } | |
| 704 | ||
| 705 | summaries.sort((a, b) => { | |
| 706 | const aTime = Date.parse(a.completedAt); | |
| 707 | const bTime = Date.parse(b.completedAt); | |
| 708 | if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 709 | return b.completedAt.localeCompare(a.completedAt); | |
| 710 | } | |
| 711 | return bTime - aTime; | |
| 712 | }); | |
| 713 | ||
| 714 | return summaries; | |
| 715 | } | |
| 716 | ||
| 441 | 717 | function toTimestamp(isoDate: string): number { |
| 442 | 718 | const parsed = Date.parse(isoDate); |
| 443 | 719 | return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; |
apps/web/src/store.test.ts+428-22| ... | ... | @@ -2,7 +2,11 @@ import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/con |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { type AppState, reducer } from "./store"; |
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; | |
| 5 | import { | |
| 6 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 7 | DEFAULT_THREAD_TERMINAL_ID, | |
| 8 | MAX_THREAD_TERMINAL_COUNT, | |
| 9 | } from "./types"; | |
| 6 | 10 | import type { Thread } from "./types"; |
| 7 | 11 | |
| 8 | 12 | type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>; |
| ... | ... | @@ -53,6 +57,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 53 | 57 | session: makeSession(), |
| 54 | 58 | messages: [], |
| 55 | 59 | events: [], |
| 60 | turnDiffSummaries: [], | |
| 56 | 61 | error: null, |
| 57 | 62 | createdAt: "2026-02-09T00:00:00.000Z", |
| 58 | 63 | branch: null, |
| ... | ... | @@ -70,12 +75,12 @@ function makeState(thread: Thread): AppState { |
| 70 | 75 | cwd: "/tmp/project", |
| 71 | 76 | model: "gpt-5.3-codex", |
| 72 | 77 | expanded: true, |
| 78 | scripts: [], | |
| 73 | 79 | }, |
| 74 | 80 | ], |
| 75 | 81 | threads: [thread], |
| 76 | activeThreadId: thread.id, | |
| 82 | threadsHydrated: true, | |
| 77 | 83 | runtimeMode: "full-access", |
| 78 | diffOpen: false, | |
| 79 | 84 | }; |
| 80 | 85 | } |
| 81 | 86 | |
| ... | ... | @@ -255,6 +260,70 @@ describe("store reducer thread continuity", () => { |
| 255 | 260 | expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); |
| 256 | 261 | }); |
| 257 | 262 | |
| 263 | it("caps split terminals at four per thread", () => { | |
| 264 | const state = makeState( | |
| 265 | makeThread({ | |
| 266 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], | |
| 267 | activeTerminalId: "term-4", | |
| 268 | terminalGroups: [ | |
| 269 | { | |
| 270 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 271 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], | |
| 272 | }, | |
| 273 | ], | |
| 274 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 275 | }), | |
| 276 | ); | |
| 277 | const next = reducer(state, { | |
| 278 | type: "SPLIT_THREAD_TERMINAL", | |
| 279 | threadId: "thread-local-1", | |
| 280 | terminalId: "term-5", | |
| 281 | }); | |
| 282 | ||
| 283 | expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); | |
| 284 | expect(next.threads[0]?.terminalIds).toEqual([ | |
| 285 | DEFAULT_THREAD_TERMINAL_ID, | |
| 286 | "term-2", | |
| 287 | "term-3", | |
| 288 | "term-4", | |
| 289 | ]); | |
| 290 | expect(next.threads[0]?.activeTerminalId).toBe("term-4"); | |
| 291 | }); | |
| 292 | ||
| 293 | it("caps new terminals at four per thread", () => { | |
| 294 | const state = makeState( | |
| 295 | makeThread({ | |
| 296 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], | |
| 297 | activeTerminalId: "term-4", | |
| 298 | terminalGroups: [ | |
| 299 | { | |
| 300 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 301 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 302 | }, | |
| 303 | { id: "group-term-2", terminalIds: ["term-2"] }, | |
| 304 | { id: "group-term-3", terminalIds: ["term-3"] }, | |
| 305 | { id: "group-term-4", terminalIds: ["term-4"] }, | |
| 306 | ], | |
| 307 | activeTerminalGroupId: "group-term-4", | |
| 308 | }), | |
| 309 | ); | |
| 310 | const next = reducer(state, { | |
| 311 | type: "NEW_THREAD_TERMINAL", | |
| 312 | threadId: "thread-local-1", | |
| 313 | terminalId: "term-5", | |
| 314 | }); | |
| 315 | ||
| 316 | expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); | |
| 317 | expect(next.threads[0]?.terminalIds).toEqual([ | |
| 318 | DEFAULT_THREAD_TERMINAL_ID, | |
| 319 | "term-2", | |
| 320 | "term-3", | |
| 321 | "term-4", | |
| 322 | ]); | |
| 323 | expect(next.threads[0]?.activeTerminalId).toBe("term-4"); | |
| 324 | expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4"); | |
| 325 | }); | |
| 326 | ||
| 258 | 327 | it("closes a terminal and keeps grouped layout coherent", () => { |
| 259 | 328 | const state = makeState( |
| 260 | 329 | makeThread({ |
| ... | ... | @@ -430,6 +499,74 @@ describe("store reducer thread continuity", () => { |
| 430 | 499 | expect(next.threads[0]?.session?.threadId).toBe("thr_new"); |
| 431 | 500 | }); |
| 432 | 501 | |
| 502 | it("preserves persisted turn diffs when events were reset and appends new completed turn diffs", () => { | |
| 503 | const state = makeState( | |
| 504 | makeThread({ | |
| 505 | events: [], | |
| 506 | turnDiffSummaries: [ | |
| 507 | { | |
| 508 | turnId: "turn-1", | |
| 509 | completedAt: "2026-02-09T00:00:01.000Z", | |
| 510 | files: [{ path: "src/existing.ts", kind: "modified" }], | |
| 511 | }, | |
| 512 | ], | |
| 513 | }), | |
| 514 | ); | |
| 515 | ||
| 516 | const withFileChange = reducer(state, { | |
| 517 | type: "APPLY_EVENT", | |
| 518 | event: makeEvent({ | |
| 519 | method: "item/completed", | |
| 520 | turnId: "turn-2", | |
| 521 | createdAt: "2026-02-09T00:00:02.000Z", | |
| 522 | payload: { | |
| 523 | item: { | |
| 524 | type: "fileChange", | |
| 525 | changes: [{ path: "src/new.ts", kind: "added" }], | |
| 526 | }, | |
| 527 | }, | |
| 528 | }), | |
| 529 | activeAssistantItemRef: { current: null }, | |
| 530 | }); | |
| 531 | ||
| 532 | expect(withFileChange.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ | |
| 533 | "turn-1", | |
| 534 | ]); | |
| 535 | ||
| 536 | const completed = reducer(withFileChange, { | |
| 537 | type: "APPLY_EVENT", | |
| 538 | event: makeEvent({ | |
| 539 | method: "turn/completed", | |
| 540 | turnId: "turn-2", | |
| 541 | createdAt: "2026-02-09T00:00:03.000Z", | |
| 542 | payload: { turn: { id: "turn-2", status: "completed" } }, | |
| 543 | }), | |
| 544 | activeAssistantItemRef: { current: null }, | |
| 545 | }); | |
| 546 | ||
| 547 | expect(completed.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ | |
| 548 | "turn-2", | |
| 549 | "turn-1", | |
| 550 | ]); | |
| 551 | }); | |
| 552 | ||
| 553 | it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => { | |
| 554 | const state = makeState(makeThread({ turnDiffSummaries: [] })); | |
| 555 | const next = reducer(state, { | |
| 556 | type: "APPLY_EVENT", | |
| 557 | event: makeEvent({ | |
| 558 | method: "turn/completed", | |
| 559 | turnId: "turn-1", | |
| 560 | createdAt: "2026-02-09T00:00:03.000Z", | |
| 561 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 562 | }), | |
| 563 | activeAssistantItemRef: { current: null }, | |
| 564 | }); | |
| 565 | ||
| 566 | expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1"); | |
| 567 | expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1); | |
| 568 | }); | |
| 569 | ||
| 433 | 570 | it("reconciles project ids by cwd when syncing backend projects", () => { |
| 434 | 571 | const state: AppState = { |
| 435 | 572 | projects: [ |
| ... | ... | @@ -439,6 +576,7 @@ describe("store reducer thread continuity", () => { |
| 439 | 576 | cwd: "/tmp/a", |
| 440 | 577 | model: "gpt-5.3-codex", |
| 441 | 578 | expanded: false, |
| 579 | scripts: [], | |
| 442 | 580 | }, |
| 443 | 581 | { |
| 444 | 582 | id: "project-old-b", |
| ... | ... | @@ -446,6 +584,7 @@ describe("store reducer thread continuity", () => { |
| 446 | 584 | cwd: "/tmp/b", |
| 447 | 585 | model: "gpt-5.3-codex", |
| 448 | 586 | expanded: true, |
| 587 | scripts: [], | |
| 449 | 588 | }, |
| 450 | 589 | ], |
| 451 | 590 | threads: [ |
| ... | ... | @@ -458,9 +597,8 @@ describe("store reducer thread continuity", () => { |
| 458 | 597 | projectId: "project-old-b", |
| 459 | 598 | }), |
| 460 | 599 | ], |
| 461 | activeThreadId: "thread-b", | |
| 600 | threadsHydrated: true, | |
| 462 | 601 | runtimeMode: "full-access", |
| 463 | diffOpen: false, | |
| 464 | 602 | }; |
| 465 | 603 | |
| 466 | 604 | const next = reducer(state, { |
| ... | ... | @@ -472,6 +610,7 @@ describe("store reducer thread continuity", () => { |
| 472 | 610 | cwd: "/tmp/a", |
| 473 | 611 | model: "gpt-5.3-codex", |
| 474 | 612 | expanded: true, |
| 613 | scripts: [], | |
| 475 | 614 | }, |
| 476 | 615 | ], |
| 477 | 616 | }); |
| ... | ... | @@ -483,7 +622,85 @@ describe("store reducer thread continuity", () => { |
| 483 | 622 | expect(next.threads).toHaveLength(1); |
| 484 | 623 | expect(next.threads[0]?.id).toBe("thread-a"); |
| 485 | 624 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 486 | expect(next.activeThreadId).toBe("thread-a"); | |
| 625 | }); | |
| 626 | ||
| 627 | it("treats empty scripts from sync as authoritative", () => { | |
| 628 | const state: AppState = { | |
| 629 | projects: [ | |
| 630 | { | |
| 631 | id: "project-old-a", | |
| 632 | name: "A", | |
| 633 | cwd: "/tmp/a", | |
| 634 | model: "gpt-5.3-codex", | |
| 635 | expanded: true, | |
| 636 | scripts: [ | |
| 637 | { | |
| 638 | id: "test", | |
| 639 | name: "Test", | |
| 640 | command: "bun test", | |
| 641 | icon: "test", | |
| 642 | runOnWorktreeCreate: false, | |
| 643 | }, | |
| 644 | ], | |
| 645 | }, | |
| 646 | ], | |
| 647 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], | |
| 648 | threadsHydrated: true, | |
| 649 | runtimeMode: "full-access", | |
| 650 | }; | |
| 651 | ||
| 652 | const next = reducer(state, { | |
| 653 | type: "SYNC_PROJECTS", | |
| 654 | projects: [ | |
| 655 | { | |
| 656 | id: "project-new-a", | |
| 657 | name: "A", | |
| 658 | cwd: "/tmp/a", | |
| 659 | model: "gpt-5.3-codex", | |
| 660 | expanded: false, | |
| 661 | scripts: [], | |
| 662 | }, | |
| 663 | ], | |
| 664 | }); | |
| 665 | ||
| 666 | expect(next.projects[0]?.scripts).toEqual([]); | |
| 667 | }); | |
| 668 | ||
| 669 | it("updates project scripts", () => { | |
| 670 | const state = makeState(makeThread()); | |
| 671 | const next = reducer(state, { | |
| 672 | type: "SET_PROJECT_SCRIPTS", | |
| 673 | projectId: "project-1", | |
| 674 | scripts: [ | |
| 675 | { | |
| 676 | id: "test", | |
| 677 | name: "Test", | |
| 678 | command: "bun test", | |
| 679 | icon: "test", | |
| 680 | runOnWorktreeCreate: false, | |
| 681 | }, | |
| 682 | ], | |
| 683 | }); | |
| 684 | ||
| 685 | expect(next.projects[0]?.scripts).toEqual([ | |
| 686 | { | |
| 687 | id: "test", | |
| 688 | name: "Test", | |
| 689 | command: "bun test", | |
| 690 | icon: "test", | |
| 691 | runOnWorktreeCreate: false, | |
| 692 | }, | |
| 693 | ]); | |
| 694 | }); | |
| 695 | ||
| 696 | it("marks threads hydration state", () => { | |
| 697 | const state = makeState(makeThread()); | |
| 698 | const next = reducer(state, { | |
| 699 | type: "SET_THREADS_HYDRATED", | |
| 700 | hydrated: false, | |
| 701 | }); | |
| 702 | ||
| 703 | expect(next.threadsHydrated).toBe(false); | |
| 487 | 704 | }); |
| 488 | 705 | |
| 489 | 706 | it("deletes a project and all of its threads", () => { |
| ... | ... | @@ -495,6 +712,7 @@ describe("store reducer thread continuity", () => { |
| 495 | 712 | cwd: "/tmp/one", |
| 496 | 713 | model: "gpt-5.3-codex", |
| 497 | 714 | expanded: true, |
| 715 | scripts: [], | |
| 498 | 716 | }, |
| 499 | 717 | { |
| 500 | 718 | id: "project-2", |
| ... | ... | @@ -502,6 +720,7 @@ describe("store reducer thread continuity", () => { |
| 502 | 720 | cwd: "/tmp/two", |
| 503 | 721 | model: "gpt-5.3-codex", |
| 504 | 722 | expanded: true, |
| 723 | scripts: [], | |
| 505 | 724 | }, |
| 506 | 725 | ], |
| 507 | 726 | threads: [ |
| ... | ... | @@ -514,9 +733,8 @@ describe("store reducer thread continuity", () => { |
| 514 | 733 | projectId: "project-2", |
| 515 | 734 | }), |
| 516 | 735 | ], |
| 517 | activeThreadId: "thread-a", | |
| 736 | threadsHydrated: true, | |
| 518 | 737 | runtimeMode: "full-access", |
| 519 | diffOpen: false, | |
| 520 | 738 | }; |
| 521 | 739 | |
| 522 | 740 | const next = reducer(state, { |
| ... | ... | @@ -528,50 +746,238 @@ describe("store reducer thread continuity", () => { |
| 528 | 746 | expect(next.projects[0]?.id).toBe("project-2"); |
| 529 | 747 | expect(next.threads).toHaveLength(1); |
| 530 | 748 | expect(next.threads[0]?.id).toBe("thread-b"); |
| 531 | expect(next.activeThreadId).toBe("thread-b"); | |
| 532 | 749 | }); |
| 533 | 750 | |
| 534 | it("marks the active thread as visited when selected", () => { | |
| 751 | it("marks completion as seen immediately for the active thread", () => { | |
| 752 | const state = makeState( | |
| 753 | makeThread({ | |
| 754 | session: makeSession({ | |
| 755 | status: "running", | |
| 756 | activeTurnId: "turn-1", | |
| 757 | }), | |
| 758 | lastVisitedAt: "2026-02-08T10:00:00.000Z", | |
| 759 | }), | |
| 760 | ); | |
| 761 | ||
| 762 | const completedAt = "2026-02-08T10:00:10.000Z"; | |
| 763 | const next = reducer(state, { | |
| 764 | type: "APPLY_EVENT", | |
| 765 | event: makeEvent({ | |
| 766 | method: "turn/completed", | |
| 767 | turnId: "turn-1", | |
| 768 | createdAt: completedAt, | |
| 769 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 770 | }), | |
| 771 | activeAssistantItemRef: { current: null }, | |
| 772 | activeThreadId: "thread-local-1", | |
| 773 | }); | |
| 774 | ||
| 775 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); | |
| 776 | expect(next.threads[0]?.lastVisitedAt).toBe(completedAt); | |
| 777 | }); | |
| 778 | ||
| 779 | it("marks a thread as visited when selected", () => { | |
| 535 | 780 | const state = makeState( |
| 536 | 781 | makeThread({ |
| 537 | lastVisitedAt: "2000-01-01T00:00:00.000Z", | |
| 782 | latestTurnCompletedAt: "2026-02-08T10:00:10.000Z", | |
| 783 | lastVisitedAt: "2026-02-08T10:00:00.000Z", | |
| 538 | 784 | }), |
| 539 | 785 | ); |
| 540 | 786 | |
| 787 | const nextVisitedAt = "2026-02-08T10:00:20.000Z"; | |
| 541 | 788 | const next = reducer(state, { |
| 542 | type: "SET_ACTIVE_THREAD", | |
| 789 | type: "MARK_THREAD_VISITED", | |
| 543 | 790 | threadId: "thread-local-1", |
| 791 | visitedAt: nextVisitedAt, | |
| 544 | 792 | }); |
| 545 | 793 | |
| 546 | expect(next.activeThreadId).toBe("thread-local-1"); | |
| 547 | expect(next.threads[0]?.lastVisitedAt).toBeDefined(); | |
| 548 | expect(next.threads[0]?.lastVisitedAt).not.toBe("2000-01-01T00:00:00.000Z"); | |
| 794 | expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt); | |
| 549 | 795 | }); |
| 550 | 796 | |
| 551 | it("marks completion as seen immediately for the active thread", () => { | |
| 797 | it("reverts thread state to a checkpoint snapshot", () => { | |
| 552 | 798 | const state = makeState( |
| 553 | 799 | makeThread({ |
| 800 | codexThreadId: "thr_before", | |
| 554 | 801 | session: makeSession({ |
| 555 | 802 | status: "running", |
| 556 | activeTurnId: "turn-1", | |
| 803 | threadId: "thr_before", | |
| 804 | activeTurnId: "turn-live", | |
| 557 | 805 | }), |
| 558 | lastVisitedAt: "2026-02-08T10:00:00.000Z", | |
| 806 | messages: [ | |
| 807 | { | |
| 808 | id: "m-1", | |
| 809 | role: "user", | |
| 810 | text: "First", | |
| 811 | createdAt: "2026-02-08T10:00:00.000Z", | |
| 812 | streaming: false, | |
| 813 | }, | |
| 814 | { | |
| 815 | id: "m-2", | |
| 816 | role: "assistant", | |
| 817 | text: "First reply", | |
| 818 | createdAt: "2026-02-08T10:00:01.000Z", | |
| 819 | streaming: false, | |
| 820 | }, | |
| 821 | { | |
| 822 | id: "m-3", | |
| 823 | role: "user", | |
| 824 | text: "Second", | |
| 825 | createdAt: "2026-02-08T10:00:02.000Z", | |
| 826 | streaming: false, | |
| 827 | }, | |
| 828 | ], | |
| 829 | events: [ | |
| 830 | makeEvent({ | |
| 831 | method: "turn/started", | |
| 832 | turnId: "turn-live", | |
| 833 | }), | |
| 834 | ], | |
| 835 | turnDiffSummaries: [ | |
| 836 | { | |
| 837 | turnId: "turn_1", | |
| 838 | completedAt: "2026-02-08T10:00:01.000Z", | |
| 839 | files: [{ path: "src/first.ts", kind: "modified" }], | |
| 840 | checkpointTurnCount: 1, | |
| 841 | }, | |
| 842 | { | |
| 843 | turnId: "turn_2", | |
| 844 | completedAt: "2026-02-08T10:00:03.000Z", | |
| 845 | files: [{ path: "src/second.ts", kind: "modified" }], | |
| 846 | checkpointTurnCount: 2, | |
| 847 | }, | |
| 848 | ], | |
| 849 | error: "temporary failure", | |
| 850 | latestTurnId: "turn-live", | |
| 851 | latestTurnStartedAt: "2026-02-08T10:00:03.000Z", | |
| 852 | }), | |
| 853 | ); | |
| 854 | ||
| 855 | const next = reducer(state, { | |
| 856 | type: "REVERT_TO_CHECKPOINT", | |
| 857 | threadId: "thread-local-1", | |
| 858 | sessionId: "sess-1", | |
| 859 | threadRuntimeId: "thr_after", | |
| 860 | turnCount: 1, | |
| 861 | messageCount: 2, | |
| 862 | }); | |
| 863 | ||
| 864 | expect(next.threads[0]?.codexThreadId).toBe("thr_after"); | |
| 865 | expect(next.threads[0]?.messages.map((message) => message.id)).toEqual(["m-1", "m-2"]); | |
| 866 | expect(next.threads[0]?.events).toEqual([]); | |
| 867 | expect(next.threads[0]?.error).toBeNull(); | |
| 868 | expect(next.threads[0]?.session?.status).toBe("ready"); | |
| 869 | expect(next.threads[0]?.session?.activeTurnId).toBeUndefined(); | |
| 870 | expect(next.threads[0]?.session?.threadId).toBe("thr_after"); | |
| 871 | expect(next.threads[0]?.latestTurnId).toBeUndefined(); | |
| 872 | expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]); | |
| 873 | }); | |
| 874 | ||
| 875 | it("keeps existing turn file metadata when later events for the same turn arrive", () => { | |
| 876 | const state = makeState( | |
| 877 | makeThread({ | |
| 878 | turnDiffSummaries: [ | |
| 879 | { | |
| 880 | turnId: "turn-1", | |
| 881 | completedAt: "2026-02-09T00:00:03.000Z", | |
| 882 | files: [ | |
| 883 | { | |
| 884 | path: "src/from-checkpoint.ts", | |
| 885 | additions: 1, | |
| 886 | deletions: 1, | |
| 887 | }, | |
| 888 | ], | |
| 889 | }, | |
| 890 | ], | |
| 559 | 891 | }), |
| 560 | 892 | ); |
| 561 | 893 | |
| 562 | const completedAt = "2026-02-08T10:00:10.000Z"; | |
| 563 | 894 | const next = reducer(state, { |
| 564 | 895 | type: "APPLY_EVENT", |
| 565 | 896 | event: makeEvent({ |
| 566 | 897 | method: "turn/completed", |
| 567 | 898 | turnId: "turn-1", |
| 568 | createdAt: completedAt, | |
| 899 | createdAt: "2026-02-09T00:00:04.000Z", | |
| 569 | 900 | payload: { turn: { id: "turn-1", status: "completed" } }, |
| 570 | 901 | }), |
| 571 | 902 | activeAssistantItemRef: { current: null }, |
| 572 | 903 | }); |
| 573 | 904 | |
| 574 | expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); | |
| 575 | expect(next.threads[0]?.lastVisitedAt).toBe(completedAt); | |
| 905 | expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([ | |
| 906 | "src/from-checkpoint.ts", | |
| 907 | ]); | |
| 908 | expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1); | |
| 909 | expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1); | |
| 910 | }); | |
| 911 | ||
| 912 | it("updates checkpoint turn counts from authoritative checkpoint mappings", () => { | |
| 913 | const state = makeState( | |
| 914 | makeThread({ | |
| 915 | turnDiffSummaries: [ | |
| 916 | { | |
| 917 | turnId: "turn-1", | |
| 918 | completedAt: "2026-02-09T00:00:03.000Z", | |
| 919 | files: [], | |
| 920 | checkpointTurnCount: 1, | |
| 921 | }, | |
| 922 | { | |
| 923 | turnId: "turn-2", | |
| 924 | completedAt: "2026-02-09T00:00:04.000Z", | |
| 925 | files: [], | |
| 926 | checkpointTurnCount: 2, | |
| 927 | }, | |
| 928 | ], | |
| 929 | }), | |
| 930 | ); | |
| 931 | ||
| 932 | const next = reducer(state, { | |
| 933 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", | |
| 934 | threadId: "thread-local-1", | |
| 935 | checkpointTurnCountByTurnId: { | |
| 936 | "turn-1": 2, | |
| 937 | "turn-2": 3, | |
| 938 | }, | |
| 939 | }); | |
| 940 | ||
| 941 | expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2); | |
| 942 | expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3); | |
| 943 | }); | |
| 944 | ||
| 945 | it("does not rederive completed turn summaries from late turn diff events", () => { | |
| 946 | const completed = reducer(makeState(makeThread()), { | |
| 947 | type: "APPLY_EVENT", | |
| 948 | event: makeEvent({ | |
| 949 | method: "turn/completed", | |
| 950 | turnId: "turn-1", | |
| 951 | createdAt: "2026-02-09T00:00:03.000Z", | |
| 952 | payload: { turn: { id: "turn-1", status: "completed" } }, | |
| 953 | }), | |
| 954 | activeAssistantItemRef: { current: null }, | |
| 955 | }); | |
| 956 | ||
| 957 | expect(completed.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); | |
| 958 | ||
| 959 | const withLateDiff = reducer(completed, { | |
| 960 | type: "APPLY_EVENT", | |
| 961 | event: makeEvent({ | |
| 962 | method: "turn/diff/updated", | |
| 963 | turnId: "turn-1", | |
| 964 | createdAt: "2026-02-09T00:00:04.000Z", | |
| 965 | payload: { | |
| 966 | diff: [ | |
| 967 | "diff --git a/src/a.ts b/src/a.ts", | |
| 968 | "@@ -1 +1 @@", | |
| 969 | "-old", | |
| 970 | "+new", | |
| 971 | "diff --git a/src/b.ts b/src/b.ts", | |
| 972 | "@@ -1 +1 @@", | |
| 973 | "-old", | |
| 974 | "+new", | |
| 975 | ].join("\n"), | |
| 976 | }, | |
| 977 | }), | |
| 978 | activeAssistantItemRef: { current: null }, | |
| 979 | }); | |
| 980 | ||
| 981 | expect(withLateDiff.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); | |
| 576 | 982 | }); |
| 577 | 983 | }); |
apps/web/src/store.ts+253-62| ... | ... | @@ -8,14 +8,23 @@ import { |
| 8 | 8 | useReducer, |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; | |
| 11 | import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; | |
| 12 | 12 | import { resolveModelSlug } from "./model-logic"; |
| 13 | 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; |
| 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; | |
| 14 | import { | |
| 15 | applyEventToMessages, | |
| 16 | asObject, | |
| 17 | asString, | |
| 18 | deriveTurnDiffSummaries, | |
| 19 | inferCheckpointTurnCountByTurnId, | |
| 20 | evolveSession, | |
| 21 | } from "./session-logic"; | |
| 15 | 22 | import { |
| 16 | 23 | type ChatAttachment, |
| 17 | 24 | DEFAULT_THREAD_TERMINAL_ID, |
| 18 | 25 | DEFAULT_RUNTIME_MODE, |
| 26 | MAX_THREAD_TERMINAL_COUNT, | |
| 27 | type ProjectScript, | |
| 19 | 28 | type Project, |
| 20 | 29 | type RuntimeMode, |
| 21 | 30 | type Thread, |
| ... | ... | @@ -26,11 +35,12 @@ import { |
| 26 | 35 | |
| 27 | 36 | type Action = |
| 28 | 37 | | { type: "ADD_PROJECT"; project: Project } |
| 38 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | |
| 29 | 39 | | { type: "SYNC_PROJECTS"; projects: Project[] } |
| 40 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | |
| 30 | 41 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 31 | 42 | | { type: "DELETE_PROJECT"; projectId: string } |
| 32 | 43 | | { type: "ADD_THREAD"; thread: Thread } |
| 33 | | { type: "SET_ACTIVE_THREAD"; threadId: string } | |
| 34 | 44 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 35 | 45 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| 36 | 46 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } |
| ... | ... | @@ -38,11 +48,11 @@ type Action = |
| 38 | 48 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } |
| 39 | 49 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } |
| 40 | 50 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } |
| 41 | | { type: "TOGGLE_DIFF" } | |
| 42 | 51 | | { |
| 43 | 52 | type: "APPLY_EVENT"; |
| 44 | 53 | event: ProviderEvent; |
| 45 | 54 | activeAssistantItemRef: { current: string | null }; |
| 55 | activeThreadId?: string | null; | |
| 46 | 56 | } |
| 47 | 57 | | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } |
| 48 | 58 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| ... | ... | @@ -56,12 +66,26 @@ type Action = |
| 56 | 66 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 57 | 67 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 58 | 68 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } |
| 69 | | { | |
| 70 | type: "REVERT_TO_CHECKPOINT"; | |
| 71 | threadId: string; | |
| 72 | sessionId: string; | |
| 73 | threadRuntimeId: string; | |
| 74 | turnCount: number; | |
| 75 | messageCount: number; | |
| 76 | } | |
| 77 | | { | |
| 78 | type: "SET_THREAD_TURN_CHECKPOINT_COUNTS"; | |
| 79 | threadId: string; | |
| 80 | checkpointTurnCountByTurnId: Record<string, number>; | |
| 81 | } | |
| 59 | 82 | | { |
| 60 | 83 | type: "SET_THREAD_BRANCH"; |
| 61 | 84 | threadId: string; |
| 62 | 85 | branch: string | null; |
| 63 | 86 | worktreePath: string | null; |
| 64 | 87 | } |
| 88 | | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } | |
| 65 | 89 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } |
| 66 | 90 | | { type: "DELETE_THREAD"; threadId: string }; |
| 67 | 91 | |
| ... | ... | @@ -70,9 +94,8 @@ type Action = |
| 70 | 94 | export interface AppState { |
| 71 | 95 | projects: Project[]; |
| 72 | 96 | threads: Thread[]; |
| 73 | activeThreadId: string | null; | |
| 97 | threadsHydrated: boolean; | |
| 74 | 98 | runtimeMode: RuntimeMode; |
| 75 | diffOpen: boolean; | |
| 76 | 99 | } |
| 77 | 100 | |
| 78 | 101 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; |
| ... | ... | @@ -90,9 +113,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [ |
| 90 | 113 | const initialState: AppState = { |
| 91 | 114 | projects: [], |
| 92 | 115 | threads: [], |
| 93 | activeThreadId: null, | |
| 116 | threadsHydrated: false, | |
| 94 | 117 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 95 | diffOpen: false, | |
| 96 | 118 | }; |
| 97 | 119 | |
| 98 | 120 | // ── Helpers ────────────────────────────────────────────────────────── |
| ... | ... | @@ -110,7 +132,13 @@ function readPersistedState(): AppState { |
| 110 | 132 | const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1); |
| 111 | 133 | if (!hydrated) return initialState; |
| 112 | 134 | |
| 113 | return { ...hydrated, diffOpen: false }; | |
| 135 | const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); | |
| 136 | ||
| 137 | return { | |
| 138 | ...hydrated, | |
| 139 | threads, | |
| 140 | threadsHydrated: threads.length > 0, | |
| 141 | }; | |
| 114 | 142 | } catch { |
| 115 | 143 | return initialState; |
| 116 | 144 | } |
| ... | ... | @@ -137,9 +165,67 @@ function updateThread( |
| 137 | 165 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 138 | 166 | } |
| 139 | 167 | |
| 168 | function mergeTurnDiffSummaries( | |
| 169 | existing: Thread["turnDiffSummaries"], | |
| 170 | next: Thread["turnDiffSummaries"], | |
| 171 | ): Thread["turnDiffSummaries"] { | |
| 172 | if (next.length === 0) return existing; | |
| 173 | ||
| 174 | const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); | |
| 175 | const merged = next.map((summary) => { | |
| 176 | const previous = existingByTurnId.get(summary.turnId); | |
| 177 | if (!previous) { | |
| 178 | return summary; | |
| 179 | } | |
| 180 | ||
| 181 | const files = | |
| 182 | summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files; | |
| 183 | ||
| 184 | return { | |
| 185 | ...summary, | |
| 186 | files, | |
| 187 | ...(summary.assistantMessageId | |
| 188 | ? {} | |
| 189 | : previous.assistantMessageId | |
| 190 | ? { assistantMessageId: previous.assistantMessageId } | |
| 191 | : {}), | |
| 192 | ...(typeof summary.checkpointTurnCount === "number" | |
| 193 | ? {} | |
| 194 | : typeof previous.checkpointTurnCount === "number" | |
| 195 | ? { checkpointTurnCount: previous.checkpointTurnCount } | |
| 196 | : {}), | |
| 197 | }; | |
| 198 | }); | |
| 199 | ||
| 200 | const mergedTurnIds = new Set(merged.map((summary) => summary.turnId)); | |
| 201 | for (const summary of existing) { | |
| 202 | if (!mergedTurnIds.has(summary.turnId)) { | |
| 203 | merged.push(summary); | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | const sorted = merged.toSorted((a, b) => { | |
| 208 | const aTime = Date.parse(a.completedAt); | |
| 209 | const bTime = Date.parse(b.completedAt); | |
| 210 | if (Number.isNaN(aTime) || Number.isNaN(bTime)) { | |
| 211 | return b.completedAt.localeCompare(a.completedAt); | |
| 212 | } | |
| 213 | return bTime - aTime; | |
| 214 | }); | |
| 215 | ||
| 216 | const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted); | |
| 217 | return sorted.map((summary) => | |
| 218 | typeof summary.checkpointTurnCount === "number" | |
| 219 | ? summary | |
| 220 | : Object.assign({}, summary, { | |
| 221 | checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId], | |
| 222 | }), | |
| 223 | ); | |
| 224 | } | |
| 225 | ||
| 140 | 226 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 141 | 227 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 142 | const unique = [...new Set(ids)]; | |
| 228 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); | |
| 143 | 229 | if (unique.length > 0) { |
| 144 | 230 | return unique; |
| 145 | 231 | } |
| ... | ... | @@ -157,6 +243,7 @@ function normalizeRunningTerminalIds( |
| 157 | 243 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); |
| 158 | 244 | } |
| 159 | 245 | |
| 246 | ||
| 160 | 247 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { |
| 161 | 248 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; |
| 162 | 249 | } |
| ... | ... | @@ -420,20 +507,42 @@ export function reducer(state: AppState, action: Action): AppState { |
| 420 | 507 | { |
| 421 | 508 | ...action.project, |
| 422 | 509 | model: resolveModelSlug(action.project.model), |
| 510 | scripts: normalizeProjectScripts(action.project.scripts), | |
| 423 | 511 | }, |
| 424 | 512 | ], |
| 425 | 513 | }; |
| 426 | 514 | |
| 515 | case "SET_PROJECT_SCRIPTS": | |
| 516 | return { | |
| 517 | ...state, | |
| 518 | projects: state.projects.map((project) => | |
| 519 | project.id === action.projectId | |
| 520 | ? { ...project, scripts: normalizeProjectScripts(action.scripts) } | |
| 521 | : project, | |
| 522 | ), | |
| 523 | }; | |
| 524 | ||
| 525 | case "SET_THREADS_HYDRATED": | |
| 526 | if (state.threadsHydrated === action.hydrated) { | |
| 527 | return state; | |
| 528 | } | |
| 529 | return { | |
| 530 | ...state, | |
| 531 | threadsHydrated: action.hydrated, | |
| 532 | }; | |
| 533 | ||
| 427 | 534 | case "SYNC_PROJECTS": { |
| 428 | 535 | const previousByCwd = new Map( |
| 429 | 536 | state.projects.map((project) => [project.cwd, project] as const), |
| 430 | 537 | ); |
| 431 | 538 | const nextProjects = action.projects.map((project) => { |
| 432 | 539 | const previous = previousByCwd.get(project.cwd); |
| 540 | const scripts = normalizeProjectScripts(project.scripts); | |
| 433 | 541 | return { |
| 434 | 542 | ...project, |
| 435 | 543 | model: resolveModelSlug(previous?.model ?? project.model), |
| 436 | 544 | expanded: previous?.expanded ?? project.expanded, |
| 545 | scripts, | |
| 437 | 546 | }; |
| 438 | 547 | }); |
| 439 | 548 | const previousProjectById = new Map( |
| ... | ... | @@ -454,15 +563,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 454 | 563 | }); |
| 455 | 564 | }) |
| 456 | 565 | .filter((thread): thread is Thread => thread !== null); |
| 457 | const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) | |
| 458 | ? state.activeThreadId | |
| 459 | : (nextThreads[0]?.id ?? null); | |
| 460 | 566 | |
| 461 | 567 | return { |
| 462 | 568 | ...state, |
| 463 | 569 | projects: nextProjects, |
| 464 | 570 | threads: nextThreads, |
| 465 | activeThreadId, | |
| 466 | 571 | }; |
| 467 | 572 | } |
| 468 | 573 | |
| ... | ... | @@ -481,15 +586,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 481 | 586 | } |
| 482 | 587 | |
| 483 | 588 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); |
| 484 | const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) | |
| 485 | ? state.activeThreadId | |
| 486 | : (threads[0]?.id ?? null); | |
| 487 | 589 | |
| 488 | 590 | return { |
| 489 | 591 | ...state, |
| 490 | 592 | projects, |
| 491 | 593 | threads, |
| 492 | activeThreadId, | |
| 493 | 594 | }; |
| 494 | 595 | } |
| 495 | 596 | |
| ... | ... | @@ -498,23 +599,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 498 | 599 | ...action.thread, |
| 499 | 600 | model: resolveModelSlug(action.thread.model), |
| 500 | 601 | lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, |
| 602 | turnDiffSummaries: action.thread.turnDiffSummaries ?? [], | |
| 501 | 603 | }); |
| 502 | 604 | return { |
| 503 | 605 | ...state, |
| 504 | 606 | threads: [...state.threads, nextThread], |
| 505 | activeThreadId: action.thread.id, | |
| 506 | }; | |
| 507 | } | |
| 508 | ||
| 509 | case "SET_ACTIVE_THREAD": { | |
| 510 | const visitedAt = new Date().toISOString(); | |
| 511 | return { | |
| 512 | ...state, | |
| 513 | activeThreadId: action.threadId, | |
| 514 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 515 | ...t, | |
| 516 | lastVisitedAt: visitedAt, | |
| 517 | })), | |
| 518 | 607 | }; |
| 519 | 608 | } |
| 520 | 609 | |
| ... | ... | @@ -550,6 +639,13 @@ export function reducer(state: AppState, action: Action): AppState { |
| 550 | 639 | ...state, |
| 551 | 640 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 552 | 641 | const normalizedThread = normalizeThreadTerminals(thread); |
| 642 | const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); | |
| 643 | if ( | |
| 644 | isNewTerminal && | |
| 645 | normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT | |
| 646 | ) { | |
| 647 | return normalizedThread; | |
| 648 | } | |
| 553 | 649 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |
| 554 | 650 | ? normalizedThread.terminalIds |
| 555 | 651 | : [...normalizedThread.terminalIds, action.terminalId]; |
| ... | ... | @@ -616,6 +712,13 @@ export function reducer(state: AppState, action: Action): AppState { |
| 616 | 712 | ...state, |
| 617 | 713 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 618 | 714 | const normalizedThread = normalizeThreadTerminals(thread); |
| 715 | const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); | |
| 716 | if ( | |
| 717 | isNewTerminal && | |
| 718 | normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT | |
| 719 | ) { | |
| 720 | return normalizedThread; | |
| 721 | } | |
| 619 | 722 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) |
| 620 | 723 | ? normalizedThread.terminalIds |
| 621 | 724 | : [...normalizedThread.terminalIds, action.terminalId]; |
| ... | ... | @@ -671,9 +774,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 671 | 774 | ), |
| 672 | 775 | }; |
| 673 | 776 | |
| 674 | case "TOGGLE_DIFF": | |
| 675 | return { ...state, diffOpen: !state.diffOpen }; | |
| 676 | ||
| 677 | 777 | case "APPLY_TERMINAL_EVENT": |
| 678 | 778 | if (!state.threads.some((thread) => thread.id === action.event.threadId)) { |
| 679 | 779 | return state; |
| ... | ... | @@ -703,34 +803,49 @@ export function reducer(state: AppState, action: Action): AppState { |
| 703 | 803 | }; |
| 704 | 804 | |
| 705 | 805 | case "APPLY_EVENT": { |
| 706 | const { event, activeAssistantItemRef } = action; | |
| 806 | const { event, activeAssistantItemRef, activeThreadId } = action; | |
| 707 | 807 | const target = findThreadBySessionId(state.threads, event.sessionId); |
| 708 | 808 | if (!target) return state; |
| 709 | 809 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; |
| 710 | 810 | |
| 711 | 811 | return { |
| 712 | 812 | ...state, |
| 713 | threads: updateThread(state.threads, target.id, (t) => ({ | |
| 714 | ...t, | |
| 715 | ...(() => { | |
| 716 | const eventThreadId = getEventThreadId(event); | |
| 717 | const shouldRebindIdentity = | |
| 718 | event.method === "thread/started" && t.session?.status === "connecting"; | |
| 719 | return { | |
| 720 | codexThreadId: shouldRebindIdentity | |
| 721 | ? (eventThreadId ?? t.codexThreadId) | |
| 722 | : (t.codexThreadId ?? eventThreadId ?? null), | |
| 723 | error: event.kind === "error" && event.message ? event.message : t.error, | |
| 724 | }; | |
| 725 | })(), | |
| 726 | session: t.session ? evolveSession(t.session, event) : t.session, | |
| 727 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | |
| 728 | events: [event, ...t.events], | |
| 729 | ...updateTurnFields(t, event), | |
| 730 | ...(event.method === "turn/completed" && t.id === state.activeThreadId | |
| 731 | ? { lastVisitedAt: event.createdAt } | |
| 732 | : {}), | |
| 733 | })), | |
| 813 | threads: updateThread(state.threads, target.id, (t) => { | |
| 814 | const nextEvents = [event, ...t.events]; | |
| 815 | const eventTurnId = getEventTurnId(event); | |
| 816 | const hasCompletedSummaryForTurn = Boolean( | |
| 817 | eventTurnId && t.turnDiffSummaries.some((summary) => summary.turnId === eventTurnId), | |
| 818 | ); | |
| 819 | const itemType = asString(asObject(asObject(event.payload)?.item)?.type); | |
| 820 | const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase(); | |
| 821 | const isMetadataItemCompleted = | |
| 822 | event.method === "item/completed" && | |
| 823 | (normalizedItemType === "agentmessage" || normalizedItemType === "filechange"); | |
| 824 | const shouldRederiveDiffs = | |
| 825 | event.method === "turn/completed" || | |
| 826 | (hasCompletedSummaryForTurn && isMetadataItemCompleted); | |
| 827 | const turnDiffSummaries = shouldRederiveDiffs | |
| 828 | ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents)) | |
| 829 | : t.turnDiffSummaries; | |
| 830 | const eventThreadId = getEventThreadId(event); | |
| 831 | const shouldRebindIdentity = | |
| 832 | event.method === "thread/started" && t.session?.status === "connecting"; | |
| 833 | return { | |
| 834 | ...t, | |
| 835 | codexThreadId: shouldRebindIdentity | |
| 836 | ? (eventThreadId ?? t.codexThreadId) | |
| 837 | : (t.codexThreadId ?? eventThreadId ?? null), | |
| 838 | error: event.kind === "error" && event.message ? event.message : t.error, | |
| 839 | session: t.session ? evolveSession(t.session, event) : t.session, | |
| 840 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | |
| 841 | events: nextEvents, | |
| 842 | turnDiffSummaries, | |
| 843 | ...updateTurnFields(t, event), | |
| 844 | ...(event.method === "turn/completed" && t.id === activeThreadId | |
| 845 | ? { lastVisitedAt: event.createdAt } | |
| 846 | : {}), | |
| 847 | }; | |
| 848 | }), | |
| 734 | 849 | }; |
| 735 | 850 | } |
| 736 | 851 | |
| ... | ... | @@ -798,6 +913,72 @@ export function reducer(state: AppState, action: Action): AppState { |
| 798 | 913 | })), |
| 799 | 914 | }; |
| 800 | 915 | |
| 916 | case "REVERT_TO_CHECKPOINT": | |
| 917 | return { | |
| 918 | ...state, | |
| 919 | threads: updateThread(state.threads, action.threadId, (t) => { | |
| 920 | const nextMessageCount = Math.max(0, Math.floor(action.messageCount)); | |
| 921 | const nextTurnCount = Math.max(0, Math.floor(action.turnCount)); | |
| 922 | const now = new Date().toISOString(); | |
| 923 | return { | |
| 924 | ...t, | |
| 925 | codexThreadId: action.threadRuntimeId, | |
| 926 | session: | |
| 927 | t.session?.sessionId === action.sessionId | |
| 928 | ? { | |
| 929 | ...t.session, | |
| 930 | status: "ready", | |
| 931 | threadId: action.threadRuntimeId, | |
| 932 | activeTurnId: undefined, | |
| 933 | updatedAt: now, | |
| 934 | lastError: undefined, | |
| 935 | } | |
| 936 | : t.session, | |
| 937 | messages: t.messages.slice(0, nextMessageCount), | |
| 938 | events: [], | |
| 939 | turnDiffSummaries: t.turnDiffSummaries.filter( | |
| 940 | (summary) => | |
| 941 | typeof summary.checkpointTurnCount === "number" && | |
| 942 | summary.checkpointTurnCount <= nextTurnCount, | |
| 943 | ), | |
| 944 | error: null, | |
| 945 | latestTurnId: undefined, | |
| 946 | latestTurnStartedAt: undefined, | |
| 947 | latestTurnCompletedAt: undefined, | |
| 948 | latestTurnDurationMs: undefined, | |
| 949 | lastVisitedAt: now, | |
| 950 | }; | |
| 951 | }), | |
| 952 | }; | |
| 953 | ||
| 954 | case "SET_THREAD_TURN_CHECKPOINT_COUNTS": | |
| 955 | return { | |
| 956 | ...state, | |
| 957 | threads: updateThread(state.threads, action.threadId, (t) => { | |
| 958 | const hasUpdates = t.turnDiffSummaries.some( | |
| 959 | (summary) => | |
| 960 | action.checkpointTurnCountByTurnId[summary.turnId] !== undefined && | |
| 961 | action.checkpointTurnCountByTurnId[summary.turnId] !== summary.checkpointTurnCount, | |
| 962 | ); | |
| 963 | if (!hasUpdates) { | |
| 964 | return t; | |
| 965 | } | |
| 966 | return { | |
| 967 | ...t, | |
| 968 | turnDiffSummaries: t.turnDiffSummaries.map((summary) => { | |
| 969 | const turnCount = action.checkpointTurnCountByTurnId[summary.turnId]; | |
| 970 | if (turnCount === undefined) { | |
| 971 | return summary; | |
| 972 | } | |
| 973 | return { | |
| 974 | ...summary, | |
| 975 | checkpointTurnCount: turnCount, | |
| 976 | }; | |
| 977 | }), | |
| 978 | }; | |
| 979 | }), | |
| 980 | }; | |
| 981 | ||
| 801 | 982 | case "SET_THREAD_BRANCH": { |
| 802 | 983 | return { |
| 803 | 984 | ...state, |
| ... | ... | @@ -816,18 +997,28 @@ export function reducer(state: AppState, action: Action): AppState { |
| 816 | 997 | }; |
| 817 | 998 | } |
| 818 | 999 | |
| 1000 | case "MARK_THREAD_VISITED": { | |
| 1001 | const visitedAt = action.visitedAt ?? new Date().toISOString(); | |
| 1002 | return { | |
| 1003 | ...state, | |
| 1004 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 1005 | ...t, | |
| 1006 | lastVisitedAt: visitedAt, | |
| 1007 | })), | |
| 1008 | }; | |
| 1009 | } | |
| 1010 | ||
| 819 | 1011 | case "SET_RUNTIME_MODE": |
| 820 | 1012 | return { |
| 821 | 1013 | ...state, |
| 822 | 1014 | runtimeMode: action.mode, |
| 823 | 1015 | }; |
| 824 | 1016 | |
| 825 | case "DELETE_THREAD": { | |
| 826 | const threads = state.threads.filter((t) => t.id !== action.threadId); | |
| 827 | const activeThreadId = | |
| 828 | state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId; | |
| 829 | return { ...state, threads, activeThreadId }; | |
| 830 | } | |
| 1017 | case "DELETE_THREAD": | |
| 1018 | return { | |
| 1019 | ...state, | |
| 1020 | threads: state.threads.filter((t) => t.id !== action.threadId), | |
| 1021 | }; | |
| 831 | 1022 | |
| 832 | 1023 | default: |
| 833 | 1024 | return state; |
apps/web/src/threadFactory.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import { DEFAULT_MODEL } from "./model-logic"; | |
| 2 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; | |
| 3 | ||
| 4 | interface CreateThreadOptions { | |
| 5 | branch?: string | null; | |
| 6 | createdAt?: string; | |
| 7 | model?: string; | |
| 8 | title?: string; | |
| 9 | worktreePath?: string | null; | |
| 10 | } | |
| 11 | ||
| 12 | export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread { | |
| 13 | const createdAt = options.createdAt ?? new Date().toISOString(); | |
| 14 | ||
| 15 | return { | |
| 16 | id: crypto.randomUUID(), | |
| 17 | codexThreadId: null, | |
| 18 | projectId, | |
| 19 | title: options.title ?? "New thread", | |
| 20 | model: options.model ?? DEFAULT_MODEL, | |
| 21 | terminalOpen: false, | |
| 22 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 23 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 24 | runningTerminalIds: [], | |
| 25 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 26 | terminalGroups: [ | |
| 27 | { | |
| 28 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 29 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 30 | }, | |
| 31 | ], | |
| 32 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 33 | session: null, | |
| 34 | messages: [], | |
| 35 | events: [], | |
| 36 | turnDiffSummaries: [], | |
| 37 | error: null, | |
| 38 | createdAt, | |
| 39 | branch: options.branch ?? null, | |
| 40 | worktreePath: options.worktreePath ?? null, | |
| 41 | }; | |
| 42 | } |
apps/web/src/truncateTitle.test.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { truncateTitle } from "./truncateTitle"; | |
| 4 | ||
| 5 | describe("truncateTitle", () => { | |
| 6 | it("trims surrounding whitespace", () => { | |
| 7 | expect(truncateTitle(" hello world ")).toBe("hello world"); | |
| 8 | }); | |
| 9 | ||
| 10 | it("returns trimmed text when within max length", () => { | |
| 11 | expect(truncateTitle("alpha", 10)).toBe("alpha"); | |
| 12 | }); | |
| 13 | ||
| 14 | it("appends ellipsis when text exceeds max length", () => { | |
| 15 | expect(truncateTitle("abcdefghij", 5)).toBe("abcde..."); | |
| 16 | }); | |
| 17 | }); |
apps/web/src/truncateTitle.ts created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | export function truncateTitle(text: string, maxLength = 50): string { | |
| 2 | const trimmed = text.trim(); | |
| 3 | if (trimmed.length <= maxLength) { | |
| 4 | return trimmed; | |
| 5 | } | |
| 6 | return `${trimmed.slice(0, maxLength)}...`; | |
| 7 | } |
apps/web/src/types.ts+25-1| ... | ... | @@ -1,10 +1,16 @@ |
| 1 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | ProjectScript as ContractProjectScript, | |
| 3 | ProviderEvent, | |
| 4 | ProviderSession, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 4 | 8 | export type RuntimeMode = "approval-required" | "full-access"; |
| 5 | 9 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 6 | 10 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 7 | 11 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; |
| 12 | export const MAX_THREAD_TERMINAL_COUNT = 4; | |
| 13 | export type ProjectScript = ContractProjectScript; | |
| 8 | 14 | |
| 9 | 15 | export interface ThreadTerminalGroup { |
| 10 | 16 | id: string; |
| ... | ... | @@ -31,12 +37,29 @@ export interface ChatMessage { |
| 31 | 37 | streaming: boolean; |
| 32 | 38 | } |
| 33 | 39 | |
| 40 | export interface TurnDiffFileChange { | |
| 41 | path: string; | |
| 42 | kind?: string | undefined; | |
| 43 | additions?: number | undefined; | |
| 44 | deletions?: number | undefined; | |
| 45 | } | |
| 46 | ||
| 47 | export interface TurnDiffSummary { | |
| 48 | turnId: string; | |
| 49 | completedAt: string; | |
| 50 | status?: string | undefined; | |
| 51 | files: TurnDiffFileChange[]; | |
| 52 | assistantMessageId?: string | undefined; | |
| 53 | checkpointTurnCount?: number | undefined; | |
| 54 | } | |
| 55 | ||
| 34 | 56 | export interface Project { |
| 35 | 57 | id: string; |
| 36 | 58 | name: string; |
| 37 | 59 | cwd: string; |
| 38 | 60 | model: string; |
| 39 | 61 | expanded: boolean; |
| 62 | scripts: ProjectScript[]; | |
| 40 | 63 | } |
| 41 | 64 | |
| 42 | 65 | export interface Thread { |
| ... | ... | @@ -64,4 +87,5 @@ export interface Thread { |
| 64 | 87 | lastVisitedAt?: string | undefined; |
| 65 | 88 | branch: string | null; |
| 66 | 89 | worktreePath: string | null; |
| 90 | turnDiffSummaries: TurnDiffSummary[]; | |
| 67 | 91 | } |
apps/web/src/vite-env.d.ts+1| ... | ... | @@ -8,6 +8,7 @@ interface DesktopBridge { |
| 8 | 8 | confirm: (message: string) => Promise<boolean>; |
| 9 | 9 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; |
| 10 | 10 | openExternal: (url: string) => Promise<boolean>; |
| 11 | onMenuAction: (listener: (action: string) => void) => () => void; | |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | declare global { |
apps/web/src/vscode-icons-language-associations.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"version":"12.17.0","source":"https://raw.githubusercontent.com/vscode-icons/vscode-icons/v12.17.0/src/iconsManifest/languages.ts","generatedAt":"2026-02-17T20:03:11.362Z","extensionToLanguageId":{"as":"actionscript","ada":"ada","prw":"advpl","affect":"affectscript","agda":"agda","al":"al","alloy":"alloy","ansible":"ansible","g4":"antlr","any":"anyscript","htaccess":"apacheconf","cls":"apex","apib":"apiblueprint","apl":"apl","applescript":"applescript","adoc":"asciidoc","asp":"asp","asm":"arm","astro":"astro","ats":"ats","ahk":"ahk","au3":"autoit","am":"automake","avcs":"avro","azcli":"azcli","azure-pipelines.yml":"azure-pipelines","bal":"ballerina","bat":"bat","bats":"bats","bzl":"bazel","bf":"befunge","bicep":"bicep","bib":"bibtex","biml":"biml","bin":"code-text-binary","rast":"biome_syntax_tree","blade.php":"blade","blitzbasic":"blitzbasic","bolt":"bolt","bsq":"bosque","bru":"bruno","buf.yaml":"buf","lockb":"bun.lockb","c":"c","c3":"c3","cal":"c-al","cabal":"cabal","capnp":"capnp","casc":"casc","cddl":"cddl","ceylon":"ceylon","cfc":"cfc","cfm":"cfmhtml","circom":"circom","clojure":"clojure","clojurescript":"clojurescript","yml":"manifest-yaml","cmake":"cmake","cbl":"cobol","ql":"ql","coffee":"coffeescript","cfml":"cfml","confluence":"confluence","ckbk":"cookbook","instructions.md":"instructions","cpp":"cpp","cr":"crystal","cs":"csharp","css":"css","wxss":"css","acss":"css","mcss":"css","pcss":"css","postcss":"css","nss":"css","ccss":"css","less":"css","scss":"css","sass":"css","csv":"csv","feature":"cucumber","cu":"cuda","cypher":"cypher","pyx":"cython","dal":"dal","dart":"dart","dhall":"dhall","html":"django-html","diff":"diff","d":"d","hcl":"dockerbake","dtx":"doctex","env":"dotenv","dot":"dotjs","dox":"doxygen","drawio":"drawio","drl":"drools","dtd":"dtd","dust":"dustjs","dylan":"dylan","earthfile":"earthfile","edge":"edge","eex":"eex","es":"es","ex":"elixir","elm":"elm","erb":"erb","erl":"erlang","yaml":"esphome","xlsx":"excel","falcon":"falcon","fql":"fql","f":"fortran","ftl":"ftl","fs":"fsharp","fthtml":"fthtml","gspec":"galen","gml":"gml-gms","gcode":"gcode","gen":"genstat","git":"git-commit","gleam":"gleam","gjs":"glimmer-js","glsl":"glsl","glyphs":"glyphs","gp":"gnuplot","go":"go","mod":"go.mod","work":"go.work","api":"goctl","gd":"gdscript","gradle.kts":"gradle-kotlin-dsl","gr":"grain","gql":"graphql","gv":"dot","grit":"grit","groovy":"groovy","haml":"haml","hbs":"handlebars","prg":"harbour","hs":"haskell","haxe":"haxe","helm.tpl":"helm","hjson":"hjson","hlsl":"hlsl","hosts":"hosts","http":"http","aff":"hunspell.aff","hy":"hy","hypr":"hypr","icl":"icl","imba":"imba","4gl":"4GL","ini":"ini","ink":"ink","iss":"innosetup","io":"io","janet":"janet","java":"java","js":"javascript","mjs":"javascript","cjs":"javascript","es6":"javascript","jake":"javascript","jsm":"javascript","jsx":"javascript","njs":"javascript","pac":"javascript","sjs":"javascript","ssjs":"javascript","xsjs":"javascript","xsjslib":"javascript","jekyll":"jekyll","jenkins":"jenkins","jest-snap":"jest-snapshot","jinja":"jinja","json":"json","jsonc":"jsonc","jsonl":"jsonl","jsonnet":"jsonnet","json5":"json5","jl":"julia","id":"iodine","k":"k","kv":"kivy","ks":"kos","kt":"kotlin","kusto":"kusto","tex":"latex","lark":"lark","lat":"latino","lean":"lean4","flex":"lex","ly":"lilypond","lisp":"lisp","lhs":"literate haskell","log":"log","lol":"lolcode","lsl":"lsl","lua":"lua","m4":"m4","mk":"makefile","md":"markdown","markdown":"markdown","mdown":"markdown","mkd":"markdown","marko":"marko","mat":"matlab","ms":"maxscript","mdx":"mdx","mediawiki":"mediawiki","mel":"mel","mmd":"mermaid","metal":"metal","mcfunction":"bc-mcfunction","mjml":"mjml","pq":"powerquery","mojo":"mojo","ep":"mojolicious","mongo":"mongo","mson":"mson","mv":"mv","mvt":"mvt","ne":"nearley","nginx":"nginx","nim":"nim","nimble":"nimble","nix":"nix","nsi":"nsis","nunjucks":"nunjucks","nu":"nushell","m":"objective-c","mm":"objective-cpp","ml":"ocaml","o3":"ogone","w":"abl","things":"openhab","org":"org","pas":"pascal","pddl":"pddl","plan":"plan","happenings":"happenings","pl":"perl","pl6":"perl6","pgsql":"pgsql","php":"php","php4":"php","php5":"php","php7":"php","phtml":"php","phps":"php","phpt":"php","phar":"php","aw":"php","ctp":"php","inc":"php","php3":"php","php8":"php","module":"php","theme":"php","tpl":"php","pine":"pine","txt":"plaintext","dbgasm":"platformio-debug.disassembly","ddl":"plsql","po":"po","dib":"polyglot-notebook","polymer":"polymer","pony":"pony","ps":"postscript","ps1":"powershell","prisma":"prisma","pde":"pde","pro":"prolog","rules":"prometheus","properties":"java-properties","proto":"proto3","pug":"jade","pp":"puppet","purs":"purescript","arr":"pyret","pyscript":"pyscript","py":"python","pyi":"python","pyw":"python","pyc":"python","pyd":"python","pyo":"python","pxd":"python","pxi":"python","rpy":"python","cpy":"python","ipy":"python","pyowo":"pythowo","qvs":"qlik","qml":"qml","qs":"qsharp","r":"r","rkt":"racket","raku":"raku","cshtml":"razor","raml":"raml","re":"reason","red":"red","res":"rescript","rst":"restructuredtext","rex":"rexx","tag":"riot","ripple":"ripple","rmd":"rmd","rnc":"rnc","robot":"robot","rb":"ruby","rbw":"ruby","rake":"ruby","gemspec":"ruby","ru":"ruby","rjs":"ruby","rxml":"ruby","rsel":"ruby","builder":"ruby","jbuilder":"ruby","rabl":"ruby","prawn":"ruby","thor":"ruby","rs":"rust","sl":"s-lang","san":"san","sas":"SAS","sbt":"sbt","scad":"scad","scala":"scala","sce":"scilab","sdl":"sdl","code-search":"search-result","shader":"shaderlab","sh":"shellscript","bash":"shellscript","zsh":"shellscript","ksh":"shellscript","fish":"shellscript","slang":"slang","ice":"slice","slim":"slim","slint":"slint","sln":"sln","ss":"silverstripe","sn":"sino","eskip":"eskip","smk":"snakemake","code-snippets":"snippets","snort":"snort","sol":"solidity","rq":"sparql","sqf":"sqf","sql":"sql","nut":"squirrel","stan":"stan","bazel":"starlark","do":"stata","stencil":"stencil","html.stencil":"stencil-html","st.css":"stylable","styled":"source.css.styled","styl":"stylus","svelte":"svelte","svg":"svg","swagger":"Swagger","swift":"swift","swig":"swig","link":"systemd-conf","sv":"systemverilog","tt":"t4","teal":"teal","templ":"templ","tt3":"tt","tera":"tera","tf":"terraform","sty":"tex","textile":"textile","json-tmlanguage":"json-tmlanguage","yaml-tmlanguage":"yaml-tmlanguage","toit":"toit","toml":"toml","toon":"toon","ttcn3":"ttcn","tuc":"tuc","twig":"twig","ts":"typescript","mts":"typescript","cts":"typescript","tsx":"typescriptreact","typoscript":"typoscript","ua":"uiua","u":"unison","vb":"vb","wsf":"vbscript","vm":"velocity","vto":"vento","v":"verilog","vhdl":"vhdl","vim":"viml","vitest-snap":"vitest-snapshot","volt":"volt","vroom":"vroom","vue":"vue","vue2":"vue","vue3":"vue","vues":"vue","vuex":"vue","vuerc":"vue","vy":"vyper","wai":"wai","wasm":"wasm","wat":"wasm","wast":"wasm","wsm":"wasm","wast.js":"wasm","wit":"wasm","witx":"wasm","wy":"wenyan","wgsl":"wgsl","wt":"wikitext","wl":"wolfram","wurst":"wurstlang","xcompose":"xcompose","xmake.lua":"xmake","xml":"xml","xquery":"xquery","xsl":"xsl","bison":"yacc","yang":"yang","zig":"zig","zip":"zip"},"fileNameToLanguageId":{"browserslist":"browserslist",".browserslistrc":"browserslist","caddyfile":"caddyfile","cmakelists.txt":"cmake","cmakecache.txt":"cmake-cache","docker-compose.yml":"dockercompose","docker-compose.yaml":"dockercompose","compose.yaml":"dockercompose","compose.yml":"dockercompose","dockerfile":"dockerfile","dockerfile.dev":"dockerfile","dockerfile.prod":"dockerfile","dockerfile.test":"dockerfile",".editorconfig":"editorconfig","justfile":"just","meson.build":"meson",".php-version":"php","artisan":"php","requirements.txt":"pip-requirements","requirements-dev.txt":"pip-requirements","requirements-test.txt":"pip-requirements","requirements-prod.txt":"pip-requirements","setup.py":"python","setup.cfg":"python",".python-version":"python","pipfile":"python","pipfile.lock":"python","robots.txt":"robots-txt","config.ru":"ruby",".bashrc":"shellscript",".zshrc":"shellscript",".profile":"shellscript",".bash_profile":"shellscript",".bash_aliases":"shellscript",".zprofile":"shellscript","tiltfile":"tiltfile",".vue-cli-rc":"vue","yarn.lock":"yarnlock"}} |
apps/web/src/vscode-icons-manifest.json created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | {"iconDefinitions":{"_file":{"iconPath":"../../icons/default_file.svg"},"_folder":{"iconPath":"../../icons/default_folder.svg"},"_folder_open":{"iconPath":"../../icons/default_folder_opened.svg"},"_root_folder":{"iconPath":"../../icons/default_root_folder.svg"},"_root_folder_open":{"iconPath":"../../icons/default_root_folder_opened.svg"},"_file_light":{"iconPath":""},"_folder_light":{"iconPath":""},"_folder_light_open":{"iconPath":""},"_root_folder_light":{"iconPath":""},"_root_folder_light_open":{"iconPath":""},"_fd_android":{"iconPath":"../../icons/folder_type_android.svg"},"_fd_android_open":{"iconPath":"../../icons/folder_type_android_opened.svg"},"_fd_angular":{"iconPath":"../../icons/folder_type_angular.svg"},"_fd_angular_open":{"iconPath":"../../icons/folder_type_angular_opened.svg"},"_fd_apache":{"iconPath":"../../icons/folder_type_apache.svg"},"_fd_apache_open":{"iconPath":"../../icons/folder_type_apache_opened.svg"},"_fd_api":{"iconPath":"../../icons/folder_type_api.svg"},"_fd_api_open":{"iconPath":"../../icons/folder_type_api_opened.svg"},"_fd_app":{"iconPath":"../../icons/folder_type_app.svg"},"_fd_app_open":{"iconPath":"../../icons/folder_type_app_opened.svg"},"_fd_arangodb":{"iconPath":"../../icons/folder_type_arangodb.svg"},"_fd_arangodb_open":{"iconPath":"../../icons/folder_type_arangodb_opened.svg"},"_fd_asset":{"iconPath":"../../icons/folder_type_asset.svg"},"_fd_asset_open":{"iconPath":"../../icons/folder_type_asset_opened.svg"},"_fd_astro":{"iconPath":"../../icons/folder_type_astro.svg"},"_fd_astro_open":{"iconPath":"../../icons/folder_type_astro_opened.svg"},"_fd_audio":{"iconPath":"../../icons/folder_type_audio.svg"},"_fd_audio_open":{"iconPath":"../../icons/folder_type_audio_opened.svg"},"_fd_aurelia":{"iconPath":"../../icons/folder_type_aurelia.svg"},"_fd_aurelia_open":{"iconPath":"../../icons/folder_type_aurelia_opened.svg"},"_fd_aws":{"iconPath":"../../icons/folder_type_aws.svg"},"_fd_aws_open":{"iconPath":"../../icons/folder_type_aws_opened.svg"},"_fd_azure":{"iconPath":"../../icons/folder_type_azure.svg"},"_fd_azure_open":{"iconPath":"../../icons/folder_type_azure_opened.svg"},"_fd_azurepipelines":{"iconPath":"../../icons/folder_type_azurepipelines.svg"},"_fd_azurepipelines_open":{"iconPath":"../../icons/folder_type_azurepipelines_opened.svg"},"_fd_binary":{"iconPath":"../../icons/folder_type_binary.svg"},"_fd_binary_open":{"iconPath":"../../icons/folder_type_binary_opened.svg"},"_fd_bloc":{"iconPath":"../../icons/folder_type_bloc.svg"},"_fd_bloc_open":{"iconPath":"../../icons/folder_type_bloc_opened.svg"},"_fd_blueprint":{"iconPath":"../../icons/folder_type_blueprint.svg"},"_fd_blueprint_open":{"iconPath":"../../icons/folder_type_blueprint_opened.svg"},"_fd_bot":{"iconPath":"../../icons/folder_type_bot.svg"},"_fd_bot_open":{"iconPath":"../../icons/folder_type_bot_opened.svg"},"_fd_bower":{"iconPath":"../../icons/folder_type_bower.svg"},"_fd_bower_open":{"iconPath":"../../icons/folder_type_bower_opened.svg"},"_fd_buildkite":{"iconPath":"../../icons/folder_type_buildkite.svg"},"_fd_buildkite_open":{"iconPath":"../../icons/folder_type_buildkite_opened.svg"},"_fd_cake":{"iconPath":"../../icons/folder_type_cake.svg"},"_fd_cake_open":{"iconPath":"../../icons/folder_type_cake_opened.svg"},"_fd_cargo":{"iconPath":"../../icons/folder_type_cargo.svg"},"_fd_cargo_open":{"iconPath":"../../icons/folder_type_cargo_opened.svg"},"_fd_certificate":{"iconPath":"../../icons/folder_type_certificate.svg"},"_fd_certificate_open":{"iconPath":"../../icons/folder_type_certificate_opened.svg"},"_fd_changesets":{"iconPath":"../../icons/folder_type_changesets.svg"},"_fd_changesets_open":{"iconPath":"../../icons/folder_type_changesets_opened.svg"},"_fd_chef":{"iconPath":"../../icons/folder_type_chef.svg"},"_fd_chef_open":{"iconPath":"../../icons/folder_type_chef_opened.svg"},"_fd_circleci":{"iconPath":"../../icons/folder_type_circleci.svg"},"_fd_circleci_open":{"iconPath":"../../icons/folder_type_circleci_opened.svg"},"_fd_claude":{"iconPath":"../../icons/folder_type_claude.svg"},"_fd_claude_open":{"iconPath":"../../icons/folder_type_claude_opened.svg"},"_fd_cli":{"iconPath":"../../icons/folder_type_cli.svg"},"_fd_cli_open":{"iconPath":"../../icons/folder_type_cli_opened.svg"},"_fd_client":{"iconPath":"../../icons/folder_type_client.svg"},"_fd_client_open":{"iconPath":"../../icons/folder_type_client_opened.svg"},"_fd_cloudflare":{"iconPath":"../../icons/folder_type_cloudflare.svg"},"_fd_cloudflare_open":{"iconPath":"../../icons/folder_type_cloudflare_opened.svg"},"_fd_cmake":{"iconPath":"../../icons/folder_type_cmake.svg"},"_fd_cmake_open":{"iconPath":"../../icons/folder_type_cmake_opened.svg"},"_fd_common":{"iconPath":"../../icons/folder_type_common.svg"},"_fd_common_open":{"iconPath":"../../icons/folder_type_common_opened.svg"},"_fd_component":{"iconPath":"../../icons/folder_type_component.svg"},"_fd_component_open":{"iconPath":"../../icons/folder_type_component_opened.svg"},"_fd_composer":{"iconPath":"../../icons/folder_type_composer.svg"},"_fd_composer_open":{"iconPath":"../../icons/folder_type_composer_opened.svg"},"_fd_config":{"iconPath":"../../icons/folder_type_config.svg"},"_fd_config_open":{"iconPath":"../../icons/folder_type_config_opened.svg"},"_fd_controller":{"iconPath":"../../icons/folder_type_controller.svg"},"_fd_controller_open":{"iconPath":"../../icons/folder_type_controller_opened.svg"},"_fd_coverage":{"iconPath":"../../icons/folder_type_coverage.svg"},"_fd_coverage_open":{"iconPath":"../../icons/folder_type_coverage_opened.svg"},"_fd_css":{"iconPath":"../../icons/folder_type_css.svg"},"_fd_css_open":{"iconPath":"../../icons/folder_type_css_opened.svg"},"_fd_cubit":{"iconPath":"../../icons/folder_type_cubit.svg"},"_fd_cubit_open":{"iconPath":"../../icons/folder_type_cubit_opened.svg"},"_fd_cursor":{"iconPath":"../../icons/folder_type_cursor.svg"},"_fd_cursor_open":{"iconPath":"../../icons/folder_type_cursor_opened.svg"},"_fd_light_cursor":{"iconPath":"../../icons/folder_type_light_cursor.svg"},"_fd_light_cursor_open":{"iconPath":"../../icons/folder_type_light_cursor_opened.svg"},"_fd_cypress":{"iconPath":"../../icons/folder_type_cypress.svg"},"_fd_cypress_open":{"iconPath":"../../icons/folder_type_cypress_opened.svg"},"_fd_light_cypress":{"iconPath":"../../icons/folder_type_light_cypress.svg"},"_fd_light_cypress_open":{"iconPath":"../../icons/folder_type_light_cypress_opened.svg"},"_fd_dapr":{"iconPath":"../../icons/folder_type_dapr.svg"},"_fd_dapr_open":{"iconPath":"../../icons/folder_type_dapr_opened.svg"},"_fd_dart":{"iconPath":"../../icons/folder_type_dart.svg"},"_fd_dart_open":{"iconPath":"../../icons/folder_type_dart_opened.svg"},"_fd_datadog":{"iconPath":"../../icons/folder_type_datadog.svg"},"_fd_datadog_open":{"iconPath":"../../icons/folder_type_datadog_opened.svg"},"_fd_db":{"iconPath":"../../icons/folder_type_db.svg"},"_fd_db_open":{"iconPath":"../../icons/folder_type_db_opened.svg"},"_fd_debian":{"iconPath":"../../icons/folder_type_debian.svg"},"_fd_debian_open":{"iconPath":"../../icons/folder_type_debian_opened.svg"},"_fd_dependabot":{"iconPath":"../../icons/folder_type_dependabot.svg"},"_fd_dependabot_open":{"iconPath":"../../icons/folder_type_dependabot_opened.svg"},"_fd_devcontainer":{"iconPath":"../../icons/folder_type_devcontainer.svg"},"_fd_devcontainer_open":{"iconPath":"../../icons/folder_type_devcontainer_opened.svg"},"_fd_dist":{"iconPath":"../../icons/folder_type_dist.svg"},"_fd_dist_open":{"iconPath":"../../icons/folder_type_dist_opened.svg"},"_fd_docker":{"iconPath":"../../icons/folder_type_docker.svg"},"_fd_docker_open":{"iconPath":"../../icons/folder_type_docker_opened.svg"},"_fd_docs":{"iconPath":"../../icons/folder_type_docs.svg"},"_fd_docs_open":{"iconPath":"../../icons/folder_type_docs_opened.svg"},"_fd_e2e":{"iconPath":"../../icons/folder_type_e2e.svg"},"_fd_e2e_open":{"iconPath":"../../icons/folder_type_e2e_opened.svg"},"_fd_elasticbeanstalk":{"iconPath":"../../icons/folder_type_elasticbeanstalk.svg"},"_fd_elasticbeanstalk_open":{"iconPath":"../../icons/folder_type_elasticbeanstalk_opened.svg"},"_fd_electron":{"iconPath":"../../icons/folder_type_electron.svg"},"_fd_electron_open":{"iconPath":"../../icons/folder_type_electron_opened.svg"},"_fd_light_electron":{"iconPath":"../../icons/folder_type_light_electron.svg"},"_fd_light_electron_open":{"iconPath":"../../icons/folder_type_light_electron_opened.svg"},"_fd_expo":{"iconPath":"../../icons/folder_type_expo.svg"},"_fd_expo_open":{"iconPath":"../../icons/folder_type_expo_opened.svg"},"_fd_light_expo":{"iconPath":"../../icons/folder_type_light_expo.svg"},"_fd_light_expo_open":{"iconPath":"../../icons/folder_type_light_expo_opened.svg"},"_fd_favicon":{"iconPath":"../../icons/folder_type_favicon.svg"},"_fd_favicon_open":{"iconPath":"../../icons/folder_type_favicon_opened.svg"},"_fd_flow":{"iconPath":"../../icons/folder_type_flow.svg"},"_fd_flow_open":{"iconPath":"../../icons/folder_type_flow_opened.svg"},"_fd_flutter":{"iconPath":"../../icons/folder_type_flutter.svg"},"_fd_flutter_open":{"iconPath":"../../icons/folder_type_flutter_opened.svg"},"_fd_fonts":{"iconPath":"../../icons/folder_type_fonts.svg"},"_fd_fonts_open":{"iconPath":"../../icons/folder_type_fonts_opened.svg"},"_fd_light_fonts":{"iconPath":"../../icons/folder_type_light_fonts.svg"},"_fd_light_fonts_open":{"iconPath":"../../icons/folder_type_light_fonts_opened.svg"},"_fd_frontcommerce":{"iconPath":"../../icons/folder_type_frontcommerce.svg"},"_fd_frontcommerce_open":{"iconPath":"../../icons/folder_type_frontcommerce_opened.svg"},"_fd_gcp":{"iconPath":"../../icons/folder_type_gcp.svg"},"_fd_gcp_open":{"iconPath":"../../icons/folder_type_gcp_opened.svg"},"_fd_gemini":{"iconPath":"../../icons/folder_type_gemini.svg"},"_fd_gemini_open":{"iconPath":"../../icons/folder_type_gemini_opened.svg"},"_fd_git":{"iconPath":"../../icons/folder_type_git.svg"},"_fd_git_open":{"iconPath":"../../icons/folder_type_git_opened.svg"},"_fd_github":{"iconPath":"../../icons/folder_type_github.svg"},"_fd_github_open":{"iconPath":"../../icons/folder_type_github_opened.svg"},"_fd_gitlab":{"iconPath":"../../icons/folder_type_gitlab.svg"},"_fd_gitlab_open":{"iconPath":"../../icons/folder_type_gitlab_opened.svg"},"_fd_godot":{"iconPath":"../../icons/folder_type_godot.svg"},"_fd_godot_open":{"iconPath":"../../icons/folder_type_godot_opened.svg"},"_fd_gradle":{"iconPath":"../../icons/folder_type_gradle.svg"},"_fd_gradle_open":{"iconPath":"../../icons/folder_type_gradle_opened.svg"},"_fd_light_gradle":{"iconPath":"../../icons/folder_type_light_gradle.svg"},"_fd_light_gradle_open":{"iconPath":"../../icons/folder_type_light_gradle_opened.svg"},"_fd_graphql":{"iconPath":"../../icons/folder_type_graphql.svg"},"_fd_graphql_open":{"iconPath":"../../icons/folder_type_graphql_opened.svg"},"_fd_grunt":{"iconPath":"../../icons/folder_type_grunt.svg"},"_fd_grunt_open":{"iconPath":"../../icons/folder_type_grunt_opened.svg"},"_fd_gulp":{"iconPath":"../../icons/folder_type_gulp.svg"},"_fd_gulp_open":{"iconPath":"../../icons/folder_type_gulp_opened.svg"},"_fd_haxelib":{"iconPath":"../../icons/folder_type_haxelib.svg"},"_fd_haxelib_open":{"iconPath":"../../icons/folder_type_haxelib_opened.svg"},"_fd_helper":{"iconPath":"../../icons/folder_type_helper.svg"},"_fd_helper_open":{"iconPath":"../../icons/folder_type_helper_opened.svg"},"_fd_histoire":{"iconPath":"../../icons/folder_type_histoire.svg"},"_fd_histoire_open":{"iconPath":"../../icons/folder_type_histoire_opened.svg"},"_fd_hook":{"iconPath":"../../icons/folder_type_hook.svg"},"_fd_hook_open":{"iconPath":"../../icons/folder_type_hook_opened.svg"},"_fd_husky":{"iconPath":"../../icons/folder_type_husky.svg"},"_fd_husky_open":{"iconPath":"../../icons/folder_type_husky_opened.svg"},"_fd_idea":{"iconPath":"../../icons/folder_type_idea.svg"},"_fd_idea_open":{"iconPath":"../../icons/folder_type_idea_opened.svg"},"_fd_images":{"iconPath":"../../icons/folder_type_images.svg"},"_fd_images_open":{"iconPath":"../../icons/folder_type_images_opened.svg"},"_fd_include":{"iconPath":"../../icons/folder_type_include.svg"},"_fd_include_open":{"iconPath":"../../icons/folder_type_include_opened.svg"},"_fd_interfaces":{"iconPath":"../../icons/folder_type_interfaces.svg"},"_fd_interfaces_open":{"iconPath":"../../icons/folder_type_interfaces_opened.svg"},"_fd_ios":{"iconPath":"../../icons/folder_type_ios.svg"},"_fd_ios_open":{"iconPath":"../../icons/folder_type_ios_opened.svg"},"_fd_js":{"iconPath":"../../icons/folder_type_js.svg"},"_fd_js_open":{"iconPath":"../../icons/folder_type_js_opened.svg"},"_fd_json":{"iconPath":"../../icons/folder_type_json.svg"},"_fd_json_open":{"iconPath":"../../icons/folder_type_json_opened.svg"},"_fd_junie":{"iconPath":"../../icons/folder_type_junie.svg"},"_fd_junie_open":{"iconPath":"../../icons/folder_type_junie_opened.svg"},"_fd_kotlin":{"iconPath":"../../icons/folder_type_kotlin.svg"},"_fd_kotlin_open":{"iconPath":"../../icons/folder_type_kotlin_opened.svg"},"_fd_kubernetes":{"iconPath":"../../icons/folder_type_kubernetes.svg"},"_fd_kubernetes_open":{"iconPath":"../../icons/folder_type_kubernetes_opened.svg"},"_fd_less":{"iconPath":"../../icons/folder_type_less.svg"},"_fd_less_open":{"iconPath":"../../icons/folder_type_less_opened.svg"},"_fd_library":{"iconPath":"../../icons/folder_type_library.svg"},"_fd_library_open":{"iconPath":"../../icons/folder_type_library_opened.svg"},"_fd_linux":{"iconPath":"../../icons/folder_type_linux.svg"},"_fd_linux_open":{"iconPath":"../../icons/folder_type_linux_opened.svg"},"_fd_locale":{"iconPath":"../../icons/folder_type_locale.svg"},"_fd_locale_open":{"iconPath":"../../icons/folder_type_locale_opened.svg"},"_fd_log":{"iconPath":"../../icons/folder_type_log.svg"},"_fd_log_open":{"iconPath":"../../icons/folder_type_log_opened.svg"},"_fd_macos":{"iconPath":"../../icons/folder_type_macos.svg"},"_fd_macos_open":{"iconPath":"../../icons/folder_type_macos_opened.svg"},"_fd_mariadb":{"iconPath":"../../icons/folder_type_mariadb.svg"},"_fd_mariadb_open":{"iconPath":"../../icons/folder_type_mariadb_opened.svg"},"_fd_maven":{"iconPath":"../../icons/folder_type_maven.svg"},"_fd_maven_open":{"iconPath":"../../icons/folder_type_maven_opened.svg"},"_fd_mediawiki":{"iconPath":"../../icons/folder_type_mediawiki.svg"},"_fd_mediawiki_open":{"iconPath":"../../icons/folder_type_mediawiki_opened.svg"},"_fd_memcached":{"iconPath":"../../icons/folder_type_memcached.svg"},"_fd_memcached_open":{"iconPath":"../../icons/folder_type_memcached_opened.svg"},"_fd_meteor":{"iconPath":"../../icons/folder_type_meteor.svg"},"_fd_meteor_open":{"iconPath":"../../icons/folder_type_meteor_opened.svg"},"_fd_light_meteor":{"iconPath":"../../icons/folder_type_light_meteor.svg"},"_fd_light_meteor_open":{"iconPath":"../../icons/folder_type_light_meteor_opened.svg"},"_fd_middleware":{"iconPath":"../../icons/folder_type_middleware.svg"},"_fd_middleware_open":{"iconPath":"../../icons/folder_type_middleware_opened.svg"},"_fd_minecraft":{"iconPath":"../../icons/folder_type_minecraft.svg"},"_fd_minecraft_open":{"iconPath":"../../icons/folder_type_minecraft_opened.svg"},"_fd_minikube":{"iconPath":"../../icons/folder_type_minikube.svg"},"_fd_minikube_open":{"iconPath":"../../icons/folder_type_minikube_opened.svg"},"_fd_mjml":{"iconPath":"../../icons/folder_type_mjml.svg"},"_fd_mjml_open":{"iconPath":"../../icons/folder_type_mjml_opened.svg"},"_fd_mock":{"iconPath":"../../icons/folder_type_mock.svg"},"_fd_mock_open":{"iconPath":"../../icons/folder_type_mock_opened.svg"},"_fd_model":{"iconPath":"../../icons/folder_type_model.svg"},"_fd_model_open":{"iconPath":"../../icons/folder_type_model_opened.svg"},"_fd_module":{"iconPath":"../../icons/folder_type_module.svg"},"_fd_module_open":{"iconPath":"../../icons/folder_type_module_opened.svg"},"_fd_mojo":{"iconPath":"../../icons/folder_type_mojo.svg"},"_fd_mojo_open":{"iconPath":"../../icons/folder_type_mojo_opened.svg"},"_fd_mongodb":{"iconPath":"../../icons/folder_type_mongodb.svg"},"_fd_mongodb_open":{"iconPath":"../../icons/folder_type_mongodb_opened.svg"},"_fd_mypy":{"iconPath":"../../icons/folder_type_mypy.svg"},"_fd_mypy_open":{"iconPath":"../../icons/folder_type_mypy_opened.svg"},"_fd_light_mypy":{"iconPath":"../../icons/folder_type_light_mypy.svg"},"_fd_light_mypy_open":{"iconPath":"../../icons/folder_type_light_mypy_opened.svg"},"_fd_mysql":{"iconPath":"../../icons/folder_type_mysql.svg"},"_fd_mysql_open":{"iconPath":"../../icons/folder_type_mysql_opened.svg"},"_fd_light_mysql":{"iconPath":"../../icons/folder_type_light_mysql.svg"},"_fd_light_mysql_open":{"iconPath":"../../icons/folder_type_light_mysql_opened.svg"},"_fd_netlify":{"iconPath":"../../icons/folder_type_netlify.svg"},"_fd_netlify_open":{"iconPath":"../../icons/folder_type_netlify_opened.svg"},"_fd_next":{"iconPath":"../../icons/folder_type_next.svg"},"_fd_next_open":{"iconPath":"../../icons/folder_type_next_opened.svg"},"_fd_nginx":{"iconPath":"../../icons/folder_type_nginx.svg"},"_fd_nginx_open":{"iconPath":"../../icons/folder_type_nginx_opened.svg"},"_fd_nix":{"iconPath":"../../icons/folder_type_nix.svg"},"_fd_nix_open":{"iconPath":"../../icons/folder_type_nix_opened.svg"},"_fd_node":{"iconPath":"../../icons/folder_type_node.svg"},"_fd_node_open":{"iconPath":"../../icons/folder_type_node_opened.svg"},"_fd_light_node":{"iconPath":"../../icons/folder_type_light_node.svg"},"_fd_light_node_open":{"iconPath":"../../icons/folder_type_light_node_opened.svg"},"_fd_notebooks":{"iconPath":"../../icons/folder_type_notebooks.svg"},"_fd_notebooks_open":{"iconPath":"../../icons/folder_type_notebooks_opened.svg"},"_fd_notification":{"iconPath":"../../icons/folder_type_notification.svg"},"_fd_notification_open":{"iconPath":"../../icons/folder_type_notification_opened.svg"},"_fd_nuget":{"iconPath":"../../icons/folder_type_nuget.svg"},"_fd_nuget_open":{"iconPath":"../../icons/folder_type_nuget_opened.svg"},"_fd_nuxt":{"iconPath":"../../icons/folder_type_nuxt.svg"},"_fd_nuxt_open":{"iconPath":"../../icons/folder_type_nuxt_opened.svg"},"_fd_package":{"iconPath":"../../icons/folder_type_package.svg"},"_fd_package_open":{"iconPath":"../../icons/folder_type_package_opened.svg"},"_fd_paket":{"iconPath":"../../icons/folder_type_paket.svg"},"_fd_paket_open":{"iconPath":"../../icons/folder_type_paket_opened.svg"},"_fd_php":{"iconPath":"../../icons/folder_type_php.svg"},"_fd_php_open":{"iconPath":"../../icons/folder_type_php_opened.svg"},"_fd_platformio":{"iconPath":"../../icons/folder_type_platformio.svg"},"_fd_platformio_open":{"iconPath":"../../icons/folder_type_platformio_opened.svg"},"_fd_plugin":{"iconPath":"../../icons/folder_type_plugin.svg"},"_fd_plugin_open":{"iconPath":"../../icons/folder_type_plugin_opened.svg"},"_fd_prisma":{"iconPath":"../../icons/folder_type_prisma.svg"},"_fd_prisma_open":{"iconPath":"../../icons/folder_type_prisma_opened.svg"},"_fd_private":{"iconPath":"../../icons/folder_type_private.svg"},"_fd_private_open":{"iconPath":"../../icons/folder_type_private_opened.svg"},"_fd_public":{"iconPath":"../../icons/folder_type_public.svg"},"_fd_public_open":{"iconPath":"../../icons/folder_type_public_opened.svg"},"_fd_pytest":{"iconPath":"../../icons/folder_type_pytest.svg"},"_fd_pytest_open":{"iconPath":"../../icons/folder_type_pytest_opened.svg"},"_fd_python":{"iconPath":"../../icons/folder_type_python.svg"},"_fd_python_open":{"iconPath":"../../icons/folder_type_python_opened.svg"},"_fd_ravendb":{"iconPath":"../../icons/folder_type_ravendb.svg"},"_fd_ravendb_open":{"iconPath":"../../icons/folder_type_ravendb_opened.svg"},"_fd_redis":{"iconPath":"../../icons/folder_type_redis.svg"},"_fd_redis_open":{"iconPath":"../../icons/folder_type_redis_opened.svg"},"_fd_redux":{"iconPath":"../../icons/folder_type_redux.svg"},"_fd_redux_open":{"iconPath":"../../icons/folder_type_redux_opened.svg"},"_fd_light_redux":{"iconPath":"../../icons/folder_type_light_redux.svg"},"_fd_light_redux_open":{"iconPath":"../../icons/folder_type_light_redux_opened.svg"},"_fd_route":{"iconPath":"../../icons/folder_type_route.svg"},"_fd_route_open":{"iconPath":"../../icons/folder_type_route_opened.svg"},"_fd_sass":{"iconPath":"../../icons/folder_type_sass.svg"},"_fd_sass_open":{"iconPath":"../../icons/folder_type_sass_opened.svg"},"_fd_light_sass":{"iconPath":"../../icons/folder_type_light_sass.svg"},"_fd_light_sass_open":{"iconPath":"../../icons/folder_type_light_sass_opened.svg"},"_fd_script":{"iconPath":"../../icons/folder_type_script.svg"},"_fd_script_open":{"iconPath":"../../icons/folder_type_script_opened.svg"},"_fd_seedkit":{"iconPath":"../../icons/folder_type_seedkit.svg"},"_fd_seedkit_open":{"iconPath":"../../icons/folder_type_seedkit_opened.svg"},"_fd_server":{"iconPath":"../../icons/folder_type_server.svg"},"_fd_server_open":{"iconPath":"../../icons/folder_type_server_opened.svg"},"_fd_services":{"iconPath":"../../icons/folder_type_services.svg"},"_fd_services_open":{"iconPath":"../../icons/folder_type_services_opened.svg"},"_fd_shared":{"iconPath":"../../icons/folder_type_shared.svg"},"_fd_shared_open":{"iconPath":"../../icons/folder_type_shared_opened.svg"},"_fd_snaplet":{"iconPath":"../../icons/folder_type_snaplet.svg"},"_fd_snaplet_open":{"iconPath":"../../icons/folder_type_snaplet_opened.svg"},"_fd_spin":{"iconPath":"../../icons/folder_type_spin.svg"},"_fd_spin_open":{"iconPath":"../../icons/folder_type_spin_opened.svg"},"_fd_src":{"iconPath":"../../icons/folder_type_src.svg"},"_fd_src_open":{"iconPath":"../../icons/folder_type_src_opened.svg"},"_fd_sso":{"iconPath":"../../icons/folder_type_sso.svg"},"_fd_sso_open":{"iconPath":"../../icons/folder_type_sso_opened.svg"},"_fd_story":{"iconPath":"../../icons/folder_type_story.svg"},"_fd_story_open":{"iconPath":"../../icons/folder_type_story_opened.svg"},"_fd_style":{"iconPath":"../../icons/folder_type_style.svg"},"_fd_style_open":{"iconPath":"../../icons/folder_type_style_opened.svg"},"_fd_supabase":{"iconPath":"../../icons/folder_type_supabase.svg"},"_fd_supabase_open":{"iconPath":"../../icons/folder_type_supabase_opened.svg"},"_fd_svelte":{"iconPath":"../../icons/folder_type_svelte.svg"},"_fd_svelte_open":{"iconPath":"../../icons/folder_type_svelte_opened.svg"},"_fd_tauri":{"iconPath":"../../icons/folder_type_tauri.svg"},"_fd_tauri_open":{"iconPath":"../../icons/folder_type_tauri_opened.svg"},"_fd_temp":{"iconPath":"../../icons/folder_type_temp.svg"},"_fd_temp_open":{"iconPath":"../../icons/folder_type_temp_opened.svg"},"_fd_template":{"iconPath":"../../icons/folder_type_template.svg"},"_fd_template_open":{"iconPath":"../../icons/folder_type_template_opened.svg"},"_fd_test":{"iconPath":"../../icons/folder_type_test.svg"},"_fd_test_open":{"iconPath":"../../icons/folder_type_test_opened.svg"},"_fd_theme":{"iconPath":"../../icons/folder_type_theme.svg"},"_fd_theme_open":{"iconPath":"../../icons/folder_type_theme_opened.svg"},"_fd_tools":{"iconPath":"../../icons/folder_type_tools.svg"},"_fd_tools_open":{"iconPath":"../../icons/folder_type_tools_opened.svg"},"_fd_travis":{"iconPath":"../../icons/folder_type_travis.svg"},"_fd_travis_open":{"iconPath":"../../icons/folder_type_travis_opened.svg"},"_fd_trunk":{"iconPath":"../../icons/folder_type_trunk.svg"},"_fd_trunk_open":{"iconPath":"../../icons/folder_type_trunk_opened.svg"},"_fd_turbo":{"iconPath":"../../icons/folder_type_turbo.svg"},"_fd_turbo_open":{"iconPath":"../../icons/folder_type_turbo_opened.svg"},"_fd_typescript":{"iconPath":"../../icons/folder_type_typescript.svg"},"_fd_typescript_open":{"iconPath":"../../icons/folder_type_typescript_opened.svg"},"_fd_typings":{"iconPath":"../../icons/folder_type_typings.svg"},"_fd_typings_open":{"iconPath":"../../icons/folder_type_typings_opened.svg"},"_fd_vagrant":{"iconPath":"../../icons/folder_type_vagrant.svg"},"_fd_vagrant_open":{"iconPath":"../../icons/folder_type_vagrant_opened.svg"},"_fd_vercel":{"iconPath":"../../icons/folder_type_vercel.svg"},"_fd_vercel_open":{"iconPath":"../../icons/folder_type_vercel_opened.svg"},"_fd_video":{"iconPath":"../../icons/folder_type_video.svg"},"_fd_video_open":{"iconPath":"../../icons/folder_type_video_opened.svg"},"_fd_view":{"iconPath":"../../icons/folder_type_view.svg"},"_fd_view_open":{"iconPath":"../../icons/folder_type_view_opened.svg"},"_fd_vitepress":{"iconPath":"../../icons/folder_type_vitepress.svg"},"_fd_vitepress_open":{"iconPath":"../../icons/folder_type_vitepress_opened.svg"},"_fd_vs":{"iconPath":"../../icons/folder_type_vs.svg"},"_fd_vs_open":{"iconPath":"../../icons/folder_type_vs_opened.svg"},"_fd_vscode":{"iconPath":"../../icons/folder_type_vscode.svg"},"_fd_vscode_open":{"iconPath":"../../icons/folder_type_vscode_opened.svg"},"_fd_vscode_test":{"iconPath":"../../icons/folder_type_vscode_test.svg"},"_fd_vscode_test_open":{"iconPath":"../../icons/folder_type_vscode_test_opened.svg"},"_fd_wasm":{"iconPath":"../../icons/folder_type_wasm.svg"},"_fd_wasm_open":{"iconPath":"../../icons/folder_type_wasm_opened.svg"},"_fd_webpack":{"iconPath":"../../icons/folder_type_webpack.svg"},"_fd_webpack_open":{"iconPath":"../../icons/folder_type_webpack_opened.svg"},"_fd_windows":{"iconPath":"../../icons/folder_type_windows.svg"},"_fd_windows_open":{"iconPath":"../../icons/folder_type_windows_opened.svg"},"_fd_windsurf":{"iconPath":"../../icons/folder_type_windsurf.svg"},"_fd_windsurf_open":{"iconPath":"../../icons/folder_type_windsurf_opened.svg"},"_fd_wit":{"iconPath":"../../icons/folder_type_wit.svg"},"_fd_wit_open":{"iconPath":"../../icons/folder_type_wit_opened.svg"},"_fd_www":{"iconPath":"../../icons/folder_type_www.svg"},"_fd_www_open":{"iconPath":"../../icons/folder_type_www_opened.svg"},"_fd_yarn":{"iconPath":"../../icons/folder_type_yarn.svg"},"_fd_yarn_open":{"iconPath":"../../icons/folder_type_yarn_opened.svg"},"_fd_zed":{"iconPath":"../../icons/folder_type_zed.svg"},"_fd_zed_open":{"iconPath":"../../icons/folder_type_zed_opened.svg"},"_fd_light_zed":{"iconPath":"../../icons/folder_type_light_zed.svg"},"_fd_light_zed_open":{"iconPath":"../../icons/folder_type_light_zed_opened.svg"},"_f_access":{"iconPath":"../../icons/file_type_access.svg"},"_f_actionscript":{"iconPath":"../../icons/file_type_actionscript.svg"},"_f_ada":{"iconPath":"../../icons/file_type_ada.svg"},"_f_light_ada":{"iconPath":"../../icons/file_type_light_ada.svg"},"_f_advpl":{"iconPath":"../../icons/file_type_advpl.svg"},"_f_affectscript":{"iconPath":"../../icons/file_type_affectscript.svg"},"_f_affinity":{"iconPath":"../../icons/file_type_affinity.svg"},"_f_affinitydesigner":{"iconPath":"../../icons/file_type_affinitydesigner.svg"},"_f_affinityphoto":{"iconPath":"../../icons/file_type_affinityphoto.svg"},"_f_affinitypublisher":{"iconPath":"../../icons/file_type_affinitypublisher.svg"},"_f_agda":{"iconPath":"../../icons/file_type_agda.svg"},"_f_light_agda":{"iconPath":"../../icons/file_type_light_agda.svg"},"_f_agents":{"iconPath":"../../icons/file_type_agents.svg"},"_f_light_agents":{"iconPath":"../../icons/file_type_light_agents.svg"},"_f_ai":{"iconPath":"../../icons/file_type_ai.svg"},"_f_al":{"iconPath":"../../icons/file_type_al.svg"},"_f_al_dal":{"iconPath":"../../icons/file_type_al_dal.svg"},"_f_alchemy":{"iconPath":"../../icons/file_type_alchemy.svg"},"_f_allcontributors":{"iconPath":"../../icons/file_type_allcontributors.svg"},"_f_alloy":{"iconPath":"../../icons/file_type_alloy.svg"},"_f_allure":{"iconPath":"../../icons/file_type_allure.svg"},"_f_angular":{"iconPath":"../../icons/file_type_angular.svg"},"_f_ansible":{"iconPath":"../../icons/file_type_ansible.svg"},"_f_antlers_html":{"iconPath":"../../icons/file_type_antlers_html.svg"},"_f_antlr":{"iconPath":"../../icons/file_type_antlr.svg"},"_f_anyscript":{"iconPath":"../../icons/file_type_anyscript.svg"},"_f_apache":{"iconPath":"../../icons/file_type_apache.svg"},"_f_apex":{"iconPath":"../../icons/file_type_apex.svg"},"_f_api_extractor":{"iconPath":"../../icons/file_type_api_extractor.svg"},"_f_apib":{"iconPath":"../../icons/file_type_apib.svg"},"_f_apl":{"iconPath":"../../icons/file_type_apl.svg"},"_f_light_apl":{"iconPath":"../../icons/file_type_light_apl.svg"},"_f_applescript":{"iconPath":"../../icons/file_type_applescript.svg"},"_f_appscript":{"iconPath":"../../icons/file_type_appscript.svg"},"_f_appsemble":{"iconPath":"../../icons/file_type_appsemble.svg"},"_f_appveyor":{"iconPath":"../../icons/file_type_appveyor.svg"},"_f_arduino":{"iconPath":"../../icons/file_type_arduino.svg"},"_f_asciidoc":{"iconPath":"../../icons/file_type_asciidoc.svg"},"_f_aseprite":{"iconPath":"../../icons/file_type_aseprite.svg"},"_f_asp":{"iconPath":"../../icons/file_type_asp.svg"},"_f_aspx":{"iconPath":"../../icons/file_type_aspx.svg"},"_f_assembly":{"iconPath":"../../icons/file_type_assembly.svg"},"_f_assembly_script":{"iconPath":"../../icons/file_type_assembly_script.svg"},"_f_astro":{"iconPath":"../../icons/file_type_astro.svg"},"_f_light_astro":{"iconPath":"../../icons/file_type_light_astro.svg"},"_f_astroconfig":{"iconPath":"../../icons/file_type_astroconfig.svg"},"_f_light_astroconfig":{"iconPath":"../../icons/file_type_light_astroconfig.svg"},"_f_atom":{"iconPath":"../../icons/file_type_atom.svg"},"_f_ats":{"iconPath":"../../icons/file_type_ats.svg"},"_f_attw":{"iconPath":"../../icons/file_type_attw.svg"},"_f_audio":{"iconPath":"../../icons/file_type_audio.svg"},"_f_aurelia":{"iconPath":"../../icons/file_type_aurelia.svg"},"_f_autohotkey":{"iconPath":"../../icons/file_type_autohotkey.svg"},"_f_autoit":{"iconPath":"../../icons/file_type_autoit.svg"},"_f_avif":{"iconPath":"../../icons/file_type_avif.svg"},"_f_avro":{"iconPath":"../../icons/file_type_avro.svg"},"_f_awk":{"iconPath":"../../icons/file_type_awk.svg"},"_f_aws":{"iconPath":"../../icons/file_type_aws.svg"},"_f_azure":{"iconPath":"../../icons/file_type_azure.svg"},"_f_azurepipelines":{"iconPath":"../../icons/file_type_azurepipelines.svg"},"_f_babel":{"iconPath":"../../icons/file_type_babel.svg"},"_f_light_babel":{"iconPath":"../../icons/file_type_light_babel.svg"},"_f_bak":{"iconPath":"../../icons/file_type_bak.svg"},"_f_ballerina":{"iconPath":"../../icons/file_type_ballerina.svg"},"_f_bat":{"iconPath":"../../icons/file_type_bat.svg"},"_f_bats":{"iconPath":"../../icons/file_type_bats.svg"},"_f_bazaar":{"iconPath":"../../icons/file_type_bazaar.svg"},"_f_bazel":{"iconPath":"../../icons/file_type_bazel.svg"},"_f_bazel_ignore":{"iconPath":"../../icons/file_type_bazel_ignore.svg"},"_f_bazel_version":{"iconPath":"../../icons/file_type_bazel_version.svg"},"_f_befunge":{"iconPath":"../../icons/file_type_befunge.svg"},"_f_bicep":{"iconPath":"../../icons/file_type_bicep.svg"},"_f_biml":{"iconPath":"../../icons/file_type_biml.svg"},"_f_binary":{"iconPath":"../../icons/file_type_binary.svg"},"_f_biome":{"iconPath":"../../icons/file_type_biome.svg"},"_f_bitbucketpipeline":{"iconPath":"../../icons/file_type_bitbucketpipeline.svg"},"_f_bithound":{"iconPath":"../../icons/file_type_bithound.svg"},"_f_blade":{"iconPath":"../../icons/file_type_blade.svg"},"_f_blender":{"iconPath":"../../icons/file_type_blender.svg"},"_f_blitzbasic":{"iconPath":"../../icons/file_type_blitzbasic.svg"},"_f_bolt":{"iconPath":"../../icons/file_type_bolt.svg"},"_f_bosque":{"iconPath":"../../icons/file_type_bosque.svg"},"_f_bower":{"iconPath":"../../icons/file_type_bower.svg"},"_f_browserslist":{"iconPath":"../../icons/file_type_browserslist.svg"},"_f_bruno":{"iconPath":"../../icons/file_type_bruno.svg"},"_f_buckbuild":{"iconPath":"../../icons/file_type_buckbuild.svg"},"_f_buf":{"iconPath":"../../icons/file_type_buf.svg"},"_f_bun":{"iconPath":"../../icons/file_type_bun.svg"},"_f_bundlemon":{"iconPath":"../../icons/file_type_bundlemon.svg"},"_f_bundler":{"iconPath":"../../icons/file_type_bundler.svg"},"_f_bunfig":{"iconPath":"../../icons/file_type_bunfig.svg"},"_f_c":{"iconPath":"../../icons/file_type_c.svg"},"_f_c3":{"iconPath":"../../icons/file_type_c3.svg"},"_f_c_al":{"iconPath":"../../icons/file_type_c_al.svg"},"_f_cabal":{"iconPath":"../../icons/file_type_cabal.svg"},"_f_caddy":{"iconPath":"../../icons/file_type_caddy.svg"},"_f_cake":{"iconPath":"../../icons/file_type_cake.svg"},"_f_cakephp":{"iconPath":"../../icons/file_type_cakephp.svg"},"_f_capacitor":{"iconPath":"../../icons/file_type_capacitor.svg"},"_f_capnp":{"iconPath":"../../icons/file_type_capnp.svg"},"_f_cargo":{"iconPath":"../../icons/file_type_cargo.svg"},"_f_casc":{"iconPath":"../../icons/file_type_casc.svg"},"_f_cddl":{"iconPath":"../../icons/file_type_cddl.svg"},"_f_cert":{"iconPath":"../../icons/file_type_cert.svg"},"_f_ceylon":{"iconPath":"../../icons/file_type_ceylon.svg"},"_f_cf":{"iconPath":"../../icons/file_type_cf.svg"},"_f_cfc":{"iconPath":"../../icons/file_type_cfc.svg"},"_f_cfm":{"iconPath":"../../icons/file_type_cfm.svg"},"_f_changie":{"iconPath":"../../icons/file_type_changie.svg"},"_f_cheader":{"iconPath":"../../icons/file_type_cheader.svg"},"_f_chef":{"iconPath":"../../icons/file_type_chef.svg"},"_f_chef_cookbook":{"iconPath":"../../icons/file_type_chef_cookbook.svg"},"_f_circleci":{"iconPath":"../../icons/file_type_circleci.svg"},"_f_light_circleci":{"iconPath":"../../icons/file_type_light_circleci.svg"},"_f_circom":{"iconPath":"../../icons/file_type_circom.svg"},"_f_class":{"iconPath":"../../icons/file_type_class.svg"},"_f_claude":{"iconPath":"../../icons/file_type_claude.svg"},"_f_clojure":{"iconPath":"../../icons/file_type_clojure.svg"},"_f_clojurescript":{"iconPath":"../../icons/file_type_clojurescript.svg"},"_f_cloudflare":{"iconPath":"../../icons/file_type_cloudflare.svg"},"_f_cloudfoundry":{"iconPath":"../../icons/file_type_cloudfoundry.svg"},"_f_light_cloudfoundry":{"iconPath":"../../icons/file_type_light_cloudfoundry.svg"},"_f_cmake":{"iconPath":"../../icons/file_type_cmake.svg"},"_f_cobol":{"iconPath":"../../icons/file_type_cobol.svg"},"_f_codacy":{"iconPath":"../../icons/file_type_codacy.svg"},"_f_light_codacy":{"iconPath":"../../icons/file_type_light_codacy.svg"},"_f_codeclimate":{"iconPath":"../../icons/file_type_codeclimate.svg"},"_f_light_codeclimate":{"iconPath":"../../icons/file_type_light_codeclimate.svg"},"_f_codecov":{"iconPath":"../../icons/file_type_codecov.svg"},"_f_codekit":{"iconPath":"../../icons/file_type_codekit.svg"},"_f_codemagic":{"iconPath":"../../icons/file_type_codemagic.svg"},"_f_codeowners":{"iconPath":"../../icons/file_type_codeowners.svg"},"_f_light_codeowners":{"iconPath":"../../icons/file_type_light_codeowners.svg"},"_f_codeql":{"iconPath":"../../icons/file_type_codeql.svg"},"_f_coderabbit":{"iconPath":"../../icons/file_type_coderabbit.svg"},"_f_coffeelint":{"iconPath":"../../icons/file_type_coffeelint.svg"},"_f_coffeescript":{"iconPath":"../../icons/file_type_coffeescript.svg"},"_f_commitizen":{"iconPath":"../../icons/file_type_commitizen.svg"},"_f_commitlint":{"iconPath":"../../icons/file_type_commitlint.svg"},"_f_compass":{"iconPath":"../../icons/file_type_compass.svg"},"_f_composer":{"iconPath":"../../icons/file_type_composer.svg"},"_f_conan":{"iconPath":"../../icons/file_type_conan.svg"},"_f_conda":{"iconPath":"../../icons/file_type_conda.svg"},"_f_config":{"iconPath":"../../icons/file_type_config.svg"},"_f_light_config":{"iconPath":"../../icons/file_type_light_config.svg"},"_f_confluence":{"iconPath":"../../icons/file_type_confluence.svg"},"_f_copilot":{"iconPath":"../../icons/file_type_copilot.svg"},"_f_light_copilot":{"iconPath":"../../icons/file_type_light_copilot.svg"},"_f_coverage":{"iconPath":"../../icons/file_type_coverage.svg"},"_f_coveralls":{"iconPath":"../../icons/file_type_coveralls.svg"},"_f_cpp":{"iconPath":"../../icons/file_type_cpp.svg"},"_f_cppheader":{"iconPath":"../../icons/file_type_cppheader.svg"},"_f_craco":{"iconPath":"../../icons/file_type_craco.svg"},"_f_crowdin":{"iconPath":"../../icons/file_type_crowdin.svg"},"_f_crystal":{"iconPath":"../../icons/file_type_crystal.svg"},"_f_light_crystal":{"iconPath":"../../icons/file_type_light_crystal.svg"},"_f_csharp":{"iconPath":"../../icons/file_type_csharp.svg"},"_f_cspell":{"iconPath":"../../icons/file_type_cspell.svg"},"_f_csproj":{"iconPath":"../../icons/file_type_csproj.svg"},"_f_css":{"iconPath":"../../icons/file_type_css.svg"},"_f_csscomb":{"iconPath":"../../icons/file_type_csscomb.svg"},"_f_csslint":{"iconPath":"../../icons/file_type_csslint.svg"},"_f_cssmap":{"iconPath":"../../icons/file_type_cssmap.svg"},"_f_cucumber":{"iconPath":"../../icons/file_type_cucumber.svg"},"_f_cuda":{"iconPath":"../../icons/file_type_cuda.svg"},"_f_cursorrules":{"iconPath":"../../icons/file_type_cursorrules.svg"},"_f_cvs":{"iconPath":"../../icons/file_type_cvs.svg"},"_f_cypress":{"iconPath":"../../icons/file_type_cypress.svg"},"_f_light_cypress":{"iconPath":"../../icons/file_type_light_cypress.svg"},"_f_cypress_spec":{"iconPath":"../../icons/file_type_cypress_spec.svg"},"_f_light_cypress_spec":{"iconPath":"../../icons/file_type_light_cypress_spec.svg"},"_f_cython":{"iconPath":"../../icons/file_type_cython.svg"},"_f_dal":{"iconPath":"../../icons/file_type_dal.svg"},"_f_darcs":{"iconPath":"../../icons/file_type_darcs.svg"},"_f_dartlang":{"iconPath":"../../icons/file_type_dartlang.svg"},"_f_dartlang_generated":{"iconPath":"../../icons/file_type_dartlang_generated.svg"},"_f_dartlang_ignore":{"iconPath":"../../icons/file_type_dartlang_ignore.svg"},"_f_datadog":{"iconPath":"../../icons/file_type_datadog.svg"},"_f_db":{"iconPath":"../../icons/file_type_db.svg"},"_f_light_db":{"iconPath":"../../icons/file_type_light_db.svg"},"_f_debian":{"iconPath":"../../icons/file_type_debian.svg"},"_f_delphi":{"iconPath":"../../icons/file_type_delphi.svg"},"_f_deno":{"iconPath":"../../icons/file_type_deno.svg"},"_f_denoify":{"iconPath":"../../icons/file_type_denoify.svg"},"_f_dependabot":{"iconPath":"../../icons/file_type_dependabot.svg"},"_f_dependencies":{"iconPath":"../../icons/file_type_dependencies.svg"},"_f_design_tokens":{"iconPath":"../../icons/file_type_design_tokens.svg"},"_f_devcontainer":{"iconPath":"../../icons/file_type_devcontainer.svg"},"_f_devvit":{"iconPath":"../../icons/file_type_devvit.svg"},"_f_dhall":{"iconPath":"../../icons/file_type_dhall.svg"},"_f_light_dhall":{"iconPath":"../../icons/file_type_light_dhall.svg"},"_f_diff":{"iconPath":"../../icons/file_type_diff.svg"},"_f_django":{"iconPath":"../../icons/file_type_django.svg"},"_f_dlang":{"iconPath":"../../icons/file_type_dlang.svg"},"_f_docker":{"iconPath":"../../icons/file_type_docker.svg"},"_f_dockertest":{"iconPath":"../../icons/file_type_dockertest.svg"},"_f_docpad":{"iconPath":"../../icons/file_type_docpad.svg"},"_f_light_docpad":{"iconPath":"../../icons/file_type_light_docpad.svg"},"_f_docusaurus":{"iconPath":"../../icons/file_type_docusaurus.svg"},"_f_docz":{"iconPath":"../../icons/file_type_docz.svg"},"_f_dojo":{"iconPath":"../../icons/file_type_dojo.svg"},"_f_doppler":{"iconPath":"../../icons/file_type_doppler.svg"},"_f_dotenv":{"iconPath":"../../icons/file_type_dotenv.svg"},"_f_dotjs":{"iconPath":"../../icons/file_type_dotjs.svg"},"_f_doxygen":{"iconPath":"../../icons/file_type_doxygen.svg"},"_f_drawio":{"iconPath":"../../icons/file_type_drawio.svg"},"_f_drone":{"iconPath":"../../icons/file_type_drone.svg"},"_f_light_drone":{"iconPath":"../../icons/file_type_light_drone.svg"},"_f_drools":{"iconPath":"../../icons/file_type_drools.svg"},"_f_dtd":{"iconPath":"../../icons/file_type_dtd.svg"},"_f_dune":{"iconPath":"../../icons/file_type_dune.svg"},"_f_dustjs":{"iconPath":"../../icons/file_type_dustjs.svg"},"_f_dvc":{"iconPath":"../../icons/file_type_dvc.svg"},"_f_dylan":{"iconPath":"../../icons/file_type_dylan.svg"},"_f_earthly":{"iconPath":"../../icons/file_type_earthly.svg"},"_f_eas-metadata":{"iconPath":"../../icons/file_type_eas-metadata.svg"},"_f_light_eas-metadata":{"iconPath":"../../icons/file_type_light_eas-metadata.svg"},"_f_edge":{"iconPath":"../../icons/file_type_edge.svg"},"_f_editorconfig":{"iconPath":"../../icons/file_type_editorconfig.svg"},"_f_eex":{"iconPath":"../../icons/file_type_eex.svg"},"_f_ejs":{"iconPath":"../../icons/file_type_ejs.svg"},"_f_elastic":{"iconPath":"../../icons/file_type_elastic.svg"},"_f_elasticbeanstalk":{"iconPath":"../../icons/file_type_elasticbeanstalk.svg"},"_f_electron":{"iconPath":"../../icons/file_type_electron.svg"},"_f_eleventy":{"iconPath":"../../icons/file_type_eleventy.svg"},"_f_light_eleventy":{"iconPath":"../../icons/file_type_light_eleventy.svg"},"_f_elixir":{"iconPath":"../../icons/file_type_elixir.svg"},"_f_elm":{"iconPath":"../../icons/file_type_elm.svg"},"_f_emacs":{"iconPath":"../../icons/file_type_emacs.svg"},"_f_ember":{"iconPath":"../../icons/file_type_ember.svg"},"_f_ensime":{"iconPath":"../../icons/file_type_ensime.svg"},"_f_eps":{"iconPath":"../../icons/file_type_eps.svg"},"_f_epub":{"iconPath":"../../icons/file_type_epub.svg"},"_f_erb":{"iconPath":"../../icons/file_type_erb.svg"},"_f_erlang":{"iconPath":"../../icons/file_type_erlang.svg"},"_f_esbuild":{"iconPath":"../../icons/file_type_esbuild.svg"},"_f_eslint":{"iconPath":"../../icons/file_type_eslint.svg"},"_f_esphome":{"iconPath":"../../icons/file_type_esphome.svg"},"_f_light_esphome":{"iconPath":"../../icons/file_type_light_esphome.svg"},"_f_excalidraw":{"iconPath":"../../icons/file_type_excalidraw.svg"},"_f_excel":{"iconPath":"../../icons/file_type_excel.svg"},"_f_expo":{"iconPath":"../../icons/file_type_expo.svg"},"_f_light_expo":{"iconPath":"../../icons/file_type_light_expo.svg"},"_f_falcon":{"iconPath":"../../icons/file_type_falcon.svg"},"_f_fantasticon":{"iconPath":"../../icons/file_type_fantasticon.svg"},"_f_fastly":{"iconPath":"../../icons/file_type_fastly.svg"},"_f_fauna":{"iconPath":"../../icons/file_type_fauna.svg"},"_f_favicon":{"iconPath":"../../icons/file_type_favicon.svg"},"_f_fbx":{"iconPath":"../../icons/file_type_fbx.svg"},"_f_firebase":{"iconPath":"../../icons/file_type_firebase.svg"},"_f_firebasehosting":{"iconPath":"../../icons/file_type_firebasehosting.svg"},"_f_light_firebasehosting":{"iconPath":"../../icons/file_type_light_firebasehosting.svg"},"_f_firebasestorage":{"iconPath":"../../icons/file_type_firebasestorage.svg"},"_f_firestore":{"iconPath":"../../icons/file_type_firestore.svg"},"_f_fitbit":{"iconPath":"../../icons/file_type_fitbit.svg"},"_f_fla":{"iconPath":"../../icons/file_type_fla.svg"},"_f_light_fla":{"iconPath":"../../icons/file_type_light_fla.svg"},"_f_flareact":{"iconPath":"../../icons/file_type_flareact.svg"},"_f_flash":{"iconPath":"../../icons/file_type_flash.svg"},"_f_floobits":{"iconPath":"../../icons/file_type_floobits.svg"},"_f_flow":{"iconPath":"../../icons/file_type_flow.svg"},"_f_flutter":{"iconPath":"../../icons/file_type_flutter.svg"},"_f_flutter_package":{"iconPath":"../../icons/file_type_flutter_package.svg"},"_f_flyio":{"iconPath":"../../icons/file_type_flyio.svg"},"_f_font":{"iconPath":"../../icons/file_type_font.svg"},"_f_light_font":{"iconPath":"../../icons/file_type_light_font.svg"},"_f_formkit":{"iconPath":"../../icons/file_type_formkit.svg"},"_f_fortran":{"iconPath":"../../icons/file_type_fortran.svg"},"_f_fossa":{"iconPath":"../../icons/file_type_fossa.svg"},"_f_fossil":{"iconPath":"../../icons/file_type_fossil.svg"},"_f_freemarker":{"iconPath":"../../icons/file_type_freemarker.svg"},"_f_frontcommerce":{"iconPath":"../../icons/file_type_frontcommerce.svg"},"_f_fsharp":{"iconPath":"../../icons/file_type_fsharp.svg"},"_f_fsproj":{"iconPath":"../../icons/file_type_fsproj.svg"},"_f_fthtml":{"iconPath":"../../icons/file_type_fthtml.svg"},"_f_funding":{"iconPath":"../../icons/file_type_funding.svg"},"_f_fusebox":{"iconPath":"../../icons/file_type_fusebox.svg"},"_f_galen":{"iconPath":"../../icons/file_type_galen.svg"},"_f_gamemaker":{"iconPath":"../../icons/file_type_gamemaker.svg"},"_f_gamemaker2":{"iconPath":"../../icons/file_type_gamemaker2.svg"},"_f_light_gamemaker2":{"iconPath":"../../icons/file_type_light_gamemaker2.svg"},"_f_gamemaker81":{"iconPath":"../../icons/file_type_gamemaker81.svg"},"_f_gatsby":{"iconPath":"../../icons/file_type_gatsby.svg"},"_f_gcloud":{"iconPath":"../../icons/file_type_gcloud.svg"},"_f_gcode":{"iconPath":"../../icons/file_type_gcode.svg"},"_f_gdscript":{"iconPath":"../../icons/file_type_gdscript.svg"},"_f_gduid":{"iconPath":"../../icons/file_type_gduid.svg"},"_f_gemini":{"iconPath":"../../icons/file_type_gemini.svg"},"_f_genstat":{"iconPath":"../../icons/file_type_genstat.svg"},"_f_geojson":{"iconPath":"../../icons/file_type_geojson.svg"},"_f_gimp":{"iconPath":"../../icons/file_type_gimp.svg"},"_f_git":{"iconPath":"../../icons/file_type_git.svg"},"_f_gitlab":{"iconPath":"../../icons/file_type_gitlab.svg"},"_f_gitpod":{"iconPath":"../../icons/file_type_gitpod.svg"},"_f_gleam":{"iconPath":"../../icons/file_type_gleam.svg"},"_f_gleamconfig":{"iconPath":"../../icons/file_type_gleamconfig.svg"},"_f_glide":{"iconPath":"../../icons/file_type_glide.svg"},"_f_glimmer":{"iconPath":"../../icons/file_type_glimmer.svg"},"_f_glitter":{"iconPath":"../../icons/file_type_glitter.svg"},"_f_glsl":{"iconPath":"../../icons/file_type_glsl.svg"},"_f_gltf":{"iconPath":"../../icons/file_type_gltf.svg"},"_f_glyphs":{"iconPath":"../../icons/file_type_glyphs.svg"},"_f_gnu":{"iconPath":"../../icons/file_type_gnu.svg"},"_f_gnuplot":{"iconPath":"../../icons/file_type_gnuplot.svg"},"_f_go":{"iconPath":"../../icons/file_type_go.svg"},"_f_go_package":{"iconPath":"../../icons/file_type_go_package.svg"},"_f_go_work":{"iconPath":"../../icons/file_type_go_work.svg"},"_f_goctl":{"iconPath":"../../icons/file_type_goctl.svg"},"_f_godot":{"iconPath":"../../icons/file_type_godot.svg"},"_f_gpg":{"iconPath":"../../icons/file_type_gpg.svg"},"_f_gradle":{"iconPath":"../../icons/file_type_gradle.svg"},"_f_light_gradle":{"iconPath":"../../icons/file_type_light_gradle.svg"},"_f_grain":{"iconPath":"../../icons/file_type_grain.svg"},"_f_graphql":{"iconPath":"../../icons/file_type_graphql.svg"},"_f_graphql_config":{"iconPath":"../../icons/file_type_graphql_config.svg"},"_f_graphviz":{"iconPath":"../../icons/file_type_graphviz.svg"},"_f_greenkeeper":{"iconPath":"../../icons/file_type_greenkeeper.svg"},"_f_gridsome":{"iconPath":"../../icons/file_type_gridsome.svg"},"_f_grit":{"iconPath":"../../icons/file_type_grit.svg"},"_f_groovy":{"iconPath":"../../icons/file_type_groovy.svg"},"_f_grunt":{"iconPath":"../../icons/file_type_grunt.svg"},"_f_gulp":{"iconPath":"../../icons/file_type_gulp.svg"},"_f_haml":{"iconPath":"../../icons/file_type_haml.svg"},"_f_handlebars":{"iconPath":"../../icons/file_type_handlebars.svg"},"_f_harbour":{"iconPath":"../../icons/file_type_harbour.svg"},"_f_hardhat":{"iconPath":"../../icons/file_type_hardhat.svg"},"_f_hashicorp":{"iconPath":"../../icons/file_type_hashicorp.svg"},"_f_light_hashicorp":{"iconPath":"../../icons/file_type_light_hashicorp.svg"},"_f_haskell":{"iconPath":"../../icons/file_type_haskell.svg"},"_f_haxe":{"iconPath":"../../icons/file_type_haxe.svg"},"_f_haxecheckstyle":{"iconPath":"../../icons/file_type_haxecheckstyle.svg"},"_f_haxedevelop":{"iconPath":"../../icons/file_type_haxedevelop.svg"},"_f_helix":{"iconPath":"../../icons/file_type_helix.svg"},"_f_helm":{"iconPath":"../../icons/file_type_helm.svg"},"_f_histoire":{"iconPath":"../../icons/file_type_histoire.svg"},"_f_hjson":{"iconPath":"../../icons/file_type_hjson.svg"},"_f_light_hjson":{"iconPath":"../../icons/file_type_light_hjson.svg"},"_f_hlsl":{"iconPath":"../../icons/file_type_hlsl.svg"},"_f_homeassistant":{"iconPath":"../../icons/file_type_homeassistant.svg"},"_f_horusec":{"iconPath":"../../icons/file_type_horusec.svg"},"_f_host":{"iconPath":"../../icons/file_type_host.svg"},"_f_html":{"iconPath":"../../icons/file_type_html.svg"},"_f_htmlhint":{"iconPath":"../../icons/file_type_htmlhint.svg"},"_f_htmlvalidate":{"iconPath":"../../icons/file_type_htmlvalidate.svg"},"_f_http":{"iconPath":"../../icons/file_type_http.svg"},"_f_hugo":{"iconPath":"../../icons/file_type_hugo.svg"},"_f_humanstxt":{"iconPath":"../../icons/file_type_humanstxt.svg"},"_f_hunspell":{"iconPath":"../../icons/file_type_hunspell.svg"},"_f_husky":{"iconPath":"../../icons/file_type_husky.svg"},"_f_hy":{"iconPath":"../../icons/file_type_hy.svg"},"_f_hygen":{"iconPath":"../../icons/file_type_hygen.svg"},"_f_hypr":{"iconPath":"../../icons/file_type_hypr.svg"},"_f_icl":{"iconPath":"../../icons/file_type_icl.svg"},"_f_idris":{"iconPath":"../../icons/file_type_idris.svg"},"_f_idrisbin":{"iconPath":"../../icons/file_type_idrisbin.svg"},"_f_idrispkg":{"iconPath":"../../icons/file_type_idrispkg.svg"},"_f_image":{"iconPath":"../../icons/file_type_image.svg"},"_f_imba":{"iconPath":"../../icons/file_type_imba.svg"},"_f_inc":{"iconPath":"../../icons/file_type_inc.svg"},"_f_infopath":{"iconPath":"../../icons/file_type_infopath.svg"},"_f_informix":{"iconPath":"../../icons/file_type_informix.svg"},"_f_ini":{"iconPath":"../../icons/file_type_ini.svg"},"_f_light_ini":{"iconPath":"../../icons/file_type_light_ini.svg"},"_f_ink":{"iconPath":"../../icons/file_type_ink.svg"},"_f_innosetup":{"iconPath":"../../icons/file_type_innosetup.svg"},"_f_io":{"iconPath":"../../icons/file_type_io.svg"},"_f_light_io":{"iconPath":"../../icons/file_type_light_io.svg"},"_f_iodine":{"iconPath":"../../icons/file_type_iodine.svg"},"_f_ionic":{"iconPath":"../../icons/file_type_ionic.svg"},"_f_jake":{"iconPath":"../../icons/file_type_jake.svg"},"_f_janet":{"iconPath":"../../icons/file_type_janet.svg"},"_f_jar":{"iconPath":"../../icons/file_type_jar.svg"},"_f_jasmine":{"iconPath":"../../icons/file_type_jasmine.svg"},"_f_java":{"iconPath":"../../icons/file_type_java.svg"},"_f_jbuilder":{"iconPath":"../../icons/file_type_jbuilder.svg"},"_f_jekyll":{"iconPath":"../../icons/file_type_jekyll.svg"},"_f_jenkins":{"iconPath":"../../icons/file_type_jenkins.svg"},"_f_jest":{"iconPath":"../../icons/file_type_jest.svg"},"_f_jest_snapshot":{"iconPath":"../../icons/file_type_jest_snapshot.svg"},"_f_jetbrains":{"iconPath":"../../icons/file_type_jetbrains.svg"},"_f_jinja":{"iconPath":"../../icons/file_type_jinja.svg"},"_f_jpm":{"iconPath":"../../icons/file_type_jpm.svg"},"_f_js":{"iconPath":"../../icons/file_type_js.svg"},"_f_light_js":{"iconPath":"../../icons/file_type_light_js.svg"},"_f_jsbeautify":{"iconPath":"../../icons/file_type_jsbeautify.svg"},"_f_jsconfig":{"iconPath":"../../icons/file_type_jsconfig.svg"},"_f_light_jsconfig":{"iconPath":"../../icons/file_type_light_jsconfig.svg"},"_f_jscpd":{"iconPath":"../../icons/file_type_jscpd.svg"},"_f_jshint":{"iconPath":"../../icons/file_type_jshint.svg"},"_f_jsmap":{"iconPath":"../../icons/file_type_jsmap.svg"},"_f_light_jsmap":{"iconPath":"../../icons/file_type_light_jsmap.svg"},"_f_json":{"iconPath":"../../icons/file_type_json.svg"},"_f_light_json":{"iconPath":"../../icons/file_type_light_json.svg"},"_f_json5":{"iconPath":"../../icons/file_type_json5.svg"},"_f_light_json5":{"iconPath":"../../icons/file_type_light_json5.svg"},"_f_json_schema":{"iconPath":"../../icons/file_type_json_schema.svg"},"_f_light_json_schema":{"iconPath":"../../icons/file_type_light_json_schema.svg"},"_f_jsonld":{"iconPath":"../../icons/file_type_jsonld.svg"},"_f_light_jsonld":{"iconPath":"../../icons/file_type_light_jsonld.svg"},"_f_jsonnet":{"iconPath":"../../icons/file_type_jsonnet.svg"},"_f_jsp":{"iconPath":"../../icons/file_type_jsp.svg"},"_f_jsr":{"iconPath":"../../icons/file_type_jsr.svg"},"_f_jss":{"iconPath":"../../icons/file_type_jss.svg"},"_f_juice":{"iconPath":"../../icons/file_type_juice.svg"},"_f_julia":{"iconPath":"../../icons/file_type_julia.svg"},"_f_jupyter":{"iconPath":"../../icons/file_type_jupyter.svg"},"_f_just":{"iconPath":"../../icons/file_type_just.svg"},"_f_k":{"iconPath":"../../icons/file_type_k.svg"},"_f_karma":{"iconPath":"../../icons/file_type_karma.svg"},"_f_key":{"iconPath":"../../icons/file_type_key.svg"},"_f_kitchenci":{"iconPath":"../../icons/file_type_kitchenci.svg"},"_f_kite":{"iconPath":"../../icons/file_type_kite.svg"},"_f_light_kite":{"iconPath":"../../icons/file_type_light_kite.svg"},"_f_kivy":{"iconPath":"../../icons/file_type_kivy.svg"},"_f_knip":{"iconPath":"../../icons/file_type_knip.svg"},"_f_kos":{"iconPath":"../../icons/file_type_kos.svg"},"_f_kotlin":{"iconPath":"../../icons/file_type_kotlin.svg"},"_f_kusto":{"iconPath":"../../icons/file_type_kusto.svg"},"_f_language_configuration":{"iconPath":"../../icons/file_type_language_configuration.svg"},"_f_lark":{"iconPath":"../../icons/file_type_lark.svg"},"_f_light_lark":{"iconPath":"../../icons/file_type_light_lark.svg"},"_f_latino":{"iconPath":"../../icons/file_type_latino.svg"},"_f_layout":{"iconPath":"../../icons/file_type_layout.svg"},"_f_lean":{"iconPath":"../../icons/file_type_lean.svg"},"_f_light_lean":{"iconPath":"../../icons/file_type_light_lean.svg"},"_f_leanconfig":{"iconPath":"../../icons/file_type_leanconfig.svg"},"_f_light_leanconfig":{"iconPath":"../../icons/file_type_light_leanconfig.svg"},"_f_lefthook":{"iconPath":"../../icons/file_type_lefthook.svg"},"_f_lerna":{"iconPath":"../../icons/file_type_lerna.svg"},"_f_light_lerna":{"iconPath":"../../icons/file_type_light_lerna.svg"},"_f_less":{"iconPath":"../../icons/file_type_less.svg"},"_f_lex":{"iconPath":"../../icons/file_type_lex.svg"},"_f_liara":{"iconPath":"../../icons/file_type_liara.svg"},"_f_libreoffice_base":{"iconPath":"../../icons/file_type_libreoffice_base.svg"},"_f_libreoffice_calc":{"iconPath":"../../icons/file_type_libreoffice_calc.svg"},"_f_libreoffice_draw":{"iconPath":"../../icons/file_type_libreoffice_draw.svg"},"_f_libreoffice_impress":{"iconPath":"../../icons/file_type_libreoffice_impress.svg"},"_f_libreoffice_math":{"iconPath":"../../icons/file_type_libreoffice_math.svg"},"_f_libreoffice_writer":{"iconPath":"../../icons/file_type_libreoffice_writer.svg"},"_f_license":{"iconPath":"../../icons/file_type_license.svg"},"_f_licensebat":{"iconPath":"../../icons/file_type_licensebat.svg"},"_f_lighthouse":{"iconPath":"../../icons/file_type_lighthouse.svg"},"_f_lilypond":{"iconPath":"../../icons/file_type_lilypond.svg"},"_f_lime":{"iconPath":"../../icons/file_type_lime.svg"},"_f_lintstagedrc":{"iconPath":"../../icons/file_type_lintstagedrc.svg"},"_f_liquid":{"iconPath":"../../icons/file_type_liquid.svg"},"_f_lisp":{"iconPath":"../../icons/file_type_lisp.svg"},"_f_livescript":{"iconPath":"../../icons/file_type_livescript.svg"},"_f_lnk":{"iconPath":"../../icons/file_type_lnk.svg"},"_f_locale":{"iconPath":"../../icons/file_type_locale.svg"},"_f_log":{"iconPath":"../../icons/file_type_log.svg"},"_f_lolcode":{"iconPath":"../../icons/file_type_lolcode.svg"},"_f_lsl":{"iconPath":"../../icons/file_type_lsl.svg"},"_f_lua":{"iconPath":"../../icons/file_type_lua.svg"},"_f_luau":{"iconPath":"../../icons/file_type_luau.svg"},"_f_lync":{"iconPath":"../../icons/file_type_lync.svg"},"_f_mailing":{"iconPath":"../../icons/file_type_mailing.svg"},"_f_light_mailing":{"iconPath":"../../icons/file_type_light_mailing.svg"},"_f_manifest":{"iconPath":"../../icons/file_type_manifest.svg"},"_f_manifest_bak":{"iconPath":"../../icons/file_type_manifest_bak.svg"},"_f_manifest_skip":{"iconPath":"../../icons/file_type_manifest_skip.svg"},"_f_map":{"iconPath":"../../icons/file_type_map.svg"},"_f_markdown":{"iconPath":"../../icons/file_type_markdown.svg"},"_f_markdownlint":{"iconPath":"../../icons/file_type_markdownlint.svg"},"_f_markdownlint_ignore":{"iconPath":"../../icons/file_type_markdownlint_ignore.svg"},"_f_marko":{"iconPath":"../../icons/file_type_marko.svg"},"_f_markojs":{"iconPath":"../../icons/file_type_markojs.svg"},"_f_markuplint":{"iconPath":"../../icons/file_type_markuplint.svg"},"_f_light_markuplint":{"iconPath":"../../icons/file_type_light_markuplint.svg"},"_f_master-co":{"iconPath":"../../icons/file_type_master-co.svg"},"_f_matlab":{"iconPath":"../../icons/file_type_matlab.svg"},"_f_maven":{"iconPath":"../../icons/file_type_maven.svg"},"_f_maxscript":{"iconPath":"../../icons/file_type_maxscript.svg"},"_f_maya":{"iconPath":"../../icons/file_type_maya.svg"},"_f_mcp":{"iconPath":"../../icons/file_type_mcp.svg"},"_f_light_mcp":{"iconPath":"../../icons/file_type_light_mcp.svg"},"_f_mdx":{"iconPath":"../../icons/file_type_mdx.svg"},"_f_light_mdx":{"iconPath":"../../icons/file_type_light_mdx.svg"},"_f_mdx-components":{"iconPath":"../../icons/file_type_mdx-components.svg"},"_f_light_mdx-components":{"iconPath":"../../icons/file_type_light_mdx-components.svg"},"_f_mdxlint":{"iconPath":"../../icons/file_type_mdxlint.svg"},"_f_mediawiki":{"iconPath":"../../icons/file_type_mediawiki.svg"},"_f_mercurial":{"iconPath":"../../icons/file_type_mercurial.svg"},"_f_mermaid":{"iconPath":"../../icons/file_type_mermaid.svg"},"_f_meson":{"iconPath":"../../icons/file_type_meson.svg"},"_f_metal":{"iconPath":"../../icons/file_type_metal.svg"},"_f_meteor":{"iconPath":"../../icons/file_type_meteor.svg"},"_f_minecraft":{"iconPath":"../../icons/file_type_minecraft.svg"},"_f_mivascript":{"iconPath":"../../icons/file_type_mivascript.svg"},"_f_mjml":{"iconPath":"../../icons/file_type_mjml.svg"},"_f_mlang":{"iconPath":"../../icons/file_type_mlang.svg"},"_f_light_mlang":{"iconPath":"../../icons/file_type_light_mlang.svg"},"_f_mocha":{"iconPath":"../../icons/file_type_mocha.svg"},"_f_modernizr":{"iconPath":"../../icons/file_type_modernizr.svg"},"_f_mojo":{"iconPath":"../../icons/file_type_mojo.svg"},"_f_mojolicious":{"iconPath":"../../icons/file_type_mojolicious.svg"},"_f_moleculer":{"iconPath":"../../icons/file_type_moleculer.svg"},"_f_mondoo":{"iconPath":"../../icons/file_type_mondoo.svg"},"_f_mongo":{"iconPath":"../../icons/file_type_mongo.svg"},"_f_monotone":{"iconPath":"../../icons/file_type_monotone.svg"},"_f_motif":{"iconPath":"../../icons/file_type_motif.svg"},"_f_mson":{"iconPath":"../../icons/file_type_mson.svg"},"_f_mustache":{"iconPath":"../../icons/file_type_mustache.svg"},"_f_light_mustache":{"iconPath":"../../icons/file_type_light_mustache.svg"},"_f_mvt":{"iconPath":"../../icons/file_type_mvt.svg"},"_f_mvtcss":{"iconPath":"../../icons/file_type_mvtcss.svg"},"_f_mvtjs":{"iconPath":"../../icons/file_type_mvtjs.svg"},"_f_mypy":{"iconPath":"../../icons/file_type_mypy.svg"},"_f_light_mypy":{"iconPath":"../../icons/file_type_light_mypy.svg"},"_f_nanostaged":{"iconPath":"../../icons/file_type_nanostaged.svg"},"_f_ndst":{"iconPath":"../../icons/file_type_ndst.svg"},"_f_nearly":{"iconPath":"../../icons/file_type_nearly.svg"},"_f_neo4j":{"iconPath":"../../icons/file_type_neo4j.svg"},"_f_light_neo4j":{"iconPath":"../../icons/file_type_light_neo4j.svg"},"_f_nestjs":{"iconPath":"../../icons/file_type_nestjs.svg"},"_f_netlify":{"iconPath":"../../icons/file_type_netlify.svg"},"_f_light_netlify":{"iconPath":"../../icons/file_type_light_netlify.svg"},"_f_next":{"iconPath":"../../icons/file_type_next.svg"},"_f_light_next":{"iconPath":"../../icons/file_type_light_next.svg"},"_f_nextflow":{"iconPath":"../../icons/file_type_nextflow.svg"},"_f_ng_tailwind":{"iconPath":"../../icons/file_type_ng_tailwind.svg"},"_f_nginx":{"iconPath":"../../icons/file_type_nginx.svg"},"_f_nim":{"iconPath":"../../icons/file_type_nim.svg"},"_f_nimble":{"iconPath":"../../icons/file_type_nimble.svg"},"_f_ninja":{"iconPath":"../../icons/file_type_ninja.svg"},"_f_nix":{"iconPath":"../../icons/file_type_nix.svg"},"_f_njsproj":{"iconPath":"../../icons/file_type_njsproj.svg"},"_f_noc":{"iconPath":"../../icons/file_type_noc.svg"},"_f_node":{"iconPath":"../../icons/file_type_node.svg"},"_f_nodemon":{"iconPath":"../../icons/file_type_nodemon.svg"},"_f_npm":{"iconPath":"../../icons/file_type_npm.svg"},"_f_npmpackagejsonlint":{"iconPath":"../../icons/file_type_npmpackagejsonlint.svg"},"_f_nsi":{"iconPath":"../../icons/file_type_nsi.svg"},"_f_nsri":{"iconPath":"../../icons/file_type_nsri.svg"},"_f_nsri-integrity":{"iconPath":"../../icons/file_type_nsri-integrity.svg"},"_f_nuget":{"iconPath":"../../icons/file_type_nuget.svg"},"_f_numpy":{"iconPath":"../../icons/file_type_numpy.svg"},"_f_nunjucks":{"iconPath":"../../icons/file_type_nunjucks.svg"},"_f_nushell":{"iconPath":"../../icons/file_type_nushell.svg"},"_f_nuxt":{"iconPath":"../../icons/file_type_nuxt.svg"},"_f_nx":{"iconPath":"../../icons/file_type_nx.svg"},"_f_light_nx":{"iconPath":"../../icons/file_type_light_nx.svg"},"_f_nyc":{"iconPath":"../../icons/file_type_nyc.svg"},"_f_objectivec":{"iconPath":"../../icons/file_type_objectivec.svg"},"_f_objectivecpp":{"iconPath":"../../icons/file_type_objectivecpp.svg"},"_f_objidconfig":{"iconPath":"../../icons/file_type_objidconfig.svg"},"_f_light_objidconfig":{"iconPath":"../../icons/file_type_light_objidconfig.svg"},"_f_ocaml":{"iconPath":"../../icons/file_type_ocaml.svg"},"_f_ocaml_intf":{"iconPath":"../../icons/file_type_ocaml_intf.svg"},"_f_ogone":{"iconPath":"../../icons/file_type_ogone.svg"},"_f_onenote":{"iconPath":"../../icons/file_type_onenote.svg"},"_f_opam":{"iconPath":"../../icons/file_type_opam.svg"},"_f_openHAB":{"iconPath":"../../icons/file_type_openHAB.svg"},"_f_light_openHAB":{"iconPath":"../../icons/file_type_light_openHAB.svg"},"_f_opencl":{"iconPath":"../../icons/file_type_opencl.svg"},"_f_openscad":{"iconPath":"../../icons/file_type_openscad.svg"},"_f_opentofu":{"iconPath":"../../icons/file_type_opentofu.svg"},"_f_org":{"iconPath":"../../icons/file_type_org.svg"},"_f_outlook":{"iconPath":"../../icons/file_type_outlook.svg"},"_f_ovpn":{"iconPath":"../../icons/file_type_ovpn.svg"},"_f_oxc":{"iconPath":"../../icons/file_type_oxc.svg"},"_f_light_oxc":{"iconPath":"../../icons/file_type_light_oxc.svg"},"_f_package":{"iconPath":"../../icons/file_type_package.svg"},"_f_packship":{"iconPath":"../../icons/file_type_packship.svg"},"_f_light_packship":{"iconPath":"../../icons/file_type_light_packship.svg"},"_f_paket":{"iconPath":"../../icons/file_type_paket.svg"},"_f_pandacss":{"iconPath":"../../icons/file_type_pandacss.svg"},"_f_patch":{"iconPath":"../../icons/file_type_patch.svg"},"_f_pcl":{"iconPath":"../../icons/file_type_pcl.svg"},"_f_light_pcl":{"iconPath":"../../icons/file_type_light_pcl.svg"},"_f_pddl":{"iconPath":"../../icons/file_type_pddl.svg"},"_f_pddl_happenings":{"iconPath":"../../icons/file_type_pddl_happenings.svg"},"_f_pddl_plan":{"iconPath":"../../icons/file_type_pddl_plan.svg"},"_f_pdf":{"iconPath":"../../icons/file_type_pdf.svg"},"_f_pdm":{"iconPath":"../../icons/file_type_pdm.svg"},"_f_peeky":{"iconPath":"../../icons/file_type_peeky.svg"},"_f_perl":{"iconPath":"../../icons/file_type_perl.svg"},"_f_perl6":{"iconPath":"../../icons/file_type_perl6.svg"},"_f_pgsql":{"iconPath":"../../icons/file_type_pgsql.svg"},"_f_photoshop":{"iconPath":"../../icons/file_type_photoshop.svg"},"_f_php":{"iconPath":"../../icons/file_type_php.svg"},"_f_phpcsfixer":{"iconPath":"../../icons/file_type_phpcsfixer.svg"},"_f_phpstan":{"iconPath":"../../icons/file_type_phpstan.svg"},"_f_phpunit":{"iconPath":"../../icons/file_type_phpunit.svg"},"_f_phraseapp":{"iconPath":"../../icons/file_type_phraseapp.svg"},"_f_pine":{"iconPath":"../../icons/file_type_pine.svg"},"_f_pip":{"iconPath":"../../icons/file_type_pip.svg"},"_f_pipeline":{"iconPath":"../../icons/file_type_pipeline.svg"},"_f_plantuml":{"iconPath":"../../icons/file_type_plantuml.svg"},"_f_platformio":{"iconPath":"../../icons/file_type_platformio.svg"},"_f_playwright":{"iconPath":"../../icons/file_type_playwright.svg"},"_f_plsql":{"iconPath":"../../icons/file_type_plsql.svg"},"_f_plsql_package":{"iconPath":"../../icons/file_type_plsql_package.svg"},"_f_plsql_package_body":{"iconPath":"../../icons/file_type_plsql_package_body.svg"},"_f_plsql_package_header":{"iconPath":"../../icons/file_type_plsql_package_header.svg"},"_f_plsql_package_spec":{"iconPath":"../../icons/file_type_plsql_package_spec.svg"},"_f_pm2":{"iconPath":"../../icons/file_type_pm2.svg"},"_f_pnpm":{"iconPath":"../../icons/file_type_pnpm.svg"},"_f_light_pnpm":{"iconPath":"../../icons/file_type_light_pnpm.svg"},"_f_poedit":{"iconPath":"../../icons/file_type_poedit.svg"},"_f_poetry":{"iconPath":"../../icons/file_type_poetry.svg"},"_f_polyglot":{"iconPath":"../../icons/file_type_polyglot.svg"},"_f_polymer":{"iconPath":"../../icons/file_type_polymer.svg"},"_f_pony":{"iconPath":"../../icons/file_type_pony.svg"},"_f_postcss":{"iconPath":"../../icons/file_type_postcss.svg"},"_f_postcssconfig":{"iconPath":"../../icons/file_type_postcssconfig.svg"},"_f_postman":{"iconPath":"../../icons/file_type_postman.svg"},"_f_postscript":{"iconPath":"../../icons/file_type_postscript.svg"},"_f_powerpoint":{"iconPath":"../../icons/file_type_powerpoint.svg"},"_f_powershell":{"iconPath":"../../icons/file_type_powershell.svg"},"_f_powershell_format":{"iconPath":"../../icons/file_type_powershell_format.svg"},"_f_powershell_psd":{"iconPath":"../../icons/file_type_powershell_psd.svg"},"_f_powershell_psm":{"iconPath":"../../icons/file_type_powershell_psm.svg"},"_f_powershell_types":{"iconPath":"../../icons/file_type_powershell_types.svg"},"_f_preact":{"iconPath":"../../icons/file_type_preact.svg"},"_f_precommit":{"iconPath":"../../icons/file_type_precommit.svg"},"_f_prettier":{"iconPath":"../../icons/file_type_prettier.svg"},"_f_light_prettier":{"iconPath":"../../icons/file_type_light_prettier.svg"},"_f_prisma":{"iconPath":"../../icons/file_type_prisma.svg"},"_f_light_prisma":{"iconPath":"../../icons/file_type_light_prisma.svg"},"_f_prismaconfig":{"iconPath":"../../icons/file_type_prismaconfig.svg"},"_f_processinglang":{"iconPath":"../../icons/file_type_processinglang.svg"},"_f_procfile":{"iconPath":"../../icons/file_type_procfile.svg"},"_f_progress":{"iconPath":"../../icons/file_type_progress.svg"},"_f_prolog":{"iconPath":"../../icons/file_type_prolog.svg"},"_f_prometheus":{"iconPath":"../../icons/file_type_prometheus.svg"},"_f_protobuf":{"iconPath":"../../icons/file_type_protobuf.svg"},"_f_protractor":{"iconPath":"../../icons/file_type_protractor.svg"},"_f_publiccode":{"iconPath":"../../icons/file_type_publiccode.svg"},"_f_publisher":{"iconPath":"../../icons/file_type_publisher.svg"},"_f_pug":{"iconPath":"../../icons/file_type_pug.svg"},"_f_pulumi":{"iconPath":"../../icons/file_type_pulumi.svg"},"_f_puppet":{"iconPath":"../../icons/file_type_puppet.svg"},"_f_purescript":{"iconPath":"../../icons/file_type_purescript.svg"},"_f_light_purescript":{"iconPath":"../../icons/file_type_light_purescript.svg"},"_f_purgecss":{"iconPath":"../../icons/file_type_purgecss.svg"},"_f_pyenv":{"iconPath":"../../icons/file_type_pyenv.svg"},"_f_pypi":{"iconPath":"../../icons/file_type_pypi.svg"},"_f_pyret":{"iconPath":"../../icons/file_type_pyret.svg"},"_f_pyscript":{"iconPath":"../../icons/file_type_pyscript.svg"},"_f_pytest":{"iconPath":"../../icons/file_type_pytest.svg"},"_f_python":{"iconPath":"../../icons/file_type_python.svg"},"_f_pythowo":{"iconPath":"../../icons/file_type_pythowo.svg"},"_f_pytyped":{"iconPath":"../../icons/file_type_pytyped.svg"},"_f_pyup":{"iconPath":"../../icons/file_type_pyup.svg"},"_f_q":{"iconPath":"../../icons/file_type_q.svg"},"_f_qbs":{"iconPath":"../../icons/file_type_qbs.svg"},"_f_qlikview":{"iconPath":"../../icons/file_type_qlikview.svg"},"_f_qml":{"iconPath":"../../icons/file_type_qml.svg"},"_f_qmldir":{"iconPath":"../../icons/file_type_qmldir.svg"},"_f_qsharp":{"iconPath":"../../icons/file_type_qsharp.svg"},"_f_quasar":{"iconPath":"../../icons/file_type_quasar.svg"},"_f_light_quasar":{"iconPath":"../../icons/file_type_light_quasar.svg"},"_f_r":{"iconPath":"../../icons/file_type_r.svg"},"_f_ra_syntax_tree":{"iconPath":"../../icons/file_type_ra_syntax_tree.svg"},"_f_racket":{"iconPath":"../../icons/file_type_racket.svg"},"_f_rails":{"iconPath":"../../icons/file_type_rails.svg"},"_f_rake":{"iconPath":"../../icons/file_type_rake.svg"},"_f_raku":{"iconPath":"../../icons/file_type_raku.svg"},"_f_light_raku":{"iconPath":"../../icons/file_type_light_raku.svg"},"_f_raml":{"iconPath":"../../icons/file_type_raml.svg"},"_f_razor":{"iconPath":"../../icons/file_type_razor.svg"},"_f_razzle":{"iconPath":"../../icons/file_type_razzle.svg"},"_f_light_razzle":{"iconPath":"../../icons/file_type_light_razzle.svg"},"_f_reactjs":{"iconPath":"../../icons/file_type_reactjs.svg"},"_f_reactrouter":{"iconPath":"../../icons/file_type_reactrouter.svg"},"_f_light_reactrouter":{"iconPath":"../../icons/file_type_light_reactrouter.svg"},"_f_reacttemplate":{"iconPath":"../../icons/file_type_reacttemplate.svg"},"_f_reactts":{"iconPath":"../../icons/file_type_reactts.svg"},"_f_reason":{"iconPath":"../../icons/file_type_reason.svg"},"_f_red":{"iconPath":"../../icons/file_type_red.svg"},"_f_registry":{"iconPath":"../../icons/file_type_registry.svg"},"_f_rego":{"iconPath":"../../icons/file_type_rego.svg"},"_f_rehype":{"iconPath":"../../icons/file_type_rehype.svg"},"_f_light_rehype":{"iconPath":"../../icons/file_type_light_rehype.svg"},"_f_remark":{"iconPath":"../../icons/file_type_remark.svg"},"_f_light_remark":{"iconPath":"../../icons/file_type_light_remark.svg"},"_f_renovate":{"iconPath":"../../icons/file_type_renovate.svg"},"_f_replit":{"iconPath":"../../icons/file_type_replit.svg"},"_f_light_replit":{"iconPath":"../../icons/file_type_light_replit.svg"},"_f_rescript":{"iconPath":"../../icons/file_type_rescript.svg"},"_f_rest":{"iconPath":"../../icons/file_type_rest.svg"},"_f_retext":{"iconPath":"../../icons/file_type_retext.svg"},"_f_light_retext":{"iconPath":"../../icons/file_type_light_retext.svg"},"_f_rexx":{"iconPath":"../../icons/file_type_rexx.svg"},"_f_riot":{"iconPath":"../../icons/file_type_riot.svg"},"_f_ripple":{"iconPath":"../../icons/file_type_ripple.svg"},"_f_rmd":{"iconPath":"../../icons/file_type_rmd.svg"},"_f_rnc":{"iconPath":"../../icons/file_type_rnc.svg"},"_f_robotframework":{"iconPath":"../../icons/file_type_robotframework.svg"},"_f_robots":{"iconPath":"../../icons/file_type_robots.svg"},"_f_rolldown":{"iconPath":"../../icons/file_type_rolldown.svg"},"_f_light_rolldown":{"iconPath":"../../icons/file_type_light_rolldown.svg"},"_f_rollup":{"iconPath":"../../icons/file_type_rollup.svg"},"_f_rome":{"iconPath":"../../icons/file_type_rome.svg"},"_f_light_rome":{"iconPath":"../../icons/file_type_light_rome.svg"},"_f_ron":{"iconPath":"../../icons/file_type_ron.svg"},"_f_rproj":{"iconPath":"../../icons/file_type_rproj.svg"},"_f_rspec":{"iconPath":"../../icons/file_type_rspec.svg"},"_f_rss":{"iconPath":"../../icons/file_type_rss.svg"},"_f_rubocop":{"iconPath":"../../icons/file_type_rubocop.svg"},"_f_light_rubocop":{"iconPath":"../../icons/file_type_light_rubocop.svg"},"_f_ruby":{"iconPath":"../../icons/file_type_ruby.svg"},"_f_rust":{"iconPath":"../../icons/file_type_rust.svg"},"_f_light_rust":{"iconPath":"../../icons/file_type_light_rust.svg"},"_f_rust_toolchain":{"iconPath":"../../icons/file_type_rust_toolchain.svg"},"_f_light_rust_toolchain":{"iconPath":"../../icons/file_type_light_rust_toolchain.svg"},"_f_s-lang":{"iconPath":"../../icons/file_type_s-lang.svg"},"_f_safetensors":{"iconPath":"../../icons/file_type_safetensors.svg"},"_f_light_safetensors":{"iconPath":"../../icons/file_type_light_safetensors.svg"},"_f_sails":{"iconPath":"../../icons/file_type_sails.svg"},"_f_saltstack":{"iconPath":"../../icons/file_type_saltstack.svg"},"_f_san":{"iconPath":"../../icons/file_type_san.svg"},"_f_sapphire_framework_cli":{"iconPath":"../../icons/file_type_sapphire_framework_cli.svg"},"_f_sas":{"iconPath":"../../icons/file_type_sas.svg"},"_f_sass":{"iconPath":"../../icons/file_type_sass.svg"},"_f_sbt":{"iconPath":"../../icons/file_type_sbt.svg"},"_f_scala":{"iconPath":"../../icons/file_type_scala.svg"},"_f_scilab":{"iconPath":"../../icons/file_type_scilab.svg"},"_f_script":{"iconPath":"../../icons/file_type_script.svg"},"_f_scss":{"iconPath":"../../icons/file_type_scss.svg"},"_f_sdlang":{"iconPath":"../../icons/file_type_sdlang.svg"},"_f_search_result":{"iconPath":"../../icons/file_type_search_result.svg"},"_f_seedkit":{"iconPath":"../../icons/file_type_seedkit.svg"},"_f_sentry":{"iconPath":"../../icons/file_type_sentry.svg"},"_f_sequelize":{"iconPath":"../../icons/file_type_sequelize.svg"},"_f_serverless":{"iconPath":"../../icons/file_type_serverless.svg"},"_f_shadcn":{"iconPath":"../../icons/file_type_shadcn.svg"},"_f_light_shadcn":{"iconPath":"../../icons/file_type_light_shadcn.svg"},"_f_shaderlab":{"iconPath":"../../icons/file_type_shaderlab.svg"},"_f_light_shaderlab":{"iconPath":"../../icons/file_type_light_shaderlab.svg"},"_f_shell":{"iconPath":"../../icons/file_type_shell.svg"},"_f_shellcheck":{"iconPath":"../../icons/file_type_shellcheck.svg"},"_f_shuttle":{"iconPath":"../../icons/file_type_shuttle.svg"},"_f_silverstripe":{"iconPath":"../../icons/file_type_silverstripe.svg"},"_f_sino":{"iconPath":"../../icons/file_type_sino.svg"},"_f_sitemap":{"iconPath":"../../icons/file_type_sitemap.svg"},"_f_siyuan":{"iconPath":"../../icons/file_type_siyuan.svg"},"_f_sketch":{"iconPath":"../../icons/file_type_sketch.svg"},"_f_skipper":{"iconPath":"../../icons/file_type_skipper.svg"},"_f_slang":{"iconPath":"../../icons/file_type_slang.svg"},"_f_slashup":{"iconPath":"../../icons/file_type_slashup.svg"},"_f_slice":{"iconPath":"../../icons/file_type_slice.svg"},"_f_slim":{"iconPath":"../../icons/file_type_slim.svg"},"_f_slint":{"iconPath":"../../icons/file_type_slint.svg"},"_f_sln":{"iconPath":"../../icons/file_type_sln.svg"},"_f_smarty":{"iconPath":"../../icons/file_type_smarty.svg"},"_f_smithery":{"iconPath":"../../icons/file_type_smithery.svg"},"_f_snakemake":{"iconPath":"../../icons/file_type_snakemake.svg"},"_f_snapcraft":{"iconPath":"../../icons/file_type_snapcraft.svg"},"_f_snaplet":{"iconPath":"../../icons/file_type_snaplet.svg"},"_f_snort":{"iconPath":"../../icons/file_type_snort.svg"},"_f_snyk":{"iconPath":"../../icons/file_type_snyk.svg"},"_f_solidarity":{"iconPath":"../../icons/file_type_solidarity.svg"},"_f_solidity":{"iconPath":"../../icons/file_type_solidity.svg"},"_f_light_solidity":{"iconPath":"../../icons/file_type_light_solidity.svg"},"_f_source":{"iconPath":"../../icons/file_type_source.svg"},"_f_spacengine":{"iconPath":"../../icons/file_type_spacengine.svg"},"_f_sparql":{"iconPath":"../../icons/file_type_sparql.svg"},"_f_spin":{"iconPath":"../../icons/file_type_spin.svg"},"_f_light_spin":{"iconPath":"../../icons/file_type_light_spin.svg"},"_f_sqf":{"iconPath":"../../icons/file_type_sqf.svg"},"_f_sql":{"iconPath":"../../icons/file_type_sql.svg"},"_f_sqlite":{"iconPath":"../../icons/file_type_sqlite.svg"},"_f_squirrel":{"iconPath":"../../icons/file_type_squirrel.svg"},"_f_sss":{"iconPath":"../../icons/file_type_sss.svg"},"_f_sst":{"iconPath":"../../icons/file_type_sst.svg"},"_f_stackblitz":{"iconPath":"../../icons/file_type_stackblitz.svg"},"_f_stan":{"iconPath":"../../icons/file_type_stan.svg"},"_f_stata":{"iconPath":"../../icons/file_type_stata.svg"},"_f_stencil":{"iconPath":"../../icons/file_type_stencil.svg"},"_f_storyboard":{"iconPath":"../../icons/file_type_storyboard.svg"},"_f_storybook":{"iconPath":"../../icons/file_type_storybook.svg"},"_f_stryker":{"iconPath":"../../icons/file_type_stryker.svg"},"_f_stylable":{"iconPath":"../../icons/file_type_stylable.svg"},"_f_style":{"iconPath":"../../icons/file_type_style.svg"},"_f_styled":{"iconPath":"../../icons/file_type_styled.svg"},"_f_stylelint":{"iconPath":"../../icons/file_type_stylelint.svg"},"_f_light_stylelint":{"iconPath":"../../icons/file_type_light_stylelint.svg"},"_f_stylish_haskell":{"iconPath":"../../icons/file_type_stylish_haskell.svg"},"_f_stylus":{"iconPath":"../../icons/file_type_stylus.svg"},"_f_light_stylus":{"iconPath":"../../icons/file_type_light_stylus.svg"},"_f_sublime":{"iconPath":"../../icons/file_type_sublime.svg"},"_f_subversion":{"iconPath":"../../icons/file_type_subversion.svg"},"_f_svelte":{"iconPath":"../../icons/file_type_svelte.svg"},"_f_svelteconfig":{"iconPath":"../../icons/file_type_svelteconfig.svg"},"_f_svg":{"iconPath":"../../icons/file_type_svg.svg"},"_f_svgo":{"iconPath":"../../icons/file_type_svgo.svg"},"_f_swagger":{"iconPath":"../../icons/file_type_swagger.svg"},"_f_swc":{"iconPath":"../../icons/file_type_swc.svg"},"_f_swift":{"iconPath":"../../icons/file_type_swift.svg"},"_f_swig":{"iconPath":"../../icons/file_type_swig.svg"},"_f_symfony":{"iconPath":"../../icons/file_type_symfony.svg"},"_f_light_symfony":{"iconPath":"../../icons/file_type_light_symfony.svg"},"_f_syncpack":{"iconPath":"../../icons/file_type_syncpack.svg"},"_f_systemd":{"iconPath":"../../icons/file_type_systemd.svg"},"_f_light_systemd":{"iconPath":"../../icons/file_type_light_systemd.svg"},"_f_systemverilog":{"iconPath":"../../icons/file_type_systemverilog.svg"},"_f_light_systemverilog":{"iconPath":"../../icons/file_type_light_systemverilog.svg"},"_f_t4tt":{"iconPath":"../../icons/file_type_t4tt.svg"},"_f_tailwind":{"iconPath":"../../icons/file_type_tailwind.svg"},"_f_tamagui":{"iconPath":"../../icons/file_type_tamagui.svg"},"_f_taplo":{"iconPath":"../../icons/file_type_taplo.svg"},"_f_taskfile":{"iconPath":"../../icons/file_type_taskfile.svg"},"_f_tauri":{"iconPath":"../../icons/file_type_tauri.svg"},"_f_tcl":{"iconPath":"../../icons/file_type_tcl.svg"},"_f_teal":{"iconPath":"../../icons/file_type_teal.svg"},"_f_templ":{"iconPath":"../../icons/file_type_templ.svg"},"_f_tera":{"iconPath":"../../icons/file_type_tera.svg"},"_f_terraform":{"iconPath":"../../icons/file_type_terraform.svg"},"_f_test":{"iconPath":"../../icons/file_type_test.svg"},"_f_testcafe":{"iconPath":"../../icons/file_type_testcafe.svg"},"_f_light_testcafe":{"iconPath":"../../icons/file_type_light_testcafe.svg"},"_f_testjs":{"iconPath":"../../icons/file_type_testjs.svg"},"_f_light_testjs":{"iconPath":"../../icons/file_type_light_testjs.svg"},"_f_testplane":{"iconPath":"../../icons/file_type_testplane.svg"},"_f_testts":{"iconPath":"../../icons/file_type_testts.svg"},"_f_tex":{"iconPath":"../../icons/file_type_tex.svg"},"_f_light_tex":{"iconPath":"../../icons/file_type_light_tex.svg"},"_f_text":{"iconPath":"../../icons/file_type_text.svg"},"_f_textile":{"iconPath":"../../icons/file_type_textile.svg"},"_f_tfs":{"iconPath":"../../icons/file_type_tfs.svg"},"_f_tiltfile":{"iconPath":"../../icons/file_type_tiltfile.svg"},"_f_tm":{"iconPath":"../../icons/file_type_tm.svg"},"_f_light_tm":{"iconPath":"../../icons/file_type_light_tm.svg"},"_f_tmux":{"iconPath":"../../icons/file_type_tmux.svg"},"_f_light_tmux":{"iconPath":"../../icons/file_type_light_tmux.svg"},"_f_todo":{"iconPath":"../../icons/file_type_todo.svg"},"_f_light_todo":{"iconPath":"../../icons/file_type_light_todo.svg"},"_f_toit":{"iconPath":"../../icons/file_type_toit.svg"},"_f_light_toit":{"iconPath":"../../icons/file_type_light_toit.svg"},"_f_toml":{"iconPath":"../../icons/file_type_toml.svg"},"_f_light_toml":{"iconPath":"../../icons/file_type_light_toml.svg"},"_f_toon":{"iconPath":"../../icons/file_type_toon.svg"},"_f_light_toon":{"iconPath":"../../icons/file_type_light_toon.svg"},"_f_tox":{"iconPath":"../../icons/file_type_tox.svg"},"_f_travis":{"iconPath":"../../icons/file_type_travis.svg"},"_f_tree":{"iconPath":"../../icons/file_type_tree.svg"},"_f_light_tree":{"iconPath":"../../icons/file_type_light_tree.svg"},"_f_tres":{"iconPath":"../../icons/file_type_tres.svg"},"_f_truffle":{"iconPath":"../../icons/file_type_truffle.svg"},"_f_trunk":{"iconPath":"../../icons/file_type_trunk.svg"},"_f_tsbuildinfo":{"iconPath":"../../icons/file_type_tsbuildinfo.svg"},"_f_tscn":{"iconPath":"../../icons/file_type_tscn.svg"},"_f_tsconfig":{"iconPath":"../../icons/file_type_tsconfig.svg"},"_f_tsdoc":{"iconPath":"../../icons/file_type_tsdoc.svg"},"_f_tsdown":{"iconPath":"../../icons/file_type_tsdown.svg"},"_f_tslint":{"iconPath":"../../icons/file_type_tslint.svg"},"_f_tt":{"iconPath":"../../icons/file_type_tt.svg"},"_f_ttcn":{"iconPath":"../../icons/file_type_ttcn.svg"},"_f_tuc":{"iconPath":"../../icons/file_type_tuc.svg"},"_f_turbo":{"iconPath":"../../icons/file_type_turbo.svg"},"_f_light_turbo":{"iconPath":"../../icons/file_type_light_turbo.svg"},"_f_twig":{"iconPath":"../../icons/file_type_twig.svg"},"_f_typedoc":{"iconPath":"../../icons/file_type_typedoc.svg"},"_f_typescript":{"iconPath":"../../icons/file_type_typescript.svg"},"_f_typescriptdef":{"iconPath":"../../icons/file_type_typescriptdef.svg"},"_f_typo3":{"iconPath":"../../icons/file_type_typo3.svg"},"_f_uiua":{"iconPath":"../../icons/file_type_uiua.svg"},"_f_unibeautify":{"iconPath":"../../icons/file_type_unibeautify.svg"},"_f_light_unibeautify":{"iconPath":"../../icons/file_type_light_unibeautify.svg"},"_f_unison":{"iconPath":"../../icons/file_type_unison.svg"},"_f_unlicense":{"iconPath":"../../icons/file_type_unlicense.svg"},"_f_unocss":{"iconPath":"../../icons/file_type_unocss.svg"},"_f_uv":{"iconPath":"../../icons/file_type_uv.svg"},"_f_vagrant":{"iconPath":"../../icons/file_type_vagrant.svg"},"_f_vala":{"iconPath":"../../icons/file_type_vala.svg"},"_f_vanilla_extract":{"iconPath":"../../icons/file_type_vanilla_extract.svg"},"_f_vapi":{"iconPath":"../../icons/file_type_vapi.svg"},"_f_vapor":{"iconPath":"../../icons/file_type_vapor.svg"},"_f_vash":{"iconPath":"../../icons/file_type_vash.svg"},"_f_light_vash":{"iconPath":"../../icons/file_type_light_vash.svg"},"_f_vb":{"iconPath":"../../icons/file_type_vb.svg"},"_f_vba":{"iconPath":"../../icons/file_type_vba.svg"},"_f_vbhtml":{"iconPath":"../../icons/file_type_vbhtml.svg"},"_f_vbproj":{"iconPath":"../../icons/file_type_vbproj.svg"},"_f_vcxproj":{"iconPath":"../../icons/file_type_vcxproj.svg"},"_f_velocity":{"iconPath":"../../icons/file_type_velocity.svg"},"_f_vento":{"iconPath":"../../icons/file_type_vento.svg"},"_f_vercel":{"iconPath":"../../icons/file_type_vercel.svg"},"_f_light_vercel":{"iconPath":"../../icons/file_type_light_vercel.svg"},"_f_verilog":{"iconPath":"../../icons/file_type_verilog.svg"},"_f_vhdl":{"iconPath":"../../icons/file_type_vhdl.svg"},"_f_video":{"iconPath":"../../icons/file_type_video.svg"},"_f_view":{"iconPath":"../../icons/file_type_view.svg"},"_f_vim":{"iconPath":"../../icons/file_type_vim.svg"},"_f_vite":{"iconPath":"../../icons/file_type_vite.svg"},"_f_light_vite":{"iconPath":"../../icons/file_type_light_vite.svg"},"_f_vitest":{"iconPath":"../../icons/file_type_vitest.svg"},"_f_light_vitest":{"iconPath":"../../icons/file_type_light_vitest.svg"},"_f_vlang":{"iconPath":"../../icons/file_type_vlang.svg"},"_f_volt":{"iconPath":"../../icons/file_type_volt.svg"},"_f_vroom":{"iconPath":"../../icons/file_type_vroom.svg"},"_f_vscode":{"iconPath":"../../icons/file_type_vscode.svg"},"_f_vscode_test":{"iconPath":"../../icons/file_type_vscode_test.svg"},"_f_vsix":{"iconPath":"../../icons/file_type_vsix.svg"},"_f_light_vsix":{"iconPath":"../../icons/file_type_light_vsix.svg"},"_f_vsixmanifest":{"iconPath":"../../icons/file_type_vsixmanifest.svg"},"_f_light_vsixmanifest":{"iconPath":"../../icons/file_type_light_vsixmanifest.svg"},"_f_vue":{"iconPath":"../../icons/file_type_vue.svg"},"_f_vueconfig":{"iconPath":"../../icons/file_type_vueconfig.svg"},"_f_vvvvvv":{"iconPath":"../../icons/file_type_vvvvvv.svg"},"_f_vyper":{"iconPath":"../../icons/file_type_vyper.svg"},"_f_wallaby":{"iconPath":"../../icons/file_type_wallaby.svg"},"_f_wally":{"iconPath":"../../icons/file_type_wally.svg"},"_f_wasm":{"iconPath":"../../icons/file_type_wasm.svg"},"_f_watchmanconfig":{"iconPath":"../../icons/file_type_watchmanconfig.svg"},"_f_wdio":{"iconPath":"../../icons/file_type_wdio.svg"},"_f_weblate":{"iconPath":"../../icons/file_type_weblate.svg"},"_f_webpack":{"iconPath":"../../icons/file_type_webpack.svg"},"_f_wenyan":{"iconPath":"../../icons/file_type_wenyan.svg"},"_f_wercker":{"iconPath":"../../icons/file_type_wercker.svg"},"_f_wgsl":{"iconPath":"../../icons/file_type_wgsl.svg"},"_f_wikitext":{"iconPath":"../../icons/file_type_wikitext.svg"},"_f_windi":{"iconPath":"../../icons/file_type_windi.svg"},"_f_wit":{"iconPath":"../../icons/file_type_wit.svg"},"_f_wolfram":{"iconPath":"../../icons/file_type_wolfram.svg"},"_f_word":{"iconPath":"../../icons/file_type_word.svg"},"_f_wpml":{"iconPath":"../../icons/file_type_wpml.svg"},"_f_wurst":{"iconPath":"../../icons/file_type_wurst.svg"},"_f_wxml":{"iconPath":"../../icons/file_type_wxml.svg"},"_f_wxss":{"iconPath":"../../icons/file_type_wxss.svg"},"_f_wxt":{"iconPath":"../../icons/file_type_wxt.svg"},"_f_xaml":{"iconPath":"../../icons/file_type_xaml.svg"},"_f_xcode":{"iconPath":"../../icons/file_type_xcode.svg"},"_f_xfl":{"iconPath":"../../icons/file_type_xfl.svg"},"_f_light_xfl":{"iconPath":"../../icons/file_type_light_xfl.svg"},"_f_xib":{"iconPath":"../../icons/file_type_xib.svg"},"_f_xliff":{"iconPath":"../../icons/file_type_xliff.svg"},"_f_xmake":{"iconPath":"../../icons/file_type_xmake.svg"},"_f_xml":{"iconPath":"../../icons/file_type_xml.svg"},"_f_xo":{"iconPath":"../../icons/file_type_xo.svg"},"_f_xorg":{"iconPath":"../../icons/file_type_xorg.svg"},"_f_light_xorg":{"iconPath":"../../icons/file_type_light_xorg.svg"},"_f_xquery":{"iconPath":"../../icons/file_type_xquery.svg"},"_f_xsl":{"iconPath":"../../icons/file_type_xsl.svg"},"_f_yacc":{"iconPath":"../../icons/file_type_yacc.svg"},"_f_yaml":{"iconPath":"../../icons/file_type_yaml.svg"},"_f_light_yaml":{"iconPath":"../../icons/file_type_light_yaml.svg"},"_f_yamllint":{"iconPath":"../../icons/file_type_yamllint.svg"},"_f_yandex":{"iconPath":"../../icons/file_type_yandex.svg"},"_f_yang":{"iconPath":"../../icons/file_type_yang.svg"},"_f_yarn":{"iconPath":"../../icons/file_type_yarn.svg"},"_f_yeoman":{"iconPath":"../../icons/file_type_yeoman.svg"},"_f_zig":{"iconPath":"../../icons/file_type_zig.svg"},"_f_zip":{"iconPath":"../../icons/file_type_zip.svg"}},"file":"_file","folder":"_folder","folderExpanded":"_folder_open","rootFolder":"_root_folder","rootFolderExpanded":"_root_folder_open","folderNames":{"android":"_fd_android",".angular":"_fd_angular","apache":"_fd_apache","apache2":"_fd_apache","httpd":"_fd_apache","api":"_fd_api",".api":"_fd_api","apis":"_fd_api",".apis":"_fd_api","app":"_fd_app","apps":"_fd_app",".app":"_fd_app","application":"_fd_app","applications":"_fd_app","arangodb":"_fd_arangodb","arango":"_fd_arangodb","assets":"_fd_asset",".assets":"_fd_asset",".astro":"_fd_astro","audio":"_fd_audio",".audio":"_fd_audio","audios":"_fd_audio",".audios":"_fd_audio","sound":"_fd_audio",".sound":"_fd_audio","sounds":"_fd_audio",".sounds":"_fd_audio","aurelia_project":"_fd_aurelia","aws":"_fd_aws",".aws":"_fd_aws","azure":"_fd_azure",".azure":"_fd_azure","azure-pipelines":"_fd_azurepipelines",".azure-pipelines":"_fd_azurepipelines","bin":"_fd_binary",".bin":"_fd_binary","blocs":"_fd_bloc","bloc":"_fd_bloc","blueprint":"_fd_blueprint",".blueprint":"_fd_blueprint","blueprints":"_fd_blueprint",".blueprints":"_fd_blueprint","bot":"_fd_bot",".bot":"_fd_bot","bower_components":"_fd_bower",".buildkite":"_fd_buildkite","cake":"_fd_cake",".cake":"_fd_cake","cargo":"_fd_cargo",".cargo":"_fd_cargo","crates":"_fd_cargo","certificates":"_fd_certificate",".certificates":"_fd_certificate","certs":"_fd_certificate","certs.":"_fd_certificate",".changeset":"_fd_changesets","chef":"_fd_chef",".chef":"_fd_chef",".circleci":"_fd_circleci",".claude":"_fd_claude","cli":"_fd_cli","cmd":"_fd_cli","command":"_fd_cli","commands":"_fd_cli","commandline":"_fd_cli","console":"_fd_cli","client":"_fd_client","clients":"_fd_client",".cloudflare":"_fd_cloudflare","cloudflare":"_fd_cloudflare",".cmake":"_fd_cmake","cmake":"_fd_cmake","common":"_fd_common","component":"_fd_component","components":"_fd_component",".components":"_fd_component","gui":"_fd_component","src-ui":"_fd_component","ui":"_fd_component","widgets":"_fd_component","composer":"_fd_composer",".composer":"_fd_composer","conf":"_fd_config",".conf":"_fd_config","config":"_fd_config",".config":"_fd_config","configs":"_fd_config",".configs":"_fd_config","configuration":"_fd_config",".configuration":"_fd_config","configurations":"_fd_config",".configurations":"_fd_config","setting":"_fd_config",".setting":"_fd_config","settings":"_fd_config",".settings":"_fd_config","ini":"_fd_config",".ini":"_fd_config","initializers":"_fd_config",".initializers":"_fd_config","__controller__":"_fd_controller","__controllers__":"_fd_controller","controller":"_fd_controller","controllers":"_fd_controller",".controllers":"_fd_controller","__handler__":"_fd_controller","__handlers__":"_fd_controller","handler":"_fd_controller",".handler":"_fd_controller","handlers":"_fd_controller",".handlers":"_fd_controller","coverage":"_fd_coverage","css":"_fd_css","_css":"_fd_css","cubits":"_fd_cubit","cubit":"_fd_cubit",".cursor":"_fd_cursor","cypress":"_fd_cypress",".dapr":"_fd_dapr","dapr":"_fd_dapr","dart":"_fd_dart",".dart_tool":"_fd_dart","datadog":"_fd_datadog",".datadog":"_fd_datadog","db":"_fd_db","database":"_fd_db","sql":"_fd_db","data":"_fd_db","repo":"_fd_db","repository":"_fd_db","repositories":"_fd_db","store":"_fd_db","stores":"_fd_db","debian":"_fd_debian","deb":"_fd_debian",".dependabot":"_fd_dependabot",".devcontainer":"_fd_devcontainer","dist":"_fd_dist",".dist":"_fd_dist","dists":"_fd_dist","out":"_fd_dist","outs":"_fd_dist","export":"_fd_dist","exports":"_fd_dist","build":"_fd_dist",".build":"_fd_dist","builds":"_fd_dist","release":"_fd_dist","releases":"_fd_dist","target":"_fd_dist","targets":"_fd_dist","docker":"_fd_docker",".docker":"_fd_docker","docs":"_fd_docs","doc":"_fd_docs","document":"_fd_docs","documents":"_fd_docs","e2e":"_fd_e2e",".elasticbeanstalk":"_fd_elasticbeanstalk",".ebextensions":"_fd_elasticbeanstalk","electron":"_fd_electron",".expo":"_fd_expo",".expo-shared":"_fd_expo","favicon":"_fd_favicon","favicons":"_fd_favicon","flow":"_fd_flow","flow-typed":"_fd_flow","flutter":"_fd_flutter","Flutter":"_fd_flutter","fonts":"_fd_fonts","font":"_fd_fonts","fnt":"_fd_fonts","webfonts":"_fd_fonts",".front-commerce":"_fd_frontcommerce","gcp":"_fd_gcp",".gcp":"_fd_gcp",".gemini":"_fd_gemini",".git":"_fd_git","submodules":"_fd_git",".submodules":"_fd_git",".github":"_fd_github",".gitlab":"_fd_gitlab","godot":"_fd_godot",".godot":"_fd_godot","gradle":"_fd_gradle",".gradle":"_fd_gradle","graphql":"_fd_graphql","grunt":"_fd_grunt","gulp":"_fd_gulp","gulpfile.js":"_fd_gulp","gulpfile.coffee":"_fd_gulp","gulpfile.ts":"_fd_gulp","gulpfile.babel.js":"_fd_gulp","gulpfile.babel.coffee":"_fd_gulp","gulpfile.babel.ts":"_fd_gulp",".haxelib":"_fd_haxelib","haxe_libraries":"_fd_haxelib","helper":"_fd_helper",".helper":"_fd_helper","helpers":"_fd_helper",".helpers":"_fd_helper",".histoire":"_fd_histoire","hook":"_fd_hook",".hook":"_fd_hook","hooks":"_fd_hook",".hooks":"_fd_hook",".husky":"_fd_husky",".idea":"_fd_idea",".fig":"_fd_images",".figs":"_fd_images","images":"_fd_images","image":"_fd_images",".img":"_fd_images","img":"_fd_images",".imgs":"_fd_images","imgs":"_fd_images","icons":"_fd_images","icon":"_fd_images","ico":"_fd_images","screenshot":"_fd_images","screenshots":"_fd_images","svg":"_fd_images","texture":"_fd_images","textures":"_fd_images","include":"_fd_include","includes":"_fd_include","incl":"_fd_include","inc":"_fd_include",".include":"_fd_include",".includes":"_fd_include",".incl":"_fd_include",".inc":"_fd_include","_include":"_fd_include","_includes":"_fd_include","_incl":"_fd_include","_inc":"_fd_include","interface":"_fd_interfaces","interfaces":"_fd_interfaces","ios":"_fd_ios","js":"_fd_js","json":"_fd_json",".junie":"_fd_junie","kotlin":"_fd_kotlin","Kotlin":"_fd_kotlin",".kotlin":"_fd_kotlin","kubernetes":"_fd_kubernetes","k8s":"_fd_kubernetes","kube":"_fd_kubernetes","kuber":"_fd_kubernetes",".kubernetes":"_fd_kubernetes",".k8s":"_fd_kubernetes",".kube":"_fd_kubernetes",".kuber":"_fd_kubernetes","less":"_fd_less","_less":"_fd_less","lib":"_fd_library","libs":"_fd_library",".lib":"_fd_library",".libs":"_fd_library","__lib__":"_fd_library","__libs__":"_fd_library","library":"_fd_library","libraries":"_fd_library","linux":"_fd_linux","lang":"_fd_locale","language":"_fd_locale","languages":"_fd_locale","locale":"_fd_locale","locales":"_fd_locale","_locale":"_fd_locale","_locales":"_fd_locale","internationalization":"_fd_locale","globalization":"_fd_locale","localization":"_fd_locale","i18n":"_fd_locale","g11n":"_fd_locale","l10n":"_fd_locale","log":"_fd_log","logs":"_fd_log","macos":"_fd_macos","darwin":"_fd_macos","mariadb":"_fd_mariadb","maria":"_fd_mariadb",".mvn":"_fd_maven","mediawiki":"_fd_mediawiki","memcached":"_fd_memcached",".memcached":"_fd_memcached",".meteor":"_fd_meteor","middleware":"_fd_middleware","middlewares":"_fd_middleware",".minecraft":"_fd_minecraft","minikube":"_fd_minikube","minik8s":"_fd_minikube","minikuber":"_fd_minikube","mjml":"_fd_mjml",".mjml":"_fd_mjml","mocks":"_fd_mock",".mocks":"_fd_mock","__mocks__":"_fd_mock","model":"_fd_model",".model":"_fd_model","models":"_fd_model",".models":"_fd_model","entities":"_fd_model",".entities":"_fd_model","modules":"_fd_module","mojo":"_fd_mojo","mongodb":"_fd_mongodb","mongo":"_fd_mongodb",".mypy_cache":"_fd_mypy","mysqldb":"_fd_mysql","mysql":"_fd_mysql","netlify":"_fd_netlify",".next":"_fd_next","nginx":"_fd_nginx","conf.d":"_fd_nginx",".niv":"_fd_nix",".nix":"_fd_nix","nix":"_fd_nix","niv":"_fd_nix","node_modules":"_fd_node","notebooks":"_fd_notebooks","notebook":"_fd_notebooks","notification":"_fd_notification","notifications":"_fd_notification","event":"_fd_notification","events":"_fd_notification",".nuget":"_fd_nuget","nuxt":"_fd_nuxt",".nuxt":"_fd_nuxt","package":"_fd_package","packages":"_fd_package",".package":"_fd_package",".packages":"_fd_package","pkg":"_fd_package",".paket":"_fd_paket","php":"_fd_php",".pio":"_fd_platformio",".pioenvs":"_fd_platformio","plugin":"_fd_plugin",".plugin":"_fd_plugin","plugins":"_fd_plugin",".plugins":"_fd_plugin","extension":"_fd_plugin",".extension":"_fd_plugin","extensions":"_fd_plugin",".extensions":"_fd_plugin","prisma":"_fd_prisma","private":"_fd_private",".private":"_fd_private","public":"_fd_public",".public":"_fd_public",".pytest_cache":"_fd_pytest",".venv":"_fd_python",".virtualenv":"_fd_python","__pycache__":"_fd_python","ravendb":"_fd_ravendb","redis":"_fd_redis","redux":"_fd_redux","route":"_fd_route","routes":"_fd_route","_route":"_fd_route","_routes":"_fd_route","router":"_fd_route","routers":"_fd_route","sass":"_fd_sass","scss":"_fd_sass","_sass":"_fd_sass","_scss":"_fd_sass","script":"_fd_script","scripts":"_fd_script","seedkit":"_fd_seedkit",".seedkit":"_fd_seedkit","server":"_fd_server","service":"_fd_services","services":"_fd_services","share":"_fd_shared","shared":"_fd_shared",".share":"_fd_shared",".shared":"_fd_shared","__shared__":"_fd_shared","__share__":"_fd_shared",".snaplet":"_fd_snaplet",".spin":"_fd_spin","src":"_fd_src","source":"_fd_src","sources":"_fd_src","__src__":"_fd_src","sso":"_fd_sso","story":"_fd_story","stories":"_fd_story","__stories__":"_fd_story",".storybook":"_fd_story","style":"_fd_style","styles":"_fd_style","supabase":"_fd_supabase","svelte":"_fd_svelte",".svelte-kit":"_fd_svelte","src-tauri":"_fd_tauri","temp":"_fd_temp",".temp":"_fd_temp","tmp":"_fd_temp",".tmp":"_fd_temp","template":"_fd_template",".template":"_fd_template","templates":"_fd_template",".templates":"_fd_template","tests":"_fd_test",".tests":"_fd_test","test":"_fd_test",".test":"_fd_test","__tests__":"_fd_test","__test__":"_fd_test","spec":"_fd_test",".spec":"_fd_test","specs":"_fd_test",".specs":"_fd_test","integration":"_fd_test","theme":"_fd_theme","themes":"_fd_theme","tool":"_fd_tools","tools":"_fd_tools",".tools":"_fd_tools","util":"_fd_tools","utils":"_fd_tools","utilities":"_fd_tools","tooling":"_fd_tools",".travis":"_fd_travis",".trunk":"_fd_trunk",".turbo":"_fd_turbo","typescript":"_fd_typescript","ts":"_fd_typescript","typings":"_fd_typings","types":"_fd_typings","@types":"_fd_typings","vagrant":"_fd_vagrant",".vagrant":"_fd_vagrant",".vercel":"_fd_vercel","video":"_fd_video",".video":"_fd_video","videos":"_fd_video",".videos":"_fd_video","html":"_fd_view","view":"_fd_view","views":"_fd_view","layout":"_fd_view","layouts":"_fd_view","page":"_fd_view","pages":"_fd_view","_view":"_fd_view","_views":"_fd_view","_layout":"_fd_view","_layouts":"_fd_view","_page":"_fd_view","_pages":"_fd_view",".vitepress":"_fd_vitepress",".vs":"_fd_vs",".vscode":"_fd_vscode","vscode":"_fd_vscode",".vscode-test":"_fd_vscode_test","wasm":"_fd_wasm","WASM":"_fd_wasm","webassembly":"_fd_wasm","WebAssembly":"_fd_wasm","webpack":"_fd_webpack",".webpack":"_fd_webpack","windows":"_fd_windows","win32":"_fd_windows",".windsurf":"_fd_windsurf","wit":"_fd_wit",".web":"_fd_www","www":"_fd_www","wwwroot":"_fd_www","web":"_fd_www",".yarn":"_fd_yarn",".zed":"_fd_zed"},"folderNamesExpanded":{"android":"_fd_android_open",".angular":"_fd_angular_open","apache":"_fd_apache_open","apache2":"_fd_apache_open","httpd":"_fd_apache_open","api":"_fd_api_open",".api":"_fd_api_open","apis":"_fd_api_open",".apis":"_fd_api_open","app":"_fd_app_open","apps":"_fd_app_open",".app":"_fd_app_open","application":"_fd_app_open","applications":"_fd_app_open","arangodb":"_fd_arangodb_open","arango":"_fd_arangodb_open","assets":"_fd_asset_open",".assets":"_fd_asset_open",".astro":"_fd_astro_open","audio":"_fd_audio_open",".audio":"_fd_audio_open","audios":"_fd_audio_open",".audios":"_fd_audio_open","sound":"_fd_audio_open",".sound":"_fd_audio_open","sounds":"_fd_audio_open",".sounds":"_fd_audio_open","aurelia_project":"_fd_aurelia_open","aws":"_fd_aws_open",".aws":"_fd_aws_open","azure":"_fd_azure_open",".azure":"_fd_azure_open","azure-pipelines":"_fd_azurepipelines_open",".azure-pipelines":"_fd_azurepipelines_open","bin":"_fd_binary_open",".bin":"_fd_binary_open","blocs":"_fd_bloc_open","bloc":"_fd_bloc_open","blueprint":"_fd_blueprint_open",".blueprint":"_fd_blueprint_open","blueprints":"_fd_blueprint_open",".blueprints":"_fd_blueprint_open","bot":"_fd_bot_open",".bot":"_fd_bot_open","bower_components":"_fd_bower_open",".buildkite":"_fd_buildkite_open","cake":"_fd_cake_open",".cake":"_fd_cake_open","cargo":"_fd_cargo_open",".cargo":"_fd_cargo_open","crates":"_fd_cargo_open","certificates":"_fd_certificate_open",".certificates":"_fd_certificate_open","certs":"_fd_certificate_open","certs.":"_fd_certificate_open",".changeset":"_fd_changesets_open","chef":"_fd_chef_open",".chef":"_fd_chef_open",".circleci":"_fd_circleci_open",".claude":"_fd_claude_open","cli":"_fd_cli_open","cmd":"_fd_cli_open","command":"_fd_cli_open","commands":"_fd_cli_open","commandline":"_fd_cli_open","console":"_fd_cli_open","client":"_fd_client_open","clients":"_fd_client_open",".cloudflare":"_fd_cloudflare_open","cloudflare":"_fd_cloudflare_open",".cmake":"_fd_cmake_open","cmake":"_fd_cmake_open","common":"_fd_common_open","component":"_fd_component_open","components":"_fd_component_open",".components":"_fd_component_open","gui":"_fd_component_open","src-ui":"_fd_component_open","ui":"_fd_component_open","widgets":"_fd_component_open","composer":"_fd_composer_open",".composer":"_fd_composer_open","conf":"_fd_config_open",".conf":"_fd_config_open","config":"_fd_config_open",".config":"_fd_config_open","configs":"_fd_config_open",".configs":"_fd_config_open","configuration":"_fd_config_open",".configuration":"_fd_config_open","configurations":"_fd_config_open",".configurations":"_fd_config_open","setting":"_fd_config_open",".setting":"_fd_config_open","settings":"_fd_config_open",".settings":"_fd_config_open","ini":"_fd_config_open",".ini":"_fd_config_open","initializers":"_fd_config_open",".initializers":"_fd_config_open","__controller__":"_fd_controller_open","__controllers__":"_fd_controller_open","controller":"_fd_controller_open","controllers":"_fd_controller_open",".controllers":"_fd_controller_open","__handler__":"_fd_controller_open","__handlers__":"_fd_controller_open","handler":"_fd_controller_open",".handler":"_fd_controller_open","handlers":"_fd_controller_open",".handlers":"_fd_controller_open","coverage":"_fd_coverage_open","css":"_fd_css_open","_css":"_fd_css_open","cubits":"_fd_cubit_open","cubit":"_fd_cubit_open",".cursor":"_fd_cursor_open","cypress":"_fd_cypress_open",".dapr":"_fd_dapr_open","dapr":"_fd_dapr_open","dart":"_fd_dart_open",".dart_tool":"_fd_dart_open","datadog":"_fd_datadog_open",".datadog":"_fd_datadog_open","db":"_fd_db_open","database":"_fd_db_open","sql":"_fd_db_open","data":"_fd_db_open","repo":"_fd_db_open","repository":"_fd_db_open","repositories":"_fd_db_open","store":"_fd_db_open","stores":"_fd_db_open","debian":"_fd_debian_open","deb":"_fd_debian_open",".dependabot":"_fd_dependabot_open",".devcontainer":"_fd_devcontainer_open","dist":"_fd_dist_open",".dist":"_fd_dist_open","dists":"_fd_dist_open","out":"_fd_dist_open","outs":"_fd_dist_open","export":"_fd_dist_open","exports":"_fd_dist_open","build":"_fd_dist_open",".build":"_fd_dist_open","builds":"_fd_dist_open","release":"_fd_dist_open","releases":"_fd_dist_open","target":"_fd_dist_open","targets":"_fd_dist_open","docker":"_fd_docker_open",".docker":"_fd_docker_open","docs":"_fd_docs_open","doc":"_fd_docs_open","document":"_fd_docs_open","documents":"_fd_docs_open","e2e":"_fd_e2e_open",".elasticbeanstalk":"_fd_elasticbeanstalk_open",".ebextensions":"_fd_elasticbeanstalk_open","electron":"_fd_electron_open",".expo":"_fd_expo_open",".expo-shared":"_fd_expo_open","favicon":"_fd_favicon_open","favicons":"_fd_favicon_open","flow":"_fd_flow_open","flow-typed":"_fd_flow_open","flutter":"_fd_flutter_open","Flutter":"_fd_flutter_open","fonts":"_fd_fonts_open","font":"_fd_fonts_open","fnt":"_fd_fonts_open","webfonts":"_fd_fonts_open",".front-commerce":"_fd_frontcommerce_open","gcp":"_fd_gcp_open",".gcp":"_fd_gcp_open",".gemini":"_fd_gemini_open",".git":"_fd_git_open","submodules":"_fd_git_open",".submodules":"_fd_git_open",".github":"_fd_github_open",".gitlab":"_fd_gitlab_open","godot":"_fd_godot_open",".godot":"_fd_godot_open","gradle":"_fd_gradle_open",".gradle":"_fd_gradle_open","graphql":"_fd_graphql_open","grunt":"_fd_grunt_open","gulp":"_fd_gulp_open","gulpfile.js":"_fd_gulp_open","gulpfile.coffee":"_fd_gulp_open","gulpfile.ts":"_fd_gulp_open","gulpfile.babel.js":"_fd_gulp_open","gulpfile.babel.coffee":"_fd_gulp_open","gulpfile.babel.ts":"_fd_gulp_open",".haxelib":"_fd_haxelib_open","haxe_libraries":"_fd_haxelib_open","helper":"_fd_helper_open",".helper":"_fd_helper_open","helpers":"_fd_helper_open",".helpers":"_fd_helper_open",".histoire":"_fd_histoire_open","hook":"_fd_hook_open",".hook":"_fd_hook_open","hooks":"_fd_hook_open",".hooks":"_fd_hook_open",".husky":"_fd_husky_open",".idea":"_fd_idea_open",".fig":"_fd_images_open",".figs":"_fd_images_open","images":"_fd_images_open","image":"_fd_images_open",".img":"_fd_images_open","img":"_fd_images_open",".imgs":"_fd_images_open","imgs":"_fd_images_open","icons":"_fd_images_open","icon":"_fd_images_open","ico":"_fd_images_open","screenshot":"_fd_images_open","screenshots":"_fd_images_open","svg":"_fd_images_open","texture":"_fd_images_open","textures":"_fd_images_open","include":"_fd_include_open","includes":"_fd_include_open","incl":"_fd_include_open","inc":"_fd_include_open",".include":"_fd_include_open",".includes":"_fd_include_open",".incl":"_fd_include_open",".inc":"_fd_include_open","_include":"_fd_include_open","_includes":"_fd_include_open","_incl":"_fd_include_open","_inc":"_fd_include_open","interface":"_fd_interfaces_open","interfaces":"_fd_interfaces_open","ios":"_fd_ios_open","js":"_fd_js_open","json":"_fd_json_open",".junie":"_fd_junie_open","kotlin":"_fd_kotlin_open","Kotlin":"_fd_kotlin_open",".kotlin":"_fd_kotlin_open","kubernetes":"_fd_kubernetes_open","k8s":"_fd_kubernetes_open","kube":"_fd_kubernetes_open","kuber":"_fd_kubernetes_open",".kubernetes":"_fd_kubernetes_open",".k8s":"_fd_kubernetes_open",".kube":"_fd_kubernetes_open",".kuber":"_fd_kubernetes_open","less":"_fd_less_open","_less":"_fd_less_open","lib":"_fd_library_open","libs":"_fd_library_open",".lib":"_fd_library_open",".libs":"_fd_library_open","__lib__":"_fd_library_open","__libs__":"_fd_library_open","library":"_fd_library_open","libraries":"_fd_library_open","linux":"_fd_linux_open","lang":"_fd_locale_open","language":"_fd_locale_open","languages":"_fd_locale_open","locale":"_fd_locale_open","locales":"_fd_locale_open","_locale":"_fd_locale_open","_locales":"_fd_locale_open","internationalization":"_fd_locale_open","globalization":"_fd_locale_open","localization":"_fd_locale_open","i18n":"_fd_locale_open","g11n":"_fd_locale_open","l10n":"_fd_locale_open","log":"_fd_log_open","logs":"_fd_log_open","macos":"_fd_macos_open","darwin":"_fd_macos_open","mariadb":"_fd_mariadb_open","maria":"_fd_mariadb_open",".mvn":"_fd_maven_open","mediawiki":"_fd_mediawiki_open","memcached":"_fd_memcached_open",".memcached":"_fd_memcached_open",".meteor":"_fd_meteor_open","middleware":"_fd_middleware_open","middlewares":"_fd_middleware_open",".minecraft":"_fd_minecraft_open","minikube":"_fd_minikube_open","minik8s":"_fd_minikube_open","minikuber":"_fd_minikube_open","mjml":"_fd_mjml_open",".mjml":"_fd_mjml_open","mocks":"_fd_mock_open",".mocks":"_fd_mock_open","__mocks__":"_fd_mock_open","model":"_fd_model_open",".model":"_fd_model_open","models":"_fd_model_open",".models":"_fd_model_open","entities":"_fd_model_open",".entities":"_fd_model_open","modules":"_fd_module_open","mojo":"_fd_mojo_open","mongodb":"_fd_mongodb_open","mongo":"_fd_mongodb_open",".mypy_cache":"_fd_mypy_open","mysqldb":"_fd_mysql_open","mysql":"_fd_mysql_open","netlify":"_fd_netlify_open",".next":"_fd_next_open","nginx":"_fd_nginx_open","conf.d":"_fd_nginx_open",".niv":"_fd_nix_open",".nix":"_fd_nix_open","nix":"_fd_nix_open","niv":"_fd_nix_open","node_modules":"_fd_node_open","notebooks":"_fd_notebooks_open","notebook":"_fd_notebooks_open","notification":"_fd_notification_open","notifications":"_fd_notification_open","event":"_fd_notification_open","events":"_fd_notification_open",".nuget":"_fd_nuget_open","nuxt":"_fd_nuxt_open",".nuxt":"_fd_nuxt_open","package":"_fd_package_open","packages":"_fd_package_open",".package":"_fd_package_open",".packages":"_fd_package_open","pkg":"_fd_package_open",".paket":"_fd_paket_open","php":"_fd_php_open",".pio":"_fd_platformio_open",".pioenvs":"_fd_platformio_open","plugin":"_fd_plugin_open",".plugin":"_fd_plugin_open","plugins":"_fd_plugin_open",".plugins":"_fd_plugin_open","extension":"_fd_plugin_open",".extension":"_fd_plugin_open","extensions":"_fd_plugin_open",".extensions":"_fd_plugin_open","prisma":"_fd_prisma_open","private":"_fd_private_open",".private":"_fd_private_open","public":"_fd_public_open",".public":"_fd_public_open",".pytest_cache":"_fd_pytest_open",".venv":"_fd_python_open",".virtualenv":"_fd_python_open","__pycache__":"_fd_python_open","ravendb":"_fd_ravendb_open","redis":"_fd_redis_open","redux":"_fd_redux_open","route":"_fd_route_open","routes":"_fd_route_open","_route":"_fd_route_open","_routes":"_fd_route_open","router":"_fd_route_open","routers":"_fd_route_open","sass":"_fd_sass_open","scss":"_fd_sass_open","_sass":"_fd_sass_open","_scss":"_fd_sass_open","script":"_fd_script_open","scripts":"_fd_script_open","seedkit":"_fd_seedkit_open",".seedkit":"_fd_seedkit_open","server":"_fd_server_open","service":"_fd_services_open","services":"_fd_services_open","share":"_fd_shared_open","shared":"_fd_shared_open",".share":"_fd_shared_open",".shared":"_fd_shared_open","__shared__":"_fd_shared_open","__share__":"_fd_shared_open",".snaplet":"_fd_snaplet_open",".spin":"_fd_spin_open","src":"_fd_src_open","source":"_fd_src_open","sources":"_fd_src_open","__src__":"_fd_src_open","sso":"_fd_sso_open","story":"_fd_story_open","stories":"_fd_story_open","__stories__":"_fd_story_open",".storybook":"_fd_story_open","style":"_fd_style_open","styles":"_fd_style_open","supabase":"_fd_supabase_open","svelte":"_fd_svelte_open",".svelte-kit":"_fd_svelte_open","src-tauri":"_fd_tauri_open","temp":"_fd_temp_open",".temp":"_fd_temp_open","tmp":"_fd_temp_open",".tmp":"_fd_temp_open","template":"_fd_template_open",".template":"_fd_template_open","templates":"_fd_template_open",".templates":"_fd_template_open","tests":"_fd_test_open",".tests":"_fd_test_open","test":"_fd_test_open",".test":"_fd_test_open","__tests__":"_fd_test_open","__test__":"_fd_test_open","spec":"_fd_test_open",".spec":"_fd_test_open","specs":"_fd_test_open",".specs":"_fd_test_open","integration":"_fd_test_open","theme":"_fd_theme_open","themes":"_fd_theme_open","tool":"_fd_tools_open","tools":"_fd_tools_open",".tools":"_fd_tools_open","util":"_fd_tools_open","utils":"_fd_tools_open","utilities":"_fd_tools_open","tooling":"_fd_tools_open",".travis":"_fd_travis_open",".trunk":"_fd_trunk_open",".turbo":"_fd_turbo_open","typescript":"_fd_typescript_open","ts":"_fd_typescript_open","typings":"_fd_typings_open","types":"_fd_typings_open","@types":"_fd_typings_open","vagrant":"_fd_vagrant_open",".vagrant":"_fd_vagrant_open",".vercel":"_fd_vercel_open","video":"_fd_video_open",".video":"_fd_video_open","videos":"_fd_video_open",".videos":"_fd_video_open","html":"_fd_view_open","view":"_fd_view_open","views":"_fd_view_open","layout":"_fd_view_open","layouts":"_fd_view_open","page":"_fd_view_open","pages":"_fd_view_open","_view":"_fd_view_open","_views":"_fd_view_open","_layout":"_fd_view_open","_layouts":"_fd_view_open","_page":"_fd_view_open","_pages":"_fd_view_open",".vitepress":"_fd_vitepress_open",".vs":"_fd_vs_open",".vscode":"_fd_vscode_open","vscode":"_fd_vscode_open",".vscode-test":"_fd_vscode_test_open","wasm":"_fd_wasm_open","WASM":"_fd_wasm_open","webassembly":"_fd_wasm_open","WebAssembly":"_fd_wasm_open","webpack":"_fd_webpack_open",".webpack":"_fd_webpack_open","windows":"_fd_windows_open","win32":"_fd_windows_open",".windsurf":"_fd_windsurf_open","wit":"_fd_wit_open",".web":"_fd_www_open","www":"_fd_www_open","wwwroot":"_fd_www_open","web":"_fd_www_open",".yarn":"_fd_yarn_open",".zed":"_fd_zed_open"},"fileExtensions":{"accdb":"_f_access","accdt":"_f_access","mdb":"_f_access","accda":"_f_access","accdc":"_f_access","accde":"_f_access","accdp":"_f_access","accdr":"_f_access","accdu":"_f_access","ade":"_f_access","adp":"_f_access","laccdb":"_f_access","ldb":"_f_access","mam":"_f_access","maq":"_f_access","mdw":"_f_access","af":"_f_affinity","aftemplate":"_f_affinity","afdesign":"_f_affinitydesigner","affinitydesigner":"_f_affinitydesigner","afphoto":"_f_affinityphoto","affinityphoto":"_f_affinityphoto","afpub":"_f_affinitypublisher","affinitypublisher":"_f_affinitypublisher","ai":"_f_ai","dal":"_f_al_dal","gs":"_f_appscript","ino":"_f_arduino","pde":"_f_arduino","ase":"_f_aseprite","aseprite":"_f_aseprite","aspx":"_f_aspx","ascx":"_f_aspx","asm":"_f_assembly","s":"_f_assembly","astro":"_f_astro","atom":"_f_atom","aac":"_f_audio","act":"_f_audio","aiff":"_f_audio","amr":"_f_audio","ape":"_f_audio","au":"_f_audio","dct":"_f_audio","dss":"_f_audio","dvf":"_f_audio","flac":"_f_audio","gsm":"_f_audio","iklax":"_f_audio","ivs":"_f_audio","m4a":"_f_audio","m4b":"_f_audio","m4p":"_f_audio","mmf":"_f_audio","mogg":"_f_audio","mp3":"_f_audio","mpc":"_f_audio","msv":"_f_audio","oga":"_f_audio","ogg":"_f_audio","opus":"_f_audio","ra":"_f_audio","raw":"_f_audio","tta":"_f_audio","vox":"_f_audio","wav":"_f_audio","wma":"_f_audio","avif":"_f_avif","awk":"_f_awk","back":"_f_bak","backup":"_f_bak","bak":"_f_bak","a":"_f_binary","app":"_f_binary","bin":"_f_binary","cmo":"_f_binary","cmx":"_f_binary","cma":"_f_binary","cmxa":"_f_binary","cmi":"_f_binary","dll":"_f_binary","exe":"_f_binary","hl":"_f_binary","ilk":"_f_binary","lib":"_f_binary","n":"_f_binary","ndll":"_f_binary","o":"_f_binary","obj":"_f_binary","pyc":"_f_binary","pyd":"_f_binary","pyo":"_f_binary","pdb":"_f_binary","scpt":"_f_binary","scptd":"_f_binary","so":"_f_binary","blend":"_f_blender","bb":"_f_blitzbasic","buf.yaml":"_f_buf","buf.yml":"_f_buf","buf.gen.yml":"_f_buf","buf.gen.yaml":"_f_buf","buf.work.yaml":"_f_buf","buf.work.yml":"_f_buf","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","cake":"_f_cake","csr":"_f_cert","crt":"_f_cert","cer":"_f_cert","der":"_f_cert","pfx":"_f_cert","p12":"_f_cert","p7b":"_f_cert","p7r":"_f_cert","src":"_f_cert","crl":"_f_cert","sst":"_f_cert","stl":"_f_cert","lucee":"_f_cf","h":"_f_cheader","class":"_f_class","cjm":"_f_clojure","cljc":"_f_clojure","cljs":"_f_clojurescript","kit":"_f_codekit","plist":"_f_config","lcov":"_f_coverage","hpp":"_f_cppheader","hh":"_f_cppheader","hxx":"_f_cppheader","h++":"_f_cppheader","csx":"_f_csharp","csproj":"_f_csproj","css.map":"_f_cssmap","cy.js":"_f_cypress_spec","cy.mjs":"_f_cypress_spec","cy.cjs":"_f_cypress_spec","cy.coffee":"_f_cypress_spec","cy.ts":"_f_cypress_spec","cy.tsx":"_f_cypress_spec","cy.jsx":"_f_cypress_spec","g.dart":"_f_dartlang_generated","freezed.dart":"_f_dartlang_generated","db":"_f_db","deb":"_f_debian","resolver.json":"_f_design_tokens","resolver.json5":"_f_design_tokens","resolver.jsonc":"_f_design_tokens","tokens.json":"_f_design_tokens","tokens.json5":"_f_design_tokens","tokens.jsonc":"_f_design_tokens","djt":"_f_django","eco":"_f_docpad","drawio":"_f_drawio","dio":"_f_drawio","drawio.png":"_f_drawio","drawio.svg":"_f_drawio","dio.png":"_f_drawio","dio.svg":"_f_drawio","dtd":"_f_dtd","eex":"_f_eex","heex":"_f_eex","phoenix-heex":"_f_eex","html-heex":"_f_eex","ejs":"_f_ejs","el":"_f_emacs","elc":"_f_emacs","ensime":"_f_ensime","eps":"_f_eps","epub":"_f_epub","excalidraw":"_f_excalidraw","excalidraw.json":"_f_excalidraw","excalidraw.svg":"_f_excalidraw","excalidraw.png":"_f_excalidraw","xls":"_f_excel","xlsx":"_f_excel","xlsm":"_f_excel","ods":"_f_libreoffice_calc","fods":"_f_libreoffice_calc","xlsb":"_f_excel","fbx":"_f_fbx","fba":"_f_fitbit","fla":"_f_fla","swf":"_f_flash","swc":"_f_flash","js.flow":"_f_flow","woff":"_f_font","woff2":"_f_font","ttf":"_f_font","otf":"_f_libreoffice_math","eot":"_f_font","pfa":"_f_font","pfb":"_f_font","sfd":"_f_font","fsproj":"_f_fsproj","gmx":"_f_gamemaker","yy":"_f_gamemaker2","yyp":"_f_gamemaker2","gd.uid":"_f_gduid","geojson":"_f_geojson","xcf":"_f_gimp","gleam":"_f_gleam","glb":"_f_gltf","gltf":"_f_gltf","am":"_f_gnu","ld":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","godot":"_f_godot","gpg":"_f_gpg","pgp":"_f_gpg","gradle":"_f_gradle","gr":"_f_grain","hcl2":"_f_hashicorp","sentinel":"_f_hashicorp","hxproj":"_f_haxedevelop","ejs.t":"_f_hygen","idr":"_f_idris","lidr":"_f_idris","ibc":"_f_idrisbin","ipkg":"_f_idrispkg","jpeg":"_f_image","jpg":"_f_image","gif":"_f_image","png":"_f_image","bmp":"_f_image","tiff":"_f_image","heic":"_f_image","ico":"_f_image","icns":"_f_image","webp":"_f_image","imba":"_f_imba","imba2":"_f_imba","inc":"_f_inc","include":"_f_inc","infopathxml":"_f_infopath","xsn":"_f_infopath","xsf":"_f_infopath","xtp2":"_f_infopath","jar":"_f_jar","jbuilder":"_f_jbuilder","iml":"_f_jetbrains","js.map":"_f_jsmap","cjs.map":"_f_jsmap","mjs.map":"_f_jsmap","jsonl":"_f_json","ndjson":"_f_json","json5":"_f_json5","schema.json":"_f_json_schema","jsonld":"_f_jsonld","json-ld":"_f_jsonld","jsp":"_f_jsp","jss":"_f_jss","juice":"_f_juice","ipynb":"_f_jupyter","key":"_f_key","pem":"_f_key","ktm":"_f_kotlin","language-configuration.json":"_f_language_configuration","master":"_f_layout","layout.html":"_f_layout","layout.htm":"_f_layout","odb":"_f_libreoffice_base","otb":"_f_libreoffice_base","ots":"_f_libreoffice_calc","odg":"_f_libreoffice_draw","otg":"_f_libreoffice_draw","odp":"_f_libreoffice_impress","otp":"_f_libreoffice_impress","odf":"_f_libreoffice_math","odt":"_f_libreoffice_writer","ott":"_f_libreoffice_writer","enc":"_f_license","license":"_f_license","lic":"_f_license","hxp":"_f_lime","liquid":"_f_liquid","ls":"_f_livescript","lnk":"_f_lnk","log":"_f_log","tlg":"_f_log","luau":"_f_luau","crec":"_f_lync","ocrec":"_f_lync","map":"_f_map","mdown":"_f_markdown","markdown":"_f_markdown","marko.js":"_f_markojs","fig":"_f_matlab","mex":"_f_matlab","mexn":"_f_matlab","mexrs6":"_f_matlab","mn":"_f_matlab","mum":"_f_matlab","mx":"_f_matlab","mx3":"_f_matlab","rwd":"_f_matlab","slx":"_f_matlab","slddc":"_f_matlab","smv":"_f_matlab","xvc":"_f_matlab","mca":"_f_minecraft","mcmeta":"_f_minecraft","mojo":"_f_mojo","🔥":"_f_mojo","mql.yaml":"_f_mondoo","mql.yml":"_f_mondoo","mustache":"_f_mustache","mst":"_f_mustache","ndst.yaml":"_f_ndst","ndst.yml":"_f_ndst","ndst.json":"_f_ndst","njsproj":"_f_njsproj","noc":"_f_noc","nupkg":"_f_nuget","snupkg":"_f_nuget","nuspec":"_f_nuget","psmdcp":"_f_nuget","npy":"_f_numpy","npz":"_f_numpy","nunj":"_f_nunjucks","njs":"_f_nunjucks","mli":"_f_ocaml_intf","one":"_f_onenote","onepkg":"_f_onenote","onetoc":"_f_onenote","onetoc2":"_f_onenote","sig":"_f_onenote","opam":"_f_opam","cl":"_f_opencl","opencl":"_f_opencl","tofu":"_f_opentofu","tofu.json":"_f_opentofu","tofutest.hcl":"_f_opentofu","tofutest.json":"_f_opentofu","org":"_f_org","pst":"_f_outlook","bcmx":"_f_outlook","otm":"_f_outlook","msg":"_f_outlook","oft":"_f_outlook","ovpn":"_f_ovpn","pkg":"_f_package","paket.references":"_f_paket","paket.template":"_f_paket","patch":"_f_patch","pcd":"_f_pcl","pdf":"_f_pdf","psd":"_f_photoshop","php1":"_f_php","php2":"_f_php","php3":"_f_php","php4":"_f_php","php5":"_f_php","php6":"_f_php","phps":"_f_php","phpsa":"_f_php","phpt":"_f_php","phtml":"_f_php","phar":"_f_php","pipeline":"_f_pipeline","pu":"_f_plantuml","plantuml":"_f_plantuml","iuml":"_f_plantuml","puml":"_f_plantuml","pck":"_f_plsql_package","pkb":"_f_plsql_package_body","pkh":"_f_plsql_package_header","pks":"_f_plsql_package_spec","po":"_f_poedit","mo":"_f_poedit","postman_collection.json":"_f_postman","postman_environment.json":"_f_postman","postman_globals.json":"_f_postman","pot":"_f_powerpoint","potx":"_f_powerpoint","potm":"_f_powerpoint","pps":"_f_powerpoint","ppsx":"_f_powerpoint","ppsm":"_f_powerpoint","ppt":"_f_powerpoint","pptx":"_f_powerpoint","pptm":"_f_powerpoint","pa":"_f_powerpoint","ppa":"_f_powerpoint","ppam":"_f_powerpoint","sldm":"_f_powerpoint","sldx":"_f_powerpoint","format.ps1xml":"_f_powershell_format","psd1":"_f_powershell_psd","psm1":"_f_powershell_psm","types.ps1xml":"_f_powershell_types","pro":"_f_prolog","pub":"_f_publisher","puz":"_f_publisher","pyowo":"_f_pythowo","q":"_f_q","qbs":"_f_qbs","qvd":"_f_qlikview","qvw":"_f_qlikview","rast":"_f_ra_syntax_tree","rake":"_f_rake","rt":"_f_reacttemplate","reg":"_f_registry","rego":"_f_rego","ripple":"_f_ripple","rnc":"_f_rnc","ron":"_f_ron","rproj":"_f_rproj","rss":"_f_rss","rbs":"_f_ruby","safetensors":"_f_safetensors","sls":"_f_saltstack","sass":"_f_sass","scssm":"_f_scss","unity":"_f_shaderlab","fish":"_f_shell","sy":"_f_siyuan","sketch":"_f_sketch","eskip":"_f_skipper","sln":"_f_sln","slnx":"_f_sln","slnf":"_f_sln","spe":"_f_spacengine","sqlite":"_f_sqlite","sqlite3":"_f_sqlite","db3":"_f_sqlite","sss":"_f_sss","dta":"_f_stata","storyboard":"_f_storyboard","story.js":"_f_storybook","story.jsx":"_f_storybook","story.ts":"_f_storybook","story.tsx":"_f_storybook","story.mdx":"_f_storybook","story.svelte":"_f_storybook","stories.js":"_f_storybook","stories.jsx":"_f_storybook","stories.ts":"_f_storybook","stories.tsx":"_f_storybook","stories.mdx":"_f_storybook","stories.svelte":"_f_storybook","sublime-project":"_f_sublime","svg":"_f_svg","tcl":"_f_tcl","exp":"_f_tcl","templ":"_f_templ","tfstate":"_f_terraform","tfvars":"_f_terraform","tf":"_f_terraform","tf.json":"_f_terraform","tst":"_f_test","test.js":"_f_testjs","test.jsx":"_f_testjs","test.mjs":"_f_testjs","spec.js":"_f_testjs","spec.jsx":"_f_testjs","spec.mjs":"_f_testjs","testplane.ts":"_f_testplane","testplane.js":"_f_testplane","testplane.cts":"_f_testplane","testplane.cjs":"_f_testplane","testplane.tsx":"_f_testplane","testplane.jsx":"_f_testplane","test.ts":"_f_testts","test.tsx":"_f_testts","test.mts":"_f_testts","spec.ts":"_f_testts","spec.tsx":"_f_testts","spec.mts":"_f_testts","e2e-test.ts":"_f_testts","e2e-test.tsx":"_f_testts","e2e-test.mts":"_f_testts","e2e-spec.ts":"_f_testts","e2e-spec.tsx":"_f_testts","e2e-spec.mts":"_f_testts","test-d.ts":"_f_testts","test-d.tsx":"_f_testts","test-d.mts":"_f_testts","texi":"_f_tex","tikz":"_f_tex","csv":"_f_text","tsv":"_f_text","tm":"_f_tm","tm.hcl":"_f_tm","todo":"_f_todo","tree":"_f_tree","tres":"_f_tres","tsbuildinfo":"_f_tsbuildinfo","tscn":"_f_tscn","tt2":"_f_tt","d.ts":"_f_typescriptdef","d.cts":"_f_typescriptdef","d.mts":"_f_typescriptdef","ua":"_f_uiua","u":"_f_unison","vala":"_f_vala","css.ts":"_f_vanilla_extract","vapi":"_f_vapi","vash":"_f_vash","vbhtml":"_f_vbhtml","vbproj":"_f_vbproj","vcxproj":"_f_vcxproj","vto":"_f_vento","vento":"_f_vento","3g2":"_f_video","3gp":"_f_video","asf":"_f_video","amv":"_f_video","avi":"_f_video","divx":"_f_video","qt":"_f_video","f4a":"_f_video","f4b":"_f_video","f4p":"_f_video","f4v":"_f_video","flv":"_f_video","m2v":"_f_video","m4v":"_f_video","mkv":"_f_video","mk3d":"_f_video","mov":"_f_video","mp2":"_f_video","mp4":"_f_video","mpe":"_f_video","mpeg":"_f_video","mpeg2":"_f_video","mpg":"_f_video","mpv":"_f_video","nsv":"_f_video","ogv":"_f_video","rm":"_f_video","rmvb":"_f_video","svi":"_f_video","vob":"_f_video","webm":"_f_video","wmv":"_f_video","code-snippets":"_f_vscode","code-workspace":"_f_vscode","vsix":"_f_vsix","vsixmanifest":"_f_vsixmanifest","vvvvvv":"_f_vvvvvv","vyi":"_f_vyper","wasm":"_f_wasm","wikitext":"_f_wikitext","wit":"_f_wit","doc":"_f_word","docx":"_f_word","docm":"_f_word","dot":"_f_word","dotx":"_f_word","dotm":"_f_word","wll":"_f_word","wxml":"_f_wxml","wxss":"_f_wxss","xaml":"_f_xaml","axaml":"_f_xaml","xcodeproj":"_f_xcode","xfl":"_f_xfl","xib":"_f_xib","xliff":"_f_xliff","xlf":"_f_xliff","pex":"_f_xml","tmlanguage":"_f_xml","zip":"_f_zip","rar":"_f_zip","7z":"_f_zip","tar":"_f_zip","tgz":"_f_zip","bz":"_f_zip","gz":"_f_zip","bzip2":"_f_zip","xz":"_f_zip","bz2":"_f_zip","whl":"_f_zip","zipx":"_f_zip","br":"_f_zip"},"fileNames":{"agents.md":"_f_agents","alchemy.run.ts":"_f_alchemy",".all-contributorsrc":"_f_allcontributors","allurerc.js":"_f_allure","allurerc.mjs":"_f_allure","allurerc.cjs":"_f_allure","allurerc.json":"_f_allure","allurerc.yaml":"_f_allure","allurerc.yml":"_f_allure",".angular-cli.json":"_f_angular","angular-cli.json":"_f_angular","angular.json":"_f_angular",".angular.json":"_f_angular","antlers.html":"_f_antlers_html","api-extractor.json":"_f_api_extractor","api-extractor-base.json":"_f_api_extractor",".appsemblerc.yaml":"_f_appsemble","app-definition.yaml":"_f_appsemble","appveyor.yml":"_f_appveyor",".appveyor.yml":"_f_appveyor","asconfig.json":"_f_assembly_script","astro.config.js":"_f_astroconfig","astro.config.cjs":"_f_astroconfig","astro.config.mjs":"_f_astroconfig","astro.config.ts":"_f_astroconfig","astro.config.cts":"_f_astroconfig","astro.config.mts":"_f_astroconfig",".attw.json":"_f_attw","aurelia.json":"_f_aurelia","azure-pipelines.yml":"_f_azurepipelines",".vsts-ci.yml":"_f_azurepipelines",".babelrc":"_f_babel",".babelignore":"_f_babel",".babelrc.js":"_f_babel",".babelrc.cjs":"_f_babel",".babelrc.mjs":"_f_babel",".babelrc.json":"_f_babel","babel.config.js":"_f_babel","babel.config.cjs":"_f_babel","babel.config.mjs":"_f_babel","babel.config.json":"_f_babel",".bzrignore":"_f_bazaar","BUILD.bazel":"_f_bazel",".bazelrc":"_f_bazel","bazel.rc":"_f_bazel","bazel.bazelrc":"_f_bazel",".bazelignore":"_f_bazel_ignore",".bazelversion":"_f_bazel_version","biome.json":"_f_biome","biome.jsonc":"_f_biome","bitbucket-pipelines.yml":"_f_bitbucketpipeline",".bithoundrc":"_f_bithound",".bowerrc":"_f_bower","bower.json":"_f_bower",".browserslistrc":"_f_browserslist","browserslist":"_f_browserslist",".buckconfig":"_f_buckbuild",".bunfig.toml":"_f_bun","bunfig.toml":"_f_bunfig","bun.lockb":"_f_bun","bun.lock":"_f_bun",".bundlemonrc":"_f_bundlemon","bundlemon.config.js":"_f_bundlemon","bundlemon.config.mjs":"_f_bundlemon","bundlemon.config.cjs":"_f_bundlemon",".bundlemonrc.js":"_f_bundlemon",".bundlemonrc.mjs":"_f_bundlemon",".bundlemonrc.cjs":"_f_bundlemon",".bundlemonrc.json":"_f_bundlemon",".bundlemonrc.yaml":"_f_bundlemon",".bundlemonrc.yml":"_f_bundlemon","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","capacitor.config.json":"_f_capacitor","capacitor.config.ts":"_f_capacitor","Cargo.toml":"_f_cargo","Cargo.lock":"_f_cargo",".changie.yaml":"_f_changie",".changie.yml":"_f_changie","chefignore":"_f_chef","berksfile":"_f_chef","berksfile.lock":"_f_chef","policyfile.rb":"_f_chef","policyfile.lock.json":"_f_chef","circle.yml":"_f_circleci","claude.md":"_f_claude","wrangler.toml":"_f_cloudflare",".cfignore":"_f_cloudfoundry",".codacy.yml":"_f_codacy",".codacy.yaml":"_f_codacy",".codeclimate.yml":"_f_codeclimate","codecov.yml":"_f_codecov",".codecov.yml":"_f_codecov","config.codekit":"_f_codekit","config.codekit2":"_f_codekit","config.codekit3":"_f_codekit",".config.codekit":"_f_codekit",".config.codekit2":"_f_codekit",".config.codekit3":"_f_codekit","codemagic.yaml":"_f_codemagic","codeowners":"_f_codeowners",".coderabbit.yaml":"_f_coderabbit","coffeelint.json":"_f_coffeelint",".coffeelintignore":"_f_coffeelint",".czrc":"_f_commitizen",".cz.json":"_f_commitizen",".commitlintrc":"_f_commitlint","commitlint.config.js":"_f_commitlint","commitlint.config.cjs":"_f_commitlint","commitlint.config.mjs":"_f_commitlint","commitlint.config.ts":"_f_commitlint","commitlint.config.cts":"_f_commitlint",".commitlintrc.json":"_f_commitlint",".commitlintrc.yaml":"_f_commitlint",".commitlintrc.yml":"_f_commitlint",".commitlintrc.js":"_f_commitlint",".commitlintrc.cjs":"_f_commitlint",".commitlintrc.mjs":"_f_commitlint",".commitlintrc.ts":"_f_commitlint",".commitlintrc.cts":"_f_commitlint","composer.json":"_f_composer","composer.lock":"_f_composer","conanfile.txt":"_f_conan","conanfile.py":"_f_conan",".condarc":"_f_conda",".tool-versions":"_f_config","copilot-instructions.md":"_f_copilot","github-copilot.xml":"_f_copilot",".coverage":"_f_coverage","lcov.info":"_f_coverage",".coveralls.yml":"_f_coveralls","craco.config.ts":"_f_craco","craco.config.js":"_f_craco","craco.config.cjs":"_f_craco",".cracorc.ts":"_f_craco",".cracorc.js":"_f_craco",".cracorc":"_f_craco","crowdin.yml":"_f_crowdin","cspell.config.json":"_f_cspell","cspell.config.js":"_f_cspell","cspell.config.mjs":"_f_cspell","cspell.config.cjs":"_f_cspell","cspell.config.yaml":"_f_cspell","cspell.config.yml":"_f_cspell","cspell.json":"_f_cspell","cspell.js":"_f_cspell","cspell.mjs":"_f_cspell","cspell.cjs":"_f_cspell","cspell.yaml":"_f_cspell","cspell.yml":"_f_cspell",".cspell.json":"_f_cspell",".cspell.js":"_f_cspell",".cspell.mjs":"_f_cspell",".cspell.cjs":"_f_cspell",".cspell.yaml":"_f_cspell",".cspell.yml":"_f_cspell",".csscomb.json":"_f_csscomb",".csslintrc":"_f_csslint",".cursorignore":"_f_cursorrules",".cursorindexingignore":"_f_cursorrules",".cursorrules":"_f_cursorrules",".cvsignore":"_f_cvs","cypress.json":"_f_cypress","cypress.env.json":"_f_cypress","cypress.config.js":"_f_cypress","cypress.config.ts":"_f_cypress","cypress.config.cjs":"_f_cypress","cypress.config.mjs":"_f_cypress",".boringignore":"_f_darcs",".pubignore":"_f_dartlang_ignore","service.datadog.yaml":"_f_datadog","datadog-ci.json":"_f_datadog","static-analysis.datadog.yml":"_f_datadog","static-analysis.datadog.yaml":"_f_datadog","aptfile":"_f_debian","deno.json":"_f_deno","deno.jsonc":"_f_deno","deno.lock":"_f_deno",".denoifyrc":"_f_denoify","denoify.config.json":"_f_denoify","denoify.config.js":"_f_denoify","dependabot.yml":"_f_dependabot","dependabot.yaml":"_f_dependabot","dependencies.yml":"_f_dependencies","devcontainer.json":"_f_devcontainer",".devcontainer.json":"_f_devcontainer","devvit.json":"_f_devvit","devvit.dev.json":"_f_devvit","devvit.prod.json":"_f_devvit","devvit.test.json":"_f_devvit","devvit.config.js":"_f_devvit","devvit.config.cjs":"_f_devvit","devvit.config.mjs":"_f_devvit","devvit.config.ts":"_f_devvit","devvit.config.mts":"_f_devvit","devvit.config.dev.js":"_f_devvit","devvit.config.dev.cjs":"_f_devvit","devvit.config.dev.mjs":"_f_devvit","devvit.config.dev.ts":"_f_devvit","devvit.config.dev.mts":"_f_devvit","devvit.config.prod.js":"_f_devvit","devvit.config.prod.cjs":"_f_devvit","devvit.config.prod.mjs":"_f_devvit","devvit.config.prod.ts":"_f_devvit","devvit.config.prod.mts":"_f_devvit","devvit.config.test.js":"_f_devvit","devvit.config.test.cjs":"_f_devvit","devvit.config.test.mjs":"_f_devvit","devvit.config.test.ts":"_f_devvit","devvit.config.test.mts":"_f_devvit",".dockerignore":"_f_docker","compose.yaml":"_f_docker","compose.yml":"_f_docker","docker-compose.yaml":"_f_docker","docker-compose.yml":"_f_docker","docker-compose.debug.yaml":"_f_docker","docker-compose.debug.yml":"_f_docker","docker-compose.ci-build.yaml":"_f_docker","docker-compose.ci-build.yml":"_f_docker","docker-compose.override.yaml":"_f_docker","docker-compose.override.yml":"_f_docker","docker-compose.vs.debug.yaml":"_f_docker","docker-compose.vs.debug.yml":"_f_docker","docker-compose.vs.release.yaml":"_f_docker","docker-compose.vs.release.yml":"_f_docker","docker-cloud.yaml":"_f_docker","docker-cloud.yml":"_f_docker","docker-compose.test.yml":"_f_dockertest","docusaurus.config.ts":"_f_docusaurus","docusaurus.config.mts":"_f_docusaurus","docusaurus.config.cts":"_f_docusaurus","docusaurus.config.js":"_f_docusaurus","docusaurus.config.mjs":"_f_docusaurus","docusaurus.config.cjs":"_f_docusaurus",".doczrc":"_f_docz","docz.js":"_f_docz","docz.json":"_f_docz",".docz.js":"_f_docz",".docz.json":"_f_docz","doczrc.js":"_f_docz","doczrc.json":"_f_docz","docz.config.js":"_f_docz","docz.config.json":"_f_docz",".dojorc":"_f_dojo","doppler.yaml":"_f_doppler","doppler-template.yaml":"_f_doppler",".drone.yml":"_f_drone",".drone.yml.sig":"_f_drone","dune":"_f_dune","dune-project":"_f_dune","dune-workspace":"_f_dune",".dvc":"_f_dvc",".earthlyignore":"_f_earthly","Earthfile":"_f_earthly","store.config.json":"_f_eas-metadata",".editorconfig":"_f_editorconfig","forge.config.js":"_f_electron","forge.config.ts":"_f_electron",".eleventy.js":"_f_eleventy","eleventy.config.js":"_f_eleventy","eleventy.config.cjs":"_f_eleventy","eleventy.config.mjs":"_f_eleventy","elm-package.json":"_f_elm",".ember-cli":"_f_ember","emakefile":"_f_erlang",".emakerfile":"_f_erlang","esbuild.js":"_f_esbuild","esbuild.mjs":"_f_esbuild","esbuild.cjs":"_f_esbuild","esbuild.ts":"_f_esbuild","esbuild.config.js":"_f_esbuild","esbuild.config.mjs":"_f_esbuild","esbuild.config.cjs":"_f_esbuild","esbuild.config.ts":"_f_esbuild",".eslintrc":"_f_eslint",".eslintignore":"_f_eslint",".eslintcache":"_f_eslint","eslint.config.js":"_f_eslint","eslint.config.mjs":"_f_eslint","eslint.config.cjs":"_f_eslint","eslint.config.ts":"_f_eslint","eslint.config.mts":"_f_eslint","eslint.config.cts":"_f_eslint",".eslintrc.js":"_f_eslint",".eslintrc.mjs":"_f_eslint",".eslintrc.cjs":"_f_eslint",".eslintrc.ts":"_f_eslint",".eslintrc.mts":"_f_eslint",".eslintrc.cts":"_f_eslint",".eslintrc.json":"_f_eslint",".eslintrc.yaml":"_f_eslint",".eslintrc.yml":"_f_eslint","app.json":"_f_expo","app.config.js":"_f_expo","app.config.json":"_f_expo","app.config.json5":"_f_expo",".fantasticonrc":"_f_fantasticon","fantasticonrc":"_f_fantasticon",".fantasticonrc.json":"_f_fantasticon","fantasticonrc.json":"_f_fantasticon",".fantasticonrc.js":"_f_fantasticon","fantasticonrc.js":"_f_fantasticon","fastly.toml":"_f_fastly",".faunarc":"_f_fauna","favicon.ico":"_f_favicon",".firebaserc":"_f_firebase","firebase.json":"_f_firebasehosting","storage.rules":"_f_firebasestorage","firestore.rules":"_f_firestore","firestore.indexes.json":"_f_firestore","flareact.config.js":"_f_flareact",".flooignore":"_f_floobits",".flowconfig":"_f_flow",".flutter-plugins":"_f_flutter",".metadata":"_f_flutter",".flutter-plugins-dependencies":"_f_flutter","pubspec.lock":"_f_flutter_package","pubspec.yaml":"_f_flutter_package",".packages":"_f_flutter_package","fly.toml":"_f_flyio","formkit.config.js":"_f_formkit","formkit.config.mjs":"_f_formkit","formkit.config.cjs":"_f_formkit","formkit.config.ts":"_f_formkit",".fossaignore":"_f_fossa","ignore-glob":"_f_fossil",".front-commerce.js":"_f_frontcommerce","front-commerce.config.ts":"_f_frontcommerce","funding.yml":"_f_funding","fuse.js":"_f_fusebox","gatsby-browser.js":"_f_gatsby","gatsby-browser.ts":"_f_gatsby","gatsby-browser.tsx":"_f_gatsby","gatsby-ssr.js":"_f_gatsby","gatsby-ssr.ts":"_f_gatsby","gatsby-ssr.tsx":"_f_gatsby","gatsby-config.js":"_f_gatsby","gatsby-config.ts":"_f_gatsby","gatsby-config.mjs":"_f_gatsby","gatsby-node.js":"_f_gatsby","gatsby-node.ts":"_f_gatsby","gatsby-node.mjs":"_f_gatsby",".gcloudignore":"_f_gcloud","gemini.md":"_f_gemini","GEMINI.md":"_f_gemini",".git":"_f_git",".gitattributes":"_f_git",".gitconfig":"_f_git",".gitignore":"_f_git",".gitmodules":"_f_git",".gitkeep":"_f_git",".git-blame-ignore-revs":"_f_git",".mailmap":"_f_git",".issuetracker":"_f_git",".gitlab-ci.yml":"_f_gitlab",".gitpod.yaml":"_f_gitpod",".gitpod.yml":"_f_gitpod","gitpod.yaml":"_f_gitpod","gitpod.yml":"_f_gitpod","gleam.toml":"_f_gleamconfig","glide.yml":"_f_glide",".glitterrc":"_f_glitter","go.sum":"_f_go_package","go.mod":"_f_go_package","go.work":"_f_go_work","go.work.sum":"_f_go_work",".gqlconfig":"_f_graphql",".graphqlconfig":"_f_graphql_config",".graphqlconfig.yml":"_f_graphql_config",".graphqlconfig.yaml":"_f_graphql_config","greenkeeper.json":"_f_greenkeeper","gridsome.config.js":"_f_gridsome","gridsome.config.ts":"_f_gridsome","gridsome.server.js":"_f_gridsome","gridsome.server.ts":"_f_gridsome","gridsome.client.js":"_f_gridsome","gridsome.client.ts":"_f_gridsome","gruntfile.js":"_f_grunt","gruntfile.cjs":"_f_grunt","gruntfile.coffee":"_f_grunt","gruntfile.ts":"_f_grunt","gruntfile.babel.js":"_f_grunt","gruntfile.babel.cjs":"_f_grunt","gruntfile.babel.coffee":"_f_grunt","gruntfile.babel.ts":"_f_grunt","gulpfile.js":"_f_gulp","gulpfile.coffee":"_f_gulp","gulpfile.ts":"_f_gulp","gulpfile.mjs":"_f_gulp","gulpfile.esm.js":"_f_gulp","gulpfile.esm.coffee":"_f_gulp","gulpfile.esm.ts":"_f_gulp","gulpfile.esm.mjs":"_f_gulp","gulpfile.babel.js":"_f_gulp","gulpfile.babel.coffee":"_f_gulp","gulpfile.babel.ts":"_f_gulp","gulpfile.babel.mjs":"_f_gulp","hardhat.config.cjs":"_f_hardhat","hardhat.config.js":"_f_hardhat","hardhat.config.ts":"_f_hardhat","haxelib.json":"_f_haxe","checkstyle.json":"_f_haxecheckstyle",".p4ignore":"_f_helix","chart.lock":"_f_helm","chart.yaml":"_f_helm","histoire.config.ts":"_f_histoire","histoire.config.js":"_f_histoire",".histoire.ts":"_f_histoire",".histoire.js":"_f_histoire","horusec-config.json":"_f_horusec",".htmlhintrc":"_f_htmlhint",".htmlvalidateignore":"_f_htmlvalidate",".htmlvalidate.js":"_f_htmlvalidate",".htmlvalidate.cjs":"_f_htmlvalidate",".htmlvalidate.json":"_f_htmlvalidate","hugo.json":"_f_hugo","hugo.toml":"_f_hugo","hugo.yaml":"_f_hugo",".hugo_build.lock":"_f_hugo","humans.txt":"_f_humanstxt",".huskyrc":"_f_husky","husky.config.js":"_f_husky",".huskyrc.js":"_f_husky",".huskyrc.json":"_f_husky",".huskyrc.yaml":"_f_husky",".huskyrc.yml":"_f_husky","ionic.project":"_f_ionic","ionic.config.json":"_f_ionic","jakefile":"_f_jake","jakefile.js":"_f_jake","jasmine.json":"_f_jasmine",".nojekyll":"_f_jekyll","jest.json":"_f_jest","jest.config.babel.js":"_f_jest",".jestrc":"_f_jest",".jestrc.js":"_f_jest",".jestrc.json":"_f_jest","jest.config.js":"_f_jest","jest.config.cjs":"_f_jest","jest.config.mjs":"_f_jest","jest.config.json":"_f_jest","jest.config.ts":"_f_jest","jest.config.base.js":"_f_jest","jest.config.base.cjs":"_f_jest","jest.config.base.mjs":"_f_jest","jest.config.base.json":"_f_jest","jest.config.base.ts":"_f_jest","jest.config.common.js":"_f_jest","jest.config.common.cjs":"_f_jest","jest.config.common.mjs":"_f_jest","jest.config.common.json":"_f_jest","jest.config.common.ts":"_f_jest","jest.config.e2e.js":"_f_jest","jest.config.e2e.cjs":"_f_jest","jest.config.e2e.mjs":"_f_jest","jest.config.e2e.json":"_f_jest","jest.config.e2e.ts":"_f_jest","jest.config.int.js":"_f_jest","jest.config.int.cjs":"_f_jest","jest.config.int.mjs":"_f_jest","jest.config.int.json":"_f_jest","jest.config.int.ts":"_f_jest","jest.config.integration.js":"_f_jest","jest.config.integration.cjs":"_f_jest","jest.config.integration.mjs":"_f_jest","jest.config.integration.json":"_f_jest","jest.config.integration.ts":"_f_jest","jest.config.unit.js":"_f_jest","jest.config.unit.cjs":"_f_jest","jest.config.unit.mjs":"_f_jest","jest.config.unit.json":"_f_jest","jest.config.unit.ts":"_f_jest",".jpmignore":"_f_jpm",".jsbeautifyrc":"_f_jsbeautify","jsbeautifyrc":"_f_jsbeautify",".jsbeautify":"_f_jsbeautify","jsbeautify":"_f_jsbeautify","jsconfig.json":"_f_jsconfig",".jscpd.json":"_f_jscpd","jscpd-report.xml":"_f_jscpd","jscpd-report.json":"_f_jscpd","jscpd-report.html":"_f_jscpd",".jshintrc":"_f_jshint",".jshintignore":"_f_jshint","jsr.json":"_f_jsr","karma.conf.js":"_f_karma","karma.conf.coffee":"_f_karma","karma.conf.ts":"_f_karma",".kitchen.yml":"_f_kitchenci","kitchen.yml":"_f_kitchenci",".kiteignore":"_f_kite","knip.json":"_f_knip","knip.jsonc":"_f_knip",".knip.json":"_f_knip",".knip.jsonc":"_f_knip","knip.ts":"_f_knip","knip.js":"_f_knip","knip.config.ts":"_f_knip","knip.config.js":"_f_knip","language-configuration.json":"_f_language_configuration","layout.html":"_f_layout","layout.htm":"_f_layout","lakefile.lean":"_f_leanconfig","lakefile.toml":"_f_leanconfig","lake-manifest.json":"_f_leanconfig","lean-toolchain":"_f_leanconfig","lefthook.json":"_f_lefthook","lefthook.toml":"_f_lefthook","lefthook.yaml":"_f_lefthook","lefthook.yml":"_f_lefthook","lefthook-local.json":"_f_lefthook","lefthook-local.toml":"_f_lefthook","lefthook-local.yaml":"_f_lefthook","lefthook-local.yml":"_f_lefthook",".lefthook.json":"_f_lefthook",".lefthook.toml":"_f_lefthook",".lefthook.yaml":"_f_lefthook",".lefthook.yml":"_f_lefthook",".lefthook-local.json":"_f_lefthook",".lefthook-local.toml":"_f_lefthook",".lefthook-local.yaml":"_f_lefthook",".lefthook-local.yml":"_f_lefthook","lerna.json":"_f_lerna","liara.json":"_f_liara",".liaraignore":"_f_liara","licence":"_f_license","LICENCE":"_f_license","license":"_f_license","LICENSE":"_f_license","copying":"_f_license","copying.lesser":"_f_license","LICENSE-MIT":"_f_license","license-mit":"_f_license","LICENSE-APACHE":"_f_license","license-apache":"_f_license","LICENSE-GPL":"_f_license","license-gpl":"_f_license","LICENSE-AGPL":"_f_license","license-agpl":"_f_license","LICENCE.md":"_f_license","LICENCE.txt":"_f_license","licence.md":"_f_license","licence.txt":"_f_license","LICENSE.md":"_f_license","LICENSE.txt":"_f_license","license.md":"_f_license","license.txt":"_f_license","copying.md":"_f_license","copying.txt":"_f_license","copying.lesser.md":"_f_license","copying.lesser.txt":"_f_license","LICENSE-MIT.md":"_f_license","LICENSE-MIT.txt":"_f_license","license-mit.md":"_f_license","license-mit.txt":"_f_license","LICENSE-APACHE.md":"_f_license","LICENSE-APACHE.txt":"_f_license","license-apache.md":"_f_license","license-apache.txt":"_f_license","LICENSE-GPL.md":"_f_license","LICENSE-GPL.txt":"_f_license","license-gpl.md":"_f_license","license-gpl.txt":"_f_license","LICENSE-AGPL.md":"_f_license","LICENSE-AGPL.txt":"_f_license","license-agpl.md":"_f_license","license-agpl.txt":"_f_license",".licrc":"_f_licensebat","budget.json":"_f_lighthouse","budgets.json":"_f_lighthouse",".lighthouserc.cjs":"_f_lighthouse",".lighthouserc.js":"_f_lighthouse",".lighthouserc.json":"_f_lighthouse",".lighthouserc.yaml":"_f_lighthouse",".lighthouserc.yml":"_f_lighthouse","include.xml":"_f_lime",".lintstagedrc":"_f_lintstagedrc",".lintstagedrc.json":"_f_lintstagedrc",".lintstagedrc.yaml":"_f_lintstagedrc",".lintstagedrc.yml":"_f_lintstagedrc",".lintstagedrc.mjs":"_f_lintstagedrc",".lintstagedrc.mts":"_f_lintstagedrc",".lintstagedrc.js":"_f_lintstagedrc",".lintstagedrc.ts":"_f_lintstagedrc",".lintstagedrc.cjs":"_f_lintstagedrc",".lintstagedrc.cts":"_f_lintstagedrc","lint-staged.config.mjs":"_f_lintstagedrc","lint-staged.config.mts":"_f_lintstagedrc","lint-staged.config.js":"_f_lintstagedrc","lint-staged.config.ts":"_f_lintstagedrc","lint-staged.config.cjs":"_f_lintstagedrc","lint-staged.config.cts":"_f_lintstagedrc","mailing.config.json":"_f_mailing","manifest":"_f_manifest","manifest.bak":"_f_manifest_bak","manifest.skip":"_f_manifest_skip",".markdownlintrc":"_f_markdownlint",".markdownlint.json":"_f_markdownlint",".markdownlint.jsonc":"_f_markdownlint",".markdownlint.yml":"_f_markdownlint",".markdownlint.yaml":"_f_markdownlint",".markdownlint.cjs":"_f_markdownlint",".markdownlint-cli2.json":"_f_markdownlint",".markdownlint-cli2.jsonc":"_f_markdownlint",".markdownlint-cli2.yml":"_f_markdownlint",".markdownlint-cli2.yaml":"_f_markdownlint",".markdownlint-cli2.cjs":"_f_markdownlint",".markdownlintignore":"_f_markdownlint_ignore",".markuplintrc":"_f_markuplint",".markuplintrc.json":"_f_markuplint",".markuplintrc.yaml":"_f_markuplint",".markuplintrc.yml":"_f_markuplint",".markuplintrc.js":"_f_markuplint",".markuplintrc.cjs":"_f_markuplint",".markuplintrc.mjs":"_f_markuplint",".markuplintrc.ts":"_f_markuplint","markuplint.config.json":"_f_markuplint","markuplint.config.yaml":"_f_markuplint","markuplint.config.yml":"_f_markuplint","markuplint.config.js":"_f_markuplint","markuplint.config.cjs":"_f_markuplint","markuplint.config.mjs":"_f_markuplint","markuplint.config.ts":"_f_markuplint","master.js":"_f_master-co","master.ts":"_f_master-co","master.mjs":"_f_master-co","master.json":"_f_master-co","master.css.js":"_f_master-co","master.css.ts":"_f_master-co","master.css.mjs":"_f_master-co","master.css.json":"_f_master-co","maven.config":"_f_maven","pom.xml":"_f_maven","extensions.xml":"_f_maven","settings.xml":"_f_maven","mcp.json":"_f_mcp","mdx-components.cjs":"_f_mdx-components","mdx-components.cts":"_f_mdx-components","mdx-components.js":"_f_mdx-components","mdx-components.jsx":"_f_mdx-components","mdx-components.mjs":"_f_mdx-components","mdx-components.mts":"_f_mdx-components","mdx-components.ts":"_f_mdx-components","mdx-components.tsx":"_f_mdx-components",".mdxlintrc":"_f_mdxlint",".mdxlintignore":"_f_mdxlint",".mdxlintrc.cjs":"_f_mdxlint",".mdxlintrc.js":"_f_mdxlint",".mdxlintrc.json":"_f_mdxlint",".mdxlintrc.mjs":"_f_mdxlint",".mdxlintrc.yml":"_f_mdxlint",".mdxlintrc.yaml":"_f_mdxlint",".hgignore":"_f_mercurial","mocha.opts":"_f_mocha",".mocharc.cjs":"_f_mocha",".mocharc.js":"_f_mocha",".mocharc.json":"_f_mocha",".mocharc.jsonc":"_f_mocha",".mocharc.mjs":"_f_mocha",".mocharc.yaml":"_f_mocha",".mocharc.yml":"_f_mocha","modernizr":"_f_modernizr","modernizr.js":"_f_modernizr","modernizrrc.js":"_f_modernizr",".modernizr.js":"_f_modernizr",".modernizrrc.js":"_f_modernizr","moleculer.config.js":"_f_moleculer","moleculer.config.json":"_f_moleculer","moleculer.config.ts":"_f_moleculer",".mtn-ignore":"_f_monotone","motif.json":"_f_motif","mypy.ini":"_f_mypy",".mypy.ini":"_f_mypy",".nano-staged.js":"_f_nanostaged","nano-staged.js":"_f_nanostaged",".nano-staged.cjs":"_f_nanostaged","nano-staged.cjs":"_f_nanostaged",".nano-staged.mjs":"_f_nanostaged","nano-staged.mjs":"_f_nanostaged",".nano-staged.json":"_f_nanostaged","nano-staged.json":"_f_nanostaged",".nanostagedrc":"_f_nanostaged",".nest-cli.json":"_f_nestjs","nest-cli.json":"_f_nestjs","nestconfig.json":"_f_nestjs",".nestconfig.json":"_f_nestjs","netlify.toml":"_f_netlify","next.config.js":"_f_next","next.config.cjs":"_f_next","next.config.mjs":"_f_next","next.config.ts":"_f_next",".nf":"_f_nextflow","ng-tailwind.js":"_f_ng_tailwind","nginx.conf":"_f_nginx","build.ninja":"_f_ninja","flake.lock":"_f_nix",".node-version":"_f_node",".nvmrc":"_f_node","node.config.json":"_f_node","nodemon.json":"_f_nodemon",".npmignore":"_f_npm",".npmrc":"_f_npm",".package-lock.json":"_f_npm","package.json":"_f_npm","package-lock.json":"_f_npm","npm-shrinkwrap.json":"_f_npm",".npmpackagejsonlintrc":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.cjs":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.js":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.json":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yaml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.mjs":"_f_npmpackagejsonlint",".npmpackagejsonlintignore":"_f_npmpackagejsonlint","npmpackagejsonlint.config.cjs":"_f_npmpackagejsonlint","npmpackagejsonlint.config.js":"_f_npmpackagejsonlint","npmpackagejsonlint.config.mjs":"_f_npmpackagejsonlint",".nsrirc":"_f_nsri",".nsriignore":"_f_nsri","nsri.config.js":"_f_nsri",".nsrirc.js":"_f_nsri",".nsrirc.json":"_f_nsri",".nsrirc.yaml":"_f_nsri",".nsrirc.yml":"_f_nsri",".integrity.json":"_f_nsri-integrity",".nuxtignore":"_f_nuxt",".nuxtrc":"_f_nuxt","nuxt.config.js":"_f_nuxt","nuxt.config.ts":"_f_nuxt","nuxt.config.mjs":"_f_nuxt","nuxt.js":"_f_nuxt","nuxt.ts":"_f_nuxt","nuxt.mjs":"_f_nuxt","nx.json":"_f_nx",".nycrc":"_f_nyc",".nycrc.json":"_f_nyc",".objidconfig":"_f_objidconfig",".merlin":"_f_ocaml",".oxlintignore":"_f_oxc",".oxlintrc.json":"_f_oxc",".oxlintrc.jsonc":"_f_oxc",".oxfmtrc.json":"_f_oxc",".oxfmtrc.jsonc":"_f_oxc",".packshiprc":"_f_packship",".packshiprc.json":"_f_packship",".packshiprc.js":"_f_packship",".packshiprc.ts":"_f_packship","packship.config.js":"_f_packship","packship.config.ts":"_f_packship","packship.config.mjs":"_f_packship","packship.config.mts":"_f_packship","packship.config.json":"_f_packship","paket.dependencies":"_f_paket","paket.lock":"_f_paket","paket.references":"_f_paket","paket.template":"_f_paket","paket.local":"_f_paket","panda.config.js":"_f_pandacss","panda.config.mjs":"_f_pandacss","panda.config.ts":"_f_pandacss",".pdm-python":"_f_pdm","pdm.lock":"_f_pdm","pdm.toml":"_f_pdm","peeky.config.ts":"_f_peeky","peeky.config.js":"_f_peeky","peeky.config.mjs":"_f_peeky",".php-cs-fixer.php":"_f_phpcsfixer",".php-cs-fixer.dist.php":"_f_phpcsfixer",".php_cs":"_f_phpcsfixer",".php_cs.dist":"_f_phpcsfixer","phpstan.neon":"_f_phpstan","phpstan.dist.neon":"_f_phpstan","phpunit":"_f_phpunit","phpunit.xml":"_f_phpunit","phpunit.xml.dist":"_f_phpunit",".phraseapp.yml":"_f_phraseapp","pipfile":"_f_pip","pipfile.lock":"_f_pip","platformio.ini":"_f_platformio","playwright.config.js":"_f_playwright","playwright.config.ts":"_f_playwright","playwright.config.mts":"_f_playwright","playwright.config.mjs":"_f_playwright","playwright.config.cts":"_f_playwright","playwright.config.cjs":"_f_playwright","ecosystem.config.js":"_f_pm2","ecosystem.config.cjs":"_f_pm2","ecosystem.config.json":"_f_pm2","ecosystem.config.yaml":"_f_pm2","ecosystem.config.yml":"_f_pm2",".pnpmfile.cjs":"_f_pnpm","pnpmfile.js":"_f_pnpm","pnpm-lock.yaml":"_f_pnpm","pnpm-workspace.yaml":"_f_pnpm","poetry.lock":"_f_poetry",".postcssrc":"_f_postcssconfig",".postcssrc.json":"_f_postcssconfig",".postcssrc.yaml":"_f_postcssconfig",".postcssrc.yml":"_f_postcssconfig",".postcssrc.ts":"_f_postcssconfig",".postcssrc.cts":"_f_postcssconfig",".postcssrc.mts":"_f_postcssconfig",".postcssrc.js":"_f_postcssconfig",".postcssrc.cjs":"_f_postcssconfig",".postcssrc.mjs":"_f_postcssconfig","postcss.config.ts":"_f_postcssconfig","postcss.config.cts":"_f_postcssconfig","postcss.config.mts":"_f_postcssconfig","postcss.config.js":"_f_postcssconfig","postcss.config.cjs":"_f_postcssconfig","postcss.config.mjs":"_f_postcssconfig","preact.config.js":"_f_preact",".pre-commit-config.yaml":"_f_precommit",".prettierrc":"_f_prettier",".prettierignore":"_f_prettier","prettier.config.js":"_f_prettier","prettier.config.cjs":"_f_prettier","prettier.config.mjs":"_f_prettier","prettier.config.ts":"_f_prettier","prettier.config.coffee":"_f_prettier",".prettierrc.js":"_f_prettier",".prettierrc.cjs":"_f_prettier",".prettierrc.mjs":"_f_prettier",".prettierrc.json":"_f_prettier",".prettierrc.json5":"_f_prettier",".prettierrc.yml":"_f_prettier",".prettierrc.yaml":"_f_prettier",".prettierrc.toml":"_f_prettier","prisma.config.js":"_f_prismaconfig","prisma.config.ts":"_f_prismaconfig","procfile":"_f_procfile","protractor.conf.js":"_f_protractor","protractor.conf.coffee":"_f_protractor","protractor.conf.ts":"_f_protractor","publiccode.yml":"_f_publiccode",".jade-lintrc":"_f_pug",".pug-lintrc":"_f_pug",".jade-lint.json":"_f_pug",".pug-lintrc.js":"_f_pug",".pug-lintrc.json":"_f_pug","Pulumi.yaml":"_f_pulumi","Pulumi.yml":"_f_pulumi","Pulumi.dev.yaml":"_f_pulumi","Pulumi.dev.yml":"_f_pulumi","Pulumi.prod.yaml":"_f_pulumi","Pulumi.prod.yml":"_f_pulumi","purgecss.config.js":"_f_purgecss",".python-version":"_f_pyenv","pyproject.toml":"_f_pypi","pytest.ini":"_f_pytest",".pytest.ini":"_f_pytest","pytest.toml":"_f_pytest",".pytest.toml":"_f_pytest","py.typed":"_f_pytyped",".pyup":"_f_pyup",".pyup.yml":"_f_pyup","qmldir":"_f_qmldir","quasar.conf.js":"_f_quasar","quasar.config.js":"_f_quasar","quasar.config.mjs":"_f_quasar","quasar.config.cjs":"_f_quasar","quasar.config.ts":"_f_quasar","Rakefile":"_f_rake","razzle.config.js":"_f_razzle","react-router.config.js":"_f_reactrouter","react-router.config.ts":"_f_reactrouter",".rehyperc":"_f_rehype",".rehypeignore":"_f_rehype",".rehyperc.cjs":"_f_rehype",".rehyperc.js":"_f_rehype",".rehyperc.json":"_f_rehype",".rehyperc.mjs":"_f_rehype",".rehyperc.yml":"_f_rehype",".rehyperc.yaml":"_f_rehype",".remarkrc":"_f_remark",".remarkignore":"_f_remark",".remarkrc.cjs":"_f_remark",".remarkrc.js":"_f_remark",".remarkrc.json":"_f_remark",".remarkrc.mjs":"_f_remark",".remarkrc.yml":"_f_remark",".remarkrc.yaml":"_f_remark",".renovaterc":"_f_renovate",".renovaterc.json":"_f_renovate","renovate.json":"_f_renovate","renovate.json5":"_f_renovate",".replit":"_f_replit","replit.nix":"_f_replit",".retextrc":"_f_retext",".retextignore":"_f_retext",".retextrc.cjs":"_f_retext",".retextrc.js":"_f_retext",".retextrc.json":"_f_retext",".retextrc.mjs":"_f_retext",".retextrc.yml":"_f_retext",".retextrc.yaml":"_f_retext","robots.txt":"_f_robots","rolldown.config.js":"_f_rolldown","rolldown.config.cjs":"_f_rolldown","rolldown.config.mjs":"_f_rolldown","rolldown.config.coffee":"_f_rolldown","rolldown.config.ts":"_f_rolldown","rolldown.config.mts":"_f_rolldown","rolldown.config.cts":"_f_rolldown","rolldown.config.common.js":"_f_rolldown","rolldown.config.common.cjs":"_f_rolldown","rolldown.config.common.mjs":"_f_rolldown","rolldown.config.common.coffee":"_f_rolldown","rolldown.config.common.ts":"_f_rolldown","rolldown.config.common.mts":"_f_rolldown","rolldown.config.common.cts":"_f_rolldown","rolldown.config.dev.js":"_f_rolldown","rolldown.config.dev.cjs":"_f_rolldown","rolldown.config.dev.mjs":"_f_rolldown","rolldown.config.dev.coffee":"_f_rolldown","rolldown.config.dev.ts":"_f_rolldown","rolldown.config.dev.mts":"_f_rolldown","rolldown.config.dev.cts":"_f_rolldown","rolldown.config.prod.js":"_f_rolldown","rolldown.config.prod.cjs":"_f_rolldown","rolldown.config.prod.mjs":"_f_rolldown","rolldown.config.prod.coffee":"_f_rolldown","rolldown.config.prod.ts":"_f_rolldown","rolldown.config.prod.mts":"_f_rolldown","rolldown.config.prod.cts":"_f_rolldown","rollup.config.js":"_f_rollup","rollup.config.cjs":"_f_rollup","rollup.config.mjs":"_f_rollup","rollup.config.coffee":"_f_rollup","rollup.config.ts":"_f_rollup","rollup.config.common.js":"_f_rollup","rollup.config.common.cjs":"_f_rollup","rollup.config.common.mjs":"_f_rollup","rollup.config.common.coffee":"_f_rollup","rollup.config.common.ts":"_f_rollup","rollup.config.dev.js":"_f_rollup","rollup.config.dev.cjs":"_f_rollup","rollup.config.dev.mjs":"_f_rollup","rollup.config.dev.coffee":"_f_rollup","rollup.config.dev.ts":"_f_rollup","rollup.config.prod.js":"_f_rollup","rollup.config.prod.cjs":"_f_rollup","rollup.config.prod.mjs":"_f_rollup","rollup.config.prod.coffee":"_f_rollup","rollup.config.prod.ts":"_f_rollup","rome.json":"_f_rome",".rspec":"_f_rspec",".rubocop.yml":"_f_rubocop",".rubocop_todo.yml":"_f_rubocop",".ruby-version":"_f_ruby","rust-toolchain":"_f_rust_toolchain","rust-toolchain.toml":"_f_rust_toolchain",".slshrc":"_f_s-lang",".isisrc":"_f_s-lang",".jedrc":"_f_s-lang",".sailsrc":"_f_sails",".sapphirerc.json":"_f_sapphire_framework_cli",".sapphirerc.yml":"_f_sapphire_framework_cli",".sapphirerc":"_f_sapphire_framework_cli","seedkit.config.js":"_f_seedkit","seedkit.config.ts":"_f_seedkit","seedkit.config.mjs":"_f_seedkit",".sentryclirc":"_f_sentry",".sequelizerc":"_f_sequelize",".sequelizerc.js":"_f_sequelize",".sequelizerc.json":"_f_sequelize","serverless.yml":"_f_serverless","serverless.json":"_f_serverless","serverless.js":"_f_serverless","serverless.ts":"_f_serverless","components.json":"_f_shadcn",".shellcheckrc":"_f_shellcheck","Shuttle.toml":"_f_shuttle","sitemap.xml":"_f_sitemap","sitemap.rss":"_f_sitemap","sitemap.txt":"_f_sitemap","sitemap.atom":"_f_sitemap","slash-up.config.js":"_f_slashup","smithery.yaml":"_f_smithery","snapcraft.yaml":"_f_snapcraft","snaplet.config.js":"_f_snaplet","snaplet.config.ts":"_f_snaplet",".snyk":"_f_snyk",".solidarity":"_f_solidarity",".solidarity.json":"_f_solidarity","spin.toml":"_f_spin","sst-env.d.ts":"_f_sst","sst-env.ts":"_f_sst","sst-env.js":"_f_sst","sst.config.d.ts":"_f_sst","sst.config.ts":"_f_sst","sst.config.js":"_f_sst",".stackblitzrc":"_f_stackblitz","stryker.conf.mjs":"_f_stryker","stryker.conf.cjs":"_f_stryker","stryker.conf.js":"_f_stryker","stryker.conf.conf":"_f_stryker","stryker.conf.json":"_f_stryker",".stryker.conf.mjs":"_f_stryker",".stryker.conf.cjs":"_f_stryker",".stryker.conf.js":"_f_stryker",".stryker.conf.conf":"_f_stryker",".stryker.conf.json":"_f_stryker","stryker.config.mjs":"_f_stryker","stryker.config.cjs":"_f_stryker","stryker.config.js":"_f_stryker","stryker.config.conf":"_f_stryker","stryker.config.json":"_f_stryker",".stryker.config.mjs":"_f_stryker",".stryker.config.cjs":"_f_stryker",".stryker.config.js":"_f_stryker",".stryker.config.conf":"_f_stryker",".stryker.config.json":"_f_stryker","stryker-config.mjs":"_f_stryker","stryker-config.cjs":"_f_stryker","stryker-config.js":"_f_stryker","stryker-config.conf":"_f_stryker","stryker-config.json":"_f_stryker","stryker4s.mjs":"_f_stryker","stryker4s.cjs":"_f_stryker","stryker4s.js":"_f_stryker","stryker4s.conf":"_f_stryker","stryker4s.json":"_f_stryker",".stylelintrc":"_f_stylelint",".stylelintignore":"_f_stylelint",".stylelintcache":"_f_stylelint","stylelint.config.js":"_f_stylelint","stylelint.config.json":"_f_stylelint","stylelint.config.yaml":"_f_stylelint","stylelint.config.yml":"_f_stylelint","stylelint.config.ts":"_f_stylelint","stylelint.config.cjs":"_f_stylelint","stylelint.config.mjs":"_f_stylelint",".stylelintrc.js":"_f_stylelint",".stylelintrc.json":"_f_stylelint",".stylelintrc.yaml":"_f_stylelint",".stylelintrc.yml":"_f_stylelint",".stylelintrc.ts":"_f_stylelint",".stylelintrc.cjs":"_f_stylelint",".stylelintrc.mjs":"_f_stylelint",".stylish-haskell.yaml":"_f_stylish_haskell",".svnignore":"_f_subversion","svelte.config.js":"_f_svelteconfig","svelte.config.ts":"_f_svelteconfig","svgo.config.cjs":"_f_svgo","svgo.config.js":"_f_svgo","svgo.config.mjs":"_f_svgo",".swcrc":"_f_swc","package.pins":"_f_swift","symfony.lock":"_f_symfony",".syncpackrc":"_f_syncpack",".syncpackrc.json":"_f_syncpack",".syncpackrc.yaml":"_f_syncpack",".syncpackrc.yml":"_f_syncpack",".syncpackrc.js":"_f_syncpack",".syncpackrc.cjs":"_f_syncpack","syncpack.config.js":"_f_syncpack","syncpack.config.cjs":"_f_syncpack","tailwind.js":"_f_tailwind","tailwind.cjs":"_f_tailwind","tailwind.coffee":"_f_tailwind","tailwind.ts":"_f_tailwind","tailwind.json":"_f_tailwind","tailwind.mjs":"_f_tailwind","tailwind.config.js":"_f_tailwind","tailwind.config.cjs":"_f_tailwind","tailwind.config.coffee":"_f_tailwind","tailwind.config.ts":"_f_tailwind","tailwind.config.json":"_f_tailwind","tailwind.config.mjs":"_f_tailwind",".tailwind.js":"_f_tailwind",".tailwind.cjs":"_f_tailwind",".tailwind.coffee":"_f_tailwind",".tailwind.ts":"_f_tailwind",".tailwind.json":"_f_tailwind",".tailwind.mjs":"_f_tailwind",".tailwindrc.js":"_f_tailwind",".tailwindrc.cjs":"_f_tailwind",".tailwindrc.coffee":"_f_tailwind",".tailwindrc.ts":"_f_tailwind",".tailwindrc.json":"_f_tailwind",".tailwindrc.mjs":"_f_tailwind","tamagui.js":"_f_tamagui","tamagui.mjs":"_f_tamagui","tamagui.cjs":"_f_tamagui","tamagui.ts":"_f_tamagui","tamagui.mts":"_f_tamagui","tamagui.cts":"_f_tamagui","tamagui.config.js":"_f_tamagui","tamagui.config.mjs":"_f_tamagui","tamagui.config.cjs":"_f_tamagui","tamagui.config.ts":"_f_tamagui","tamagui.config.mts":"_f_tamagui","tamagui.config.cts":"_f_tamagui",".taplo.toml":"_f_taplo","taplo.toml":"_f_taplo","taskfile.yml":"_f_taskfile","taskfile.yaml":"_f_taskfile","taskfile.dist.yml":"_f_taskfile","taskfile.dist.yaml":"_f_taskfile","tauri.conf.json":"_f_tauri",".taurignore":"_f_tauri",".testcaferc.json":"_f_testcafe","testplane.conf.ts":"_f_testplane","testplane.conf.js":"_f_testplane","testplane.conf.cts":"_f_testplane","testplane.conf.cjs":"_f_testplane","testplane.config.ts":"_f_testplane","testplane.config.js":"_f_testplane","testplane.config.cts":"_f_testplane","testplane.config.cjs":"_f_testplane",".tfignore":"_f_tfs",".tiltignore":"_f_tiltfile",".tmux.conf":"_f_tmux","tox.ini":"_f_tox",".travis.yml":"_f_travis","truffle-config.js":"_f_truffle","truffle-config.ts":"_f_truffle","trunk.yaml":"_f_trunk","tsconfig.json":"_f_tsconfig","tsconfig.app.json":"_f_tsconfig","tsconfig.base.json":"_f_tsconfig","tsconfig.build.json":"_f_tsconfig","tsconfig.cjs.json":"_f_tsconfig","tsconfig.common.json":"_f_tsconfig","tsconfig.debug.json":"_f_tsconfig","tsconfig.dev.json":"_f_tsconfig","tsconfig.development.json":"_f_tsconfig","tsconfig.e2e.json":"_f_tsconfig","tsconfig.eslint.json":"_f_tsconfig","tsconfig.esm.json":"_f_tsconfig","tsconfig.mjs.json":"_f_tsconfig","tsconfig.node.json":"_f_tsconfig","tsconfig.prod.json":"_f_tsconfig","tsconfig.production.json":"_f_tsconfig","tsconfig.release.json":"_f_tsconfig","tsconfig.server.json":"_f_tsconfig","tsconfig.spec.json":"_f_tsconfig","tsconfig.staging.json":"_f_tsconfig","tsconfig.test.json":"_f_tsconfig","tsconfig.types.json":"_f_tsconfig","tsconfig.lib.json":"_f_tsconfig","tsconfig.lib.prod.json":"_f_tsconfig","tsdoc.json":"_f_tsdoc","tsdown.config.ts":"_f_tsdown","tsdown.config.mts":"_f_tsdown","tsdown.config.cts":"_f_tsdown","tsdown.config.js":"_f_tsdown","tsdown.config.mjs":"_f_tsdown","tsdown.config.cjs":"_f_tsdown","tsdown.config.json":"_f_tsdown","tslint.json":"_f_tslint","tslint.yaml":"_f_tslint","tslint.yml":"_f_tslint","turbo.json":"_f_turbo","turbo.jsonc":"_f_turbo","microfrontends.json":"_f_turbo","microfrontends.jsonc":"_f_turbo","typedoc.cjs":"_f_typedoc","typedoc.js":"_f_typedoc","typedoc.json":"_f_typedoc","typedoc.jsonc":"_f_typedoc","typedoc.mjs":"_f_typedoc","typedoc.config.cjs":"_f_typedoc","typedoc.config.js":"_f_typedoc","typedoc.config.json":"_f_typedoc","typedoc.config.jsonc":"_f_typedoc","typedoc.config.mjs":"_f_typedoc",".unibeautifyrc":"_f_unibeautify","unibeautify.config.js":"_f_unibeautify",".unibeautifyrc.js":"_f_unibeautify",".unibeautifyrc.json":"_f_unibeautify",".unibeautifyrc.yaml":"_f_unibeautify",".unibeautifyrc.yml":"_f_unibeautify","unlicense":"_f_unlicense","unlicence":"_f_unlicense","UNLICENSE":"_f_unlicense","UNLICENCE":"_f_unlicense","unlicense.md":"_f_unlicense","unlicense.txt":"_f_unlicense","unlicence.md":"_f_unlicense","unlicence.txt":"_f_unlicense","UNLICENSE.md":"_f_unlicense","UNLICENSE.txt":"_f_unlicense","UNLICENCE.md":"_f_unlicense","UNLICENCE.txt":"_f_unlicense","uno.config.js":"_f_unocss","uno.config.mjs":"_f_unocss","uno.config.ts":"_f_unocss","uno.config.mts":"_f_unocss","unocss.config.js":"_f_unocss","unocss.config.mjs":"_f_unocss","unocss.config.ts":"_f_unocss","unocss.config.mts":"_f_unocss","uv.lock":"_f_uv","vagrantfile":"_f_vagrant","vapor.yml":"_f_vapor","now.json":"_f_vercel",".nowignore":"_f_vercel","vercel.json":"_f_vercel",".vercelignore":"_f_vercel",".vimrc":"_f_vim",".gvimrc":"_f_vim","vite.config.js":"_f_vite","vite.config.mjs":"_f_vite","vite.config.ts":"_f_vite","vite.config.cjs":"_f_vite","vite.config.mts":"_f_vite","vite.config.cts":"_f_vite","vitest.config.js":"_f_vitest","vitest.config.mjs":"_f_vitest","vitest.config.ts":"_f_vitest","vitest.config.cjs":"_f_vitest","vitest.config.mts":"_f_vitest","vitest.config.cts":"_f_vitest","vitest.workspace.json":"_f_vitest","vitest.workspace.js":"_f_vitest","vitest.workspace.mjs":"_f_vitest","vitest.workspace.ts":"_f_vitest","vitest.workspace.cjs":"_f_vitest","vitest.workspace.mts":"_f_vitest","vitest.workspace.cts":"_f_vitest","vitest.projects.json":"_f_vitest","vitest.projects.js":"_f_vitest","vitest.projects.mjs":"_f_vitest","vitest.projects.ts":"_f_vitest","vitest.projects.cjs":"_f_vitest","vitest.projects.mts":"_f_vitest","vitest.projects.cts":"_f_vitest",".vscodeignore":"_f_vscode","launch.json":"_f_vscode","tasks.json":"_f_vscode","vscodeignore.json":"_f_vscode",".vscode-test.js":"_f_vscode_test",".vscode-test.json":"_f_vscode_test",".vscode-test.mjs":"_f_vscode_test",".vuerc":"_f_vueconfig","vue.config.js":"_f_vueconfig","vue.config.cjs":"_f_vueconfig","vue.config.mjs":"_f_vueconfig","wallaby.json":"_f_wallaby","wallaby.js":"_f_wallaby","wallaby.ts":"_f_wallaby","wallaby.coffee":"_f_wallaby","wallaby.conf.json":"_f_wallaby","wallaby.conf.js":"_f_wallaby","wallaby.conf.ts":"_f_wallaby","wallaby.conf.coffee":"_f_wallaby",".wallaby.json":"_f_wallaby",".wallaby.js":"_f_wallaby",".wallaby.ts":"_f_wallaby",".wallaby.coffee":"_f_wallaby",".wallaby.conf.json":"_f_wallaby",".wallaby.conf.js":"_f_wallaby",".wallaby.conf.ts":"_f_wallaby",".wallaby.conf.coffee":"_f_wallaby","wally.toml":"_f_wally","wally.lock":"_f_wally",".watchmanconfig":"_f_watchmanconfig","wdio.json":"_f_wdio","wdio.js":"_f_wdio","wdio.mjs":"_f_wdio","wdio.cjs":"_f_wdio","wdio.ts":"_f_wdio","wdio.mts":"_f_wdio","wdio.cts":"_f_wdio","wdio.conf.json":"_f_wdio","wdio.conf.js":"_f_wdio","wdio.conf.mjs":"_f_wdio","wdio.conf.cjs":"_f_wdio","wdio.conf.ts":"_f_wdio","wdio.conf.mts":"_f_wdio","wdio.conf.cts":"_f_wdio",".weblate":"_f_weblate",".weblate.ini":"_f_weblate","weblate":"_f_weblate","weblate.ini":"_f_weblate","webpack.base.conf.js":"_f_webpack","webpack.base.conf.cjs":"_f_webpack","webpack.base.conf.mjs":"_f_webpack","webpack.base.conf.coffee":"_f_webpack","webpack.base.conf.ts":"_f_webpack","webpack.common.js":"_f_webpack","webpack.common.cjs":"_f_webpack","webpack.common.mjs":"_f_webpack","webpack.common.coffee":"_f_webpack","webpack.common.ts":"_f_webpack","webpack.config.js":"_f_webpack","webpack.config.cjs":"_f_webpack","webpack.config.mjs":"_f_webpack","webpack.config.coffee":"_f_webpack","webpack.config.ts":"_f_webpack","webpack.config.base.js":"_f_webpack","webpack.config.base.cjs":"_f_webpack","webpack.config.base.mjs":"_f_webpack","webpack.config.base.coffee":"_f_webpack","webpack.config.base.ts":"_f_webpack","webpack.config.build.js":"_f_webpack","webpack.config.build.cjs":"_f_webpack","webpack.config.build.mjs":"_f_webpack","webpack.config.build.coffee":"_f_webpack","webpack.config.build.ts":"_f_webpack","webpack.config.common.js":"_f_webpack","webpack.config.common.cjs":"_f_webpack","webpack.config.common.mjs":"_f_webpack","webpack.config.common.coffee":"_f_webpack","webpack.config.common.ts":"_f_webpack","webpack.config.dev.js":"_f_webpack","webpack.config.dev.cjs":"_f_webpack","webpack.config.dev.mjs":"_f_webpack","webpack.config.dev.coffee":"_f_webpack","webpack.config.dev.ts":"_f_webpack","webpack.config.development.js":"_f_webpack","webpack.config.development.cjs":"_f_webpack","webpack.config.development.mjs":"_f_webpack","webpack.config.development.coffee":"_f_webpack","webpack.config.development.ts":"_f_webpack","webpack.config.staging.js":"_f_webpack","webpack.config.staging.cjs":"_f_webpack","webpack.config.staging.mjs":"_f_webpack","webpack.config.staging.coffee":"_f_webpack","webpack.config.staging.ts":"_f_webpack","webpack.config.test.js":"_f_webpack","webpack.config.test.cjs":"_f_webpack","webpack.config.test.mjs":"_f_webpack","webpack.config.test.coffee":"_f_webpack","webpack.config.test.ts":"_f_webpack","webpack.config.prod.js":"_f_webpack","webpack.config.prod.cjs":"_f_webpack","webpack.config.prod.mjs":"_f_webpack","webpack.config.prod.coffee":"_f_webpack","webpack.config.prod.ts":"_f_webpack","webpack.config.production.js":"_f_webpack","webpack.config.production.cjs":"_f_webpack","webpack.config.production.mjs":"_f_webpack","webpack.config.production.coffee":"_f_webpack","webpack.config.production.ts":"_f_webpack","webpack.config.babel.js":"_f_webpack","webpack.config.babel.cjs":"_f_webpack","webpack.config.babel.mjs":"_f_webpack","webpack.config.babel.coffee":"_f_webpack","webpack.config.babel.ts":"_f_webpack","webpack.config.base.babel.js":"_f_webpack","webpack.config.base.babel.cjs":"_f_webpack","webpack.config.base.babel.mjs":"_f_webpack","webpack.config.base.babel.coffee":"_f_webpack","webpack.config.base.babel.ts":"_f_webpack","webpack.config.common.babel.js":"_f_webpack","webpack.config.common.babel.cjs":"_f_webpack","webpack.config.common.babel.mjs":"_f_webpack","webpack.config.common.babel.coffee":"_f_webpack","webpack.config.common.babel.ts":"_f_webpack","webpack.config.dev.babel.js":"_f_webpack","webpack.config.dev.babel.cjs":"_f_webpack","webpack.config.dev.babel.mjs":"_f_webpack","webpack.config.dev.babel.coffee":"_f_webpack","webpack.config.dev.babel.ts":"_f_webpack","webpack.config.development.babel.js":"_f_webpack","webpack.config.development.babel.cjs":"_f_webpack","webpack.config.development.babel.mjs":"_f_webpack","webpack.config.development.babel.coffee":"_f_webpack","webpack.config.development.babel.ts":"_f_webpack","webpack.config.staging.babel.js":"_f_webpack","webpack.config.staging.babel.cjs":"_f_webpack","webpack.config.staging.babel.mjs":"_f_webpack","webpack.config.staging.babel.coffee":"_f_webpack","webpack.config.staging.babel.ts":"_f_webpack","webpack.config.test.babel.js":"_f_webpack","webpack.config.test.babel.cjs":"_f_webpack","webpack.config.test.babel.mjs":"_f_webpack","webpack.config.test.babel.coffee":"_f_webpack","webpack.config.test.babel.ts":"_f_webpack","webpack.config.prod.babel.js":"_f_webpack","webpack.config.prod.babel.cjs":"_f_webpack","webpack.config.prod.babel.mjs":"_f_webpack","webpack.config.prod.babel.coffee":"_f_webpack","webpack.config.prod.babel.ts":"_f_webpack","webpack.config.production.babel.js":"_f_webpack","webpack.config.production.babel.cjs":"_f_webpack","webpack.config.production.babel.mjs":"_f_webpack","webpack.config.production.babel.coffee":"_f_webpack","webpack.config.production.babel.ts":"_f_webpack","webpack.dev.js":"_f_webpack","webpack.dev.cjs":"_f_webpack","webpack.dev.mjs":"_f_webpack","webpack.dev.coffee":"_f_webpack","webpack.dev.ts":"_f_webpack","webpack.dev.conf.js":"_f_webpack","webpack.dev.conf.cjs":"_f_webpack","webpack.dev.conf.mjs":"_f_webpack","webpack.dev.conf.coffee":"_f_webpack","webpack.dev.conf.ts":"_f_webpack","webpack.local.js":"_f_webpack","webpack.local.cjs":"_f_webpack","webpack.local.mjs":"_f_webpack","webpack.local.coffee":"_f_webpack","webpack.local.ts":"_f_webpack","webpack.prod.js":"_f_webpack","webpack.prod.cjs":"_f_webpack","webpack.prod.mjs":"_f_webpack","webpack.prod.coffee":"_f_webpack","webpack.prod.ts":"_f_webpack","webpack.prod.conf.js":"_f_webpack","webpack.prod.conf.cjs":"_f_webpack","webpack.prod.conf.mjs":"_f_webpack","webpack.prod.conf.coffee":"_f_webpack","webpack.prod.conf.ts":"_f_webpack","webpack.main.config.js":"_f_webpack","webpack.main.config.cjs":"_f_webpack","webpack.main.config.mjs":"_f_webpack","webpack.main.config.coffee":"_f_webpack","webpack.main.config.ts":"_f_webpack","webpack.mix.js":"_f_webpack","webpack.mix.cjs":"_f_webpack","webpack.mix.mjs":"_f_webpack","webpack.mix.coffee":"_f_webpack","webpack.mix.ts":"_f_webpack","webpack.plugins.js":"_f_webpack","webpack.plugins.cjs":"_f_webpack","webpack.plugins.mjs":"_f_webpack","webpack.plugins.coffee":"_f_webpack","webpack.plugins.ts":"_f_webpack","webpack.renderer.config.js":"_f_webpack","webpack.renderer.config.cjs":"_f_webpack","webpack.renderer.config.mjs":"_f_webpack","webpack.renderer.config.coffee":"_f_webpack","webpack.renderer.config.ts":"_f_webpack","webpack.rules.js":"_f_webpack","webpack.rules.cjs":"_f_webpack","webpack.rules.mjs":"_f_webpack","webpack.rules.coffee":"_f_webpack","webpack.rules.ts":"_f_webpack","webpack.test.conf.js":"_f_webpack","webpack.test.conf.cjs":"_f_webpack","webpack.test.conf.mjs":"_f_webpack","webpack.test.conf.coffee":"_f_webpack","webpack.test.conf.ts":"_f_webpack","wercker.yml":"_f_wercker","windi.config.ts":"_f_windi","windi.config.js":"_f_windi","wpml-config.xml":"_f_wpml","wxt.config.js":"_f_wxt","wxt.config.cjs":"_f_wxt","wxt.config.mjs":"_f_wxt","wxt.config.ts":"_f_wxt","wxt.config.cts":"_f_wxt","wxt.config.mts":"_f_wxt",".xo-config":"_f_xo","xo.config.js":"_f_xo","xo.config.cjs":"_f_xo","xo.config.mjs":"_f_xo","xo.config.ts":"_f_xo","xo.config.cts":"_f_xo","xo.config.mts":"_f_xo",".xo-config.js":"_f_xo",".xo-config.json":"_f_xo",".xo-config.cjs":"_f_xo",".xcompose":"_f_xorg",".yamllint":"_f_yamllint",".yaspellerrc":"_f_yandex",".yaspeller.json":"_f_yandex","yarn.lock":"_f_yarn",".yarnrc":"_f_yarn",".yarnrc.yml":"_f_yarn",".yarnclean":"_f_yarn",".yarn-integrity":"_f_yarn",".yarn-metadata.json":"_f_yarn",".yarn-state.yml":"_f_yarn",".yarnignore":"_f_yarn",".yo-rc.json":"_f_yeoman"},"languageIds":{"actionscript":"_f_actionscript","ada":"_f_ada","advpl":"_f_advpl","affectscript":"_f_affectscript","agda":"_f_agda","lagda-md":"_f_agda","lagda-rst":"_f_agda","lagda-tex":"_f_agda","al":"_f_al","alloy":"_f_alloy","ansible":"_f_ansible","antlr":"_f_antlr","anyscript":"_f_anyscript","apacheconf":"_f_apache","apex":"_f_apex","apiblueprint":"_f_apib","apl":"_f_apl","applescript":"_f_applescript","asciidoc":"_f_asciidoc","asp":"_f_asp","asp (html)":"_f_asp","arm":"_f_assembly","asm":"_f_assembly","asm-intel-x86-generic":"_f_assembly","platformio-debug.asm":"_f_platformio","asm-collection":"_f_assembly","astro":"_f_astro","ats":"_f_ats","ahk":"_f_autohotkey","ahk2":"_f_autohotkey","autoit":"_f_autoit","avro":"_f_avro","azcli":"_f_azure","azure-pipelines":"_f_azurepipelines","ballerina":"_f_ballerina","bat":"_f_bat","bats":"_f_bats","bazel":"_f_bazel","bazelrc":"_f_bazel","starlark":"_f_bazel","befunge":"_f_befunge","befunge98":"_f_befunge","bicep":"_f_bicep","biml":"_f_biml","code-text-binary":"_f_binary","biome_syntax_tree":"_f_biome","blade":"_f_blade","laravel-blade":"_f_blade","blitzbasic":"_f_blitzbasic","bolt":"_f_bolt","bosque":"_f_bosque","browserslist":"_f_browserslist","bruno":"_f_bruno","buf":"_f_buf","buf-gen":"_f_buf","bun.lockb":"_f_bun","c":"_f_c","c3":"_f_c3","c-al":"_f_c_al","cabal":"_f_cabal","caddyfile":"_f_caddy","capnp":"_f_capnp","capnproto":"_f_capnp","casc":"_f_casc","cddl":"_f_cddl","ceylon":"_f_ceylon","cfml":"_f_cf","lang-cfml":"_f_cf","cfc":"_f_cfc","cfmhtml":"_f_cfm","cookbook":"_f_chef_cookbook","circom":"_f_circom","clojure":"_f_clojure","clojurescript":"_f_clojurescript","manifest-yaml":"_f_cloudfoundry","cmake":"_f_cmake","cmake-cache":"_f_cmake","cobol":"_f_cobol","ql":"_f_codeql","coffeescript":"_f_coffeescript","java-properties":"_f_config","properties":"_f_config","spring-boot-properties":"_f_config","confluence":"_f_confluence","instructions":"_f_copilot","prompt":"_f_copilot","cpp":"_f_cpp","crystal":"_f_crystal","csharp":"_f_csharp","css":"_f_css","cucumber":"_f_cucumber","feature":"_f_cucumber","cuda":"_f_cuda","cuda-cpp":"_f_cuda","cython":"_f_cython","dal":"_f_dal","dart":"_f_dartlang","debian-changelog":"_f_debian","debian-control":"_f_debian","debian-copyright":"_f_debian","debian-upstream-metadata":"_f_debian","pascal":"_f_delphi","objectpascal":"_f_delphi","dhall":"_f_dhall","diff":"_f_diff","django-html":"_f_django","django-txt":"_f_django","d":"_f_dlang","dscript":"_f_dlang","dml":"_f_dlang","diet":"_f_dlang","dockerbake":"_f_docker","dockercompose":"_f_docker","dockerfile":"_f_docker","dotenv":"_f_dotenv","env":"_f_dotenv","dotjs":"_f_dotjs","doxygen":"_f_doxygen","drawio":"_f_drawio","drools":"_f_drools","dtd":"_f_dtd","dustjs":"_f_dustjs","dylan":"_f_dylan","dylan-lid":"_f_dylan","earthfile":"_f_earthly","edge":"_f_edge","editorconfig":"_f_editorconfig","eex":"_f_eex","html-eex":"_f_eex","es":"_f_elastic","elixir":"_f_elixir","elm":"_f_elm","erb":"_f_erb","html.erb":"_f_erb","erlang":"_f_erlang","esphome":"_f_esphome","excel":"_f_excel","falcon":"_f_falcon","fql":"_f_fauna","fortran":"_f_fortran","fortran-modern":"_f_fortran","FortranFreeForm":"_f_fortran","FortranFixedForm":"_f_fortran","fortran_fixed-form":"_f_fortran","ftl":"_f_freemarker","fsharp":"_f_fsharp","fthtml":"_f_fthtml","galen":"_f_galen","gml-gms":"_f_gamemaker","gml-gms2":"_f_gamemaker2","gml-gm81":"_f_gamemaker81","gcode":"_f_gcode","gdscript":"_f_gdscript","genstat":"_f_genstat","git-commit":"_f_git","git-rebase":"_f_git","ignore":"_f_git","gleam":"_f_gleam","glimmer-js":"_f_glimmer","glimmer-ts":"_f_glimmer","glsl":"_f_glsl","glyphs":"_f_glyphs","automake":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","makefile2":"_f_gnu","gnuplot":"_f_gnuplot","go":"_f_go","go.mod":"_f_go_package","go.work":"_f_go_work","goctl":"_f_goctl","gradle-kotlin-dsl":"_f_gradle","grain":"_f_grain","graphql":"_f_graphql","dot":"_f_graphviz","grit":"_f_grit","groovy":"_f_groovy","haml":"_f_haml","handlebars":"_f_handlebars","harbour":"_f_harbour","hcl":"_f_hashicorp","haskell":"_f_haskell","literate haskell":"_f_haskell","haxe":"_f_haxe","hxml":"_f_haxe","Haxe AST dump":"_f_haxe","helm":"_f_helm","hjson":"_f_hjson","hlsl":"_f_hlsl","home-assistant":"_f_homeassistant","hosts":"_f_host","html":"_f_html","http":"_f_http","hunspell.aff":"_f_hunspell","hunspell.dic":"_f_hunspell","hy":"_f_hy","hypr":"_f_hypr","icl":"_f_icl","imba":"_f_imba","4GL":"_f_informix","ini":"_f_ini","ink":"_f_ink","innosetup":"_f_innosetup","io":"_f_io","iodine":"_f_iodine","janet":"_f_janet","java":"_f_java","jekyll":"_f_jekyll","jenkins":"_f_jenkins","declarative":"_f_jenkins","jenkinsfile":"_f_jenkins","jest-snapshot":"_f_jest_snapshot","jinja":"_f_jinja","jinja-java":"_f_jinja","jinja-html":"_f_jinja","jinja-xml":"_f_jinja","jinja-css":"_f_jinja","jinja-json":"_f_jinja","jinja-md":"_f_jinja","jinja-py":"_f_jinja","jinja-rb":"_f_jinja","jinja-js":"_f_jinja","jinja-yaml":"_f_jinja","jinja-toml":"_f_jinja","jinja-latex":"_f_jinja","jinja-lua":"_f_jinja","jinja-properties":"_f_jinja","jinja-shell":"_f_jinja","jinja-dockerfile":"_f_jinja","jinja-sql":"_f_jinja","jinja-terraform":"_f_jinja","jinja-nginx":"_f_jinja","jinja-groovy":"_f_jinja","jinja-systemd":"_f_jinja","jinja-cpp":"_f_jinja","javascript":"_f_js","json":"_f_json","json-tmlanguage":"_f_json","jsonc":"_f_json","jsonl":"_f_json","json5":"_f_json5","jsonnet":"_f_jsonnet","julia":"_f_julia","juliamarkdown":"_f_julia","just":"_f_just","k":"_f_k","kivy":"_f_kivy","kos":"_f_kos","kotlin":"_f_kotlin","kotlinscript":"_f_kotlin","kusto":"_f_kusto","lark":"_f_lark","latino":"_f_latino","lean4":"_f_lean","lean4markdown":"_f_lean","less":"_f_less","lex":"_f_lex","lilypond":"_f_lilypond","lisp":"_f_lisp","autolisp":"_f_lisp","autolispdcl":"_f_lisp","log":"_f_log","lolcode":"_f_lolcode","lsl":"_f_lsl","lua":"_f_lua","markdown":"_f_markdown","marko":"_f_marko","matlab":"_f_matlab","maxscript":"_f_maxscript","mel":"_f_maya","mdx":"_f_mdx","mediawiki":"_f_mediawiki","mermaid":"_f_mermaid","meson":"_f_meson","metal":"_f_metal","bc-mcfunction":"_f_minecraft","bc-minecraft-language":"_f_minecraft","bc-minecraft-molang":"_f_minecraft","bc-minecraft-project":"_f_minecraft","mcdoc":"_f_minecraft","mcfunction":"_f_minecraft","mcmeta":"_f_minecraft","mcscript":"_f_minecraft","minecraft-lang":"_f_minecraft","options-txt":"_f_minecraft","snbt":"_f_minecraft","mv":"_f_mivascript","mjml":"_f_mjml","powerquery":"_f_mlang","mojo":"_f_mojo","mojolicious":"_f_mojolicious","mongo":"_f_mongo","mson":"_f_mson","mvt":"_f_mvt","mvtcss":"_f_mvtcss","mvtjs":"_f_mvtjs","nearley":"_f_nearly","cypher":"_f_neo4j","nginx":"_f_nginx","nim":"_f_nim","nimble":"_f_nimble","nix":"_f_nix","nsis":"_f_nsi","nfl":"_f_nsi","nsl":"_f_nsi","bridlensis":"_f_nsi","nunjucks":"_f_nunjucks","nushell":"_f_nushell","objective-c":"_f_objectivec","objective-cpp":"_f_objectivecpp","ocaml":"_f_ocaml","ocamllex":"_f_ocaml","menhir":"_f_ocaml","ogone":"_f_ogone","openhab":"_f_openHAB","scad":"_f_openscad","org":"_f_org","pddl":"_f_pddl","happenings":"_f_pddl_happenings","plan":"_f_pddl_plan","perl":"_f_perl","perl6":"_f_perl6","pgsql":"_f_pgsql","php":"_f_php","pine":"_f_pine","pinescript":"_f_pine","pip-requirements":"_f_pip","platformio-debug.disassembly":"_f_platformio","platformio-debug.memoryview":"_f_platformio","plsql":"_f_plsql","oracle":"_f_plsql","oraclesql":"_f_plsql","oracle-sql":"_f_plsql","po":"_f_poedit","polyglot-notebook":"_f_polyglot","polymer":"_f_polymer","pony":"_f_pony","postcss":"_f_postcss","postscript":"_f_postscript","powershell":"_f_powershell","prisma":"_f_prisma","pde":"_f_processinglang","abl":"_f_progress","prolog":"_f_prolog","prometheus":"_f_prometheus","proto3":"_f_protobuf","proto":"_f_protobuf","prototext":"_f_protobuf","jade":"_f_pug","puppet":"_f_puppet","purescript":"_f_purescript","pyret":"_f_pyret","pyscript":"_f_pyscript","python":"_f_python","pythowo":"_f_pythowo","qlik":"_f_qlikview","qml":"_f_qml","qsharp":"_f_qsharp","r":"_f_r","ra_syntax_tree":"_f_ra_syntax_tree","racket":"_f_racket","raku":"_f_raku","raml":"_f_raml","razor":"_f_razor","aspnetcorerazor":"_f_razor","javascriptreact":"_f_reactjs","typescriptreact":"_f_reactts","reason":"_f_reason","red":"_f_red","rescript":"_f_rescript","restructuredtext":"_f_rest","rexx":"_f_rexx","riot":"_f_riot","ripple":"_f_ripple","rmd":"_f_rmd","rnc":"_f_rnc","robot":"_f_robotframework","robots-txt":"_f_robots","ruby":"_f_ruby","rust":"_f_rust","s-lang":"_f_s-lang","san":"_f_san","SAS":"_f_sas","sass":"_f_sass","sass.hover":"_f_sass","sbt":"_f_sbt","scala":"_f_scala","scilab":"_f_scilab","vbscript":"_f_script","scss":"_f_scss","sdl":"_f_sdlang","search-result":"_f_search_result","shaderlab":"_f_shaderlab","shellscript":"_f_shell","silverstripe":"_f_silverstripe","sino":"_f_sino","eskip":"_f_skipper","slang":"_f_slang","slice":"_f_slice","slim":"_f_slim","slint":"_f_slint","sln":"_f_sln","smarty":"_f_smarty","snakemake":"_f_snakemake","snort":"_f_snort","solidity":"_f_solidity","sparql":"_f_sparql","sqf":"_f_sqf","sql":"_f_sql","squirrel":"_f_squirrel","stan":"_f_stan","stata":"_f_stata","stencil":"_f_stencil","stencil-html":"_f_stencil","stylable":"_f_stylable","source.css.styled":"_f_styled","stylus":"_f_stylus","svelte":"_f_svelte","svg":"_f_svg","Swagger":"_f_swagger","swagger":"_f_swagger","swift":"_f_swift","swig":"_f_swig","systemd-conf":"_f_systemd","systemd-unit-file":"_f_systemd","systemverilog":"_f_systemverilog","t4":"_f_t4tt","tailwindcss":"_f_tailwind","teal":"_f_teal","templ":"_f_templ","tera":"_f_tera","terraform":"_f_terraform","tex":"_f_tex","latex":"_f_tex","bibtex":"_f_tex","doctex":"_f_tex","csv":"_f_text","csv (pipe)":"_f_text","csv (semicolon)":"_f_text","csv (whitespace)":"_f_text","dynamic csv":"_f_text","tsv":"_f_text","plaintext":"_f_text","vim-help":"_f_text","textile":"_f_textile","tiltfile":"_f_tiltfile","toit":"_f_toit","toml":"_f_toml","toon":"_f_toon","tt":"_f_tt","ttcn":"_f_ttcn","tuc":"_f_tuc","twig":"_f_twig","typescript":"_f_typescript","typoscript":"_f_typo3","uiua":"_f_uiua","unison":"_f_unison","vb":"_f_vb","vba":"_f_vba","velocity":"_f_velocity","vento":"_f_vento","verilog":"_f_verilog","vhdl":"_f_vhdl","viml":"_f_vim","vim-snippet":"_f_vim","vitest-snapshot":"_f_vitest","v":"_f_vlang","volt":"_f_volt","vroom":"_f_vroom","snippets":"_f_vscode","vue":"_f_vue","vyper":"_f_vyper","wasm":"_f_wasm","wat":"_f_wasm","wenyan":"_f_wenyan","wgsl":"_f_wgsl","wikitext":"_f_wikitext","wai":"_f_wit","wolfram":"_f_wolfram","wurstlang":"_f_wurst","wurst":"_f_wurst","xmake":"_f_xmake","xml":"_f_xml","xcompose":"_f_xorg","xquery":"_f_xquery","xsl":"_f_xsl","xslt":"_f_xsl","yacc":"_f_yacc","yaml":"_f_yaml","yaml-tmlanguage":"_f_yaml","spring-boot-properties-yaml":"_f_yaml","yang":"_f_yang","yarnlock":"_f_yarn","zig":"_f_zig","zip":"_f_zip"},"light":{"file":"_file_light","folder":"_folder_light","folderExpanded":"_folder_light_open","rootFolder":"_root_folder_light","rootFolderExpanded":"_root_folder_light_open","folderNames":{"android":"_fd_android",".angular":"_fd_angular","apache":"_fd_apache","apache2":"_fd_apache","httpd":"_fd_apache","api":"_fd_api",".api":"_fd_api","apis":"_fd_api",".apis":"_fd_api","app":"_fd_app","apps":"_fd_app",".app":"_fd_app","application":"_fd_app","applications":"_fd_app","arangodb":"_fd_arangodb","arango":"_fd_arangodb","assets":"_fd_asset",".assets":"_fd_asset",".astro":"_fd_astro","audio":"_fd_audio",".audio":"_fd_audio","audios":"_fd_audio",".audios":"_fd_audio","sound":"_fd_audio",".sound":"_fd_audio","sounds":"_fd_audio",".sounds":"_fd_audio","aurelia_project":"_fd_aurelia","aws":"_fd_aws",".aws":"_fd_aws","azure":"_fd_azure",".azure":"_fd_azure","azure-pipelines":"_fd_azurepipelines",".azure-pipelines":"_fd_azurepipelines","bin":"_fd_binary",".bin":"_fd_binary","blocs":"_fd_bloc","bloc":"_fd_bloc","blueprint":"_fd_blueprint",".blueprint":"_fd_blueprint","blueprints":"_fd_blueprint",".blueprints":"_fd_blueprint","bot":"_fd_bot",".bot":"_fd_bot","bower_components":"_fd_bower",".buildkite":"_fd_buildkite","cake":"_fd_cake",".cake":"_fd_cake","cargo":"_fd_cargo",".cargo":"_fd_cargo","crates":"_fd_cargo","certificates":"_fd_certificate",".certificates":"_fd_certificate","certs":"_fd_certificate","certs.":"_fd_certificate",".changeset":"_fd_changesets","chef":"_fd_chef",".chef":"_fd_chef",".circleci":"_fd_circleci",".claude":"_fd_claude","cli":"_fd_cli","cmd":"_fd_cli","command":"_fd_cli","commands":"_fd_cli","commandline":"_fd_cli","console":"_fd_cli","client":"_fd_client","clients":"_fd_client",".cloudflare":"_fd_cloudflare","cloudflare":"_fd_cloudflare",".cmake":"_fd_cmake","cmake":"_fd_cmake","common":"_fd_common","component":"_fd_component","components":"_fd_component",".components":"_fd_component","gui":"_fd_component","src-ui":"_fd_component","ui":"_fd_component","widgets":"_fd_component","composer":"_fd_composer",".composer":"_fd_composer","conf":"_fd_config",".conf":"_fd_config","config":"_fd_config",".config":"_fd_config","configs":"_fd_config",".configs":"_fd_config","configuration":"_fd_config",".configuration":"_fd_config","configurations":"_fd_config",".configurations":"_fd_config","setting":"_fd_config",".setting":"_fd_config","settings":"_fd_config",".settings":"_fd_config","ini":"_fd_config",".ini":"_fd_config","initializers":"_fd_config",".initializers":"_fd_config","__controller__":"_fd_controller","__controllers__":"_fd_controller","controller":"_fd_controller","controllers":"_fd_controller",".controllers":"_fd_controller","__handler__":"_fd_controller","__handlers__":"_fd_controller","handler":"_fd_controller",".handler":"_fd_controller","handlers":"_fd_controller",".handlers":"_fd_controller","coverage":"_fd_coverage","css":"_fd_css","_css":"_fd_css","cubits":"_fd_cubit","cubit":"_fd_cubit",".cursor":"_fd_light_cursor","cypress":"_fd_light_cypress",".dapr":"_fd_dapr","dapr":"_fd_dapr","dart":"_fd_dart",".dart_tool":"_fd_dart","datadog":"_fd_datadog",".datadog":"_fd_datadog","db":"_fd_db","database":"_fd_db","sql":"_fd_db","data":"_fd_db","repo":"_fd_db","repository":"_fd_db","repositories":"_fd_db","store":"_fd_db","stores":"_fd_db","debian":"_fd_debian","deb":"_fd_debian",".dependabot":"_fd_dependabot",".devcontainer":"_fd_devcontainer","dist":"_fd_dist",".dist":"_fd_dist","dists":"_fd_dist","out":"_fd_dist","outs":"_fd_dist","export":"_fd_dist","exports":"_fd_dist","build":"_fd_dist",".build":"_fd_dist","builds":"_fd_dist","release":"_fd_dist","releases":"_fd_dist","target":"_fd_dist","targets":"_fd_dist","docker":"_fd_docker",".docker":"_fd_docker","docs":"_fd_docs","doc":"_fd_docs","document":"_fd_docs","documents":"_fd_docs","e2e":"_fd_e2e",".elasticbeanstalk":"_fd_elasticbeanstalk",".ebextensions":"_fd_elasticbeanstalk","electron":"_fd_light_electron",".expo":"_fd_light_expo",".expo-shared":"_fd_light_expo","favicon":"_fd_favicon","favicons":"_fd_favicon","flow":"_fd_flow","flow-typed":"_fd_flow","flutter":"_fd_flutter","Flutter":"_fd_flutter","fonts":"_fd_light_fonts","font":"_fd_light_fonts","fnt":"_fd_light_fonts","webfonts":"_fd_light_fonts",".front-commerce":"_fd_frontcommerce","gcp":"_fd_gcp",".gcp":"_fd_gcp",".gemini":"_fd_gemini",".git":"_fd_git","submodules":"_fd_git",".submodules":"_fd_git",".github":"_fd_github",".gitlab":"_fd_gitlab","godot":"_fd_godot",".godot":"_fd_godot","gradle":"_fd_light_gradle",".gradle":"_fd_light_gradle","graphql":"_fd_graphql","grunt":"_fd_grunt","gulp":"_fd_gulp","gulpfile.js":"_fd_gulp","gulpfile.coffee":"_fd_gulp","gulpfile.ts":"_fd_gulp","gulpfile.babel.js":"_fd_gulp","gulpfile.babel.coffee":"_fd_gulp","gulpfile.babel.ts":"_fd_gulp",".haxelib":"_fd_haxelib","haxe_libraries":"_fd_haxelib","helper":"_fd_helper",".helper":"_fd_helper","helpers":"_fd_helper",".helpers":"_fd_helper",".histoire":"_fd_histoire","hook":"_fd_hook",".hook":"_fd_hook","hooks":"_fd_hook",".hooks":"_fd_hook",".husky":"_fd_husky",".idea":"_fd_idea",".fig":"_fd_images",".figs":"_fd_images","images":"_fd_images","image":"_fd_images",".img":"_fd_images","img":"_fd_images",".imgs":"_fd_images","imgs":"_fd_images","icons":"_fd_images","icon":"_fd_images","ico":"_fd_images","screenshot":"_fd_images","screenshots":"_fd_images","svg":"_fd_images","texture":"_fd_images","textures":"_fd_images","include":"_fd_include","includes":"_fd_include","incl":"_fd_include","inc":"_fd_include",".include":"_fd_include",".includes":"_fd_include",".incl":"_fd_include",".inc":"_fd_include","_include":"_fd_include","_includes":"_fd_include","_incl":"_fd_include","_inc":"_fd_include","interface":"_fd_interfaces","interfaces":"_fd_interfaces","ios":"_fd_ios","js":"_fd_js","json":"_fd_json",".junie":"_fd_junie","kotlin":"_fd_kotlin","Kotlin":"_fd_kotlin",".kotlin":"_fd_kotlin","kubernetes":"_fd_kubernetes","k8s":"_fd_kubernetes","kube":"_fd_kubernetes","kuber":"_fd_kubernetes",".kubernetes":"_fd_kubernetes",".k8s":"_fd_kubernetes",".kube":"_fd_kubernetes",".kuber":"_fd_kubernetes","less":"_fd_less","_less":"_fd_less","lib":"_fd_library","libs":"_fd_library",".lib":"_fd_library",".libs":"_fd_library","__lib__":"_fd_library","__libs__":"_fd_library","library":"_fd_library","libraries":"_fd_library","linux":"_fd_linux","lang":"_fd_locale","language":"_fd_locale","languages":"_fd_locale","locale":"_fd_locale","locales":"_fd_locale","_locale":"_fd_locale","_locales":"_fd_locale","internationalization":"_fd_locale","globalization":"_fd_locale","localization":"_fd_locale","i18n":"_fd_locale","g11n":"_fd_locale","l10n":"_fd_locale","log":"_fd_log","logs":"_fd_log","macos":"_fd_macos","darwin":"_fd_macos","mariadb":"_fd_mariadb","maria":"_fd_mariadb",".mvn":"_fd_maven","mediawiki":"_fd_mediawiki","memcached":"_fd_memcached",".memcached":"_fd_memcached",".meteor":"_fd_light_meteor","middleware":"_fd_middleware","middlewares":"_fd_middleware",".minecraft":"_fd_minecraft","minikube":"_fd_minikube","minik8s":"_fd_minikube","minikuber":"_fd_minikube","mjml":"_fd_mjml",".mjml":"_fd_mjml","mocks":"_fd_mock",".mocks":"_fd_mock","__mocks__":"_fd_mock","model":"_fd_model",".model":"_fd_model","models":"_fd_model",".models":"_fd_model","entities":"_fd_model",".entities":"_fd_model","modules":"_fd_module","mojo":"_fd_mojo","mongodb":"_fd_mongodb","mongo":"_fd_mongodb",".mypy_cache":"_fd_light_mypy","mysqldb":"_fd_light_mysql","mysql":"_fd_light_mysql","netlify":"_fd_netlify",".next":"_fd_next","nginx":"_fd_nginx","conf.d":"_fd_nginx",".niv":"_fd_nix",".nix":"_fd_nix","nix":"_fd_nix","niv":"_fd_nix","node_modules":"_fd_light_node","notebooks":"_fd_notebooks","notebook":"_fd_notebooks","notification":"_fd_notification","notifications":"_fd_notification","event":"_fd_notification","events":"_fd_notification",".nuget":"_fd_nuget","nuxt":"_fd_nuxt",".nuxt":"_fd_nuxt","package":"_fd_package","packages":"_fd_package",".package":"_fd_package",".packages":"_fd_package","pkg":"_fd_package",".paket":"_fd_paket","php":"_fd_php",".pio":"_fd_platformio",".pioenvs":"_fd_platformio","plugin":"_fd_plugin",".plugin":"_fd_plugin","plugins":"_fd_plugin",".plugins":"_fd_plugin","extension":"_fd_plugin",".extension":"_fd_plugin","extensions":"_fd_plugin",".extensions":"_fd_plugin","prisma":"_fd_prisma","private":"_fd_private",".private":"_fd_private","public":"_fd_public",".public":"_fd_public",".pytest_cache":"_fd_pytest",".venv":"_fd_python",".virtualenv":"_fd_python","__pycache__":"_fd_python","ravendb":"_fd_ravendb","redis":"_fd_redis","redux":"_fd_light_redux","route":"_fd_route","routes":"_fd_route","_route":"_fd_route","_routes":"_fd_route","router":"_fd_route","routers":"_fd_route","sass":"_fd_light_sass","scss":"_fd_light_sass","_sass":"_fd_light_sass","_scss":"_fd_light_sass","script":"_fd_script","scripts":"_fd_script","seedkit":"_fd_seedkit",".seedkit":"_fd_seedkit","server":"_fd_server","service":"_fd_services","services":"_fd_services","share":"_fd_shared","shared":"_fd_shared",".share":"_fd_shared",".shared":"_fd_shared","__shared__":"_fd_shared","__share__":"_fd_shared",".snaplet":"_fd_snaplet",".spin":"_fd_spin","src":"_fd_src","source":"_fd_src","sources":"_fd_src","__src__":"_fd_src","sso":"_fd_sso","story":"_fd_story","stories":"_fd_story","__stories__":"_fd_story",".storybook":"_fd_story","style":"_fd_style","styles":"_fd_style","supabase":"_fd_supabase","svelte":"_fd_svelte",".svelte-kit":"_fd_svelte","src-tauri":"_fd_tauri","temp":"_fd_temp",".temp":"_fd_temp","tmp":"_fd_temp",".tmp":"_fd_temp","template":"_fd_template",".template":"_fd_template","templates":"_fd_template",".templates":"_fd_template","tests":"_fd_test",".tests":"_fd_test","test":"_fd_test",".test":"_fd_test","__tests__":"_fd_test","__test__":"_fd_test","spec":"_fd_test",".spec":"_fd_test","specs":"_fd_test",".specs":"_fd_test","integration":"_fd_test","theme":"_fd_theme","themes":"_fd_theme","tool":"_fd_tools","tools":"_fd_tools",".tools":"_fd_tools","util":"_fd_tools","utils":"_fd_tools","utilities":"_fd_tools","tooling":"_fd_tools",".travis":"_fd_travis",".trunk":"_fd_trunk",".turbo":"_fd_turbo","typescript":"_fd_typescript","ts":"_fd_typescript","typings":"_fd_typings","types":"_fd_typings","@types":"_fd_typings","vagrant":"_fd_vagrant",".vagrant":"_fd_vagrant",".vercel":"_fd_vercel","video":"_fd_video",".video":"_fd_video","videos":"_fd_video",".videos":"_fd_video","html":"_fd_view","view":"_fd_view","views":"_fd_view","layout":"_fd_view","layouts":"_fd_view","page":"_fd_view","pages":"_fd_view","_view":"_fd_view","_views":"_fd_view","_layout":"_fd_view","_layouts":"_fd_view","_page":"_fd_view","_pages":"_fd_view",".vitepress":"_fd_vitepress",".vs":"_fd_vs",".vscode":"_fd_vscode","vscode":"_fd_vscode",".vscode-test":"_fd_vscode_test","wasm":"_fd_wasm","WASM":"_fd_wasm","webassembly":"_fd_wasm","WebAssembly":"_fd_wasm","webpack":"_fd_webpack",".webpack":"_fd_webpack","windows":"_fd_windows","win32":"_fd_windows",".windsurf":"_fd_windsurf","wit":"_fd_wit",".web":"_fd_www","www":"_fd_www","wwwroot":"_fd_www","web":"_fd_www",".yarn":"_fd_yarn",".zed":"_fd_light_zed"},"folderNamesExpanded":{"android":"_fd_android_open",".angular":"_fd_angular_open","apache":"_fd_apache_open","apache2":"_fd_apache_open","httpd":"_fd_apache_open","api":"_fd_api_open",".api":"_fd_api_open","apis":"_fd_api_open",".apis":"_fd_api_open","app":"_fd_app_open","apps":"_fd_app_open",".app":"_fd_app_open","application":"_fd_app_open","applications":"_fd_app_open","arangodb":"_fd_arangodb_open","arango":"_fd_arangodb_open","assets":"_fd_asset_open",".assets":"_fd_asset_open",".astro":"_fd_astro_open","audio":"_fd_audio_open",".audio":"_fd_audio_open","audios":"_fd_audio_open",".audios":"_fd_audio_open","sound":"_fd_audio_open",".sound":"_fd_audio_open","sounds":"_fd_audio_open",".sounds":"_fd_audio_open","aurelia_project":"_fd_aurelia_open","aws":"_fd_aws_open",".aws":"_fd_aws_open","azure":"_fd_azure_open",".azure":"_fd_azure_open","azure-pipelines":"_fd_azurepipelines_open",".azure-pipelines":"_fd_azurepipelines_open","bin":"_fd_binary_open",".bin":"_fd_binary_open","blocs":"_fd_bloc_open","bloc":"_fd_bloc_open","blueprint":"_fd_blueprint_open",".blueprint":"_fd_blueprint_open","blueprints":"_fd_blueprint_open",".blueprints":"_fd_blueprint_open","bot":"_fd_bot_open",".bot":"_fd_bot_open","bower_components":"_fd_bower_open",".buildkite":"_fd_buildkite_open","cake":"_fd_cake_open",".cake":"_fd_cake_open","cargo":"_fd_cargo_open",".cargo":"_fd_cargo_open","crates":"_fd_cargo_open","certificates":"_fd_certificate_open",".certificates":"_fd_certificate_open","certs":"_fd_certificate_open","certs.":"_fd_certificate_open",".changeset":"_fd_changesets_open","chef":"_fd_chef_open",".chef":"_fd_chef_open",".circleci":"_fd_circleci_open",".claude":"_fd_claude_open","cli":"_fd_cli_open","cmd":"_fd_cli_open","command":"_fd_cli_open","commands":"_fd_cli_open","commandline":"_fd_cli_open","console":"_fd_cli_open","client":"_fd_client_open","clients":"_fd_client_open",".cloudflare":"_fd_cloudflare_open","cloudflare":"_fd_cloudflare_open",".cmake":"_fd_cmake_open","cmake":"_fd_cmake_open","common":"_fd_common_open","component":"_fd_component_open","components":"_fd_component_open",".components":"_fd_component_open","gui":"_fd_component_open","src-ui":"_fd_component_open","ui":"_fd_component_open","widgets":"_fd_component_open","composer":"_fd_composer_open",".composer":"_fd_composer_open","conf":"_fd_config_open",".conf":"_fd_config_open","config":"_fd_config_open",".config":"_fd_config_open","configs":"_fd_config_open",".configs":"_fd_config_open","configuration":"_fd_config_open",".configuration":"_fd_config_open","configurations":"_fd_config_open",".configurations":"_fd_config_open","setting":"_fd_config_open",".setting":"_fd_config_open","settings":"_fd_config_open",".settings":"_fd_config_open","ini":"_fd_config_open",".ini":"_fd_config_open","initializers":"_fd_config_open",".initializers":"_fd_config_open","__controller__":"_fd_controller_open","__controllers__":"_fd_controller_open","controller":"_fd_controller_open","controllers":"_fd_controller_open",".controllers":"_fd_controller_open","__handler__":"_fd_controller_open","__handlers__":"_fd_controller_open","handler":"_fd_controller_open",".handler":"_fd_controller_open","handlers":"_fd_controller_open",".handlers":"_fd_controller_open","coverage":"_fd_coverage_open","css":"_fd_css_open","_css":"_fd_css_open","cubits":"_fd_cubit_open","cubit":"_fd_cubit_open",".cursor":"_fd_light_cursor_open","cypress":"_fd_light_cypress_open",".dapr":"_fd_dapr_open","dapr":"_fd_dapr_open","dart":"_fd_dart_open",".dart_tool":"_fd_dart_open","datadog":"_fd_datadog_open",".datadog":"_fd_datadog_open","db":"_fd_db_open","database":"_fd_db_open","sql":"_fd_db_open","data":"_fd_db_open","repo":"_fd_db_open","repository":"_fd_db_open","repositories":"_fd_db_open","store":"_fd_db_open","stores":"_fd_db_open","debian":"_fd_debian_open","deb":"_fd_debian_open",".dependabot":"_fd_dependabot_open",".devcontainer":"_fd_devcontainer_open","dist":"_fd_dist_open",".dist":"_fd_dist_open","dists":"_fd_dist_open","out":"_fd_dist_open","outs":"_fd_dist_open","export":"_fd_dist_open","exports":"_fd_dist_open","build":"_fd_dist_open",".build":"_fd_dist_open","builds":"_fd_dist_open","release":"_fd_dist_open","releases":"_fd_dist_open","target":"_fd_dist_open","targets":"_fd_dist_open","docker":"_fd_docker_open",".docker":"_fd_docker_open","docs":"_fd_docs_open","doc":"_fd_docs_open","document":"_fd_docs_open","documents":"_fd_docs_open","e2e":"_fd_e2e_open",".elasticbeanstalk":"_fd_elasticbeanstalk_open",".ebextensions":"_fd_elasticbeanstalk_open","electron":"_fd_light_electron_open",".expo":"_fd_light_expo_open",".expo-shared":"_fd_light_expo_open","favicon":"_fd_favicon_open","favicons":"_fd_favicon_open","flow":"_fd_flow_open","flow-typed":"_fd_flow_open","flutter":"_fd_flutter_open","Flutter":"_fd_flutter_open","fonts":"_fd_light_fonts_open","font":"_fd_light_fonts_open","fnt":"_fd_light_fonts_open","webfonts":"_fd_light_fonts_open",".front-commerce":"_fd_frontcommerce_open","gcp":"_fd_gcp_open",".gcp":"_fd_gcp_open",".gemini":"_fd_gemini_open",".git":"_fd_git_open","submodules":"_fd_git_open",".submodules":"_fd_git_open",".github":"_fd_github_open",".gitlab":"_fd_gitlab_open","godot":"_fd_godot_open",".godot":"_fd_godot_open","gradle":"_fd_light_gradle_open",".gradle":"_fd_light_gradle_open","graphql":"_fd_graphql_open","grunt":"_fd_grunt_open","gulp":"_fd_gulp_open","gulpfile.js":"_fd_gulp_open","gulpfile.coffee":"_fd_gulp_open","gulpfile.ts":"_fd_gulp_open","gulpfile.babel.js":"_fd_gulp_open","gulpfile.babel.coffee":"_fd_gulp_open","gulpfile.babel.ts":"_fd_gulp_open",".haxelib":"_fd_haxelib_open","haxe_libraries":"_fd_haxelib_open","helper":"_fd_helper_open",".helper":"_fd_helper_open","helpers":"_fd_helper_open",".helpers":"_fd_helper_open",".histoire":"_fd_histoire_open","hook":"_fd_hook_open",".hook":"_fd_hook_open","hooks":"_fd_hook_open",".hooks":"_fd_hook_open",".husky":"_fd_husky_open",".idea":"_fd_idea_open",".fig":"_fd_images_open",".figs":"_fd_images_open","images":"_fd_images_open","image":"_fd_images_open",".img":"_fd_images_open","img":"_fd_images_open",".imgs":"_fd_images_open","imgs":"_fd_images_open","icons":"_fd_images_open","icon":"_fd_images_open","ico":"_fd_images_open","screenshot":"_fd_images_open","screenshots":"_fd_images_open","svg":"_fd_images_open","texture":"_fd_images_open","textures":"_fd_images_open","include":"_fd_include_open","includes":"_fd_include_open","incl":"_fd_include_open","inc":"_fd_include_open",".include":"_fd_include_open",".includes":"_fd_include_open",".incl":"_fd_include_open",".inc":"_fd_include_open","_include":"_fd_include_open","_includes":"_fd_include_open","_incl":"_fd_include_open","_inc":"_fd_include_open","interface":"_fd_interfaces_open","interfaces":"_fd_interfaces_open","ios":"_fd_ios_open","js":"_fd_js_open","json":"_fd_json_open",".junie":"_fd_junie_open","kotlin":"_fd_kotlin_open","Kotlin":"_fd_kotlin_open",".kotlin":"_fd_kotlin_open","kubernetes":"_fd_kubernetes_open","k8s":"_fd_kubernetes_open","kube":"_fd_kubernetes_open","kuber":"_fd_kubernetes_open",".kubernetes":"_fd_kubernetes_open",".k8s":"_fd_kubernetes_open",".kube":"_fd_kubernetes_open",".kuber":"_fd_kubernetes_open","less":"_fd_less_open","_less":"_fd_less_open","lib":"_fd_library_open","libs":"_fd_library_open",".lib":"_fd_library_open",".libs":"_fd_library_open","__lib__":"_fd_library_open","__libs__":"_fd_library_open","library":"_fd_library_open","libraries":"_fd_library_open","linux":"_fd_linux_open","lang":"_fd_locale_open","language":"_fd_locale_open","languages":"_fd_locale_open","locale":"_fd_locale_open","locales":"_fd_locale_open","_locale":"_fd_locale_open","_locales":"_fd_locale_open","internationalization":"_fd_locale_open","globalization":"_fd_locale_open","localization":"_fd_locale_open","i18n":"_fd_locale_open","g11n":"_fd_locale_open","l10n":"_fd_locale_open","log":"_fd_log_open","logs":"_fd_log_open","macos":"_fd_macos_open","darwin":"_fd_macos_open","mariadb":"_fd_mariadb_open","maria":"_fd_mariadb_open",".mvn":"_fd_maven_open","mediawiki":"_fd_mediawiki_open","memcached":"_fd_memcached_open",".memcached":"_fd_memcached_open",".meteor":"_fd_light_meteor_open","middleware":"_fd_middleware_open","middlewares":"_fd_middleware_open",".minecraft":"_fd_minecraft_open","minikube":"_fd_minikube_open","minik8s":"_fd_minikube_open","minikuber":"_fd_minikube_open","mjml":"_fd_mjml_open",".mjml":"_fd_mjml_open","mocks":"_fd_mock_open",".mocks":"_fd_mock_open","__mocks__":"_fd_mock_open","model":"_fd_model_open",".model":"_fd_model_open","models":"_fd_model_open",".models":"_fd_model_open","entities":"_fd_model_open",".entities":"_fd_model_open","modules":"_fd_module_open","mojo":"_fd_mojo_open","mongodb":"_fd_mongodb_open","mongo":"_fd_mongodb_open",".mypy_cache":"_fd_light_mypy_open","mysqldb":"_fd_light_mysql_open","mysql":"_fd_light_mysql_open","netlify":"_fd_netlify_open",".next":"_fd_next_open","nginx":"_fd_nginx_open","conf.d":"_fd_nginx_open",".niv":"_fd_nix_open",".nix":"_fd_nix_open","nix":"_fd_nix_open","niv":"_fd_nix_open","node_modules":"_fd_light_node_open","notebooks":"_fd_notebooks_open","notebook":"_fd_notebooks_open","notification":"_fd_notification_open","notifications":"_fd_notification_open","event":"_fd_notification_open","events":"_fd_notification_open",".nuget":"_fd_nuget_open","nuxt":"_fd_nuxt_open",".nuxt":"_fd_nuxt_open","package":"_fd_package_open","packages":"_fd_package_open",".package":"_fd_package_open",".packages":"_fd_package_open","pkg":"_fd_package_open",".paket":"_fd_paket_open","php":"_fd_php_open",".pio":"_fd_platformio_open",".pioenvs":"_fd_platformio_open","plugin":"_fd_plugin_open",".plugin":"_fd_plugin_open","plugins":"_fd_plugin_open",".plugins":"_fd_plugin_open","extension":"_fd_plugin_open",".extension":"_fd_plugin_open","extensions":"_fd_plugin_open",".extensions":"_fd_plugin_open","prisma":"_fd_prisma_open","private":"_fd_private_open",".private":"_fd_private_open","public":"_fd_public_open",".public":"_fd_public_open",".pytest_cache":"_fd_pytest_open",".venv":"_fd_python_open",".virtualenv":"_fd_python_open","__pycache__":"_fd_python_open","ravendb":"_fd_ravendb_open","redis":"_fd_redis_open","redux":"_fd_light_redux_open","route":"_fd_route_open","routes":"_fd_route_open","_route":"_fd_route_open","_routes":"_fd_route_open","router":"_fd_route_open","routers":"_fd_route_open","sass":"_fd_light_sass_open","scss":"_fd_light_sass_open","_sass":"_fd_light_sass_open","_scss":"_fd_light_sass_open","script":"_fd_script_open","scripts":"_fd_script_open","seedkit":"_fd_seedkit_open",".seedkit":"_fd_seedkit_open","server":"_fd_server_open","service":"_fd_services_open","services":"_fd_services_open","share":"_fd_shared_open","shared":"_fd_shared_open",".share":"_fd_shared_open",".shared":"_fd_shared_open","__shared__":"_fd_shared_open","__share__":"_fd_shared_open",".snaplet":"_fd_snaplet_open",".spin":"_fd_spin_open","src":"_fd_src_open","source":"_fd_src_open","sources":"_fd_src_open","__src__":"_fd_src_open","sso":"_fd_sso_open","story":"_fd_story_open","stories":"_fd_story_open","__stories__":"_fd_story_open",".storybook":"_fd_story_open","style":"_fd_style_open","styles":"_fd_style_open","supabase":"_fd_supabase_open","svelte":"_fd_svelte_open",".svelte-kit":"_fd_svelte_open","src-tauri":"_fd_tauri_open","temp":"_fd_temp_open",".temp":"_fd_temp_open","tmp":"_fd_temp_open",".tmp":"_fd_temp_open","template":"_fd_template_open",".template":"_fd_template_open","templates":"_fd_template_open",".templates":"_fd_template_open","tests":"_fd_test_open",".tests":"_fd_test_open","test":"_fd_test_open",".test":"_fd_test_open","__tests__":"_fd_test_open","__test__":"_fd_test_open","spec":"_fd_test_open",".spec":"_fd_test_open","specs":"_fd_test_open",".specs":"_fd_test_open","integration":"_fd_test_open","theme":"_fd_theme_open","themes":"_fd_theme_open","tool":"_fd_tools_open","tools":"_fd_tools_open",".tools":"_fd_tools_open","util":"_fd_tools_open","utils":"_fd_tools_open","utilities":"_fd_tools_open","tooling":"_fd_tools_open",".travis":"_fd_travis_open",".trunk":"_fd_trunk_open",".turbo":"_fd_turbo_open","typescript":"_fd_typescript_open","ts":"_fd_typescript_open","typings":"_fd_typings_open","types":"_fd_typings_open","@types":"_fd_typings_open","vagrant":"_fd_vagrant_open",".vagrant":"_fd_vagrant_open",".vercel":"_fd_vercel_open","video":"_fd_video_open",".video":"_fd_video_open","videos":"_fd_video_open",".videos":"_fd_video_open","html":"_fd_view_open","view":"_fd_view_open","views":"_fd_view_open","layout":"_fd_view_open","layouts":"_fd_view_open","page":"_fd_view_open","pages":"_fd_view_open","_view":"_fd_view_open","_views":"_fd_view_open","_layout":"_fd_view_open","_layouts":"_fd_view_open","_page":"_fd_view_open","_pages":"_fd_view_open",".vitepress":"_fd_vitepress_open",".vs":"_fd_vs_open",".vscode":"_fd_vscode_open","vscode":"_fd_vscode_open",".vscode-test":"_fd_vscode_test_open","wasm":"_fd_wasm_open","WASM":"_fd_wasm_open","webassembly":"_fd_wasm_open","WebAssembly":"_fd_wasm_open","webpack":"_fd_webpack_open",".webpack":"_fd_webpack_open","windows":"_fd_windows_open","win32":"_fd_windows_open",".windsurf":"_fd_windsurf_open","wit":"_fd_wit_open",".web":"_fd_www_open","www":"_fd_www_open","wwwroot":"_fd_www_open","web":"_fd_www_open",".yarn":"_fd_yarn_open",".zed":"_fd_light_zed_open"},"fileExtensions":{"accdb":"_f_access","accdt":"_f_access","mdb":"_f_access","accda":"_f_access","accdc":"_f_access","accde":"_f_access","accdp":"_f_access","accdr":"_f_access","accdu":"_f_access","ade":"_f_access","adp":"_f_access","laccdb":"_f_access","ldb":"_f_access","mam":"_f_access","maq":"_f_access","mdw":"_f_access","af":"_f_affinity","aftemplate":"_f_affinity","afdesign":"_f_affinitydesigner","affinitydesigner":"_f_affinitydesigner","afphoto":"_f_affinityphoto","affinityphoto":"_f_affinityphoto","afpub":"_f_affinitypublisher","affinitypublisher":"_f_affinitypublisher","ai":"_f_ai","dal":"_f_al_dal","gs":"_f_appscript","ino":"_f_arduino","pde":"_f_arduino","ase":"_f_aseprite","aseprite":"_f_aseprite","aspx":"_f_aspx","ascx":"_f_aspx","asm":"_f_assembly","s":"_f_assembly","astro":"_f_light_astro","atom":"_f_atom","aac":"_f_audio","act":"_f_audio","aiff":"_f_audio","amr":"_f_audio","ape":"_f_audio","au":"_f_audio","dct":"_f_audio","dss":"_f_audio","dvf":"_f_audio","flac":"_f_audio","gsm":"_f_audio","iklax":"_f_audio","ivs":"_f_audio","m4a":"_f_audio","m4b":"_f_audio","m4p":"_f_audio","mmf":"_f_audio","mogg":"_f_audio","mp3":"_f_audio","mpc":"_f_audio","msv":"_f_audio","oga":"_f_audio","ogg":"_f_audio","opus":"_f_audio","ra":"_f_audio","raw":"_f_audio","tta":"_f_audio","vox":"_f_audio","wav":"_f_audio","wma":"_f_audio","avif":"_f_avif","awk":"_f_awk","back":"_f_bak","backup":"_f_bak","bak":"_f_bak","a":"_f_binary","app":"_f_binary","bin":"_f_binary","cmo":"_f_binary","cmx":"_f_binary","cma":"_f_binary","cmxa":"_f_binary","cmi":"_f_binary","dll":"_f_binary","exe":"_f_binary","hl":"_f_binary","ilk":"_f_binary","lib":"_f_binary","n":"_f_binary","ndll":"_f_binary","o":"_f_binary","obj":"_f_binary","pyc":"_f_binary","pyd":"_f_binary","pyo":"_f_binary","pdb":"_f_binary","scpt":"_f_binary","scptd":"_f_binary","so":"_f_binary","blend":"_f_blender","bb":"_f_blitzbasic","buf.yaml":"_f_buf","buf.yml":"_f_buf","buf.gen.yml":"_f_buf","buf.gen.yaml":"_f_buf","buf.work.yaml":"_f_buf","buf.work.yml":"_f_buf","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","cake":"_f_cake","csr":"_f_cert","crt":"_f_cert","cer":"_f_cert","der":"_f_cert","pfx":"_f_cert","p12":"_f_cert","p7b":"_f_cert","p7r":"_f_cert","src":"_f_cert","crl":"_f_cert","sst":"_f_cert","stl":"_f_cert","lucee":"_f_cf","h":"_f_cheader","class":"_f_class","cjm":"_f_clojure","cljc":"_f_clojure","cljs":"_f_clojurescript","kit":"_f_codekit","plist":"_f_light_config","lcov":"_f_coverage","hpp":"_f_cppheader","hh":"_f_cppheader","hxx":"_f_cppheader","h++":"_f_cppheader","csx":"_f_csharp","csproj":"_f_csproj","css.map":"_f_cssmap","cy.js":"_f_light_cypress_spec","cy.mjs":"_f_light_cypress_spec","cy.cjs":"_f_light_cypress_spec","cy.coffee":"_f_light_cypress_spec","cy.ts":"_f_light_cypress_spec","cy.tsx":"_f_light_cypress_spec","cy.jsx":"_f_light_cypress_spec","g.dart":"_f_dartlang_generated","freezed.dart":"_f_dartlang_generated","db":"_f_light_db","deb":"_f_debian","resolver.json":"_f_design_tokens","resolver.json5":"_f_design_tokens","resolver.jsonc":"_f_design_tokens","tokens.json":"_f_design_tokens","tokens.json5":"_f_design_tokens","tokens.jsonc":"_f_design_tokens","djt":"_f_django","eco":"_f_light_docpad","drawio":"_f_drawio","dio":"_f_drawio","drawio.png":"_f_drawio","drawio.svg":"_f_drawio","dio.png":"_f_drawio","dio.svg":"_f_drawio","dtd":"_f_dtd","eex":"_f_eex","heex":"_f_eex","phoenix-heex":"_f_eex","html-heex":"_f_eex","ejs":"_f_ejs","el":"_f_emacs","elc":"_f_emacs","ensime":"_f_ensime","eps":"_f_eps","epub":"_f_epub","excalidraw":"_f_excalidraw","excalidraw.json":"_f_excalidraw","excalidraw.svg":"_f_excalidraw","excalidraw.png":"_f_excalidraw","xls":"_f_excel","xlsx":"_f_excel","xlsm":"_f_excel","ods":"_f_libreoffice_calc","fods":"_f_libreoffice_calc","xlsb":"_f_excel","fbx":"_f_fbx","fba":"_f_fitbit","fla":"_f_light_fla","swf":"_f_flash","swc":"_f_flash","js.flow":"_f_flow","woff":"_f_light_font","woff2":"_f_light_font","ttf":"_f_light_font","otf":"_f_libreoffice_math","eot":"_f_light_font","pfa":"_f_light_font","pfb":"_f_light_font","sfd":"_f_light_font","fsproj":"_f_fsproj","gmx":"_f_gamemaker","yy":"_f_light_gamemaker2","yyp":"_f_light_gamemaker2","gd.uid":"_f_gduid","geojson":"_f_geojson","xcf":"_f_gimp","gleam":"_f_gleam","glb":"_f_gltf","gltf":"_f_gltf","am":"_f_gnu","ld":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","godot":"_f_godot","gpg":"_f_gpg","pgp":"_f_gpg","gradle":"_f_light_gradle","gr":"_f_grain","hcl2":"_f_light_hashicorp","sentinel":"_f_light_hashicorp","hxproj":"_f_haxedevelop","ejs.t":"_f_hygen","idr":"_f_idris","lidr":"_f_idris","ibc":"_f_idrisbin","ipkg":"_f_idrispkg","jpeg":"_f_image","jpg":"_f_image","gif":"_f_image","png":"_f_image","bmp":"_f_image","tiff":"_f_image","heic":"_f_image","ico":"_f_image","icns":"_f_image","webp":"_f_image","imba":"_f_imba","imba2":"_f_imba","inc":"_f_inc","include":"_f_inc","infopathxml":"_f_infopath","xsn":"_f_infopath","xsf":"_f_infopath","xtp2":"_f_infopath","jar":"_f_jar","jbuilder":"_f_jbuilder","iml":"_f_jetbrains","js.map":"_f_light_jsmap","cjs.map":"_f_light_jsmap","mjs.map":"_f_light_jsmap","jsonl":"_f_light_json","ndjson":"_f_light_json","json5":"_f_light_json5","schema.json":"_f_light_json_schema","jsonld":"_f_light_jsonld","json-ld":"_f_light_jsonld","jsp":"_f_jsp","jss":"_f_jss","juice":"_f_juice","ipynb":"_f_jupyter","key":"_f_key","pem":"_f_key","ktm":"_f_kotlin","language-configuration.json":"_f_language_configuration","master":"_f_layout","layout.html":"_f_layout","layout.htm":"_f_layout","odb":"_f_libreoffice_base","otb":"_f_libreoffice_base","ots":"_f_libreoffice_calc","odg":"_f_libreoffice_draw","otg":"_f_libreoffice_draw","odp":"_f_libreoffice_impress","otp":"_f_libreoffice_impress","odf":"_f_libreoffice_math","odt":"_f_libreoffice_writer","ott":"_f_libreoffice_writer","enc":"_f_license","license":"_f_license","lic":"_f_license","hxp":"_f_lime","liquid":"_f_liquid","ls":"_f_livescript","lnk":"_f_lnk","log":"_f_log","tlg":"_f_log","luau":"_f_luau","crec":"_f_lync","ocrec":"_f_lync","map":"_f_map","mdown":"_f_markdown","markdown":"_f_markdown","marko.js":"_f_markojs","fig":"_f_matlab","mex":"_f_matlab","mexn":"_f_matlab","mexrs6":"_f_matlab","mn":"_f_matlab","mum":"_f_matlab","mx":"_f_matlab","mx3":"_f_matlab","rwd":"_f_matlab","slx":"_f_matlab","slddc":"_f_matlab","smv":"_f_matlab","xvc":"_f_matlab","mca":"_f_minecraft","mcmeta":"_f_minecraft","mojo":"_f_mojo","🔥":"_f_mojo","mql.yaml":"_f_mondoo","mql.yml":"_f_mondoo","mustache":"_f_light_mustache","mst":"_f_light_mustache","ndst.yaml":"_f_ndst","ndst.yml":"_f_ndst","ndst.json":"_f_ndst","njsproj":"_f_njsproj","noc":"_f_noc","nupkg":"_f_nuget","snupkg":"_f_nuget","nuspec":"_f_nuget","psmdcp":"_f_nuget","npy":"_f_numpy","npz":"_f_numpy","nunj":"_f_nunjucks","njs":"_f_nunjucks","mli":"_f_ocaml_intf","one":"_f_onenote","onepkg":"_f_onenote","onetoc":"_f_onenote","onetoc2":"_f_onenote","sig":"_f_onenote","opam":"_f_opam","cl":"_f_opencl","opencl":"_f_opencl","tofu":"_f_opentofu","tofu.json":"_f_opentofu","tofutest.hcl":"_f_opentofu","tofutest.json":"_f_opentofu","org":"_f_org","pst":"_f_outlook","bcmx":"_f_outlook","otm":"_f_outlook","msg":"_f_outlook","oft":"_f_outlook","ovpn":"_f_ovpn","pkg":"_f_package","paket.references":"_f_paket","paket.template":"_f_paket","patch":"_f_patch","pcd":"_f_light_pcl","pdf":"_f_pdf","psd":"_f_photoshop","php1":"_f_php","php2":"_f_php","php3":"_f_php","php4":"_f_php","php5":"_f_php","php6":"_f_php","phps":"_f_php","phpsa":"_f_php","phpt":"_f_php","phtml":"_f_php","phar":"_f_php","pipeline":"_f_pipeline","pu":"_f_plantuml","plantuml":"_f_plantuml","iuml":"_f_plantuml","puml":"_f_plantuml","pck":"_f_plsql_package","pkb":"_f_plsql_package_body","pkh":"_f_plsql_package_header","pks":"_f_plsql_package_spec","po":"_f_poedit","mo":"_f_poedit","postman_collection.json":"_f_postman","postman_environment.json":"_f_postman","postman_globals.json":"_f_postman","pot":"_f_powerpoint","potx":"_f_powerpoint","potm":"_f_powerpoint","pps":"_f_powerpoint","ppsx":"_f_powerpoint","ppsm":"_f_powerpoint","ppt":"_f_powerpoint","pptx":"_f_powerpoint","pptm":"_f_powerpoint","pa":"_f_powerpoint","ppa":"_f_powerpoint","ppam":"_f_powerpoint","sldm":"_f_powerpoint","sldx":"_f_powerpoint","format.ps1xml":"_f_powershell_format","psd1":"_f_powershell_psd","psm1":"_f_powershell_psm","types.ps1xml":"_f_powershell_types","pro":"_f_prolog","pub":"_f_publisher","puz":"_f_publisher","pyowo":"_f_pythowo","q":"_f_q","qbs":"_f_qbs","qvd":"_f_qlikview","qvw":"_f_qlikview","rast":"_f_ra_syntax_tree","rake":"_f_rake","rt":"_f_reacttemplate","reg":"_f_registry","rego":"_f_rego","ripple":"_f_ripple","rnc":"_f_rnc","ron":"_f_ron","rproj":"_f_rproj","rss":"_f_rss","rbs":"_f_ruby","safetensors":"_f_light_safetensors","sls":"_f_saltstack","sass":"_f_sass","scssm":"_f_scss","unity":"_f_light_shaderlab","fish":"_f_shell","sy":"_f_siyuan","sketch":"_f_sketch","eskip":"_f_skipper","sln":"_f_sln","slnx":"_f_sln","slnf":"_f_sln","spe":"_f_spacengine","sqlite":"_f_sqlite","sqlite3":"_f_sqlite","db3":"_f_sqlite","sss":"_f_sss","dta":"_f_stata","storyboard":"_f_storyboard","story.js":"_f_storybook","story.jsx":"_f_storybook","story.ts":"_f_storybook","story.tsx":"_f_storybook","story.mdx":"_f_storybook","story.svelte":"_f_storybook","stories.js":"_f_storybook","stories.jsx":"_f_storybook","stories.ts":"_f_storybook","stories.tsx":"_f_storybook","stories.mdx":"_f_storybook","stories.svelte":"_f_storybook","sublime-project":"_f_sublime","svg":"_f_svg","tcl":"_f_tcl","exp":"_f_tcl","templ":"_f_templ","tfstate":"_f_terraform","tfvars":"_f_terraform","tf":"_f_terraform","tf.json":"_f_terraform","tst":"_f_test","test.js":"_f_light_testjs","test.jsx":"_f_light_testjs","test.mjs":"_f_light_testjs","spec.js":"_f_light_testjs","spec.jsx":"_f_light_testjs","spec.mjs":"_f_light_testjs","testplane.ts":"_f_testplane","testplane.js":"_f_testplane","testplane.cts":"_f_testplane","testplane.cjs":"_f_testplane","testplane.tsx":"_f_testplane","testplane.jsx":"_f_testplane","test.ts":"_f_testts","test.tsx":"_f_testts","test.mts":"_f_testts","spec.ts":"_f_testts","spec.tsx":"_f_testts","spec.mts":"_f_testts","e2e-test.ts":"_f_testts","e2e-test.tsx":"_f_testts","e2e-test.mts":"_f_testts","e2e-spec.ts":"_f_testts","e2e-spec.tsx":"_f_testts","e2e-spec.mts":"_f_testts","test-d.ts":"_f_testts","test-d.tsx":"_f_testts","test-d.mts":"_f_testts","texi":"_f_light_tex","tikz":"_f_light_tex","csv":"_f_text","tsv":"_f_text","tm":"_f_light_tm","tm.hcl":"_f_light_tm","todo":"_f_light_todo","tree":"_f_light_tree","tres":"_f_tres","tsbuildinfo":"_f_tsbuildinfo","tscn":"_f_tscn","tt2":"_f_tt","d.ts":"_f_typescriptdef","d.cts":"_f_typescriptdef","d.mts":"_f_typescriptdef","ua":"_f_uiua","u":"_f_unison","vala":"_f_vala","css.ts":"_f_vanilla_extract","vapi":"_f_vapi","vash":"_f_light_vash","vbhtml":"_f_vbhtml","vbproj":"_f_vbproj","vcxproj":"_f_vcxproj","vto":"_f_vento","vento":"_f_vento","3g2":"_f_video","3gp":"_f_video","asf":"_f_video","amv":"_f_video","avi":"_f_video","divx":"_f_video","qt":"_f_video","f4a":"_f_video","f4b":"_f_video","f4p":"_f_video","f4v":"_f_video","flv":"_f_video","m2v":"_f_video","m4v":"_f_video","mkv":"_f_video","mk3d":"_f_video","mov":"_f_video","mp2":"_f_video","mp4":"_f_video","mpe":"_f_video","mpeg":"_f_video","mpeg2":"_f_video","mpg":"_f_video","mpv":"_f_video","nsv":"_f_video","ogv":"_f_video","rm":"_f_video","rmvb":"_f_video","svi":"_f_video","vob":"_f_video","webm":"_f_video","wmv":"_f_video","code-snippets":"_f_vscode","code-workspace":"_f_vscode","vsix":"_f_light_vsix","vsixmanifest":"_f_light_vsixmanifest","vvvvvv":"_f_vvvvvv","vyi":"_f_vyper","wasm":"_f_wasm","wikitext":"_f_wikitext","wit":"_f_wit","doc":"_f_word","docx":"_f_word","docm":"_f_word","dot":"_f_word","dotx":"_f_word","dotm":"_f_word","wll":"_f_word","wxml":"_f_wxml","wxss":"_f_wxss","xaml":"_f_xaml","axaml":"_f_xaml","xcodeproj":"_f_xcode","xfl":"_f_light_xfl","xib":"_f_xib","xliff":"_f_xliff","xlf":"_f_xliff","pex":"_f_xml","tmlanguage":"_f_xml","zip":"_f_zip","rar":"_f_zip","7z":"_f_zip","tar":"_f_zip","tgz":"_f_zip","bz":"_f_zip","gz":"_f_zip","bzip2":"_f_zip","xz":"_f_zip","bz2":"_f_zip","whl":"_f_zip","zipx":"_f_zip","br":"_f_zip"},"fileNames":{"agents.md":"_f_light_agents","alchemy.run.ts":"_f_alchemy",".all-contributorsrc":"_f_allcontributors","allurerc.js":"_f_allure","allurerc.mjs":"_f_allure","allurerc.cjs":"_f_allure","allurerc.json":"_f_allure","allurerc.yaml":"_f_allure","allurerc.yml":"_f_allure",".angular-cli.json":"_f_angular","angular-cli.json":"_f_angular","angular.json":"_f_angular",".angular.json":"_f_angular","antlers.html":"_f_antlers_html","api-extractor.json":"_f_api_extractor","api-extractor-base.json":"_f_api_extractor",".appsemblerc.yaml":"_f_appsemble","app-definition.yaml":"_f_appsemble","appveyor.yml":"_f_appveyor",".appveyor.yml":"_f_appveyor","asconfig.json":"_f_assembly_script","astro.config.js":"_f_light_astroconfig","astro.config.cjs":"_f_light_astroconfig","astro.config.mjs":"_f_light_astroconfig","astro.config.ts":"_f_light_astroconfig","astro.config.cts":"_f_light_astroconfig","astro.config.mts":"_f_light_astroconfig",".attw.json":"_f_attw","aurelia.json":"_f_aurelia","azure-pipelines.yml":"_f_azurepipelines",".vsts-ci.yml":"_f_azurepipelines",".babelrc":"_f_light_babel",".babelignore":"_f_light_babel",".babelrc.js":"_f_light_babel",".babelrc.cjs":"_f_light_babel",".babelrc.mjs":"_f_light_babel",".babelrc.json":"_f_light_babel","babel.config.js":"_f_light_babel","babel.config.cjs":"_f_light_babel","babel.config.mjs":"_f_light_babel","babel.config.json":"_f_light_babel",".bzrignore":"_f_bazaar","BUILD.bazel":"_f_bazel",".bazelrc":"_f_bazel","bazel.rc":"_f_bazel","bazel.bazelrc":"_f_bazel",".bazelignore":"_f_bazel_ignore",".bazelversion":"_f_bazel_version","biome.json":"_f_biome","biome.jsonc":"_f_biome","bitbucket-pipelines.yml":"_f_bitbucketpipeline",".bithoundrc":"_f_bithound",".bowerrc":"_f_bower","bower.json":"_f_bower",".browserslistrc":"_f_browserslist","browserslist":"_f_browserslist",".buckconfig":"_f_buckbuild",".bunfig.toml":"_f_bun","bunfig.toml":"_f_bunfig","bun.lockb":"_f_bun","bun.lock":"_f_bun",".bundlemonrc":"_f_bundlemon","bundlemon.config.js":"_f_bundlemon","bundlemon.config.mjs":"_f_bundlemon","bundlemon.config.cjs":"_f_bundlemon",".bundlemonrc.js":"_f_bundlemon",".bundlemonrc.mjs":"_f_bundlemon",".bundlemonrc.cjs":"_f_bundlemon",".bundlemonrc.json":"_f_bundlemon",".bundlemonrc.yaml":"_f_bundlemon",".bundlemonrc.yml":"_f_bundlemon","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","capacitor.config.json":"_f_capacitor","capacitor.config.ts":"_f_capacitor","Cargo.toml":"_f_cargo","Cargo.lock":"_f_cargo",".changie.yaml":"_f_changie",".changie.yml":"_f_changie","chefignore":"_f_chef","berksfile":"_f_chef","berksfile.lock":"_f_chef","policyfile.rb":"_f_chef","policyfile.lock.json":"_f_chef","circle.yml":"_f_light_circleci","claude.md":"_f_claude","wrangler.toml":"_f_cloudflare",".cfignore":"_f_light_cloudfoundry",".codacy.yml":"_f_light_codacy",".codacy.yaml":"_f_light_codacy",".codeclimate.yml":"_f_light_codeclimate","codecov.yml":"_f_codecov",".codecov.yml":"_f_codecov","config.codekit":"_f_codekit","config.codekit2":"_f_codekit","config.codekit3":"_f_codekit",".config.codekit":"_f_codekit",".config.codekit2":"_f_codekit",".config.codekit3":"_f_codekit","codemagic.yaml":"_f_codemagic","codeowners":"_f_light_codeowners",".coderabbit.yaml":"_f_coderabbit","coffeelint.json":"_f_coffeelint",".coffeelintignore":"_f_coffeelint",".czrc":"_f_commitizen",".cz.json":"_f_commitizen",".commitlintrc":"_f_commitlint","commitlint.config.js":"_f_commitlint","commitlint.config.cjs":"_f_commitlint","commitlint.config.mjs":"_f_commitlint","commitlint.config.ts":"_f_commitlint","commitlint.config.cts":"_f_commitlint",".commitlintrc.json":"_f_commitlint",".commitlintrc.yaml":"_f_commitlint",".commitlintrc.yml":"_f_commitlint",".commitlintrc.js":"_f_commitlint",".commitlintrc.cjs":"_f_commitlint",".commitlintrc.mjs":"_f_commitlint",".commitlintrc.ts":"_f_commitlint",".commitlintrc.cts":"_f_commitlint","composer.json":"_f_composer","composer.lock":"_f_composer","conanfile.txt":"_f_conan","conanfile.py":"_f_conan",".condarc":"_f_conda",".tool-versions":"_f_light_config","copilot-instructions.md":"_f_light_copilot","github-copilot.xml":"_f_light_copilot",".coverage":"_f_coverage","lcov.info":"_f_coverage",".coveralls.yml":"_f_coveralls","craco.config.ts":"_f_craco","craco.config.js":"_f_craco","craco.config.cjs":"_f_craco",".cracorc.ts":"_f_craco",".cracorc.js":"_f_craco",".cracorc":"_f_craco","crowdin.yml":"_f_crowdin","cspell.config.json":"_f_cspell","cspell.config.js":"_f_cspell","cspell.config.mjs":"_f_cspell","cspell.config.cjs":"_f_cspell","cspell.config.yaml":"_f_cspell","cspell.config.yml":"_f_cspell","cspell.json":"_f_cspell","cspell.js":"_f_cspell","cspell.mjs":"_f_cspell","cspell.cjs":"_f_cspell","cspell.yaml":"_f_cspell","cspell.yml":"_f_cspell",".cspell.json":"_f_cspell",".cspell.js":"_f_cspell",".cspell.mjs":"_f_cspell",".cspell.cjs":"_f_cspell",".cspell.yaml":"_f_cspell",".cspell.yml":"_f_cspell",".csscomb.json":"_f_csscomb",".csslintrc":"_f_csslint",".cursorignore":"_f_cursorrules",".cursorindexingignore":"_f_cursorrules",".cursorrules":"_f_cursorrules",".cvsignore":"_f_cvs","cypress.json":"_f_light_cypress","cypress.env.json":"_f_light_cypress","cypress.config.js":"_f_light_cypress","cypress.config.ts":"_f_light_cypress","cypress.config.cjs":"_f_light_cypress","cypress.config.mjs":"_f_light_cypress",".boringignore":"_f_darcs",".pubignore":"_f_dartlang_ignore","service.datadog.yaml":"_f_datadog","datadog-ci.json":"_f_datadog","static-analysis.datadog.yml":"_f_datadog","static-analysis.datadog.yaml":"_f_datadog","aptfile":"_f_debian","deno.json":"_f_deno","deno.jsonc":"_f_deno","deno.lock":"_f_deno",".denoifyrc":"_f_denoify","denoify.config.json":"_f_denoify","denoify.config.js":"_f_denoify","dependabot.yml":"_f_dependabot","dependabot.yaml":"_f_dependabot","dependencies.yml":"_f_dependencies","devcontainer.json":"_f_devcontainer",".devcontainer.json":"_f_devcontainer","devvit.json":"_f_devvit","devvit.dev.json":"_f_devvit","devvit.prod.json":"_f_devvit","devvit.test.json":"_f_devvit","devvit.config.js":"_f_devvit","devvit.config.cjs":"_f_devvit","devvit.config.mjs":"_f_devvit","devvit.config.ts":"_f_devvit","devvit.config.mts":"_f_devvit","devvit.config.dev.js":"_f_devvit","devvit.config.dev.cjs":"_f_devvit","devvit.config.dev.mjs":"_f_devvit","devvit.config.dev.ts":"_f_devvit","devvit.config.dev.mts":"_f_devvit","devvit.config.prod.js":"_f_devvit","devvit.config.prod.cjs":"_f_devvit","devvit.config.prod.mjs":"_f_devvit","devvit.config.prod.ts":"_f_devvit","devvit.config.prod.mts":"_f_devvit","devvit.config.test.js":"_f_devvit","devvit.config.test.cjs":"_f_devvit","devvit.config.test.mjs":"_f_devvit","devvit.config.test.ts":"_f_devvit","devvit.config.test.mts":"_f_devvit",".dockerignore":"_f_docker","compose.yaml":"_f_docker","compose.yml":"_f_docker","docker-compose.yaml":"_f_docker","docker-compose.yml":"_f_docker","docker-compose.debug.yaml":"_f_docker","docker-compose.debug.yml":"_f_docker","docker-compose.ci-build.yaml":"_f_docker","docker-compose.ci-build.yml":"_f_docker","docker-compose.override.yaml":"_f_docker","docker-compose.override.yml":"_f_docker","docker-compose.vs.debug.yaml":"_f_docker","docker-compose.vs.debug.yml":"_f_docker","docker-compose.vs.release.yaml":"_f_docker","docker-compose.vs.release.yml":"_f_docker","docker-cloud.yaml":"_f_docker","docker-cloud.yml":"_f_docker","docker-compose.test.yml":"_f_dockertest","docusaurus.config.ts":"_f_docusaurus","docusaurus.config.mts":"_f_docusaurus","docusaurus.config.cts":"_f_docusaurus","docusaurus.config.js":"_f_docusaurus","docusaurus.config.mjs":"_f_docusaurus","docusaurus.config.cjs":"_f_docusaurus",".doczrc":"_f_docz","docz.js":"_f_docz","docz.json":"_f_docz",".docz.js":"_f_docz",".docz.json":"_f_docz","doczrc.js":"_f_docz","doczrc.json":"_f_docz","docz.config.js":"_f_docz","docz.config.json":"_f_docz",".dojorc":"_f_dojo","doppler.yaml":"_f_doppler","doppler-template.yaml":"_f_doppler",".drone.yml":"_f_light_drone",".drone.yml.sig":"_f_light_drone","dune":"_f_dune","dune-project":"_f_dune","dune-workspace":"_f_dune",".dvc":"_f_dvc",".earthlyignore":"_f_earthly","Earthfile":"_f_earthly","store.config.json":"_f_light_eas-metadata",".editorconfig":"_f_editorconfig","forge.config.js":"_f_electron","forge.config.ts":"_f_electron",".eleventy.js":"_f_light_eleventy","eleventy.config.js":"_f_light_eleventy","eleventy.config.cjs":"_f_light_eleventy","eleventy.config.mjs":"_f_light_eleventy","elm-package.json":"_f_elm",".ember-cli":"_f_ember","emakefile":"_f_erlang",".emakerfile":"_f_erlang","esbuild.js":"_f_esbuild","esbuild.mjs":"_f_esbuild","esbuild.cjs":"_f_esbuild","esbuild.ts":"_f_esbuild","esbuild.config.js":"_f_esbuild","esbuild.config.mjs":"_f_esbuild","esbuild.config.cjs":"_f_esbuild","esbuild.config.ts":"_f_esbuild",".eslintrc":"_f_eslint",".eslintignore":"_f_eslint",".eslintcache":"_f_eslint","eslint.config.js":"_f_eslint","eslint.config.mjs":"_f_eslint","eslint.config.cjs":"_f_eslint","eslint.config.ts":"_f_eslint","eslint.config.mts":"_f_eslint","eslint.config.cts":"_f_eslint",".eslintrc.js":"_f_eslint",".eslintrc.mjs":"_f_eslint",".eslintrc.cjs":"_f_eslint",".eslintrc.ts":"_f_eslint",".eslintrc.mts":"_f_eslint",".eslintrc.cts":"_f_eslint",".eslintrc.json":"_f_eslint",".eslintrc.yaml":"_f_eslint",".eslintrc.yml":"_f_eslint","app.json":"_f_light_expo","app.config.js":"_f_light_expo","app.config.json":"_f_light_expo","app.config.json5":"_f_light_expo",".fantasticonrc":"_f_fantasticon","fantasticonrc":"_f_fantasticon",".fantasticonrc.json":"_f_fantasticon","fantasticonrc.json":"_f_fantasticon",".fantasticonrc.js":"_f_fantasticon","fantasticonrc.js":"_f_fantasticon","fastly.toml":"_f_fastly",".faunarc":"_f_fauna","favicon.ico":"_f_favicon",".firebaserc":"_f_firebase","firebase.json":"_f_light_firebasehosting","storage.rules":"_f_firebasestorage","firestore.rules":"_f_firestore","firestore.indexes.json":"_f_firestore","flareact.config.js":"_f_flareact",".flooignore":"_f_floobits",".flowconfig":"_f_flow",".flutter-plugins":"_f_flutter",".metadata":"_f_flutter",".flutter-plugins-dependencies":"_f_flutter","pubspec.lock":"_f_flutter_package","pubspec.yaml":"_f_flutter_package",".packages":"_f_flutter_package","fly.toml":"_f_flyio","formkit.config.js":"_f_formkit","formkit.config.mjs":"_f_formkit","formkit.config.cjs":"_f_formkit","formkit.config.ts":"_f_formkit",".fossaignore":"_f_fossa","ignore-glob":"_f_fossil",".front-commerce.js":"_f_frontcommerce","front-commerce.config.ts":"_f_frontcommerce","funding.yml":"_f_funding","fuse.js":"_f_fusebox","gatsby-browser.js":"_f_gatsby","gatsby-browser.ts":"_f_gatsby","gatsby-browser.tsx":"_f_gatsby","gatsby-ssr.js":"_f_gatsby","gatsby-ssr.ts":"_f_gatsby","gatsby-ssr.tsx":"_f_gatsby","gatsby-config.js":"_f_gatsby","gatsby-config.ts":"_f_gatsby","gatsby-config.mjs":"_f_gatsby","gatsby-node.js":"_f_gatsby","gatsby-node.ts":"_f_gatsby","gatsby-node.mjs":"_f_gatsby",".gcloudignore":"_f_gcloud","gemini.md":"_f_gemini","GEMINI.md":"_f_gemini",".git":"_f_git",".gitattributes":"_f_git",".gitconfig":"_f_git",".gitignore":"_f_git",".gitmodules":"_f_git",".gitkeep":"_f_git",".git-blame-ignore-revs":"_f_git",".mailmap":"_f_git",".issuetracker":"_f_git",".gitlab-ci.yml":"_f_gitlab",".gitpod.yaml":"_f_gitpod",".gitpod.yml":"_f_gitpod","gitpod.yaml":"_f_gitpod","gitpod.yml":"_f_gitpod","gleam.toml":"_f_gleamconfig","glide.yml":"_f_glide",".glitterrc":"_f_glitter","go.sum":"_f_go_package","go.mod":"_f_go_package","go.work":"_f_go_work","go.work.sum":"_f_go_work",".gqlconfig":"_f_graphql",".graphqlconfig":"_f_graphql_config",".graphqlconfig.yml":"_f_graphql_config",".graphqlconfig.yaml":"_f_graphql_config","greenkeeper.json":"_f_greenkeeper","gridsome.config.js":"_f_gridsome","gridsome.config.ts":"_f_gridsome","gridsome.server.js":"_f_gridsome","gridsome.server.ts":"_f_gridsome","gridsome.client.js":"_f_gridsome","gridsome.client.ts":"_f_gridsome","gruntfile.js":"_f_grunt","gruntfile.cjs":"_f_grunt","gruntfile.coffee":"_f_grunt","gruntfile.ts":"_f_grunt","gruntfile.babel.js":"_f_grunt","gruntfile.babel.cjs":"_f_grunt","gruntfile.babel.coffee":"_f_grunt","gruntfile.babel.ts":"_f_grunt","gulpfile.js":"_f_gulp","gulpfile.coffee":"_f_gulp","gulpfile.ts":"_f_gulp","gulpfile.mjs":"_f_gulp","gulpfile.esm.js":"_f_gulp","gulpfile.esm.coffee":"_f_gulp","gulpfile.esm.ts":"_f_gulp","gulpfile.esm.mjs":"_f_gulp","gulpfile.babel.js":"_f_gulp","gulpfile.babel.coffee":"_f_gulp","gulpfile.babel.ts":"_f_gulp","gulpfile.babel.mjs":"_f_gulp","hardhat.config.cjs":"_f_hardhat","hardhat.config.js":"_f_hardhat","hardhat.config.ts":"_f_hardhat","haxelib.json":"_f_haxe","checkstyle.json":"_f_haxecheckstyle",".p4ignore":"_f_helix","chart.lock":"_f_helm","chart.yaml":"_f_helm","histoire.config.ts":"_f_histoire","histoire.config.js":"_f_histoire",".histoire.ts":"_f_histoire",".histoire.js":"_f_histoire","horusec-config.json":"_f_horusec",".htmlhintrc":"_f_htmlhint",".htmlvalidateignore":"_f_htmlvalidate",".htmlvalidate.js":"_f_htmlvalidate",".htmlvalidate.cjs":"_f_htmlvalidate",".htmlvalidate.json":"_f_htmlvalidate","hugo.json":"_f_hugo","hugo.toml":"_f_hugo","hugo.yaml":"_f_hugo",".hugo_build.lock":"_f_hugo","humans.txt":"_f_humanstxt",".huskyrc":"_f_husky","husky.config.js":"_f_husky",".huskyrc.js":"_f_husky",".huskyrc.json":"_f_husky",".huskyrc.yaml":"_f_husky",".huskyrc.yml":"_f_husky","ionic.project":"_f_ionic","ionic.config.json":"_f_ionic","jakefile":"_f_jake","jakefile.js":"_f_jake","jasmine.json":"_f_jasmine",".nojekyll":"_f_jekyll","jest.json":"_f_jest","jest.config.babel.js":"_f_jest",".jestrc":"_f_jest",".jestrc.js":"_f_jest",".jestrc.json":"_f_jest","jest.config.js":"_f_jest","jest.config.cjs":"_f_jest","jest.config.mjs":"_f_jest","jest.config.json":"_f_jest","jest.config.ts":"_f_jest","jest.config.base.js":"_f_jest","jest.config.base.cjs":"_f_jest","jest.config.base.mjs":"_f_jest","jest.config.base.json":"_f_jest","jest.config.base.ts":"_f_jest","jest.config.common.js":"_f_jest","jest.config.common.cjs":"_f_jest","jest.config.common.mjs":"_f_jest","jest.config.common.json":"_f_jest","jest.config.common.ts":"_f_jest","jest.config.e2e.js":"_f_jest","jest.config.e2e.cjs":"_f_jest","jest.config.e2e.mjs":"_f_jest","jest.config.e2e.json":"_f_jest","jest.config.e2e.ts":"_f_jest","jest.config.int.js":"_f_jest","jest.config.int.cjs":"_f_jest","jest.config.int.mjs":"_f_jest","jest.config.int.json":"_f_jest","jest.config.int.ts":"_f_jest","jest.config.integration.js":"_f_jest","jest.config.integration.cjs":"_f_jest","jest.config.integration.mjs":"_f_jest","jest.config.integration.json":"_f_jest","jest.config.integration.ts":"_f_jest","jest.config.unit.js":"_f_jest","jest.config.unit.cjs":"_f_jest","jest.config.unit.mjs":"_f_jest","jest.config.unit.json":"_f_jest","jest.config.unit.ts":"_f_jest",".jpmignore":"_f_jpm",".jsbeautifyrc":"_f_jsbeautify","jsbeautifyrc":"_f_jsbeautify",".jsbeautify":"_f_jsbeautify","jsbeautify":"_f_jsbeautify","jsconfig.json":"_f_light_jsconfig",".jscpd.json":"_f_jscpd","jscpd-report.xml":"_f_jscpd","jscpd-report.json":"_f_jscpd","jscpd-report.html":"_f_jscpd",".jshintrc":"_f_jshint",".jshintignore":"_f_jshint","jsr.json":"_f_jsr","karma.conf.js":"_f_karma","karma.conf.coffee":"_f_karma","karma.conf.ts":"_f_karma",".kitchen.yml":"_f_kitchenci","kitchen.yml":"_f_kitchenci",".kiteignore":"_f_light_kite","knip.json":"_f_knip","knip.jsonc":"_f_knip",".knip.json":"_f_knip",".knip.jsonc":"_f_knip","knip.ts":"_f_knip","knip.js":"_f_knip","knip.config.ts":"_f_knip","knip.config.js":"_f_knip","language-configuration.json":"_f_language_configuration","layout.html":"_f_layout","layout.htm":"_f_layout","lakefile.lean":"_f_light_leanconfig","lakefile.toml":"_f_light_leanconfig","lake-manifest.json":"_f_light_leanconfig","lean-toolchain":"_f_light_leanconfig","lefthook.json":"_f_lefthook","lefthook.toml":"_f_lefthook","lefthook.yaml":"_f_lefthook","lefthook.yml":"_f_lefthook","lefthook-local.json":"_f_lefthook","lefthook-local.toml":"_f_lefthook","lefthook-local.yaml":"_f_lefthook","lefthook-local.yml":"_f_lefthook",".lefthook.json":"_f_lefthook",".lefthook.toml":"_f_lefthook",".lefthook.yaml":"_f_lefthook",".lefthook.yml":"_f_lefthook",".lefthook-local.json":"_f_lefthook",".lefthook-local.toml":"_f_lefthook",".lefthook-local.yaml":"_f_lefthook",".lefthook-local.yml":"_f_lefthook","lerna.json":"_f_light_lerna","liara.json":"_f_liara",".liaraignore":"_f_liara","licence":"_f_license","LICENCE":"_f_license","license":"_f_license","LICENSE":"_f_license","copying":"_f_license","copying.lesser":"_f_license","LICENSE-MIT":"_f_license","license-mit":"_f_license","LICENSE-APACHE":"_f_license","license-apache":"_f_license","LICENSE-GPL":"_f_license","license-gpl":"_f_license","LICENSE-AGPL":"_f_license","license-agpl":"_f_license","LICENCE.md":"_f_license","LICENCE.txt":"_f_license","licence.md":"_f_license","licence.txt":"_f_license","LICENSE.md":"_f_license","LICENSE.txt":"_f_license","license.md":"_f_license","license.txt":"_f_license","copying.md":"_f_license","copying.txt":"_f_license","copying.lesser.md":"_f_license","copying.lesser.txt":"_f_license","LICENSE-MIT.md":"_f_license","LICENSE-MIT.txt":"_f_license","license-mit.md":"_f_license","license-mit.txt":"_f_license","LICENSE-APACHE.md":"_f_license","LICENSE-APACHE.txt":"_f_license","license-apache.md":"_f_license","license-apache.txt":"_f_license","LICENSE-GPL.md":"_f_license","LICENSE-GPL.txt":"_f_license","license-gpl.md":"_f_license","license-gpl.txt":"_f_license","LICENSE-AGPL.md":"_f_license","LICENSE-AGPL.txt":"_f_license","license-agpl.md":"_f_license","license-agpl.txt":"_f_license",".licrc":"_f_licensebat","budget.json":"_f_lighthouse","budgets.json":"_f_lighthouse",".lighthouserc.cjs":"_f_lighthouse",".lighthouserc.js":"_f_lighthouse",".lighthouserc.json":"_f_lighthouse",".lighthouserc.yaml":"_f_lighthouse",".lighthouserc.yml":"_f_lighthouse","include.xml":"_f_lime",".lintstagedrc":"_f_lintstagedrc",".lintstagedrc.json":"_f_lintstagedrc",".lintstagedrc.yaml":"_f_lintstagedrc",".lintstagedrc.yml":"_f_lintstagedrc",".lintstagedrc.mjs":"_f_lintstagedrc",".lintstagedrc.mts":"_f_lintstagedrc",".lintstagedrc.js":"_f_lintstagedrc",".lintstagedrc.ts":"_f_lintstagedrc",".lintstagedrc.cjs":"_f_lintstagedrc",".lintstagedrc.cts":"_f_lintstagedrc","lint-staged.config.mjs":"_f_lintstagedrc","lint-staged.config.mts":"_f_lintstagedrc","lint-staged.config.js":"_f_lintstagedrc","lint-staged.config.ts":"_f_lintstagedrc","lint-staged.config.cjs":"_f_lintstagedrc","lint-staged.config.cts":"_f_lintstagedrc","mailing.config.json":"_f_light_mailing","manifest":"_f_manifest","manifest.bak":"_f_manifest_bak","manifest.skip":"_f_manifest_skip",".markdownlintrc":"_f_markdownlint",".markdownlint.json":"_f_markdownlint",".markdownlint.jsonc":"_f_markdownlint",".markdownlint.yml":"_f_markdownlint",".markdownlint.yaml":"_f_markdownlint",".markdownlint.cjs":"_f_markdownlint",".markdownlint-cli2.json":"_f_markdownlint",".markdownlint-cli2.jsonc":"_f_markdownlint",".markdownlint-cli2.yml":"_f_markdownlint",".markdownlint-cli2.yaml":"_f_markdownlint",".markdownlint-cli2.cjs":"_f_markdownlint",".markdownlintignore":"_f_markdownlint_ignore",".markuplintrc":"_f_light_markuplint",".markuplintrc.json":"_f_light_markuplint",".markuplintrc.yaml":"_f_light_markuplint",".markuplintrc.yml":"_f_light_markuplint",".markuplintrc.js":"_f_light_markuplint",".markuplintrc.cjs":"_f_light_markuplint",".markuplintrc.mjs":"_f_light_markuplint",".markuplintrc.ts":"_f_light_markuplint","markuplint.config.json":"_f_light_markuplint","markuplint.config.yaml":"_f_light_markuplint","markuplint.config.yml":"_f_light_markuplint","markuplint.config.js":"_f_light_markuplint","markuplint.config.cjs":"_f_light_markuplint","markuplint.config.mjs":"_f_light_markuplint","markuplint.config.ts":"_f_light_markuplint","master.js":"_f_master-co","master.ts":"_f_master-co","master.mjs":"_f_master-co","master.json":"_f_master-co","master.css.js":"_f_master-co","master.css.ts":"_f_master-co","master.css.mjs":"_f_master-co","master.css.json":"_f_master-co","maven.config":"_f_maven","pom.xml":"_f_maven","extensions.xml":"_f_maven","settings.xml":"_f_maven","mcp.json":"_f_light_mcp","mdx-components.cjs":"_f_light_mdx-components","mdx-components.cts":"_f_light_mdx-components","mdx-components.js":"_f_light_mdx-components","mdx-components.jsx":"_f_light_mdx-components","mdx-components.mjs":"_f_light_mdx-components","mdx-components.mts":"_f_light_mdx-components","mdx-components.ts":"_f_light_mdx-components","mdx-components.tsx":"_f_light_mdx-components",".mdxlintrc":"_f_mdxlint",".mdxlintignore":"_f_mdxlint",".mdxlintrc.cjs":"_f_mdxlint",".mdxlintrc.js":"_f_mdxlint",".mdxlintrc.json":"_f_mdxlint",".mdxlintrc.mjs":"_f_mdxlint",".mdxlintrc.yml":"_f_mdxlint",".mdxlintrc.yaml":"_f_mdxlint",".hgignore":"_f_mercurial","mocha.opts":"_f_mocha",".mocharc.cjs":"_f_mocha",".mocharc.js":"_f_mocha",".mocharc.json":"_f_mocha",".mocharc.jsonc":"_f_mocha",".mocharc.mjs":"_f_mocha",".mocharc.yaml":"_f_mocha",".mocharc.yml":"_f_mocha","modernizr":"_f_modernizr","modernizr.js":"_f_modernizr","modernizrrc.js":"_f_modernizr",".modernizr.js":"_f_modernizr",".modernizrrc.js":"_f_modernizr","moleculer.config.js":"_f_moleculer","moleculer.config.json":"_f_moleculer","moleculer.config.ts":"_f_moleculer",".mtn-ignore":"_f_monotone","motif.json":"_f_motif","mypy.ini":"_f_light_mypy",".mypy.ini":"_f_light_mypy",".nano-staged.js":"_f_nanostaged","nano-staged.js":"_f_nanostaged",".nano-staged.cjs":"_f_nanostaged","nano-staged.cjs":"_f_nanostaged",".nano-staged.mjs":"_f_nanostaged","nano-staged.mjs":"_f_nanostaged",".nano-staged.json":"_f_nanostaged","nano-staged.json":"_f_nanostaged",".nanostagedrc":"_f_nanostaged",".nest-cli.json":"_f_nestjs","nest-cli.json":"_f_nestjs","nestconfig.json":"_f_nestjs",".nestconfig.json":"_f_nestjs","netlify.toml":"_f_light_netlify","next.config.js":"_f_light_next","next.config.cjs":"_f_light_next","next.config.mjs":"_f_light_next","next.config.ts":"_f_light_next",".nf":"_f_nextflow","ng-tailwind.js":"_f_ng_tailwind","nginx.conf":"_f_nginx","build.ninja":"_f_ninja","flake.lock":"_f_nix",".node-version":"_f_node",".nvmrc":"_f_node","node.config.json":"_f_node","nodemon.json":"_f_nodemon",".npmignore":"_f_npm",".npmrc":"_f_npm",".package-lock.json":"_f_npm","package.json":"_f_npm","package-lock.json":"_f_npm","npm-shrinkwrap.json":"_f_npm",".npmpackagejsonlintrc":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.cjs":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.js":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.json":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yaml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.mjs":"_f_npmpackagejsonlint",".npmpackagejsonlintignore":"_f_npmpackagejsonlint","npmpackagejsonlint.config.cjs":"_f_npmpackagejsonlint","npmpackagejsonlint.config.js":"_f_npmpackagejsonlint","npmpackagejsonlint.config.mjs":"_f_npmpackagejsonlint",".nsrirc":"_f_nsri",".nsriignore":"_f_nsri","nsri.config.js":"_f_nsri",".nsrirc.js":"_f_nsri",".nsrirc.json":"_f_nsri",".nsrirc.yaml":"_f_nsri",".nsrirc.yml":"_f_nsri",".integrity.json":"_f_nsri-integrity",".nuxtignore":"_f_nuxt",".nuxtrc":"_f_nuxt","nuxt.config.js":"_f_nuxt","nuxt.config.ts":"_f_nuxt","nuxt.config.mjs":"_f_nuxt","nuxt.js":"_f_nuxt","nuxt.ts":"_f_nuxt","nuxt.mjs":"_f_nuxt","nx.json":"_f_light_nx",".nycrc":"_f_nyc",".nycrc.json":"_f_nyc",".objidconfig":"_f_light_objidconfig",".merlin":"_f_ocaml",".oxlintignore":"_f_light_oxc",".oxlintrc.json":"_f_light_oxc",".oxlintrc.jsonc":"_f_light_oxc",".oxfmtrc.json":"_f_light_oxc",".oxfmtrc.jsonc":"_f_light_oxc",".packshiprc":"_f_light_packship",".packshiprc.json":"_f_light_packship",".packshiprc.js":"_f_light_packship",".packshiprc.ts":"_f_light_packship","packship.config.js":"_f_light_packship","packship.config.ts":"_f_light_packship","packship.config.mjs":"_f_light_packship","packship.config.mts":"_f_light_packship","packship.config.json":"_f_light_packship","paket.dependencies":"_f_paket","paket.lock":"_f_paket","paket.references":"_f_paket","paket.template":"_f_paket","paket.local":"_f_paket","panda.config.js":"_f_pandacss","panda.config.mjs":"_f_pandacss","panda.config.ts":"_f_pandacss",".pdm-python":"_f_pdm","pdm.lock":"_f_pdm","pdm.toml":"_f_pdm","peeky.config.ts":"_f_peeky","peeky.config.js":"_f_peeky","peeky.config.mjs":"_f_peeky",".php-cs-fixer.php":"_f_phpcsfixer",".php-cs-fixer.dist.php":"_f_phpcsfixer",".php_cs":"_f_phpcsfixer",".php_cs.dist":"_f_phpcsfixer","phpstan.neon":"_f_phpstan","phpstan.dist.neon":"_f_phpstan","phpunit":"_f_phpunit","phpunit.xml":"_f_phpunit","phpunit.xml.dist":"_f_phpunit",".phraseapp.yml":"_f_phraseapp","pipfile":"_f_pip","pipfile.lock":"_f_pip","platformio.ini":"_f_platformio","playwright.config.js":"_f_playwright","playwright.config.ts":"_f_playwright","playwright.config.mts":"_f_playwright","playwright.config.mjs":"_f_playwright","playwright.config.cts":"_f_playwright","playwright.config.cjs":"_f_playwright","ecosystem.config.js":"_f_pm2","ecosystem.config.cjs":"_f_pm2","ecosystem.config.json":"_f_pm2","ecosystem.config.yaml":"_f_pm2","ecosystem.config.yml":"_f_pm2",".pnpmfile.cjs":"_f_light_pnpm","pnpmfile.js":"_f_light_pnpm","pnpm-lock.yaml":"_f_light_pnpm","pnpm-workspace.yaml":"_f_light_pnpm","poetry.lock":"_f_poetry",".postcssrc":"_f_postcssconfig",".postcssrc.json":"_f_postcssconfig",".postcssrc.yaml":"_f_postcssconfig",".postcssrc.yml":"_f_postcssconfig",".postcssrc.ts":"_f_postcssconfig",".postcssrc.cts":"_f_postcssconfig",".postcssrc.mts":"_f_postcssconfig",".postcssrc.js":"_f_postcssconfig",".postcssrc.cjs":"_f_postcssconfig",".postcssrc.mjs":"_f_postcssconfig","postcss.config.ts":"_f_postcssconfig","postcss.config.cts":"_f_postcssconfig","postcss.config.mts":"_f_postcssconfig","postcss.config.js":"_f_postcssconfig","postcss.config.cjs":"_f_postcssconfig","postcss.config.mjs":"_f_postcssconfig","preact.config.js":"_f_preact",".pre-commit-config.yaml":"_f_precommit",".prettierrc":"_f_light_prettier",".prettierignore":"_f_light_prettier","prettier.config.js":"_f_light_prettier","prettier.config.cjs":"_f_light_prettier","prettier.config.mjs":"_f_light_prettier","prettier.config.ts":"_f_light_prettier","prettier.config.coffee":"_f_light_prettier",".prettierrc.js":"_f_light_prettier",".prettierrc.cjs":"_f_light_prettier",".prettierrc.mjs":"_f_light_prettier",".prettierrc.json":"_f_light_prettier",".prettierrc.json5":"_f_light_prettier",".prettierrc.yml":"_f_light_prettier",".prettierrc.yaml":"_f_light_prettier",".prettierrc.toml":"_f_light_prettier","prisma.config.js":"_f_prismaconfig","prisma.config.ts":"_f_prismaconfig","procfile":"_f_procfile","protractor.conf.js":"_f_protractor","protractor.conf.coffee":"_f_protractor","protractor.conf.ts":"_f_protractor","publiccode.yml":"_f_publiccode",".jade-lintrc":"_f_pug",".pug-lintrc":"_f_pug",".jade-lint.json":"_f_pug",".pug-lintrc.js":"_f_pug",".pug-lintrc.json":"_f_pug","Pulumi.yaml":"_f_pulumi","Pulumi.yml":"_f_pulumi","Pulumi.dev.yaml":"_f_pulumi","Pulumi.dev.yml":"_f_pulumi","Pulumi.prod.yaml":"_f_pulumi","Pulumi.prod.yml":"_f_pulumi","purgecss.config.js":"_f_purgecss",".python-version":"_f_pyenv","pyproject.toml":"_f_pypi","pytest.ini":"_f_pytest",".pytest.ini":"_f_pytest","pytest.toml":"_f_pytest",".pytest.toml":"_f_pytest","py.typed":"_f_pytyped",".pyup":"_f_pyup",".pyup.yml":"_f_pyup","qmldir":"_f_qmldir","quasar.conf.js":"_f_light_quasar","quasar.config.js":"_f_light_quasar","quasar.config.mjs":"_f_light_quasar","quasar.config.cjs":"_f_light_quasar","quasar.config.ts":"_f_light_quasar","Rakefile":"_f_rake","razzle.config.js":"_f_light_razzle","react-router.config.js":"_f_light_reactrouter","react-router.config.ts":"_f_light_reactrouter",".rehyperc":"_f_light_rehype",".rehypeignore":"_f_light_rehype",".rehyperc.cjs":"_f_light_rehype",".rehyperc.js":"_f_light_rehype",".rehyperc.json":"_f_light_rehype",".rehyperc.mjs":"_f_light_rehype",".rehyperc.yml":"_f_light_rehype",".rehyperc.yaml":"_f_light_rehype",".remarkrc":"_f_light_remark",".remarkignore":"_f_light_remark",".remarkrc.cjs":"_f_light_remark",".remarkrc.js":"_f_light_remark",".remarkrc.json":"_f_light_remark",".remarkrc.mjs":"_f_light_remark",".remarkrc.yml":"_f_light_remark",".remarkrc.yaml":"_f_light_remark",".renovaterc":"_f_renovate",".renovaterc.json":"_f_renovate","renovate.json":"_f_renovate","renovate.json5":"_f_renovate",".replit":"_f_light_replit","replit.nix":"_f_light_replit",".retextrc":"_f_light_retext",".retextignore":"_f_light_retext",".retextrc.cjs":"_f_light_retext",".retextrc.js":"_f_light_retext",".retextrc.json":"_f_light_retext",".retextrc.mjs":"_f_light_retext",".retextrc.yml":"_f_light_retext",".retextrc.yaml":"_f_light_retext","robots.txt":"_f_robots","rolldown.config.js":"_f_light_rolldown","rolldown.config.cjs":"_f_light_rolldown","rolldown.config.mjs":"_f_light_rolldown","rolldown.config.coffee":"_f_light_rolldown","rolldown.config.ts":"_f_light_rolldown","rolldown.config.mts":"_f_light_rolldown","rolldown.config.cts":"_f_light_rolldown","rolldown.config.common.js":"_f_light_rolldown","rolldown.config.common.cjs":"_f_light_rolldown","rolldown.config.common.mjs":"_f_light_rolldown","rolldown.config.common.coffee":"_f_light_rolldown","rolldown.config.common.ts":"_f_light_rolldown","rolldown.config.common.mts":"_f_light_rolldown","rolldown.config.common.cts":"_f_light_rolldown","rolldown.config.dev.js":"_f_light_rolldown","rolldown.config.dev.cjs":"_f_light_rolldown","rolldown.config.dev.mjs":"_f_light_rolldown","rolldown.config.dev.coffee":"_f_light_rolldown","rolldown.config.dev.ts":"_f_light_rolldown","rolldown.config.dev.mts":"_f_light_rolldown","rolldown.config.dev.cts":"_f_light_rolldown","rolldown.config.prod.js":"_f_light_rolldown","rolldown.config.prod.cjs":"_f_light_rolldown","rolldown.config.prod.mjs":"_f_light_rolldown","rolldown.config.prod.coffee":"_f_light_rolldown","rolldown.config.prod.ts":"_f_light_rolldown","rolldown.config.prod.mts":"_f_light_rolldown","rolldown.config.prod.cts":"_f_light_rolldown","rollup.config.js":"_f_rollup","rollup.config.cjs":"_f_rollup","rollup.config.mjs":"_f_rollup","rollup.config.coffee":"_f_rollup","rollup.config.ts":"_f_rollup","rollup.config.common.js":"_f_rollup","rollup.config.common.cjs":"_f_rollup","rollup.config.common.mjs":"_f_rollup","rollup.config.common.coffee":"_f_rollup","rollup.config.common.ts":"_f_rollup","rollup.config.dev.js":"_f_rollup","rollup.config.dev.cjs":"_f_rollup","rollup.config.dev.mjs":"_f_rollup","rollup.config.dev.coffee":"_f_rollup","rollup.config.dev.ts":"_f_rollup","rollup.config.prod.js":"_f_rollup","rollup.config.prod.cjs":"_f_rollup","rollup.config.prod.mjs":"_f_rollup","rollup.config.prod.coffee":"_f_rollup","rollup.config.prod.ts":"_f_rollup","rome.json":"_f_light_rome",".rspec":"_f_rspec",".rubocop.yml":"_f_light_rubocop",".rubocop_todo.yml":"_f_light_rubocop",".ruby-version":"_f_ruby","rust-toolchain":"_f_light_rust_toolchain","rust-toolchain.toml":"_f_light_rust_toolchain",".slshrc":"_f_s-lang",".isisrc":"_f_s-lang",".jedrc":"_f_s-lang",".sailsrc":"_f_sails",".sapphirerc.json":"_f_sapphire_framework_cli",".sapphirerc.yml":"_f_sapphire_framework_cli",".sapphirerc":"_f_sapphire_framework_cli","seedkit.config.js":"_f_seedkit","seedkit.config.ts":"_f_seedkit","seedkit.config.mjs":"_f_seedkit",".sentryclirc":"_f_sentry",".sequelizerc":"_f_sequelize",".sequelizerc.js":"_f_sequelize",".sequelizerc.json":"_f_sequelize","serverless.yml":"_f_serverless","serverless.json":"_f_serverless","serverless.js":"_f_serverless","serverless.ts":"_f_serverless","components.json":"_f_light_shadcn",".shellcheckrc":"_f_shellcheck","Shuttle.toml":"_f_shuttle","sitemap.xml":"_f_sitemap","sitemap.rss":"_f_sitemap","sitemap.txt":"_f_sitemap","sitemap.atom":"_f_sitemap","slash-up.config.js":"_f_slashup","smithery.yaml":"_f_smithery","snapcraft.yaml":"_f_snapcraft","snaplet.config.js":"_f_snaplet","snaplet.config.ts":"_f_snaplet",".snyk":"_f_snyk",".solidarity":"_f_solidarity",".solidarity.json":"_f_solidarity","spin.toml":"_f_light_spin","sst-env.d.ts":"_f_sst","sst-env.ts":"_f_sst","sst-env.js":"_f_sst","sst.config.d.ts":"_f_sst","sst.config.ts":"_f_sst","sst.config.js":"_f_sst",".stackblitzrc":"_f_stackblitz","stryker.conf.mjs":"_f_stryker","stryker.conf.cjs":"_f_stryker","stryker.conf.js":"_f_stryker","stryker.conf.conf":"_f_stryker","stryker.conf.json":"_f_stryker",".stryker.conf.mjs":"_f_stryker",".stryker.conf.cjs":"_f_stryker",".stryker.conf.js":"_f_stryker",".stryker.conf.conf":"_f_stryker",".stryker.conf.json":"_f_stryker","stryker.config.mjs":"_f_stryker","stryker.config.cjs":"_f_stryker","stryker.config.js":"_f_stryker","stryker.config.conf":"_f_stryker","stryker.config.json":"_f_stryker",".stryker.config.mjs":"_f_stryker",".stryker.config.cjs":"_f_stryker",".stryker.config.js":"_f_stryker",".stryker.config.conf":"_f_stryker",".stryker.config.json":"_f_stryker","stryker-config.mjs":"_f_stryker","stryker-config.cjs":"_f_stryker","stryker-config.js":"_f_stryker","stryker-config.conf":"_f_stryker","stryker-config.json":"_f_stryker","stryker4s.mjs":"_f_stryker","stryker4s.cjs":"_f_stryker","stryker4s.js":"_f_stryker","stryker4s.conf":"_f_stryker","stryker4s.json":"_f_stryker",".stylelintrc":"_f_light_stylelint",".stylelintignore":"_f_light_stylelint",".stylelintcache":"_f_light_stylelint","stylelint.config.js":"_f_light_stylelint","stylelint.config.json":"_f_light_stylelint","stylelint.config.yaml":"_f_light_stylelint","stylelint.config.yml":"_f_light_stylelint","stylelint.config.ts":"_f_light_stylelint","stylelint.config.cjs":"_f_light_stylelint","stylelint.config.mjs":"_f_light_stylelint",".stylelintrc.js":"_f_light_stylelint",".stylelintrc.json":"_f_light_stylelint",".stylelintrc.yaml":"_f_light_stylelint",".stylelintrc.yml":"_f_light_stylelint",".stylelintrc.ts":"_f_light_stylelint",".stylelintrc.cjs":"_f_light_stylelint",".stylelintrc.mjs":"_f_light_stylelint",".stylish-haskell.yaml":"_f_stylish_haskell",".svnignore":"_f_subversion","svelte.config.js":"_f_svelteconfig","svelte.config.ts":"_f_svelteconfig","svgo.config.cjs":"_f_svgo","svgo.config.js":"_f_svgo","svgo.config.mjs":"_f_svgo",".swcrc":"_f_swc","package.pins":"_f_swift","symfony.lock":"_f_light_symfony",".syncpackrc":"_f_syncpack",".syncpackrc.json":"_f_syncpack",".syncpackrc.yaml":"_f_syncpack",".syncpackrc.yml":"_f_syncpack",".syncpackrc.js":"_f_syncpack",".syncpackrc.cjs":"_f_syncpack","syncpack.config.js":"_f_syncpack","syncpack.config.cjs":"_f_syncpack","tailwind.js":"_f_tailwind","tailwind.cjs":"_f_tailwind","tailwind.coffee":"_f_tailwind","tailwind.ts":"_f_tailwind","tailwind.json":"_f_tailwind","tailwind.mjs":"_f_tailwind","tailwind.config.js":"_f_tailwind","tailwind.config.cjs":"_f_tailwind","tailwind.config.coffee":"_f_tailwind","tailwind.config.ts":"_f_tailwind","tailwind.config.json":"_f_tailwind","tailwind.config.mjs":"_f_tailwind",".tailwind.js":"_f_tailwind",".tailwind.cjs":"_f_tailwind",".tailwind.coffee":"_f_tailwind",".tailwind.ts":"_f_tailwind",".tailwind.json":"_f_tailwind",".tailwind.mjs":"_f_tailwind",".tailwindrc.js":"_f_tailwind",".tailwindrc.cjs":"_f_tailwind",".tailwindrc.coffee":"_f_tailwind",".tailwindrc.ts":"_f_tailwind",".tailwindrc.json":"_f_tailwind",".tailwindrc.mjs":"_f_tailwind","tamagui.js":"_f_tamagui","tamagui.mjs":"_f_tamagui","tamagui.cjs":"_f_tamagui","tamagui.ts":"_f_tamagui","tamagui.mts":"_f_tamagui","tamagui.cts":"_f_tamagui","tamagui.config.js":"_f_tamagui","tamagui.config.mjs":"_f_tamagui","tamagui.config.cjs":"_f_tamagui","tamagui.config.ts":"_f_tamagui","tamagui.config.mts":"_f_tamagui","tamagui.config.cts":"_f_tamagui",".taplo.toml":"_f_taplo","taplo.toml":"_f_taplo","taskfile.yml":"_f_taskfile","taskfile.yaml":"_f_taskfile","taskfile.dist.yml":"_f_taskfile","taskfile.dist.yaml":"_f_taskfile","tauri.conf.json":"_f_tauri",".taurignore":"_f_tauri",".testcaferc.json":"_f_light_testcafe","testplane.conf.ts":"_f_testplane","testplane.conf.js":"_f_testplane","testplane.conf.cts":"_f_testplane","testplane.conf.cjs":"_f_testplane","testplane.config.ts":"_f_testplane","testplane.config.js":"_f_testplane","testplane.config.cts":"_f_testplane","testplane.config.cjs":"_f_testplane",".tfignore":"_f_tfs",".tiltignore":"_f_tiltfile",".tmux.conf":"_f_light_tmux","tox.ini":"_f_tox",".travis.yml":"_f_travis","truffle-config.js":"_f_truffle","truffle-config.ts":"_f_truffle","trunk.yaml":"_f_trunk","tsconfig.json":"_f_tsconfig","tsconfig.app.json":"_f_tsconfig","tsconfig.base.json":"_f_tsconfig","tsconfig.build.json":"_f_tsconfig","tsconfig.cjs.json":"_f_tsconfig","tsconfig.common.json":"_f_tsconfig","tsconfig.debug.json":"_f_tsconfig","tsconfig.dev.json":"_f_tsconfig","tsconfig.development.json":"_f_tsconfig","tsconfig.e2e.json":"_f_tsconfig","tsconfig.eslint.json":"_f_tsconfig","tsconfig.esm.json":"_f_tsconfig","tsconfig.mjs.json":"_f_tsconfig","tsconfig.node.json":"_f_tsconfig","tsconfig.prod.json":"_f_tsconfig","tsconfig.production.json":"_f_tsconfig","tsconfig.release.json":"_f_tsconfig","tsconfig.server.json":"_f_tsconfig","tsconfig.spec.json":"_f_tsconfig","tsconfig.staging.json":"_f_tsconfig","tsconfig.test.json":"_f_tsconfig","tsconfig.types.json":"_f_tsconfig","tsconfig.lib.json":"_f_tsconfig","tsconfig.lib.prod.json":"_f_tsconfig","tsdoc.json":"_f_tsdoc","tsdown.config.ts":"_f_tsdown","tsdown.config.mts":"_f_tsdown","tsdown.config.cts":"_f_tsdown","tsdown.config.js":"_f_tsdown","tsdown.config.mjs":"_f_tsdown","tsdown.config.cjs":"_f_tsdown","tsdown.config.json":"_f_tsdown","tslint.json":"_f_tslint","tslint.yaml":"_f_tslint","tslint.yml":"_f_tslint","turbo.json":"_f_light_turbo","turbo.jsonc":"_f_light_turbo","microfrontends.json":"_f_light_turbo","microfrontends.jsonc":"_f_light_turbo","typedoc.cjs":"_f_typedoc","typedoc.js":"_f_typedoc","typedoc.json":"_f_typedoc","typedoc.jsonc":"_f_typedoc","typedoc.mjs":"_f_typedoc","typedoc.config.cjs":"_f_typedoc","typedoc.config.js":"_f_typedoc","typedoc.config.json":"_f_typedoc","typedoc.config.jsonc":"_f_typedoc","typedoc.config.mjs":"_f_typedoc",".unibeautifyrc":"_f_light_unibeautify","unibeautify.config.js":"_f_light_unibeautify",".unibeautifyrc.js":"_f_light_unibeautify",".unibeautifyrc.json":"_f_light_unibeautify",".unibeautifyrc.yaml":"_f_light_unibeautify",".unibeautifyrc.yml":"_f_light_unibeautify","unlicense":"_f_unlicense","unlicence":"_f_unlicense","UNLICENSE":"_f_unlicense","UNLICENCE":"_f_unlicense","unlicense.md":"_f_unlicense","unlicense.txt":"_f_unlicense","unlicence.md":"_f_unlicense","unlicence.txt":"_f_unlicense","UNLICENSE.md":"_f_unlicense","UNLICENSE.txt":"_f_unlicense","UNLICENCE.md":"_f_unlicense","UNLICENCE.txt":"_f_unlicense","uno.config.js":"_f_unocss","uno.config.mjs":"_f_unocss","uno.config.ts":"_f_unocss","uno.config.mts":"_f_unocss","unocss.config.js":"_f_unocss","unocss.config.mjs":"_f_unocss","unocss.config.ts":"_f_unocss","unocss.config.mts":"_f_unocss","uv.lock":"_f_uv","vagrantfile":"_f_vagrant","vapor.yml":"_f_vapor","now.json":"_f_light_vercel",".nowignore":"_f_light_vercel","vercel.json":"_f_light_vercel",".vercelignore":"_f_light_vercel",".vimrc":"_f_vim",".gvimrc":"_f_vim","vite.config.js":"_f_light_vite","vite.config.mjs":"_f_light_vite","vite.config.ts":"_f_light_vite","vite.config.cjs":"_f_light_vite","vite.config.mts":"_f_light_vite","vite.config.cts":"_f_light_vite","vitest.config.js":"_f_light_vitest","vitest.config.mjs":"_f_light_vitest","vitest.config.ts":"_f_light_vitest","vitest.config.cjs":"_f_light_vitest","vitest.config.mts":"_f_light_vitest","vitest.config.cts":"_f_light_vitest","vitest.workspace.json":"_f_light_vitest","vitest.workspace.js":"_f_light_vitest","vitest.workspace.mjs":"_f_light_vitest","vitest.workspace.ts":"_f_light_vitest","vitest.workspace.cjs":"_f_light_vitest","vitest.workspace.mts":"_f_light_vitest","vitest.workspace.cts":"_f_light_vitest","vitest.projects.json":"_f_light_vitest","vitest.projects.js":"_f_light_vitest","vitest.projects.mjs":"_f_light_vitest","vitest.projects.ts":"_f_light_vitest","vitest.projects.cjs":"_f_light_vitest","vitest.projects.mts":"_f_light_vitest","vitest.projects.cts":"_f_light_vitest",".vscodeignore":"_f_vscode","launch.json":"_f_vscode","tasks.json":"_f_vscode","vscodeignore.json":"_f_vscode",".vscode-test.js":"_f_vscode_test",".vscode-test.json":"_f_vscode_test",".vscode-test.mjs":"_f_vscode_test",".vuerc":"_f_vueconfig","vue.config.js":"_f_vueconfig","vue.config.cjs":"_f_vueconfig","vue.config.mjs":"_f_vueconfig","wallaby.json":"_f_wallaby","wallaby.js":"_f_wallaby","wallaby.ts":"_f_wallaby","wallaby.coffee":"_f_wallaby","wallaby.conf.json":"_f_wallaby","wallaby.conf.js":"_f_wallaby","wallaby.conf.ts":"_f_wallaby","wallaby.conf.coffee":"_f_wallaby",".wallaby.json":"_f_wallaby",".wallaby.js":"_f_wallaby",".wallaby.ts":"_f_wallaby",".wallaby.coffee":"_f_wallaby",".wallaby.conf.json":"_f_wallaby",".wallaby.conf.js":"_f_wallaby",".wallaby.conf.ts":"_f_wallaby",".wallaby.conf.coffee":"_f_wallaby","wally.toml":"_f_wally","wally.lock":"_f_wally",".watchmanconfig":"_f_watchmanconfig","wdio.json":"_f_wdio","wdio.js":"_f_wdio","wdio.mjs":"_f_wdio","wdio.cjs":"_f_wdio","wdio.ts":"_f_wdio","wdio.mts":"_f_wdio","wdio.cts":"_f_wdio","wdio.conf.json":"_f_wdio","wdio.conf.js":"_f_wdio","wdio.conf.mjs":"_f_wdio","wdio.conf.cjs":"_f_wdio","wdio.conf.ts":"_f_wdio","wdio.conf.mts":"_f_wdio","wdio.conf.cts":"_f_wdio",".weblate":"_f_weblate",".weblate.ini":"_f_weblate","weblate":"_f_weblate","weblate.ini":"_f_weblate","webpack.base.conf.js":"_f_webpack","webpack.base.conf.cjs":"_f_webpack","webpack.base.conf.mjs":"_f_webpack","webpack.base.conf.coffee":"_f_webpack","webpack.base.conf.ts":"_f_webpack","webpack.common.js":"_f_webpack","webpack.common.cjs":"_f_webpack","webpack.common.mjs":"_f_webpack","webpack.common.coffee":"_f_webpack","webpack.common.ts":"_f_webpack","webpack.config.js":"_f_webpack","webpack.config.cjs":"_f_webpack","webpack.config.mjs":"_f_webpack","webpack.config.coffee":"_f_webpack","webpack.config.ts":"_f_webpack","webpack.config.base.js":"_f_webpack","webpack.config.base.cjs":"_f_webpack","webpack.config.base.mjs":"_f_webpack","webpack.config.base.coffee":"_f_webpack","webpack.config.base.ts":"_f_webpack","webpack.config.build.js":"_f_webpack","webpack.config.build.cjs":"_f_webpack","webpack.config.build.mjs":"_f_webpack","webpack.config.build.coffee":"_f_webpack","webpack.config.build.ts":"_f_webpack","webpack.config.common.js":"_f_webpack","webpack.config.common.cjs":"_f_webpack","webpack.config.common.mjs":"_f_webpack","webpack.config.common.coffee":"_f_webpack","webpack.config.common.ts":"_f_webpack","webpack.config.dev.js":"_f_webpack","webpack.config.dev.cjs":"_f_webpack","webpack.config.dev.mjs":"_f_webpack","webpack.config.dev.coffee":"_f_webpack","webpack.config.dev.ts":"_f_webpack","webpack.config.development.js":"_f_webpack","webpack.config.development.cjs":"_f_webpack","webpack.config.development.mjs":"_f_webpack","webpack.config.development.coffee":"_f_webpack","webpack.config.development.ts":"_f_webpack","webpack.config.staging.js":"_f_webpack","webpack.config.staging.cjs":"_f_webpack","webpack.config.staging.mjs":"_f_webpack","webpack.config.staging.coffee":"_f_webpack","webpack.config.staging.ts":"_f_webpack","webpack.config.test.js":"_f_webpack","webpack.config.test.cjs":"_f_webpack","webpack.config.test.mjs":"_f_webpack","webpack.config.test.coffee":"_f_webpack","webpack.config.test.ts":"_f_webpack","webpack.config.prod.js":"_f_webpack","webpack.config.prod.cjs":"_f_webpack","webpack.config.prod.mjs":"_f_webpack","webpack.config.prod.coffee":"_f_webpack","webpack.config.prod.ts":"_f_webpack","webpack.config.production.js":"_f_webpack","webpack.config.production.cjs":"_f_webpack","webpack.config.production.mjs":"_f_webpack","webpack.config.production.coffee":"_f_webpack","webpack.config.production.ts":"_f_webpack","webpack.config.babel.js":"_f_webpack","webpack.config.babel.cjs":"_f_webpack","webpack.config.babel.mjs":"_f_webpack","webpack.config.babel.coffee":"_f_webpack","webpack.config.babel.ts":"_f_webpack","webpack.config.base.babel.js":"_f_webpack","webpack.config.base.babel.cjs":"_f_webpack","webpack.config.base.babel.mjs":"_f_webpack","webpack.config.base.babel.coffee":"_f_webpack","webpack.config.base.babel.ts":"_f_webpack","webpack.config.common.babel.js":"_f_webpack","webpack.config.common.babel.cjs":"_f_webpack","webpack.config.common.babel.mjs":"_f_webpack","webpack.config.common.babel.coffee":"_f_webpack","webpack.config.common.babel.ts":"_f_webpack","webpack.config.dev.babel.js":"_f_webpack","webpack.config.dev.babel.cjs":"_f_webpack","webpack.config.dev.babel.mjs":"_f_webpack","webpack.config.dev.babel.coffee":"_f_webpack","webpack.config.dev.babel.ts":"_f_webpack","webpack.config.development.babel.js":"_f_webpack","webpack.config.development.babel.cjs":"_f_webpack","webpack.config.development.babel.mjs":"_f_webpack","webpack.config.development.babel.coffee":"_f_webpack","webpack.config.development.babel.ts":"_f_webpack","webpack.config.staging.babel.js":"_f_webpack","webpack.config.staging.babel.cjs":"_f_webpack","webpack.config.staging.babel.mjs":"_f_webpack","webpack.config.staging.babel.coffee":"_f_webpack","webpack.config.staging.babel.ts":"_f_webpack","webpack.config.test.babel.js":"_f_webpack","webpack.config.test.babel.cjs":"_f_webpack","webpack.config.test.babel.mjs":"_f_webpack","webpack.config.test.babel.coffee":"_f_webpack","webpack.config.test.babel.ts":"_f_webpack","webpack.config.prod.babel.js":"_f_webpack","webpack.config.prod.babel.cjs":"_f_webpack","webpack.config.prod.babel.mjs":"_f_webpack","webpack.config.prod.babel.coffee":"_f_webpack","webpack.config.prod.babel.ts":"_f_webpack","webpack.config.production.babel.js":"_f_webpack","webpack.config.production.babel.cjs":"_f_webpack","webpack.config.production.babel.mjs":"_f_webpack","webpack.config.production.babel.coffee":"_f_webpack","webpack.config.production.babel.ts":"_f_webpack","webpack.dev.js":"_f_webpack","webpack.dev.cjs":"_f_webpack","webpack.dev.mjs":"_f_webpack","webpack.dev.coffee":"_f_webpack","webpack.dev.ts":"_f_webpack","webpack.dev.conf.js":"_f_webpack","webpack.dev.conf.cjs":"_f_webpack","webpack.dev.conf.mjs":"_f_webpack","webpack.dev.conf.coffee":"_f_webpack","webpack.dev.conf.ts":"_f_webpack","webpack.local.js":"_f_webpack","webpack.local.cjs":"_f_webpack","webpack.local.mjs":"_f_webpack","webpack.local.coffee":"_f_webpack","webpack.local.ts":"_f_webpack","webpack.prod.js":"_f_webpack","webpack.prod.cjs":"_f_webpack","webpack.prod.mjs":"_f_webpack","webpack.prod.coffee":"_f_webpack","webpack.prod.ts":"_f_webpack","webpack.prod.conf.js":"_f_webpack","webpack.prod.conf.cjs":"_f_webpack","webpack.prod.conf.mjs":"_f_webpack","webpack.prod.conf.coffee":"_f_webpack","webpack.prod.conf.ts":"_f_webpack","webpack.main.config.js":"_f_webpack","webpack.main.config.cjs":"_f_webpack","webpack.main.config.mjs":"_f_webpack","webpack.main.config.coffee":"_f_webpack","webpack.main.config.ts":"_f_webpack","webpack.mix.js":"_f_webpack","webpack.mix.cjs":"_f_webpack","webpack.mix.mjs":"_f_webpack","webpack.mix.coffee":"_f_webpack","webpack.mix.ts":"_f_webpack","webpack.plugins.js":"_f_webpack","webpack.plugins.cjs":"_f_webpack","webpack.plugins.mjs":"_f_webpack","webpack.plugins.coffee":"_f_webpack","webpack.plugins.ts":"_f_webpack","webpack.renderer.config.js":"_f_webpack","webpack.renderer.config.cjs":"_f_webpack","webpack.renderer.config.mjs":"_f_webpack","webpack.renderer.config.coffee":"_f_webpack","webpack.renderer.config.ts":"_f_webpack","webpack.rules.js":"_f_webpack","webpack.rules.cjs":"_f_webpack","webpack.rules.mjs":"_f_webpack","webpack.rules.coffee":"_f_webpack","webpack.rules.ts":"_f_webpack","webpack.test.conf.js":"_f_webpack","webpack.test.conf.cjs":"_f_webpack","webpack.test.conf.mjs":"_f_webpack","webpack.test.conf.coffee":"_f_webpack","webpack.test.conf.ts":"_f_webpack","wercker.yml":"_f_wercker","windi.config.ts":"_f_windi","windi.config.js":"_f_windi","wpml-config.xml":"_f_wpml","wxt.config.js":"_f_wxt","wxt.config.cjs":"_f_wxt","wxt.config.mjs":"_f_wxt","wxt.config.ts":"_f_wxt","wxt.config.cts":"_f_wxt","wxt.config.mts":"_f_wxt",".xo-config":"_f_xo","xo.config.js":"_f_xo","xo.config.cjs":"_f_xo","xo.config.mjs":"_f_xo","xo.config.ts":"_f_xo","xo.config.cts":"_f_xo","xo.config.mts":"_f_xo",".xo-config.js":"_f_xo",".xo-config.json":"_f_xo",".xo-config.cjs":"_f_xo",".xcompose":"_f_light_xorg",".yamllint":"_f_yamllint",".yaspellerrc":"_f_yandex",".yaspeller.json":"_f_yandex","yarn.lock":"_f_yarn",".yarnrc":"_f_yarn",".yarnrc.yml":"_f_yarn",".yarnclean":"_f_yarn",".yarn-integrity":"_f_yarn",".yarn-metadata.json":"_f_yarn",".yarn-state.yml":"_f_yarn",".yarnignore":"_f_yarn",".yo-rc.json":"_f_yeoman"},"languageIds":{"actionscript":"_f_actionscript","ada":"_f_light_ada","advpl":"_f_advpl","affectscript":"_f_affectscript","agda":"_f_light_agda","lagda-md":"_f_light_agda","lagda-rst":"_f_light_agda","lagda-tex":"_f_light_agda","al":"_f_al","alloy":"_f_alloy","ansible":"_f_ansible","antlr":"_f_antlr","anyscript":"_f_anyscript","apacheconf":"_f_apache","apex":"_f_apex","apiblueprint":"_f_apib","apl":"_f_light_apl","applescript":"_f_applescript","asciidoc":"_f_asciidoc","asp":"_f_asp","asp (html)":"_f_asp","arm":"_f_assembly","asm":"_f_assembly","asm-intel-x86-generic":"_f_assembly","platformio-debug.asm":"_f_platformio","asm-collection":"_f_assembly","astro":"_f_light_astro","ats":"_f_ats","ahk":"_f_autohotkey","ahk2":"_f_autohotkey","autoit":"_f_autoit","avro":"_f_avro","azcli":"_f_azure","azure-pipelines":"_f_azurepipelines","ballerina":"_f_ballerina","bat":"_f_bat","bats":"_f_bats","bazel":"_f_bazel","bazelrc":"_f_bazel","starlark":"_f_bazel","befunge":"_f_befunge","befunge98":"_f_befunge","bicep":"_f_bicep","biml":"_f_biml","code-text-binary":"_f_binary","biome_syntax_tree":"_f_biome","blade":"_f_blade","laravel-blade":"_f_blade","blitzbasic":"_f_blitzbasic","bolt":"_f_bolt","bosque":"_f_bosque","browserslist":"_f_browserslist","bruno":"_f_bruno","buf":"_f_buf","buf-gen":"_f_buf","bun.lockb":"_f_bun","c":"_f_c","c3":"_f_c3","c-al":"_f_c_al","cabal":"_f_cabal","caddyfile":"_f_caddy","capnp":"_f_capnp","capnproto":"_f_capnp","casc":"_f_casc","cddl":"_f_cddl","ceylon":"_f_ceylon","cfml":"_f_cf","lang-cfml":"_f_cf","cfc":"_f_cfc","cfmhtml":"_f_cfm","cookbook":"_f_chef_cookbook","circom":"_f_circom","clojure":"_f_clojure","clojurescript":"_f_clojurescript","manifest-yaml":"_f_light_cloudfoundry","cmake":"_f_cmake","cmake-cache":"_f_cmake","cobol":"_f_cobol","ql":"_f_codeql","coffeescript":"_f_coffeescript","java-properties":"_f_light_config","properties":"_f_light_config","spring-boot-properties":"_f_light_config","confluence":"_f_confluence","instructions":"_f_light_copilot","prompt":"_f_light_copilot","cpp":"_f_cpp","crystal":"_f_light_crystal","csharp":"_f_csharp","css":"_f_css","cucumber":"_f_cucumber","feature":"_f_cucumber","cuda":"_f_cuda","cuda-cpp":"_f_cuda","cython":"_f_cython","dal":"_f_dal","dart":"_f_dartlang","debian-changelog":"_f_debian","debian-control":"_f_debian","debian-copyright":"_f_debian","debian-upstream-metadata":"_f_debian","pascal":"_f_delphi","objectpascal":"_f_delphi","dhall":"_f_light_dhall","diff":"_f_diff","django-html":"_f_django","django-txt":"_f_django","d":"_f_dlang","dscript":"_f_dlang","dml":"_f_dlang","diet":"_f_dlang","dockerbake":"_f_docker","dockercompose":"_f_docker","dockerfile":"_f_docker","dotenv":"_f_dotenv","env":"_f_dotenv","dotjs":"_f_dotjs","doxygen":"_f_doxygen","drawio":"_f_drawio","drools":"_f_drools","dtd":"_f_dtd","dustjs":"_f_dustjs","dylan":"_f_dylan","dylan-lid":"_f_dylan","earthfile":"_f_earthly","edge":"_f_edge","editorconfig":"_f_editorconfig","eex":"_f_eex","html-eex":"_f_eex","es":"_f_elastic","elixir":"_f_elixir","elm":"_f_elm","erb":"_f_erb","html.erb":"_f_erb","erlang":"_f_erlang","esphome":"_f_light_esphome","excel":"_f_excel","falcon":"_f_falcon","fql":"_f_fauna","fortran":"_f_fortran","fortran-modern":"_f_fortran","FortranFreeForm":"_f_fortran","FortranFixedForm":"_f_fortran","fortran_fixed-form":"_f_fortran","ftl":"_f_freemarker","fsharp":"_f_fsharp","fthtml":"_f_fthtml","galen":"_f_galen","gml-gms":"_f_gamemaker","gml-gms2":"_f_light_gamemaker2","gml-gm81":"_f_gamemaker81","gcode":"_f_gcode","gdscript":"_f_gdscript","genstat":"_f_genstat","git-commit":"_f_git","git-rebase":"_f_git","ignore":"_f_git","gleam":"_f_gleam","glimmer-js":"_f_glimmer","glimmer-ts":"_f_glimmer","glsl":"_f_glsl","glyphs":"_f_glyphs","automake":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","makefile2":"_f_gnu","gnuplot":"_f_gnuplot","go":"_f_go","go.mod":"_f_go_package","go.work":"_f_go_work","goctl":"_f_goctl","gradle-kotlin-dsl":"_f_light_gradle","grain":"_f_grain","graphql":"_f_graphql","dot":"_f_graphviz","grit":"_f_grit","groovy":"_f_groovy","haml":"_f_haml","handlebars":"_f_handlebars","harbour":"_f_harbour","hcl":"_f_light_hashicorp","haskell":"_f_haskell","literate haskell":"_f_haskell","haxe":"_f_haxe","hxml":"_f_haxe","Haxe AST dump":"_f_haxe","helm":"_f_helm","hjson":"_f_light_hjson","hlsl":"_f_hlsl","home-assistant":"_f_homeassistant","hosts":"_f_host","html":"_f_html","http":"_f_http","hunspell.aff":"_f_hunspell","hunspell.dic":"_f_hunspell","hy":"_f_hy","hypr":"_f_hypr","icl":"_f_icl","imba":"_f_imba","4GL":"_f_informix","ini":"_f_light_ini","ink":"_f_ink","innosetup":"_f_innosetup","io":"_f_light_io","iodine":"_f_iodine","janet":"_f_janet","java":"_f_java","jekyll":"_f_jekyll","jenkins":"_f_jenkins","declarative":"_f_jenkins","jenkinsfile":"_f_jenkins","jest-snapshot":"_f_jest_snapshot","jinja":"_f_jinja","jinja-java":"_f_jinja","jinja-html":"_f_jinja","jinja-xml":"_f_jinja","jinja-css":"_f_jinja","jinja-json":"_f_jinja","jinja-md":"_f_jinja","jinja-py":"_f_jinja","jinja-rb":"_f_jinja","jinja-js":"_f_jinja","jinja-yaml":"_f_jinja","jinja-toml":"_f_jinja","jinja-latex":"_f_jinja","jinja-lua":"_f_jinja","jinja-properties":"_f_jinja","jinja-shell":"_f_jinja","jinja-dockerfile":"_f_jinja","jinja-sql":"_f_jinja","jinja-terraform":"_f_jinja","jinja-nginx":"_f_jinja","jinja-groovy":"_f_jinja","jinja-systemd":"_f_jinja","jinja-cpp":"_f_jinja","javascript":"_f_light_js","json":"_f_light_json","json-tmlanguage":"_f_light_json","jsonc":"_f_light_json","jsonl":"_f_light_json","json5":"_f_light_json5","jsonnet":"_f_jsonnet","julia":"_f_julia","juliamarkdown":"_f_julia","just":"_f_just","k":"_f_k","kivy":"_f_kivy","kos":"_f_kos","kotlin":"_f_kotlin","kotlinscript":"_f_kotlin","kusto":"_f_kusto","lark":"_f_light_lark","latino":"_f_latino","lean4":"_f_light_lean","lean4markdown":"_f_light_lean","less":"_f_less","lex":"_f_lex","lilypond":"_f_lilypond","lisp":"_f_lisp","autolisp":"_f_lisp","autolispdcl":"_f_lisp","log":"_f_log","lolcode":"_f_lolcode","lsl":"_f_lsl","lua":"_f_lua","markdown":"_f_markdown","marko":"_f_marko","matlab":"_f_matlab","maxscript":"_f_maxscript","mel":"_f_maya","mdx":"_f_light_mdx","mediawiki":"_f_mediawiki","mermaid":"_f_mermaid","meson":"_f_meson","metal":"_f_metal","bc-mcfunction":"_f_minecraft","bc-minecraft-language":"_f_minecraft","bc-minecraft-molang":"_f_minecraft","bc-minecraft-project":"_f_minecraft","mcdoc":"_f_minecraft","mcfunction":"_f_minecraft","mcmeta":"_f_minecraft","mcscript":"_f_minecraft","minecraft-lang":"_f_minecraft","options-txt":"_f_minecraft","snbt":"_f_minecraft","mv":"_f_mivascript","mjml":"_f_mjml","powerquery":"_f_light_mlang","mojo":"_f_mojo","mojolicious":"_f_mojolicious","mongo":"_f_mongo","mson":"_f_mson","mvt":"_f_mvt","mvtcss":"_f_mvtcss","mvtjs":"_f_mvtjs","nearley":"_f_nearly","cypher":"_f_light_neo4j","nginx":"_f_nginx","nim":"_f_nim","nimble":"_f_nimble","nix":"_f_nix","nsis":"_f_nsi","nfl":"_f_nsi","nsl":"_f_nsi","bridlensis":"_f_nsi","nunjucks":"_f_nunjucks","nushell":"_f_nushell","objective-c":"_f_objectivec","objective-cpp":"_f_objectivecpp","ocaml":"_f_ocaml","ocamllex":"_f_ocaml","menhir":"_f_ocaml","ogone":"_f_ogone","openhab":"_f_light_openHAB","scad":"_f_openscad","org":"_f_org","pddl":"_f_pddl","happenings":"_f_pddl_happenings","plan":"_f_pddl_plan","perl":"_f_perl","perl6":"_f_perl6","pgsql":"_f_pgsql","php":"_f_php","pine":"_f_pine","pinescript":"_f_pine","pip-requirements":"_f_pip","platformio-debug.disassembly":"_f_platformio","platformio-debug.memoryview":"_f_platformio","plsql":"_f_plsql","oracle":"_f_plsql","oraclesql":"_f_plsql","oracle-sql":"_f_plsql","po":"_f_poedit","polyglot-notebook":"_f_polyglot","polymer":"_f_polymer","pony":"_f_pony","postcss":"_f_postcss","postscript":"_f_postscript","powershell":"_f_powershell","prisma":"_f_light_prisma","pde":"_f_processinglang","abl":"_f_progress","prolog":"_f_prolog","prometheus":"_f_prometheus","proto3":"_f_protobuf","proto":"_f_protobuf","prototext":"_f_protobuf","jade":"_f_pug","puppet":"_f_puppet","purescript":"_f_light_purescript","pyret":"_f_pyret","pyscript":"_f_pyscript","python":"_f_python","pythowo":"_f_pythowo","qlik":"_f_qlikview","qml":"_f_qml","qsharp":"_f_qsharp","r":"_f_r","ra_syntax_tree":"_f_ra_syntax_tree","racket":"_f_racket","raku":"_f_light_raku","raml":"_f_raml","razor":"_f_razor","aspnetcorerazor":"_f_razor","javascriptreact":"_f_reactjs","typescriptreact":"_f_reactts","reason":"_f_reason","red":"_f_red","rescript":"_f_rescript","restructuredtext":"_f_rest","rexx":"_f_rexx","riot":"_f_riot","ripple":"_f_ripple","rmd":"_f_rmd","rnc":"_f_rnc","robot":"_f_robotframework","robots-txt":"_f_robots","ruby":"_f_ruby","rust":"_f_light_rust","s-lang":"_f_s-lang","san":"_f_san","SAS":"_f_sas","sass":"_f_sass","sass.hover":"_f_sass","sbt":"_f_sbt","scala":"_f_scala","scilab":"_f_scilab","vbscript":"_f_script","scss":"_f_scss","sdl":"_f_sdlang","search-result":"_f_search_result","shaderlab":"_f_light_shaderlab","shellscript":"_f_shell","silverstripe":"_f_silverstripe","sino":"_f_sino","eskip":"_f_skipper","slang":"_f_slang","slice":"_f_slice","slim":"_f_slim","slint":"_f_slint","sln":"_f_sln","smarty":"_f_smarty","snakemake":"_f_snakemake","snort":"_f_snort","solidity":"_f_light_solidity","sparql":"_f_sparql","sqf":"_f_sqf","sql":"_f_sql","squirrel":"_f_squirrel","stan":"_f_stan","stata":"_f_stata","stencil":"_f_stencil","stencil-html":"_f_stencil","stylable":"_f_stylable","source.css.styled":"_f_styled","stylus":"_f_light_stylus","svelte":"_f_svelte","svg":"_f_svg","Swagger":"_f_swagger","swagger":"_f_swagger","swift":"_f_swift","swig":"_f_swig","systemd-conf":"_f_light_systemd","systemd-unit-file":"_f_light_systemd","systemverilog":"_f_light_systemverilog","t4":"_f_t4tt","tailwindcss":"_f_tailwind","teal":"_f_teal","templ":"_f_templ","tera":"_f_tera","terraform":"_f_terraform","tex":"_f_light_tex","latex":"_f_light_tex","bibtex":"_f_light_tex","doctex":"_f_light_tex","csv":"_f_text","csv (pipe)":"_f_text","csv (semicolon)":"_f_text","csv (whitespace)":"_f_text","dynamic csv":"_f_text","tsv":"_f_text","plaintext":"_f_text","vim-help":"_f_text","textile":"_f_textile","tiltfile":"_f_tiltfile","toit":"_f_light_toit","toml":"_f_light_toml","toon":"_f_light_toon","tt":"_f_tt","ttcn":"_f_ttcn","tuc":"_f_tuc","twig":"_f_twig","typescript":"_f_typescript","typoscript":"_f_typo3","uiua":"_f_uiua","unison":"_f_unison","vb":"_f_vb","vba":"_f_vba","velocity":"_f_velocity","vento":"_f_vento","verilog":"_f_verilog","vhdl":"_f_vhdl","viml":"_f_vim","vim-snippet":"_f_vim","vitest-snapshot":"_f_light_vitest","v":"_f_vlang","volt":"_f_volt","vroom":"_f_vroom","snippets":"_f_vscode","vue":"_f_vue","vyper":"_f_vyper","wasm":"_f_wasm","wat":"_f_wasm","wenyan":"_f_wenyan","wgsl":"_f_wgsl","wikitext":"_f_wikitext","wai":"_f_wit","wolfram":"_f_wolfram","wurstlang":"_f_wurst","wurst":"_f_wurst","xmake":"_f_xmake","xml":"_f_xml","xcompose":"_f_light_xorg","xquery":"_f_xquery","xsl":"_f_xsl","xslt":"_f_xsl","yacc":"_f_yacc","yaml":"_f_light_yaml","yaml-tmlanguage":"_f_light_yaml","spring-boot-properties-yaml":"_f_light_yaml","yang":"_f_yang","yarnlock":"_f_yarn","zig":"_f_zig","zip":"_f_zip"}},"hidesExplorerArrows":false} |
apps/web/src/vscode-icons.test.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import { assert, describe, it } from "vitest"; | |
| 2 | ||
| 3 | import { getVscodeIconUrlForEntry } from "./vscode-icons"; | |
| 4 | ||
| 5 | describe("getVscodeIconUrlForEntry", () => { | |
| 6 | it("uses exact filename matches from the vscode-icons manifest", () => { | |
| 7 | const darkUrl = getVscodeIconUrlForEntry("pnpm-workspace.yaml", "file", "dark"); | |
| 8 | const lightUrl = getVscodeIconUrlForEntry("pnpm-workspace.yaml", "file", "light"); | |
| 9 | ||
| 10 | assert.isTrue(darkUrl.endsWith("/file_type_pnpm.svg")); | |
| 11 | assert.isTrue(lightUrl.endsWith("/file_type_light_pnpm.svg")); | |
| 12 | }); | |
| 13 | ||
| 14 | it("uses longest extension match for compound extensions", () => { | |
| 15 | const iconUrl = getVscodeIconUrlForEntry("tsconfig.tsbuildinfo", "file", "dark"); | |
| 16 | const manifestStyleUrl = getVscodeIconUrlForEntry("buf.yaml", "file", "dark"); | |
| 17 | assert.isTrue(iconUrl.endsWith("/file_type_tsbuildinfo.svg")); | |
| 18 | assert.isTrue(manifestStyleUrl.endsWith("/file_type_buf.svg")); | |
| 19 | }); | |
| 20 | ||
| 21 | it("uses folder mappings and light-aware filename mappings", () => { | |
| 22 | const folderUrl = getVscodeIconUrlForEntry("packages/src", "directory", "light"); | |
| 23 | const fileUrl = getVscodeIconUrlForEntry("AGENTS.md", "file", "light"); | |
| 24 | ||
| 25 | assert.isTrue(folderUrl.endsWith("/folder_type_src.svg")); | |
| 26 | assert.isTrue(fileUrl.endsWith("/file_type_light_agents.svg")); | |
| 27 | }); | |
| 28 | ||
| 29 | it("falls back to language-based mappings for path-only cases", () => { | |
| 30 | const tsxUrl = getVscodeIconUrlForEntry("checkbox.tsx", "file", "light"); | |
| 31 | const dockerfileUrl = getVscodeIconUrlForEntry("Dockerfile", "file", "dark"); | |
| 32 | const shellUrl = getVscodeIconUrlForEntry("entrypoint.sh", "file", "dark"); | |
| 33 | const cursorRulesUrl = getVscodeIconUrlForEntry("general.mdc", "file", "dark"); | |
| 34 | const githubWorkflowUrl = getVscodeIconUrlForEntry(".github/workflows/ci.yml", "file", "light"); | |
| 35 | ||
| 36 | assert.isTrue(tsxUrl.endsWith("/file_type_reactts.svg")); | |
| 37 | assert.isTrue(dockerfileUrl.endsWith("/file_type_docker.svg")); | |
| 38 | assert.isTrue(shellUrl.endsWith("/file_type_shell.svg")); | |
| 39 | assert.isTrue(cursorRulesUrl.endsWith("/file_type_markdown.svg")); | |
| 40 | assert.isTrue(githubWorkflowUrl.endsWith("/file_type_light_yaml.svg")); | |
| 41 | }); | |
| 42 | ||
| 43 | it("falls back to defaults when there is no match", () => { | |
| 44 | const fileUrl = getVscodeIconUrlForEntry("foo.unknown-ext", "file", "dark"); | |
| 45 | const folderUrl = getVscodeIconUrlForEntry("totally-unknown-folder", "directory", "dark"); | |
| 46 | ||
| 47 | assert.isTrue(fileUrl.endsWith("/default_file.svg")); | |
| 48 | assert.isTrue(folderUrl.endsWith("/default_folder.svg")); | |
| 49 | }); | |
| 50 | }); |
apps/web/src/vscode-icons.ts created+168| ... | ... | @@ -0,0 +1,168 @@ |
| 1 | import vscodeIconsManifest from "./vscode-icons-manifest.json"; | |
| 2 | import languageAssociationsData from "./vscode-icons-language-associations.json"; | |
| 3 | ||
| 4 | const VSCODE_ICONS_VERSION = "v12.17.0"; | |
| 5 | const VSCODE_ICONS_BASE_URL = `https://cdn.jsdelivr.net/gh/vscode-icons/vscode-icons@${VSCODE_ICONS_VERSION}/icons`; | |
| 6 | ||
| 7 | interface IconDefinition { | |
| 8 | iconPath: string; | |
| 9 | } | |
| 10 | ||
| 11 | interface IconLookupSection { | |
| 12 | file?: string; | |
| 13 | folder?: string; | |
| 14 | fileNames: Record<string, string>; | |
| 15 | fileExtensions: Record<string, string>; | |
| 16 | folderNames: Record<string, string>; | |
| 17 | languageIds?: Record<string, string>; | |
| 18 | } | |
| 19 | ||
| 20 | interface VscodeIconsManifest extends IconLookupSection { | |
| 21 | iconDefinitions: Record<string, IconDefinition>; | |
| 22 | light: IconLookupSection; | |
| 23 | } | |
| 24 | ||
| 25 | interface LanguageAssociations { | |
| 26 | version: string; | |
| 27 | extensionToLanguageId: Record<string, string>; | |
| 28 | fileNameToLanguageId: Record<string, string>; | |
| 29 | } | |
| 30 | ||
| 31 | const manifest = vscodeIconsManifest as VscodeIconsManifest; | |
| 32 | const languageAssociations = languageAssociationsData as LanguageAssociations; | |
| 33 | const iconDefinitions = manifest.iconDefinitions; | |
| 34 | ||
| 35 | const darkFileNames = toLowercaseLookup(manifest.fileNames); | |
| 36 | const lightFileNames = toLowercaseLookup(manifest.light.fileNames); | |
| 37 | const darkFileExtensions = toLowercaseLookup(manifest.fileExtensions); | |
| 38 | const lightFileExtensions = toLowercaseLookup(manifest.light.fileExtensions); | |
| 39 | const darkFolderNames = toLowercaseLookup(manifest.folderNames); | |
| 40 | const lightFolderNames = toLowercaseLookup(manifest.light.folderNames); | |
| 41 | const darkLanguageIds = toLowercaseLookup(manifest.languageIds ?? {}); | |
| 42 | const lightLanguageIds = toLowercaseLookup(manifest.light.languageIds ?? {}); | |
| 43 | const languageIdByExtension = toLowercaseLookup(languageAssociations.extensionToLanguageId); | |
| 44 | const languageIdByFileName = toLowercaseLookup(languageAssociations.fileNameToLanguageId); | |
| 45 | const localLanguageIdByExtensionOverrides = { | |
| 46 | // Cursor rules files (*.mdc) are commonly treated as markdown in VSCode/Cursor. | |
| 47 | mdc: "markdown", | |
| 48 | // Upstream languages.ts maps yml/yaml to specialized language ids that can produce | |
| 49 | // non-generic YAML icons (for example cloudfoundry/esphome). Prefer the base YAML icon | |
| 50 | // unless a more specific basename/extension match (e.g. azure-pipelines.yml) is found. | |
| 51 | yml: "yaml", | |
| 52 | yaml: "yaml", | |
| 53 | } as const; | |
| 54 | ||
| 55 | const defaultDarkFileIconDefinition = manifest.file ?? "_file"; | |
| 56 | const defaultLightFileIconDefinition = manifest.light.file ?? defaultDarkFileIconDefinition; | |
| 57 | const defaultDarkFolderIconDefinition = manifest.folder ?? "_folder"; | |
| 58 | const defaultLightFolderIconDefinition = manifest.light.folder ?? defaultDarkFolderIconDefinition; | |
| 59 | ||
| 60 | function toLowercaseLookup(source: Record<string, string>): Record<string, string> { | |
| 61 | const entries = Object.entries(source); | |
| 62 | const lookup: Record<string, string> = {}; | |
| 63 | for (const [key, value] of entries) { | |
| 64 | lookup[key.toLowerCase()] = value; | |
| 65 | } | |
| 66 | return lookup; | |
| 67 | } | |
| 68 | ||
| 69 | export function basenameOfPath(pathValue: string): string { | |
| 70 | const slashIndex = pathValue.lastIndexOf("/"); | |
| 71 | if (slashIndex === -1) return pathValue; | |
| 72 | return pathValue.slice(slashIndex + 1); | |
| 73 | } | |
| 74 | ||
| 75 | function extensionCandidates(fileName: string): string[] { | |
| 76 | const candidates = new Set<string>(); | |
| 77 | if (fileName.includes(".")) { | |
| 78 | candidates.add(fileName); | |
| 79 | } | |
| 80 | let dotIndex = fileName.indexOf("."); | |
| 81 | while (dotIndex !== -1 && dotIndex < fileName.length - 1) { | |
| 82 | const candidate = fileName.slice(dotIndex + 1); | |
| 83 | if (candidate.length > 0) { | |
| 84 | candidates.add(candidate); | |
| 85 | } | |
| 86 | dotIndex = fileName.indexOf(".", dotIndex + 1); | |
| 87 | } | |
| 88 | return [...candidates]; | |
| 89 | } | |
| 90 | ||
| 91 | function resolveLanguageFallbackDefinition( | |
| 92 | pathValue: string, | |
| 93 | theme: "light" | "dark", | |
| 94 | ): string | null { | |
| 95 | const basename = basenameOfPath(pathValue).toLowerCase(); | |
| 96 | const languageIds = theme === "light" ? lightLanguageIds : darkLanguageIds; | |
| 97 | ||
| 98 | const fromBasenameLanguage = languageIdByFileName[basename]; | |
| 99 | if (fromBasenameLanguage) { | |
| 100 | return languageIds[fromBasenameLanguage] ?? darkLanguageIds[fromBasenameLanguage] ?? null; | |
| 101 | } | |
| 102 | ||
| 103 | for (const candidate of extensionCandidates(basename)) { | |
| 104 | const languageId = | |
| 105 | localLanguageIdByExtensionOverrides[ | |
| 106 | candidate as keyof typeof localLanguageIdByExtensionOverrides | |
| 107 | ] ?? languageIdByExtension[candidate]; | |
| 108 | if (!languageId) continue; | |
| 109 | return languageIds[languageId] ?? darkLanguageIds[languageId] ?? null; | |
| 110 | } | |
| 111 | ||
| 112 | return null; | |
| 113 | } | |
| 114 | ||
| 115 | function iconFilenameForDefinitionKey(definitionKey: string | undefined): string | null { | |
| 116 | if (!definitionKey) return null; | |
| 117 | const iconPath = iconDefinitions[definitionKey]?.iconPath; | |
| 118 | if (!iconPath) return null; | |
| 119 | const slashIndex = iconPath.lastIndexOf("/"); | |
| 120 | if (slashIndex === -1) { | |
| 121 | return iconPath; | |
| 122 | } | |
| 123 | return iconPath.slice(slashIndex + 1); | |
| 124 | } | |
| 125 | ||
| 126 | function resolveFileDefinition(pathValue: string, theme: "light" | "dark"): string { | |
| 127 | const basename = basenameOfPath(pathValue).toLowerCase(); | |
| 128 | const fileNames = theme === "light" ? lightFileNames : darkFileNames; | |
| 129 | const fileExtensions = theme === "light" ? lightFileExtensions : darkFileExtensions; | |
| 130 | ||
| 131 | const fromFileName = fileNames[basename] ?? darkFileNames[basename]; | |
| 132 | if (fromFileName) return fromFileName; | |
| 133 | ||
| 134 | for (const candidate of extensionCandidates(basename)) { | |
| 135 | const fromExtension = fileExtensions[candidate] ?? darkFileExtensions[candidate]; | |
| 136 | if (fromExtension) return fromExtension; | |
| 137 | } | |
| 138 | ||
| 139 | const fromLanguage = resolveLanguageFallbackDefinition(pathValue, theme); | |
| 140 | if (fromLanguage) return fromLanguage; | |
| 141 | ||
| 142 | return theme === "light" ? defaultLightFileIconDefinition : defaultDarkFileIconDefinition; | |
| 143 | } | |
| 144 | ||
| 145 | function resolveFolderDefinition(pathValue: string, theme: "light" | "dark"): string { | |
| 146 | const basename = basenameOfPath(pathValue).toLowerCase(); | |
| 147 | const folderNames = theme === "light" ? lightFolderNames : darkFolderNames; | |
| 148 | return ( | |
| 149 | folderNames[basename] ?? | |
| 150 | darkFolderNames[basename] ?? | |
| 151 | (theme === "light" ? defaultLightFolderIconDefinition : defaultDarkFolderIconDefinition) | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | export function getVscodeIconUrlForEntry( | |
| 156 | pathValue: string, | |
| 157 | kind: "file" | "directory", | |
| 158 | theme: "light" | "dark", | |
| 159 | ): string { | |
| 160 | const definitionKey = | |
| 161 | kind === "directory" | |
| 162 | ? resolveFolderDefinition(pathValue, theme) | |
| 163 | : resolveFileDefinition(pathValue, theme); | |
| 164 | const iconFilename = | |
| 165 | iconFilenameForDefinitionKey(definitionKey) ?? | |
| 166 | (kind === "directory" ? "default_folder.svg" : "default_file.svg"); | |
| 167 | return `${VSCODE_ICONS_BASE_URL}/${iconFilename}`; | |
| 168 | } |
apps/web/src/workers/diffs.worker.ts created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | import "@pierre/diffs/worker/worker.js"; |
apps/web/src/worktreeCleanup.test.ts+1| ... | ... | @@ -25,6 +25,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 25 | 25 | session: null, |
| 26 | 26 | messages: [], |
| 27 | 27 | events: [], |
| 28 | turnDiffSummaries: [], | |
| 28 | 29 | error: null, |
| 29 | 30 | createdAt: "2026-02-13T00:00:00.000Z", |
| 30 | 31 | branch: null, |
apps/web/src/wsNativeApi.ts+7| ... | ... | @@ -91,6 +91,10 @@ export function createWsNativeApi(): NativeApi { |
| 91 | 91 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), |
| 92 | 92 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), |
| 93 | 93 | listSessions: () => transport.request(WS_METHODS.providersListSessions), |
| 94 | listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), | |
| 95 | getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), | |
| 96 | revertToCheckpoint: (input) => | |
| 97 | transport.request(WS_METHODS.providersRevertToCheckpoint, input), | |
| 94 | 98 | onEvent: (callback) => |
| 95 | 99 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), |
| 96 | 100 | }, |
| ... | ... | @@ -98,6 +102,8 @@ export function createWsNativeApi(): NativeApi { |
| 98 | 102 | list: () => transport.request(WS_METHODS.projectsList), |
| 99 | 103 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), |
| 100 | 104 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), |
| 105 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), | |
| 106 | updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input), | |
| 101 | 107 | }, |
| 102 | 108 | shell: { |
| 103 | 109 | openInEditor: (cwd, editor) => |
| ... | ... | @@ -141,6 +147,7 @@ export function createWsNativeApi(): NativeApi { |
| 141 | 147 | }, |
| 142 | 148 | server: { |
| 143 | 149 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), |
| 150 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), | |
| 144 | 151 | }, |
| 145 | 152 | }; |
| 146 | 153 |
apps/web/src/wsTransport.ts+3| ... | ... | @@ -37,6 +37,9 @@ export class WsTransport { |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | async request<T = unknown>(method: string, params?: unknown): Promise<T> { |
| 40 | if (typeof method !== "string" || method.length === 0) { | |
| 41 | throw new Error("Request method is required"); | |
| 42 | } | |
| 40 | 43 | const id = String(this.nextId++); |
| 41 | 44 | const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) }; |
| 42 | 45 |
apps/web/vite.config.ts+5| ... | ... | @@ -1,11 +1,13 @@ |
| 1 | 1 | import tailwindcss from "@tailwindcss/vite"; |
| 2 | 2 | import react from "@vitejs/plugin-react"; |
| 3 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; | |
| 3 | 4 | import { defineConfig } from "vite"; |
| 4 | 5 | |
| 5 | 6 | const port = Number(process.env.PORT ?? 5173); |
| 6 | 7 | |
| 7 | 8 | export default defineConfig({ |
| 8 | 9 | plugins: [ |
| 10 | tanstackRouter(), | |
| 9 | 11 | react({ |
| 10 | 12 | babel: { |
| 11 | 13 | plugins: [["babel-plugin-react-compiler", { target: "19" }]], |
| ... | ... | @@ -13,6 +15,9 @@ export default defineConfig({ |
| 13 | 15 | }), |
| 14 | 16 | tailwindcss(), |
| 15 | 17 | ], |
| 18 | optimizeDeps: { | |
| 19 | include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], | |
| 20 | }, | |
| 16 | 21 | define: { |
| 17 | 22 | // In dev mode, tell the web app where the WebSocket server lives |
| 18 | 23 | "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""), |
assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png created| Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png differ |
assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png created| Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png differ |
assets/T3 Code logo.svg	 created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/> | |
| 3 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/> | |
| 4 | </svg> |
assets/T3 Code logo@16x.png created| Binary files /dev/null and b/assets/T3 Code logo@16x.png differ |
assets/T3 Code logo@32x.png created| Binary files /dev/null and b/assets/T3 Code logo@32x.png differ |
assets/T3 Code logo@8x.png created| Binary files /dev/null and b/assets/T3 Code logo@8x.png differ |
assets/T3 Code-macOS-Default-1024x1024@1x.png created| Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@1x.png differ |
assets/T3 Code-macOS-Default-1024x1024@4x.png created| Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@4x.png differ |
assets/logo-1024.png created| Binary files /dev/null and b/assets/logo-1024.png differ |
assets/logo.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/> | |
| 3 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/> | |
| 4 | </svg> |
assets/macos-icon-1024.png created| Binary files /dev/null and b/assets/macos-icon-1024.png differ |
bun.lock+138-1| ... | ... | @@ -52,8 +52,12 @@ |
| 52 | 52 | "version": "0.0.0", |
| 53 | 53 | "dependencies": { |
| 54 | 54 | "@base-ui/react": "^1.2.0", |
| 55 | "@pierre/diffs": "^1.1.0-beta.16", | |
| 55 | 56 | "@t3tools/contracts": "workspace:*", |
| 57 | "@tanstack/react-pacer": "^0.19.4", | |
| 56 | 58 | "@tanstack/react-query": "^5.90.0", |
| 59 | "@tanstack/react-router": "^1.160.2", | |
| 60 | "@tanstack/react-virtual": "^3.13.18", | |
| 57 | 61 | "@xterm/addon-fit": "^0.11.0", |
| 58 | 62 | "@xterm/xterm": "^6.0.0", |
| 59 | 63 | "class-variance-authority": "^0.7.1", |
| ... | ... | @@ -69,6 +73,7 @@ |
| 69 | 73 | }, |
| 70 | 74 | "devDependencies": { |
| 71 | 75 | "@tailwindcss/vite": "^4.0.0", |
| 76 | "@tanstack/router-plugin": "^1.161.0", | |
| 72 | 77 | "@types/react": "^19.0.0", |
| 73 | 78 | "@types/react-dom": "^19.0.0", |
| 74 | 79 | "@vitejs/plugin-react": "^5.1.4", |
| ... | ... | @@ -119,6 +124,10 @@ |
| 119 | 124 | |
| 120 | 125 | "@babel/parser": ["@babel/parser@8.0.0-rc.1", "", { "dependencies": { "@babel/types": "^8.0.0-rc.1" }, "bin": "./bin/babel-parser.js" }, "sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA=="], |
| 121 | 126 | |
| 127 | "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], | |
| 128 | ||
| 129 | "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], | |
| 130 | ||
| 122 | 131 | "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], |
| 123 | 132 | |
| 124 | 133 | "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], |
| ... | ... | @@ -303,6 +312,8 @@ |
| 303 | 312 | |
| 304 | 313 | "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="], |
| 305 | 314 | |
| 315 | "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], | |
| 316 | ||
| 306 | 317 | "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], |
| 307 | 318 | |
| 308 | 319 | "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="], |
| ... | ... | @@ -383,6 +394,22 @@ |
| 383 | 394 | |
| 384 | 395 | "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], |
| 385 | 396 | |
| 397 | "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="], | |
| 398 | ||
| 399 | "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="], | |
| 400 | ||
| 401 | "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="], | |
| 402 | ||
| 403 | "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="], | |
| 404 | ||
| 405 | "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="], | |
| 406 | ||
| 407 | "@shikijs/transformers": ["@shikijs/transformers@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/types": "3.22.0" } }, "sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw=="], | |
| 408 | ||
| 409 | "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], | |
| 410 | ||
| 411 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], | |
| 412 | ||
| 386 | 413 | "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], |
| 387 | 414 | |
| 388 | 415 | "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], |
| ... | ... | @@ -425,10 +452,38 @@ |
| 425 | 452 | |
| 426 | 453 | "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], |
| 427 | 454 | |
| 455 | "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="], | |
| 456 | ||
| 457 | "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="], | |
| 458 | ||
| 459 | "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="], | |
| 460 | ||
| 428 | 461 | "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], |
| 429 | 462 | |
| 463 | "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="], | |
| 464 | ||
| 430 | 465 | "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], |
| 431 | 466 | |
| 467 | "@tanstack/react-router": ["@tanstack/react-router@1.160.2", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.160.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-EJWAMS4qCfWKNCzzYGy6ZuWTdBATYEEWieaQdmM7zUesyOQ01j7o6aKXdmCp9rWuSKjPHXagWubEnEo+Puhi3w=="], | |
| 468 | ||
| 469 | "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], | |
| 470 | ||
| 471 | "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], | |
| 472 | ||
| 473 | "@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="], | |
| 474 | ||
| 475 | "@tanstack/router-generator": ["@tanstack/router-generator@1.160.1", "", { "dependencies": { "@tanstack/router-core": "1.160.0", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-De6TicInwy3/9rQ++RZAyFOvB2oi5UV5T0iiIlxe3jgiOLFxMA4EKKVlT+alDxKnq6udTLam9xqhvGOVZ6a2hw=="], | |
| 476 | ||
| 477 | "@tanstack/router-plugin": ["@tanstack/router-plugin@1.161.0", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.160.0", "@tanstack/router-generator": "1.160.1", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.160.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-E0JGeMdKGYYMMjOEiM+330nIs0PG3nE/9KyU+7+SV54hDf4Xdt6BC3J9pDj1qA3oNqI5DwwTsG7GBA+7m47O4A=="], | |
| 478 | ||
| 479 | "@tanstack/router-utils": ["@tanstack/router-utils@1.158.0", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-qZ76eaLKU6Ae9iI/mc5zizBX149DXXZkBVVO3/QRIll79uKLJZHQlMKR++2ba7JsciBWz1pgpIBcCJPE9S0LVg=="], | |
| 480 | ||
| 481 | "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], | |
| 482 | ||
| 483 | "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], | |
| 484 | ||
| 485 | "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="], | |
| 486 | ||
| 432 | 487 | "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], |
| 433 | 488 | |
| 434 | 489 | "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], |
| ... | ... | @@ -501,26 +556,36 @@ |
| 501 | 556 | |
| 502 | 557 | "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], |
| 503 | 558 | |
| 559 | "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], | |
| 560 | ||
| 504 | 561 | "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], |
| 505 | 562 | |
| 506 | 563 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], |
| 507 | 564 | |
| 508 | 565 | "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], |
| 509 | 566 | |
| 567 | "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], | |
| 568 | ||
| 510 | 569 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| 511 | 570 | |
| 512 | 571 | "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], |
| 513 | 572 | |
| 573 | "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], | |
| 574 | ||
| 514 | 575 | "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], |
| 515 | 576 | |
| 516 | 577 | "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], |
| 517 | 578 | |
| 579 | "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], | |
| 580 | ||
| 518 | 581 | "babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g=="], |
| 519 | 582 | |
| 520 | 583 | "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], |
| 521 | 584 | |
| 522 | 585 | "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], |
| 523 | 586 | |
| 587 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], | |
| 588 | ||
| 524 | 589 | "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], |
| 525 | 590 | |
| 526 | 591 | "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], |
| ... | ... | @@ -557,6 +622,8 @@ |
| 557 | 622 | |
| 558 | 623 | "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], |
| 559 | 624 | |
| 625 | "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], | |
| 626 | ||
| 560 | 627 | "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], |
| 561 | 628 | |
| 562 | 629 | "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], |
| ... | ... | @@ -579,6 +646,8 @@ |
| 579 | 646 | |
| 580 | 647 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], |
| 581 | 648 | |
| 649 | "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], | |
| 650 | ||
| 582 | 651 | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], |
| 583 | 652 | |
| 584 | 653 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], |
| ... | ... | @@ -611,6 +680,8 @@ |
| 611 | 680 | |
| 612 | 681 | "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], |
| 613 | 682 | |
| 683 | "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], | |
| 684 | ||
| 614 | 685 | "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], |
| 615 | 686 | |
| 616 | 687 | "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], |
| ... | ... | @@ -649,6 +720,8 @@ |
| 649 | 720 | |
| 650 | 721 | "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], |
| 651 | 722 | |
| 723 | "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], | |
| 724 | ||
| 652 | 725 | "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], |
| 653 | 726 | |
| 654 | 727 | "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], |
| ... | ... | @@ -687,6 +760,8 @@ |
| 687 | 760 | |
| 688 | 761 | "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], |
| 689 | 762 | |
| 763 | "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], | |
| 764 | ||
| 690 | 765 | "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], |
| 691 | 766 | |
| 692 | 767 | "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], |
| ... | ... | @@ -711,6 +786,8 @@ |
| 711 | 786 | |
| 712 | 787 | "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], |
| 713 | 788 | |
| 789 | "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], | |
| 790 | ||
| 714 | 791 | "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], |
| 715 | 792 | |
| 716 | 793 | "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], |
| ... | ... | @@ -725,6 +802,8 @@ |
| 725 | 802 | |
| 726 | 803 | "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], |
| 727 | 804 | |
| 805 | "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], | |
| 806 | ||
| 728 | 807 | "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], |
| 729 | 808 | |
| 730 | 809 | "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], |
| ... | ... | @@ -739,12 +818,18 @@ |
| 739 | 818 | |
| 740 | 819 | "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], |
| 741 | 820 | |
| 821 | "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], | |
| 822 | ||
| 742 | 823 | "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], |
| 743 | 824 | |
| 744 | 825 | "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], |
| 745 | 826 | |
| 827 | "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], | |
| 828 | ||
| 746 | 829 | "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], |
| 747 | 830 | |
| 831 | "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], | |
| 832 | ||
| 748 | 833 | "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], |
| 749 | 834 | |
| 750 | 835 | "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], |
| ... | ... | @@ -757,6 +842,8 @@ |
| 757 | 842 | |
| 758 | 843 | "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], |
| 759 | 844 | |
| 845 | "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], | |
| 846 | ||
| 760 | 847 | "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], |
| 761 | 848 | |
| 762 | 849 | "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="], |
| ... | ... | @@ -811,6 +898,8 @@ |
| 811 | 898 | |
| 812 | 899 | "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], |
| 813 | 900 | |
| 901 | "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], | |
| 902 | ||
| 814 | 903 | "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="], |
| 815 | 904 | |
| 816 | 905 | "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], |
| ... | ... | @@ -931,7 +1020,7 @@ |
| 931 | 1020 | |
| 932 | 1021 | "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], |
| 933 | 1022 | |
| 934 | "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="], | |
| 1023 | "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], | |
| 935 | 1024 | |
| 936 | 1025 | "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], |
| 937 | 1026 | |
| ... | ... | @@ -941,6 +1030,10 @@ |
| 941 | 1030 | |
| 942 | 1031 | "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], |
| 943 | 1032 | |
| 1033 | "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], | |
| 1034 | ||
| 1035 | "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], | |
| 1036 | ||
| 944 | 1037 | "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], |
| 945 | 1038 | |
| 946 | 1039 | "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], |
| ... | ... | @@ -967,6 +1060,8 @@ |
| 967 | 1060 | |
| 968 | 1061 | "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], |
| 969 | 1062 | |
| 1063 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], | |
| 1064 | ||
| 970 | 1065 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], |
| 971 | 1066 | |
| 972 | 1067 | "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], |
| ... | ... | @@ -987,6 +1082,16 @@ |
| 987 | 1082 | |
| 988 | 1083 | "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], |
| 989 | 1084 | |
| 1085 | "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], | |
| 1086 | ||
| 1087 | "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], | |
| 1088 | ||
| 1089 | "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], | |
| 1090 | ||
| 1091 | "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], | |
| 1092 | ||
| 1093 | "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], | |
| 1094 | ||
| 990 | 1095 | "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], |
| 991 | 1096 | |
| 992 | 1097 | "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], |
| ... | ... | @@ -1035,10 +1140,18 @@ |
| 1035 | 1140 | |
| 1036 | 1141 | "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], |
| 1037 | 1142 | |
| 1143 | "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], | |
| 1144 | ||
| 1145 | "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], | |
| 1146 | ||
| 1147 | "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="], | |
| 1148 | ||
| 1038 | 1149 | "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], |
| 1039 | 1150 | |
| 1040 | 1151 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], |
| 1041 | 1152 | |
| 1153 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], | |
| 1154 | ||
| 1042 | 1155 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], |
| 1043 | 1156 | |
| 1044 | 1157 | "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], |
| ... | ... | @@ -1079,6 +1192,10 @@ |
| 1079 | 1192 | |
| 1080 | 1193 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], |
| 1081 | 1194 | |
| 1195 | "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], | |
| 1196 | ||
| 1197 | "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], | |
| 1198 | ||
| 1082 | 1199 | "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], |
| 1083 | 1200 | |
| 1084 | 1201 | "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], |
| ... | ... | @@ -1107,6 +1224,8 @@ |
| 1107 | 1224 | |
| 1108 | 1225 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], |
| 1109 | 1226 | |
| 1227 | "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], | |
| 1228 | ||
| 1110 | 1229 | "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="], |
| 1111 | 1230 | |
| 1112 | 1231 | "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], |
| ... | ... | @@ -1147,6 +1266,8 @@ |
| 1147 | 1266 | |
| 1148 | 1267 | "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], |
| 1149 | 1268 | |
| 1269 | "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], | |
| 1270 | ||
| 1150 | 1271 | "unrun": ["unrun@0.2.27", "", { "dependencies": { "rolldown": "1.0.0-rc.3" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw=="], |
| 1151 | 1272 | |
| 1152 | 1273 | "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], |
| ... | ... | @@ -1167,6 +1288,8 @@ |
| 1167 | 1288 | |
| 1168 | 1289 | "watchboy": ["watchboy@0.4.3", "", { "dependencies": { "lodash.difference": "^4.5.0", "micromatch": "^4.0.2", "pify": "^4.0.1", "unixify": "^1.0.0" } }, "sha512-GHs1HxwvxSMBsqd/WfTOZhj5gBdMqf5HQpfgtKxDfZRxrlYPDdVLRB61LCeRzJaWANmvSIMlfmRVDwVmJFgAKA=="], |
| 1169 | 1290 | |
| 1291 | "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], | |
| 1292 | ||
| 1170 | 1293 | "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], |
| 1171 | 1294 | |
| 1172 | 1295 | "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], |
| ... | ... | @@ -1235,12 +1358,20 @@ |
| 1235 | 1358 | |
| 1236 | 1359 | "@tailwindcss/vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], |
| 1237 | 1360 | |
| 1361 | "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], | |
| 1362 | ||
| 1363 | "@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], | |
| 1364 | ||
| 1238 | 1365 | "@types/babel__core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], |
| 1239 | 1366 | |
| 1240 | 1367 | "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], |
| 1241 | 1368 | |
| 1242 | 1369 | "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], |
| 1243 | 1370 | |
| 1371 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | |
| 1372 | ||
| 1373 | "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], | |
| 1374 | ||
| 1244 | 1375 | "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], |
| 1245 | 1376 | |
| 1246 | 1377 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], |
| ... | ... | @@ -1253,10 +1384,16 @@ |
| 1253 | 1384 | |
| 1254 | 1385 | "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], |
| 1255 | 1386 | |
| 1387 | "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | |
| 1388 | ||
| 1389 | "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], | |
| 1390 | ||
| 1256 | 1391 | "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="], |
| 1257 | 1392 | |
| 1258 | 1393 | "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], |
| 1259 | 1394 | |
| 1395 | "unixify/normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="], | |
| 1396 | ||
| 1260 | 1397 | "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], |
| 1261 | 1398 | |
| 1262 | 1399 | "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], |
package.json+6-1| ... | ... | @@ -19,7 +19,12 @@ |
| 19 | 19 | "test": "turbo run test", |
| 20 | 20 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 21 | 21 | "fmt": "oxfmt", |
| 22 | "build:contracts": "turbo run build --filter=@t3tools/contracts" | |
| 22 | "build:contracts": "turbo run build --filter=@t3tools/contracts", | |
| 23 | "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs", | |
| 24 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64", | |
| 25 | "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64", | |
| 26 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", | |
| 27 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" | |
| 23 | 28 | }, |
| 24 | 29 | "devDependencies": { |
| 25 | 30 | "oxfmt": "^0.28.0", |
packages/contracts/package.json+1-1| ... | ... | @@ -12,7 +12,7 @@ |
| 12 | 12 | "exports": { |
| 13 | 13 | ".": { |
| 14 | 14 | "types": "./src/index.ts", |
| 15 | "import": "./dist/index.mjs", | |
| 15 | "import": "./src/index.ts", | |
| 16 | 16 | "require": "./dist/index.cjs" |
| 17 | 17 | } |
| 18 | 18 | }, |
packages/contracts/src/git.ts+1| ... | ... | @@ -16,6 +16,7 @@ export const gitCreateWorktreeInputSchema = z.object({ |
| 16 | 16 | export const gitRemoveWorktreeInputSchema = z.object({ |
| 17 | 17 | cwd: z.string().min(1), |
| 18 | 18 | path: z.string().min(1), |
| 19 | force: z.boolean().optional(), | |
| 19 | 20 | }); |
| 20 | 21 | |
| 21 | 22 | export const gitCreateBranchInputSchema = z.object({ |
packages/contracts/src/ipc.ts+23| ... | ... | @@ -17,7 +17,13 @@ import type { |
| 17 | 17 | } from "./git"; |
| 18 | 18 | import type { |
| 19 | 19 | ProviderEvent, |
| 20 | ProviderGetCheckpointDiffInput, | |
| 21 | ProviderGetCheckpointDiffResult, | |
| 20 | 22 | ProviderInterruptTurnInput, |
| 23 | ProviderListCheckpointsInput, | |
| 24 | ProviderListCheckpointsResult, | |
| 25 | ProviderRevertToCheckpointInput, | |
| 26 | ProviderRevertToCheckpointResult, | |
| 21 | 27 | ProviderRespondToRequestInput, |
| 22 | 28 | ProviderSendTurnInput, |
| 23 | 29 | ProviderSession, |
| ... | ... | @@ -29,7 +35,11 @@ import type { |
| 29 | 35 | ProjectAddInput, |
| 30 | 36 | ProjectAddResult, |
| 31 | 37 | ProjectListResult, |
| 38 | ProjectSearchEntriesInput, | |
| 39 | ProjectSearchEntriesResult, | |
| 32 | 40 | ProjectRemoveInput, |
| 41 | ProjectUpdateScriptsInput, | |
| 42 | ProjectUpdateScriptsResult, | |
| 33 | 43 | } from "./project"; |
| 34 | 44 | import type { ServerConfig } from "./server"; |
| 35 | 45 | import type { |
| ... | ... | @@ -42,6 +52,7 @@ import type { |
| 42 | 52 | TerminalWriteInput, |
| 43 | 53 | } from "./terminal"; |
| 44 | 54 | import type { NewTodoInput, Todo } from "./todo"; |
| 55 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; | |
| 45 | 56 | |
| 46 | 57 | export const EDITORS = [ |
| 47 | 58 | { id: "cursor", label: "Cursor", command: "cursor" }, |
| ... | ... | @@ -84,12 +95,23 @@ export interface NativeApi { |
| 84 | 95 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; |
| 85 | 96 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 86 | 97 | listSessions: () => Promise<ProviderSession[]>; |
| 98 | listCheckpoints: ( | |
| 99 | input: ProviderListCheckpointsInput, | |
| 100 | ) => Promise<ProviderListCheckpointsResult>; | |
| 101 | getCheckpointDiff: ( | |
| 102 | input: ProviderGetCheckpointDiffInput, | |
| 103 | ) => Promise<ProviderGetCheckpointDiffResult>; | |
| 104 | revertToCheckpoint: ( | |
| 105 | input: ProviderRevertToCheckpointInput, | |
| 106 | ) => Promise<ProviderRevertToCheckpointResult>; | |
| 87 | 107 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
| 88 | 108 | }; |
| 89 | 109 | projects: { |
| 90 | 110 | list: () => Promise<ProjectListResult>; |
| 91 | 111 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; |
| 92 | 112 | remove: (input: ProjectRemoveInput) => Promise<void>; |
| 113 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; | |
| 114 | updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>; | |
| 93 | 115 | }; |
| 94 | 116 | shell: { |
| 95 | 117 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| ... | ... | @@ -116,5 +138,6 @@ export interface NativeApi { |
| 116 | 138 | }; |
| 117 | 139 | server: { |
| 118 | 140 | getConfig: () => Promise<ServerConfig>; |
| 141 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | |
| 119 | 142 | }; |
| 120 | 143 | } |
packages/contracts/src/keybindings.test.ts+9-1| ... | ... | @@ -32,11 +32,19 @@ describe("keybindings contracts", () => { |
| 32 | 32 | assert.throws(() => |
| 33 | 33 | keybindingRuleSchema.parse({ |
| 34 | 34 | key: "mod+j", |
| 35 | command: "invalid.command", | |
| 35 | command: "script.Test.run", | |
| 36 | 36 | }), |
| 37 | 37 | ); |
| 38 | 38 | }); |
| 39 | 39 | |
| 40 | it("accepts dynamic script run commands", () => { | |
| 41 | const parsed = keybindingRuleSchema.parse({ | |
| 42 | key: "mod+r", | |
| 43 | command: "script.setup.run", | |
| 44 | }); | |
| 45 | assert.strictEqual(parsed.command, "script.setup.run"); | |
| 46 | }); | |
| 47 | ||
| 40 | 48 | it("parses keybindings array payload", () => { |
| 41 | 49 | const parsed = keybindingsConfigSchema.parse([ |
| 42 | 50 | { key: "mod+j", command: "terminal.toggle" }, |
packages/contracts/src/keybindings.ts+28-14| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | export const keybindingCommandSchema = z.enum([ | |
| 3 | export const STATIC_KEYBINDING_COMMANDS = [ | |
| 4 | 4 | "terminal.toggle", |
| 5 | 5 | "terminal.split", |
| 6 | 6 | "terminal.new", |
| ... | ... | @@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([ |
| 8 | 8 | "chat.new", |
| 9 | 9 | "chat.newLocal", |
| 10 | 10 | "editor.openFavorite", |
| 11 | ] as const; | |
| 12 | ||
| 13 | export const keybindingCommandSchema = z.union([ | |
| 14 | z.enum(STATIC_KEYBINDING_COMMANDS), | |
| 15 | z | |
| 16 | .string() | |
| 17 | .trim() | |
| 18 | .min(1) | |
| 19 | .max(96) | |
| 20 | .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/), | |
| 11 | 21 | ]); |
| 12 | 22 | |
| 13 | 23 | const keybindingValueSchema = z.string().trim().min(1).max(64); |
| ... | ... | @@ -21,14 +31,16 @@ export const keybindingRuleSchema = z.object({ |
| 21 | 31 | |
| 22 | 32 | export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); |
| 23 | 33 | |
| 24 | export const keybindingShortcutSchema = z.object({ | |
| 25 | key: z.string().trim().min(1).max(32), | |
| 26 | metaKey: z.boolean(), | |
| 27 | ctrlKey: z.boolean(), | |
| 28 | shiftKey: z.boolean(), | |
| 29 | altKey: z.boolean(), | |
| 30 | modKey: z.boolean(), | |
| 31 | }).strict(); | |
| 34 | export const keybindingShortcutSchema = z | |
| 35 | .object({ | |
| 36 | key: z.string().trim().min(1).max(32), | |
| 37 | metaKey: z.boolean(), | |
| 38 | ctrlKey: z.boolean(), | |
| 39 | shiftKey: z.boolean(), | |
| 40 | altKey: z.boolean(), | |
| 41 | modKey: z.boolean(), | |
| 42 | }) | |
| 43 | .strict(); | |
| 32 | 44 | |
| 33 | 45 | export type KeybindingWhenNode = |
| 34 | 46 | | { type: "identifier"; name: string } |
| ... | ... | @@ -59,11 +71,13 @@ export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() |
| 59 | 71 | ]), |
| 60 | 72 | ); |
| 61 | 73 | |
| 62 | export const resolvedKeybindingRuleSchema = z.object({ | |
| 63 | command: keybindingCommandSchema, | |
| 64 | shortcut: keybindingShortcutSchema, | |
| 65 | whenAst: keybindingWhenNodeSchema.optional(), | |
| 66 | }).strict(); | |
| 74 | export const resolvedKeybindingRuleSchema = z | |
| 75 | .object({ | |
| 76 | command: keybindingCommandSchema, | |
| 77 | shortcut: keybindingShortcutSchema, | |
| 78 | whenAst: keybindingWhenNodeSchema.optional(), | |
| 79 | }) | |
| 80 | .strict(); | |
| 67 | 81 | |
| 68 | 82 | export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); |
| 69 | 83 |
packages/contracts/src/project.test.ts+98| ... | ... | @@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest"; |
| 3 | 3 | import { |
| 4 | 4 | projectAddInputSchema, |
| 5 | 5 | projectAddResultSchema, |
| 6 | projectSearchEntriesInputSchema, | |
| 7 | projectSearchEntriesResultSchema, | |
| 6 | 8 | projectListResultSchema, |
| 7 | 9 | projectRemoveInputSchema, |
| 10 | projectUpdateScriptsInputSchema, | |
| 11 | projectUpdateScriptsResultSchema, | |
| 8 | 12 | } from "./project"; |
| 9 | 13 | |
| 10 | 14 | describe("project contracts", () => { |
| ... | ... | @@ -14,6 +18,7 @@ describe("project contracts", () => { |
| 14 | 18 | id: "project-1", |
| 15 | 19 | cwd: "/tmp/project", |
| 16 | 20 | name: "project", |
| 21 | scripts: [], | |
| 17 | 22 | createdAt: "2026-02-10T00:00:00.000Z", |
| 18 | 23 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 19 | 24 | }, |
| ... | ... | @@ -34,6 +39,7 @@ describe("project contracts", () => { |
| 34 | 39 | id: "project-1", |
| 35 | 40 | cwd: "/tmp/project", |
| 36 | 41 | name: "project", |
| 42 | scripts: [], | |
| 37 | 43 | createdAt: "2026-02-10T00:00:00.000Z", |
| 38 | 44 | updatedAt: "2026-02-10T00:00:00.000Z", |
| 39 | 45 | }, |
| ... | ... | @@ -46,4 +52,96 @@ describe("project contracts", () => { |
| 46 | 52 | const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); |
| 47 | 53 | expect(parsed.id).toBe("project-1"); |
| 48 | 54 | }); |
| 55 | ||
| 56 | it("parses workspace entry search input with defaults", () => { | |
| 57 | const parsed = projectSearchEntriesInputSchema.parse({ | |
| 58 | cwd: " /tmp/project ", | |
| 59 | query: " src ", | |
| 60 | }); | |
| 61 | ||
| 62 | expect(parsed).toEqual({ | |
| 63 | cwd: "/tmp/project", | |
| 64 | query: "src", | |
| 65 | limit: 80, | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | it("parses workspace entry search result", () => { | |
| 70 | const parsed = projectSearchEntriesResultSchema.parse({ | |
| 71 | entries: [ | |
| 72 | { | |
| 73 | path: "src/components", | |
| 74 | kind: "directory", | |
| 75 | parentPath: "src", | |
| 76 | }, | |
| 77 | { | |
| 78 | path: "src/components/Button.tsx", | |
| 79 | kind: "file", | |
| 80 | parentPath: "src/components", | |
| 81 | }, | |
| 82 | ], | |
| 83 | truncated: false, | |
| 84 | }); | |
| 85 | ||
| 86 | expect(parsed.entries).toHaveLength(2); | |
| 87 | expect(parsed.entries[0]?.kind).toBe("directory"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("parses update scripts input", () => { | |
| 91 | const parsed = projectUpdateScriptsInputSchema.parse({ | |
| 92 | id: "project-1", | |
| 93 | scripts: [ | |
| 94 | { | |
| 95 | id: "test", | |
| 96 | name: "Test", | |
| 97 | command: "bun test", | |
| 98 | icon: "test", | |
| 99 | runOnWorktreeCreate: false, | |
| 100 | }, | |
| 101 | ], | |
| 102 | }); | |
| 103 | ||
| 104 | expect(parsed.scripts).toHaveLength(1); | |
| 105 | expect(parsed.scripts[0]?.id).toBe("test"); | |
| 106 | }); | |
| 107 | ||
| 108 | it("parses debug icon in scripts", () => { | |
| 109 | const parsed = projectUpdateScriptsInputSchema.parse({ | |
| 110 | id: "project-1", | |
| 111 | scripts: [ | |
| 112 | { | |
| 113 | id: "debug", | |
| 114 | name: "Debug", | |
| 115 | command: "bun --inspect test", | |
| 116 | icon: "debug", | |
| 117 | runOnWorktreeCreate: false, | |
| 118 | }, | |
| 119 | ], | |
| 120 | }); | |
| 121 | ||
| 122 | expect(parsed.scripts[0]?.icon).toBe("debug"); | |
| 123 | }); | |
| 124 | ||
| 125 | it("parses update scripts result", () => { | |
| 126 | const parsed = projectUpdateScriptsResultSchema.parse({ | |
| 127 | project: { | |
| 128 | id: "project-1", | |
| 129 | cwd: "/tmp/project", | |
| 130 | name: "project", | |
| 131 | scripts: [ | |
| 132 | { | |
| 133 | id: "setup", | |
| 134 | name: "Setup", | |
| 135 | command: "bun install", | |
| 136 | icon: "configure", | |
| 137 | runOnWorktreeCreate: true, | |
| 138 | }, | |
| 139 | ], | |
| 140 | createdAt: "2026-02-10T00:00:00.000Z", | |
| 141 | updatedAt: "2026-02-10T00:00:00.000Z", | |
| 142 | }, | |
| 143 | }); | |
| 144 | ||
| 145 | expect(parsed.project.scripts[0]?.id).toBe("setup"); | |
| 146 | }); | |
| 49 | 147 | }); |
packages/contracts/src/project.ts+104| ... | ... | @@ -1,9 +1,38 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | 2 | |
| 3 | export const PROJECT_SCRIPT_ICON_VALUES = [ | |
| 4 | "play", | |
| 5 | "test", | |
| 6 | "lint", | |
| 7 | "configure", | |
| 8 | "build", | |
| 9 | "debug", | |
| 10 | ] as const; | |
| 11 | ||
| 12 | export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); | |
| 13 | ||
| 14 | export const projectScriptSchema = z | |
| 15 | .object({ | |
| 16 | id: z | |
| 17 | .string() | |
| 18 | .trim() | |
| 19 | .min(1) | |
| 20 | .max(64) | |
| 21 | .regex(/^[a-z0-9][a-z0-9-]*$/), | |
| 22 | name: z.string().trim().min(1).max(64), | |
| 23 | command: z.string().trim().min(1).max(10_000), | |
| 24 | icon: projectScriptIconSchema, | |
| 25 | runOnWorktreeCreate: z.boolean().default(false), | |
| 26 | }) | |
| 27 | .strict(); | |
| 28 | ||
| 29 | export const projectScriptsSchema = z.array(projectScriptSchema).max(64); | |
| 30 | ||
| 3 | 31 | export const projectRecordSchema = z.object({ |
| 4 | 32 | id: z.string().min(1), |
| 5 | 33 | cwd: z.string().min(1), |
| 6 | 34 | name: z.string().min(1), |
| 35 | scripts: projectScriptsSchema.default([]), | |
| 7 | 36 | createdAt: z.string().datetime(), |
| 8 | 37 | updatedAt: z.string().datetime(), |
| 9 | 38 | }); |
| ... | ... | @@ -23,8 +52,83 @@ export const projectRemoveInputSchema = z.object({ |
| 23 | 52 | id: z.string().min(1), |
| 24 | 53 | }); |
| 25 | 54 | |
| 55 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; | |
| 56 | ||
| 57 | export const projectSearchEntriesInputSchema = z.object({ | |
| 58 | cwd: z.string().trim().min(1), | |
| 59 | query: z.string().trim().max(256).default(""), | |
| 60 | limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80), | |
| 61 | }); | |
| 62 | ||
| 63 | export const projectEntryKindSchema = z.enum(["file", "directory"]); | |
| 64 | ||
| 65 | export const projectEntrySchema = z.object({ | |
| 66 | path: z.string().min(1), | |
| 67 | kind: projectEntryKindSchema, | |
| 68 | parentPath: z.string().optional(), | |
| 69 | }); | |
| 70 | ||
| 71 | export const projectSearchEntriesResultSchema = z.object({ | |
| 72 | entries: z.array(projectEntrySchema), | |
| 73 | truncated: z.boolean(), | |
| 74 | }); | |
| 75 | ||
| 76 | export const projectUpdateScriptsInputSchema = z.object({ | |
| 77 | id: z.string().min(1), | |
| 78 | scripts: projectScriptsSchema, | |
| 79 | }); | |
| 80 | ||
| 81 | export const projectUpdateScriptsResultSchema = z.object({ | |
| 82 | project: projectRecordSchema, | |
| 83 | }); | |
| 84 | ||
| 85 | export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>; | |
| 86 | export type ProjectScript = z.infer<typeof projectScriptSchema>; | |
| 87 | ||
| 88 | /** | |
| 89 | * Deduplicate scripts by ID and enforce at most one `runOnWorktreeCreate: true` | |
| 90 | * flag (the first one wins). Fields are trimmed defensively so this is safe to | |
| 91 | * call on data that hasn't been through Zod parsing. | |
| 92 | */ | |
| 93 | export function normalizeProjectScripts(scripts: readonly ProjectScript[]): ProjectScript[] { | |
| 94 | const seenIds = new Set<string>(); | |
| 95 | const deduped: ProjectScript[] = []; | |
| 96 | for (const script of scripts) { | |
| 97 | const id = script.id.trim(); | |
| 98 | if (id.length === 0 || seenIds.has(id)) continue; | |
| 99 | seenIds.add(id); | |
| 100 | deduped.push({ | |
| 101 | ...script, | |
| 102 | id, | |
| 103 | name: script.name.trim(), | |
| 104 | command: script.command.trim(), | |
| 105 | }); | |
| 106 | } | |
| 107 | ||
| 108 | let setupAssigned = false; | |
| 109 | const normalized: ProjectScript[] = []; | |
| 110 | for (const script of deduped) { | |
| 111 | if (!script.runOnWorktreeCreate) { | |
| 112 | normalized.push(script); | |
| 113 | continue; | |
| 114 | } | |
| 115 | if (!setupAssigned) { | |
| 116 | setupAssigned = true; | |
| 117 | normalized.push(script); | |
| 118 | continue; | |
| 119 | } | |
| 120 | normalized.push({ ...script, runOnWorktreeCreate: false }); | |
| 121 | } | |
| 122 | return normalized; | |
| 123 | } | |
| 26 | 124 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; |
| 27 | 125 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; |
| 28 | 126 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; |
| 29 | 127 | export type ProjectAddResult = z.infer<typeof projectAddResultSchema>; |
| 30 | 128 | export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>; |
| 129 | export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>; | |
| 130 | export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>; | |
| 131 | export type ProjectEntry = z.infer<typeof projectEntrySchema>; | |
| 132 | export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>; | |
| 133 | export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>; | |
| 134 | export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>; |
packages/contracts/src/provider.test.ts+124| ... | ... | @@ -2,7 +2,14 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 5 | providerCheckpointSchema, | |
| 5 | 6 | providerEventSchema, |
| 7 | providerGetCheckpointDiffInputSchema, | |
| 8 | providerGetCheckpointDiffResultSchema, | |
| 9 | providerListCheckpointsInputSchema, | |
| 10 | providerListCheckpointsResultSchema, | |
| 11 | providerRevertToCheckpointInputSchema, | |
| 12 | providerRevertToCheckpointResultSchema, | |
| 6 | 13 | providerRespondToRequestInputSchema, |
| 7 | 14 | providerSendTurnInputSchema, |
| 8 | 15 | providerSessionStartInputSchema, |
| ... | ... | @@ -30,6 +37,28 @@ describe("providerSessionStartInputSchema", () => { |
| 30 | 37 | }), |
| 31 | 38 | ).toThrow(); |
| 32 | 39 | }); |
| 40 | ||
| 41 | it("accepts optional codex binary and home path overrides", () => { | |
| 42 | const parsed = providerSessionStartInputSchema.parse({ | |
| 43 | codexBinaryPath: "/opt/codex/bin/codex", | |
| 44 | codexHomePath: "/Users/theo/.codex", | |
| 45 | }); | |
| 46 | expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex"); | |
| 47 | expect(parsed.codexHomePath).toBe("/Users/theo/.codex"); | |
| 48 | }); | |
| 49 | ||
| 50 | it("rejects blank codex overrides", () => { | |
| 51 | expect(() => | |
| 52 | providerSessionStartInputSchema.parse({ | |
| 53 | codexBinaryPath: " ", | |
| 54 | }), | |
| 55 | ).toThrow(); | |
| 56 | expect(() => | |
| 57 | providerSessionStartInputSchema.parse({ | |
| 58 | codexHomePath: " ", | |
| 59 | }), | |
| 60 | ).toThrow(); | |
| 61 | }); | |
| 33 | 62 | }); |
| 34 | 63 | |
| 35 | 64 | describe("providerSendTurnInputSchema", () => { |
| ... | ... | @@ -157,3 +186,98 @@ describe("providerRespondToRequestInputSchema", () => { |
| 157 | 186 | ).toThrow(); |
| 158 | 187 | }); |
| 159 | 188 | }); |
| 189 | ||
| 190 | describe("provider checkpoint schemas", () => { | |
| 191 | it("accepts list checkpoint inputs", () => { | |
| 192 | const parsed = providerListCheckpointsInputSchema.parse({ | |
| 193 | sessionId: "sess_1", | |
| 194 | }); | |
| 195 | expect(parsed.sessionId).toBe("sess_1"); | |
| 196 | }); | |
| 197 | ||
| 198 | it("accepts checkpoint records and list results", () => { | |
| 199 | const checkpoint = providerCheckpointSchema.parse({ | |
| 200 | id: "turn_1", | |
| 201 | turnCount: 1, | |
| 202 | messageCount: 2, | |
| 203 | label: "Turn 1", | |
| 204 | preview: "Summarize this file", | |
| 205 | isCurrent: true, | |
| 206 | }); | |
| 207 | expect(checkpoint.turnCount).toBe(1); | |
| 208 | ||
| 209 | const listResult = providerListCheckpointsResultSchema.parse({ | |
| 210 | threadId: "thr_1", | |
| 211 | checkpoints: [checkpoint], | |
| 212 | }); | |
| 213 | expect(listResult.checkpoints).toHaveLength(1); | |
| 214 | }); | |
| 215 | ||
| 216 | it("accepts revert checkpoint input/result", () => { | |
| 217 | const input = providerRevertToCheckpointInputSchema.parse({ | |
| 218 | sessionId: "sess_1", | |
| 219 | turnCount: 2, | |
| 220 | }); | |
| 221 | expect(input.turnCount).toBe(2); | |
| 222 | ||
| 223 | const result = providerRevertToCheckpointResultSchema.parse({ | |
| 224 | threadId: "thr_1", | |
| 225 | turnCount: 2, | |
| 226 | messageCount: 4, | |
| 227 | rolledBackTurns: 1, | |
| 228 | checkpoints: [ | |
| 229 | { | |
| 230 | id: "root", | |
| 231 | turnCount: 0, | |
| 232 | messageCount: 0, | |
| 233 | label: "Start of conversation", | |
| 234 | isCurrent: false, | |
| 235 | }, | |
| 236 | { | |
| 237 | id: "turn_2", | |
| 238 | turnCount: 2, | |
| 239 | messageCount: 4, | |
| 240 | label: "Turn 2", | |
| 241 | isCurrent: true, | |
| 242 | }, | |
| 243 | ], | |
| 244 | }); | |
| 245 | expect(result.rolledBackTurns).toBe(1); | |
| 246 | }); | |
| 247 | ||
| 248 | it("rejects negative turn counts", () => { | |
| 249 | expect(() => | |
| 250 | providerRevertToCheckpointInputSchema.parse({ | |
| 251 | sessionId: "sess_1", | |
| 252 | turnCount: -1, | |
| 253 | }), | |
| 254 | ).toThrow(); | |
| 255 | }); | |
| 256 | ||
| 257 | it("accepts checkpoint diff input/result", () => { | |
| 258 | const input = providerGetCheckpointDiffInputSchema.parse({ | |
| 259 | sessionId: "sess_1", | |
| 260 | fromTurnCount: 1, | |
| 261 | toTurnCount: 2, | |
| 262 | }); | |
| 263 | expect(input.fromTurnCount).toBe(1); | |
| 264 | ||
| 265 | const result = providerGetCheckpointDiffResultSchema.parse({ | |
| 266 | threadId: "thr_1", | |
| 267 | fromTurnCount: 1, | |
| 268 | toTurnCount: 2, | |
| 269 | diff: "diff --git a/src/app.ts b/src/app.ts", | |
| 270 | }); | |
| 271 | expect(result.toTurnCount).toBe(2); | |
| 272 | }); | |
| 273 | ||
| 274 | it("rejects checkpoint diff ranges where start is after end", () => { | |
| 275 | expect(() => | |
| 276 | providerGetCheckpointDiffInputSchema.parse({ | |
| 277 | sessionId: "sess_1", | |
| 278 | fromTurnCount: 3, | |
| 279 | toTurnCount: 2, | |
| 280 | }), | |
| 281 | ).toThrow(); | |
| 282 | }); | |
| 283 | }); |
packages/contracts/src/provider.ts+65| ... | ... | @@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({ |
| 50 | 50 | cwd: z.string().min(1).optional(), |
| 51 | 51 | model: z.string().trim().min(1).optional(), |
| 52 | 52 | resumeThreadId: z.string().trim().min(1).optional(), |
| 53 | codexBinaryPath: z.string().trim().min(1).optional(), | |
| 54 | codexHomePath: z.string().trim().min(1).optional(), | |
| 53 | 55 | approvalPolicy: providerApprovalPolicySchema.default("never"), |
| 54 | 56 | sandboxMode: providerSandboxModeSchema.default("workspace-write"), |
| 55 | 57 | }); |
| ... | ... | @@ -111,6 +113,60 @@ export const providerStopSessionInputSchema = z.object({ |
| 111 | 113 | sessionId: z.string().min(1), |
| 112 | 114 | }); |
| 113 | 115 | |
| 116 | export const providerListCheckpointsInputSchema = z.object({ | |
| 117 | sessionId: z.string().min(1), | |
| 118 | }); | |
| 119 | ||
| 120 | export const providerCheckpointSchema = z.object({ | |
| 121 | id: z.string().min(1), | |
| 122 | turnCount: z.number().int().min(0), | |
| 123 | messageCount: z.number().int().min(0), | |
| 124 | label: z.string().min(1), | |
| 125 | preview: z.string().min(1).optional(), | |
| 126 | isCurrent: z.boolean(), | |
| 127 | }); | |
| 128 | ||
| 129 | export const providerListCheckpointsResultSchema = z.object({ | |
| 130 | threadId: z.string().min(1), | |
| 131 | checkpoints: z.array(providerCheckpointSchema), | |
| 132 | }); | |
| 133 | ||
| 134 | export const providerRevertToCheckpointInputSchema = z.object({ | |
| 135 | sessionId: z.string().min(1), | |
| 136 | turnCount: z.number().int().min(0), | |
| 137 | }); | |
| 138 | ||
| 139 | export const providerRevertToCheckpointResultSchema = z.object({ | |
| 140 | threadId: z.string().min(1), | |
| 141 | turnCount: z.number().int().min(0), | |
| 142 | messageCount: z.number().int().min(0), | |
| 143 | rolledBackTurns: z.number().int().min(0), | |
| 144 | checkpoints: z.array(providerCheckpointSchema), | |
| 145 | }); | |
| 146 | ||
| 147 | export const providerGetCheckpointDiffInputSchema = z | |
| 148 | .object({ | |
| 149 | sessionId: z.string().min(1), | |
| 150 | fromTurnCount: z.number().int().min(0), | |
| 151 | toTurnCount: z.number().int().min(0), | |
| 152 | }) | |
| 153 | .superRefine((value, ctx) => { | |
| 154 | if (value.fromTurnCount > value.toTurnCount) { | |
| 155 | ctx.addIssue({ | |
| 156 | code: z.ZodIssueCode.custom, | |
| 157 | message: "fromTurnCount must be less than or equal to toTurnCount", | |
| 158 | path: ["fromTurnCount"], | |
| 159 | }); | |
| 160 | } | |
| 161 | }); | |
| 162 | ||
| 163 | export const providerGetCheckpointDiffResultSchema = z.object({ | |
| 164 | threadId: z.string().min(1), | |
| 165 | fromTurnCount: z.number().int().min(0), | |
| 166 | toTurnCount: z.number().int().min(0), | |
| 167 | diff: z.string(), | |
| 168 | }); | |
| 169 | ||
| 114 | 170 | export const providerRespondToRequestInputSchema = z.object({ |
| 115 | 171 | sessionId: z.string().min(1), |
| 116 | 172 | requestId: z.string().min(1), |
| ... | ... | @@ -151,6 +207,15 @@ export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; |
| 151 | 207 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; |
| 152 | 208 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; |
| 153 | 209 | export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>; |
| 210 | export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>; | |
| 211 | export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>; | |
| 212 | export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>; | |
| 213 | export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>; | |
| 214 | export type ProviderRevertToCheckpointResult = z.infer< | |
| 215 | typeof providerRevertToCheckpointResultSchema | |
| 216 | >; | |
| 217 | export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>; | |
| 218 | export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>; | |
| 154 | 219 | export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>; |
| 155 | 220 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 156 | 221 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |
packages/contracts/src/server.ts+9-1| ... | ... | @@ -1,9 +1,17 @@ |
| 1 | 1 | import { z } from "zod"; |
| 2 | import { resolvedKeybindingsConfigSchema } from "./keybindings"; | |
| 2 | import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; | |
| 3 | 3 | |
| 4 | 4 | export const serverConfigSchema = z.object({ |
| 5 | 5 | cwd: z.string().min(1), |
| 6 | 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), |
| 7 | 7 | }); |
| 8 | 8 | |
| 9 | export const serverUpsertKeybindingInputSchema = keybindingRuleSchema; | |
| 10 | ||
| 11 | export const serverUpsertKeybindingResultSchema = z.object({ | |
| 12 | keybindings: resolvedKeybindingsConfigSchema.default([]), | |
| 13 | }); | |
| 14 | ||
| 9 | 15 | export type ServerConfig = z.infer<typeof serverConfigSchema>; |
| 16 | export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>; | |
| 17 | export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>; |
packages/contracts/src/terminal.test.ts+30| ... | ... | @@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => { |
| 42 | 42 | }); |
| 43 | 43 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 44 | 44 | }); |
| 45 | ||
| 46 | it("accepts optional env overrides", () => { | |
| 47 | const parsed = terminalOpenInputSchema.parse({ | |
| 48 | threadId: "thread-1", | |
| 49 | cwd: "/tmp/project", | |
| 50 | cols: 100, | |
| 51 | rows: 24, | |
| 52 | env: { | |
| 53 | T3CODE_PROJECT_ROOT: "/tmp/project", | |
| 54 | CUSTOM_FLAG: "1", | |
| 55 | }, | |
| 56 | }); | |
| 57 | expect(parsed.env).toMatchObject({ | |
| 58 | T3CODE_PROJECT_ROOT: "/tmp/project", | |
| 59 | CUSTOM_FLAG: "1", | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | it("rejects invalid env keys", () => { | |
| 64 | const result = terminalOpenInputSchema.safeParse({ | |
| 65 | threadId: "thread-1", | |
| 66 | cwd: "/tmp/project", | |
| 67 | cols: 100, | |
| 68 | rows: 24, | |
| 69 | env: { | |
| 70 | "bad-key": "1", | |
| 71 | }, | |
| 72 | }); | |
| 73 | expect(result.success).toBe(false); | |
| 74 | }); | |
| 45 | 75 | }); |
| 46 | 76 | |
| 47 | 77 | describe("terminalWriteInputSchema", () => { |
packages/contracts/src/terminal.ts+8-2| ... | ... | @@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default"; |
| 5 | 5 | const terminalColsSchema = z.number().int().min(20).max(400); |
| 6 | 6 | const terminalRowsSchema = z.number().int().min(5).max(200); |
| 7 | 7 | const terminalIdSchema = z.string().trim().min(1).max(128); |
| 8 | const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128); | |
| 9 | const terminalEnvValueSchema = z.string().max(8_192); | |
| 10 | const terminalEnvSchema = z | |
| 11 | .record(terminalEnvKeySchema, terminalEnvValueSchema) | |
| 12 | .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars"); | |
| 8 | 13 | |
| 9 | 14 | export const terminalThreadInputSchema = z.object({ |
| 10 | 15 | threadId: z.string().trim().min(1), |
| ... | ... | @@ -16,8 +21,9 @@ export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ |
| 16 | 21 | |
| 17 | 22 | export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ |
| 18 | 23 | cwd: z.string().trim().min(1), |
| 19 | cols: terminalColsSchema, | |
| 20 | rows: terminalRowsSchema, | |
| 24 | cols: terminalColsSchema.optional(), | |
| 25 | rows: terminalRowsSchema.optional(), | |
| 26 | env: terminalEnvSchema.optional(), | |
| 21 | 27 | }); |
| 22 | 28 | |
| 23 | 29 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ |
packages/contracts/src/ws.ts+6| ... | ... | @@ -10,11 +10,16 @@ export const WS_METHODS = { |
| 10 | 10 | providersRespondToRequest: "providers.respondToRequest", |
| 11 | 11 | providersStopSession: "providers.stopSession", |
| 12 | 12 | providersListSessions: "providers.listSessions", |
| 13 | providersListCheckpoints: "providers.listCheckpoints", | |
| 14 | providersGetCheckpointDiff: "providers.getCheckpointDiff", | |
| 15 | providersRevertToCheckpoint: "providers.revertToCheckpoint", | |
| 13 | 16 | |
| 14 | 17 | // Project registry methods |
| 15 | 18 | projectsList: "projects.list", |
| 16 | 19 | projectsAdd: "projects.add", |
| 17 | 20 | projectsRemove: "projects.remove", |
| 21 | projectsSearchEntries: "projects.searchEntries", | |
| 22 | projectsUpdateScripts: "projects.updateScripts", | |
| 18 | 23 | |
| 19 | 24 | // Shell methods |
| 20 | 25 | shellOpenInEditor: "shell.openInEditor", |
| ... | ... | @@ -40,6 +45,7 @@ export const WS_METHODS = { |
| 40 | 45 | |
| 41 | 46 | // Server meta |
| 42 | 47 | serverGetConfig: "server.getConfig", |
| 48 | serverUpsertKeybinding: "server.upsertKeybinding", | |
| 43 | 49 | } as const; |
| 44 | 50 | |
| 45 | 51 | // ── Push Event Channels ────────────────────────────────────────────── |
scripts/build-desktop-dmg.mjs created+343| ... | ... | @@ -0,0 +1,343 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import { spawn } from "node:child_process"; | |
| 4 | import fs from "node:fs/promises"; | |
| 5 | import os from "node:os"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | const repoRoot = path.resolve(__dirname, ".."); | |
| 11 | const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); | |
| 12 | ||
| 13 | const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); | |
| 14 | ||
| 15 | function printUsage() { | |
| 16 | console.log(`Build a shareable macOS .dmg for T3 Code desktop. | |
| 17 | ||
| 18 | Usage: | |
| 19 | node scripts/build-desktop-dmg.mjs [options] | |
| 20 | ||
| 21 | Options: | |
| 22 | --arch <arm64|x64|universal> Target arch (default: host arch if supported) | |
| 23 | --version <semver> App version in DMG metadata (default: apps/server version) | |
| 24 | --output-dir <path> Output folder for final artifacts (default: release) | |
| 25 | --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) | |
| 26 | --keep-stage Keep temporary staging directory | |
| 27 | --signed Allow automatic code signing discovery | |
| 28 | --help Show this message | |
| 29 | `); | |
| 30 | } | |
| 31 | ||
| 32 | function fail(message) { | |
| 33 | console.error(`[desktop-dmg] ${message}`); | |
| 34 | process.exit(1); | |
| 35 | } | |
| 36 | ||
| 37 | function parseArgs(argv) { | |
| 38 | const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64"; | |
| 39 | const options = { | |
| 40 | arch: defaultArch, | |
| 41 | version: null, | |
| 42 | skipBuild: false, | |
| 43 | keepStage: false, | |
| 44 | signed: false, | |
| 45 | outputDir: path.join(repoRoot, "release"), | |
| 46 | }; | |
| 47 | ||
| 48 | for (let i = 0; i < argv.length; i += 1) { | |
| 49 | const arg = argv[i]; | |
| 50 | if (arg === "--help") { | |
| 51 | options.help = true; | |
| 52 | continue; | |
| 53 | } | |
| 54 | if (arg === "--skip-build") { | |
| 55 | options.skipBuild = true; | |
| 56 | continue; | |
| 57 | } | |
| 58 | if (arg === "--keep-stage") { | |
| 59 | options.keepStage = true; | |
| 60 | continue; | |
| 61 | } | |
| 62 | if (arg === "--signed") { | |
| 63 | options.signed = true; | |
| 64 | continue; | |
| 65 | } | |
| 66 | if (arg === "--arch" || arg === "--version" || arg === "--output-dir") { | |
| 67 | const value = argv[i + 1]; | |
| 68 | if (!value || value.startsWith("--")) { | |
| 69 | fail(`Missing value for ${arg}`); | |
| 70 | } | |
| 71 | i += 1; | |
| 72 | if (arg === "--arch") options.arch = value; | |
| 73 | if (arg === "--version") options.version = value; | |
| 74 | if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value); | |
| 75 | continue; | |
| 76 | } | |
| 77 | fail(`Unknown argument: ${arg}`); | |
| 78 | } | |
| 79 | ||
| 80 | if (!ARCH_CHOICES.has(options.arch)) { | |
| 81 | fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`); | |
| 82 | } | |
| 83 | ||
| 84 | return options; | |
| 85 | } | |
| 86 | ||
| 87 | async function readJson(filePath) { | |
| 88 | const raw = await fs.readFile(filePath, "utf8"); | |
| 89 | return JSON.parse(raw); | |
| 90 | } | |
| 91 | ||
| 92 | async function exists(filePath) { | |
| 93 | try { | |
| 94 | await fs.access(filePath); | |
| 95 | return true; | |
| 96 | } catch { | |
| 97 | return false; | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { | |
| 102 | return new Promise((resolve, reject) => { | |
| 103 | const child = spawn(command, args, { | |
| 104 | cwd, | |
| 105 | env, | |
| 106 | stdio, | |
| 107 | }); | |
| 108 | ||
| 109 | child.on("error", reject); | |
| 110 | child.on("exit", (code, signal) => { | |
| 111 | if (signal) { | |
| 112 | reject(new Error(`${command} exited via signal ${signal}`)); | |
| 113 | return; | |
| 114 | } | |
| 115 | if (code !== 0) { | |
| 116 | reject(new Error(`${command} exited with code ${code}`)); | |
| 117 | return; | |
| 118 | } | |
| 119 | resolve(); | |
| 120 | }); | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { | |
| 125 | const iconsetDir = path.join(tmpRoot, "icon.iconset"); | |
| 126 | await fs.mkdir(iconsetDir, { recursive: true }); | |
| 127 | ||
| 128 | const iconSizes = [16, 32, 128, 256, 512]; | |
| 129 | for (const size of iconSizes) { | |
| 130 | await run( | |
| 131 | "sips", | |
| 132 | ["-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`)], | |
| 133 | { stdio: "ignore" }, | |
| 134 | ); | |
| 135 | const retinaSize = size * 2; | |
| 136 | await run( | |
| 137 | "sips", | |
| 138 | [ | |
| 139 | "-z", | |
| 140 | String(retinaSize), | |
| 141 | String(retinaSize), | |
| 142 | sourcePng, | |
| 143 | "--out", | |
| 144 | path.join(iconsetDir, `icon_${size}x${size}@2x.png`), | |
| 145 | ], | |
| 146 | { stdio: "ignore" }, | |
| 147 | ); | |
| 148 | } | |
| 149 | ||
| 150 | await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); | |
| 151 | } | |
| 152 | ||
| 153 | async function stageProductionIcons(stageResourcesDir) { | |
| 154 | if (!(await exists(PRODUCTION_ICON_SOURCE))) { | |
| 155 | fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); | |
| 156 | } | |
| 157 | ||
| 158 | const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); | |
| 159 | try { | |
| 160 | const iconPngPath = path.join(stageResourcesDir, "icon.png"); | |
| 161 | const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); | |
| 162 | await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { | |
| 163 | stdio: "ignore", | |
| 164 | }); | |
| 165 | await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); | |
| 166 | } finally { | |
| 167 | await fs.rm(tmpRoot, { recursive: true, force: true }); | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | async function validateBundledClientAssets(clientDir) { | |
| 172 | const indexPath = path.join(clientDir, "index.html"); | |
| 173 | const indexHtml = await fs.readFile(indexPath, "utf8"); | |
| 174 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); | |
| 175 | const missing = []; | |
| 176 | ||
| 177 | for (const ref of refs) { | |
| 178 | const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; | |
| 179 | if (!normalizedRef) continue; | |
| 180 | if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; | |
| 181 | if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; | |
| 182 | ||
| 183 | const ext = path.extname(normalizedRef); | |
| 184 | if (!ext) continue; | |
| 185 | ||
| 186 | const relativePath = normalizedRef.replace(/^\/+/, ""); | |
| 187 | const assetPath = path.join(clientDir, relativePath); | |
| 188 | if (!(await exists(assetPath))) { | |
| 189 | missing.push(normalizedRef); | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | if (missing.length > 0) { | |
| 194 | const preview = missing.slice(0, 6).join(", "); | |
| 195 | const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; | |
| 196 | fail( | |
| 197 | `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, | |
| 198 | ); | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | async function main() { | |
| 203 | const options = parseArgs(process.argv.slice(2)); | |
| 204 | if (options.help) { | |
| 205 | printUsage(); | |
| 206 | return; | |
| 207 | } | |
| 208 | ||
| 209 | if (process.platform !== "darwin") { | |
| 210 | fail("This script only builds macOS .dmg artifacts and must run on macOS."); | |
| 211 | } | |
| 212 | ||
| 213 | const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); | |
| 214 | const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); | |
| 215 | ||
| 216 | const electronVersion = desktopPkg.dependencies?.electron; | |
| 217 | if (typeof electronVersion !== "string" || electronVersion.length === 0) { | |
| 218 | fail("Could not resolve apps/desktop electron dependency version."); | |
| 219 | } | |
| 220 | ||
| 221 | const dependencies = serverPkg.dependencies; | |
| 222 | if (!dependencies || Object.keys(dependencies).length === 0) { | |
| 223 | fail("Could not resolve production dependencies from apps/server/package.json."); | |
| 224 | } | |
| 225 | ||
| 226 | const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; | |
| 227 | const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); | |
| 228 | const stageAppDir = path.join(stageRoot, "app"); | |
| 229 | const distDirs = { | |
| 230 | desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), | |
| 231 | desktopResources: path.join(repoRoot, "apps/desktop/resources"), | |
| 232 | serverDist: path.join(repoRoot, "apps/server/dist"), | |
| 233 | }; | |
| 234 | const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); | |
| 235 | ||
| 236 | if (!options.skipBuild) { | |
| 237 | console.log("[desktop-dmg] Building desktop/server/web artifacts..."); | |
| 238 | await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); | |
| 239 | } | |
| 240 | ||
| 241 | for (const [label, dir] of Object.entries(distDirs)) { | |
| 242 | if (!(await exists(dir))) { | |
| 243 | fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); | |
| 244 | } | |
| 245 | } | |
| 246 | if (!(await exists(bundledClientEntry))) { | |
| 247 | fail( | |
| 248 | `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, | |
| 249 | ); | |
| 250 | } | |
| 251 | await validateBundledClientAssets(path.dirname(bundledClientEntry)); | |
| 252 | ||
| 253 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 254 | await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); | |
| 255 | await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); | |
| 256 | ||
| 257 | console.log("[desktop-dmg] Staging release app..."); | |
| 258 | await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { | |
| 259 | recursive: true, | |
| 260 | }); | |
| 261 | await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), { | |
| 262 | recursive: true, | |
| 263 | }); | |
| 264 | await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); | |
| 265 | await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources")); | |
| 266 | ||
| 267 | const stagePackageJson = { | |
| 268 | name: "t3-code-desktop", | |
| 269 | version: appVersion, | |
| 270 | private: true, | |
| 271 | description: "T3 Code desktop build", | |
| 272 | author: "T3 Tools", | |
| 273 | main: "apps/desktop/dist-electron/main.js", | |
| 274 | build: { | |
| 275 | appId: "com.t3tools.t3code", | |
| 276 | productName: desktopPkg.productName ?? "T3 Code", | |
| 277 | artifactName: "T3-Code-${version}-${arch}.${ext}", | |
| 278 | directories: { | |
| 279 | buildResources: "apps/desktop/resources", | |
| 280 | }, | |
| 281 | mac: { | |
| 282 | target: ["dmg"], | |
| 283 | icon: "icon.icns", | |
| 284 | category: "public.app-category.developer-tools", | |
| 285 | }, | |
| 286 | }, | |
| 287 | dependencies, | |
| 288 | devDependencies: { | |
| 289 | electron: electronVersion, | |
| 290 | }, | |
| 291 | }; | |
| 292 | ||
| 293 | await fs.writeFile( | |
| 294 | path.join(stageAppDir, "package.json"), | |
| 295 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | |
| 296 | "utf8", | |
| 297 | ); | |
| 298 | ||
| 299 | console.log("[desktop-dmg] Installing staged production dependencies..."); | |
| 300 | await run("bun", ["install", "--production"], { cwd: stageAppDir }); | |
| 301 | ||
| 302 | const buildEnv = { | |
| 303 | ...process.env, | |
| 304 | }; | |
| 305 | if (!options.signed) { | |
| 306 | buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; | |
| 307 | } | |
| 308 | ||
| 309 | const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`]; | |
| 310 | console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`); | |
| 311 | await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); | |
| 312 | ||
| 313 | const stageDistDir = path.join(stageAppDir, "dist"); | |
| 314 | const stageFiles = await fs.readdir(stageDistDir); | |
| 315 | const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg")); | |
| 316 | if (dmgFiles.length === 0) { | |
| 317 | fail(`Build completed but no .dmg found in ${stageDistDir}`); | |
| 318 | } | |
| 319 | ||
| 320 | await fs.mkdir(options.outputDir, { recursive: true }); | |
| 321 | const copiedArtifacts = []; | |
| 322 | for (const file of stageFiles) { | |
| 323 | if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue; | |
| 324 | const from = path.join(stageDistDir, file); | |
| 325 | const to = path.join(options.outputDir, file); | |
| 326 | await fs.copyFile(from, to); | |
| 327 | copiedArtifacts.push(to); | |
| 328 | } | |
| 329 | ||
| 330 | if (!options.keepStage) { | |
| 331 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 332 | } | |
| 333 | ||
| 334 | console.log("[desktop-dmg] Done. Artifacts:"); | |
| 335 | for (const artifact of copiedArtifacts) { | |
| 336 | console.log(` - ${artifact}`); | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | main().catch((error) => { | |
| 341 | const message = error instanceof Error ? error.message : String(error); | |
| 342 | fail(message); | |
| 343 | }); |
scripts/sync-vscode-icons.mjs created+153| ... | ... | @@ -0,0 +1,153 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import fs from "node:fs/promises"; | |
| 4 | import os from "node:os"; | |
| 5 | import path from "node:path"; | |
| 6 | import vm from "node:vm"; | |
| 7 | import { execFile } from "node:child_process"; | |
| 8 | import { promisify } from "node:util"; | |
| 9 | ||
| 10 | const execFileAsync = promisify(execFile); | |
| 11 | ||
| 12 | const VERSION = process.argv[2] ?? "12.17.0"; | |
| 13 | const VERSION_TAG = `v${VERSION}`; | |
| 14 | const VSIX_URL = `https://open-vsx.org/api/vscode-icons-team/vscode-icons/${VERSION}/file/vscode-icons-team.vscode-icons-${VERSION}.vsix`; | |
| 15 | const LANGUAGES_URL = `https://raw.githubusercontent.com/vscode-icons/vscode-icons/${VERSION_TAG}/src/iconsManifest/languages.ts`; | |
| 16 | ||
| 17 | const REPO_ROOT = process.cwd(); | |
| 18 | const MANIFEST_PATH = path.join(REPO_ROOT, "apps/web/src/vscode-icons-manifest.json"); | |
| 19 | const ASSOCIATIONS_PATH = path.join( | |
| 20 | REPO_ROOT, | |
| 21 | "apps/web/src/vscode-icons-language-associations.json", | |
| 22 | ); | |
| 23 | ||
| 24 | function normalizeExtension(value) { | |
| 25 | return value.trim().toLowerCase().replace(/^\./, ""); | |
| 26 | } | |
| 27 | ||
| 28 | function normalizeFileName(value) { | |
| 29 | return value.trim().toLowerCase(); | |
| 30 | } | |
| 31 | ||
| 32 | function putIfAbsent(target, key, value) { | |
| 33 | if (!(key in target)) { | |
| 34 | target[key] = value; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | async function downloadVsix(tmpDir) { | |
| 39 | const vsixPath = path.join(tmpDir, `vscode-icons-${VERSION}.vsix`); | |
| 40 | const response = await fetch(VSIX_URL); | |
| 41 | if (!response.ok) { | |
| 42 | throw new Error(`Failed to download VSIX: ${response.status} ${response.statusText}`); | |
| 43 | } | |
| 44 | const bytes = Buffer.from(await response.arrayBuffer()); | |
| 45 | await fs.writeFile(vsixPath, bytes); | |
| 46 | return vsixPath; | |
| 47 | } | |
| 48 | ||
| 49 | async function extractManifestFromVsix(vsixPath) { | |
| 50 | const { stdout } = await execFileAsync("unzip", [ | |
| 51 | "-p", | |
| 52 | vsixPath, | |
| 53 | "extension/dist/src/vsicons-icon-theme.json", | |
| 54 | ]); | |
| 55 | if (!stdout || stdout.trim().length === 0) { | |
| 56 | throw new Error("Could not extract vsicons-icon-theme.json from VSIX"); | |
| 57 | } | |
| 58 | return JSON.parse(stdout); | |
| 59 | } | |
| 60 | ||
| 61 | async function loadLanguagesCollection() { | |
| 62 | const response = await fetch(LANGUAGES_URL); | |
| 63 | if (!response.ok) { | |
| 64 | throw new Error(`Failed to fetch languages.ts: ${response.status} ${response.statusText}`); | |
| 65 | } | |
| 66 | const rawSource = await response.text(); | |
| 67 | const source = rawSource | |
| 68 | .replace(/^import[^;]+;\s*/gm, "") | |
| 69 | .replace(/export const languages(?:\s*:\s*[^=]+)?\s*=/, "const languages =") | |
| 70 | .replace(/\}\s*satisfies\s*Record<[^;]+>;/, "};"); | |
| 71 | ||
| 72 | const context = {}; | |
| 73 | vm.createContext(context); | |
| 74 | vm.runInContext(`${source}\n;globalThis.__languages = languages;`, context); | |
| 75 | const languages = context.__languages; | |
| 76 | if (!languages || typeof languages !== "object") { | |
| 77 | throw new Error("Failed to parse languages.ts into a collection"); | |
| 78 | } | |
| 79 | return languages; | |
| 80 | } | |
| 81 | ||
| 82 | function buildLanguageAssociations(manifest, languages) { | |
| 83 | const availableLanguageIds = new Set(Object.keys(manifest.languageIds ?? {})); | |
| 84 | const extensionToLanguageId = {}; | |
| 85 | const fileNameToLanguageId = {}; | |
| 86 | ||
| 87 | for (const entry of Object.values(languages)) { | |
| 88 | if (!entry || typeof entry !== "object") continue; | |
| 89 | const idsRaw = entry.ids; | |
| 90 | const ids = Array.isArray(idsRaw) ? idsRaw : [idsRaw]; | |
| 91 | const knownExtensions = Array.isArray(entry.knownExtensions) ? entry.knownExtensions : []; | |
| 92 | const knownFilenames = Array.isArray(entry.knownFilenames) ? entry.knownFilenames : []; | |
| 93 | ||
| 94 | for (const idValue of ids) { | |
| 95 | if (typeof idValue !== "string") continue; | |
| 96 | const languageId = idValue.trim(); | |
| 97 | if (languageId.length === 0) continue; | |
| 98 | if (!availableLanguageIds.has(languageId)) continue; | |
| 99 | ||
| 100 | for (const extensionValue of knownExtensions) { | |
| 101 | if (typeof extensionValue !== "string") continue; | |
| 102 | const extension = normalizeExtension(extensionValue); | |
| 103 | if (extension.length === 0) continue; | |
| 104 | putIfAbsent(extensionToLanguageId, extension, languageId); | |
| 105 | } | |
| 106 | ||
| 107 | for (const fileNameValue of knownFilenames) { | |
| 108 | if (typeof fileNameValue !== "string") continue; | |
| 109 | const fileName = normalizeFileName(fileNameValue); | |
| 110 | if (fileName.length === 0) continue; | |
| 111 | putIfAbsent(fileNameToLanguageId, fileName, languageId); | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | return { | |
| 117 | version: VERSION, | |
| 118 | source: LANGUAGES_URL, | |
| 119 | generatedAt: new Date().toISOString(), | |
| 120 | extensionToLanguageId, | |
| 121 | fileNameToLanguageId, | |
| 122 | }; | |
| 123 | } | |
| 124 | ||
| 125 | async function main() { | |
| 126 | const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "t3-vscode-icons-sync-")); | |
| 127 | try { | |
| 128 | const vsixPath = await downloadVsix(tmpDir); | |
| 129 | const manifest = await extractManifestFromVsix(vsixPath); | |
| 130 | const languages = await loadLanguagesCollection(); | |
| 131 | const associations = buildLanguageAssociations(manifest, languages); | |
| 132 | ||
| 133 | await fs.writeFile(MANIFEST_PATH, `${JSON.stringify(manifest)}\n`, "utf8"); | |
| 134 | await fs.writeFile(ASSOCIATIONS_PATH, `${JSON.stringify(associations)}\n`, "utf8"); | |
| 135 | ||
| 136 | process.stdout.write( | |
| 137 | [ | |
| 138 | `Synced vscode-icons ${VERSION}`, | |
| 139 | `manifest: ${MANIFEST_PATH}`, | |
| 140 | `language associations: ${ASSOCIATIONS_PATH}`, | |
| 141 | `extension mappings: ${Object.keys(associations.extensionToLanguageId).length}`, | |
| 142 | `filename mappings: ${Object.keys(associations.fileNameToLanguageId).length}`, | |
| 143 | ].join("\n") + "\n", | |
| 144 | ); | |
| 145 | } finally { | |
| 146 | await fs.rm(tmpDir, { recursive: true, force: true }); | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | main().catch((error) => { | |
| 151 | process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); | |
| 152 | process.exit(1); | |
| 153 | }); |