From 5d61e573fd32db7ac31a310727ba5a8df467a319 Mon Sep 17 00:00:00 2001 From: Craig Constable Date: Mon, 15 Jun 2026 21:31:51 +1000 Subject: [PATCH] feat: add file preview panel --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 26 + apps/server/src/vcs/GitVcsDriverCore.ts | 55 +- .../workspace/Layers/WorkspaceEntries.test.ts | 27 + .../src/workspace/Layers/WorkspaceEntries.ts | 34 +- .../Layers/WorkspaceFileSystem.test.ts | 61 ++ .../workspace/Layers/WorkspaceFileSystem.ts | 133 ++- .../workspace/Services/WorkspaceEntries.ts | 6 + .../workspace/Services/WorkspaceFileSystem.ts | 14 +- apps/server/src/ws.ts | 34 + apps/web/package.json | 1 + .../src/components/ChatMarkdown.browser.tsx | 42 + apps/web/src/components/ChatMarkdown.tsx | 40 +- apps/web/src/components/ChatView.tsx | 52 ++ apps/web/src/components/FilePreviewPanel.tsx | 811 ++++++++++++++++++ apps/web/src/components/RightPanelTabs.tsx | 25 +- apps/web/src/environmentApi.ts | 2 + .../environments/runtime/connection.test.ts | 2 + .../runtime/service.savedEnvironments.test.ts | 2 + .../service.threadSubscriptions.test.ts | 2 + apps/web/src/filePreviewActions.ts | 57 ++ apps/web/src/localApi.test.ts | 2 + apps/web/src/rightPanelStore.test.ts | 16 + apps/web/src/rightPanelStore.ts | 27 +- packages/client-runtime/src/wsRpcClient.ts | 6 + packages/contracts/src/git.ts | 11 + packages/contracts/src/ipc.ts | 6 + packages/contracts/src/project.ts | 39 + packages/contracts/src/rpc.ts | 22 + pnpm-lock.yaml | 30 + 29 files changed, 1570 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/FilePreviewPanel.tsx create mode 100644 apps/web/src/filePreviewActions.ts diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c0e0f1876c4..fc850af774b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -132,6 +132,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports working tree file statuses", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "tracked.txt", "tracked\n"); + yield* git(cwd, ["add", "tracked.txt"]); + yield* git(cwd, ["commit", "-m", "tracked file"]); + + yield* writeTextFile(cwd, "added.ts", "export const added = true;\n"); + yield* git(cwd, ["add", "added.ts"]); + yield* git(cwd, ["rm", "README.md"]); + yield* git(cwd, ["mv", "tracked.txt", "renamed.txt"]); + yield* writeTextFile(cwd, "untracked.ts", "export const untracked = true;\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + const statusByPath = new Map( + status.workingTree.files.map((file) => [file.path, file.status]), + ); + + assert.equal(statusByPath.get("added.ts"), "added"); + assert.equal(statusByPath.get("README.md"), "deleted"); + assert.equal(statusByPath.get("renamed.txt"), "renamed"); + assert.equal(statusByPath.get("untracked.ts"), "untracked"); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33d009b9dc2..6149ad61efd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -24,6 +24,7 @@ import { type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, type VcsRef, + type VcsWorkingTreeFileStatus, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; @@ -146,6 +147,13 @@ function parsePorcelainPath(line: string): string | null { return null; } + if (line.startsWith("2 ")) { + const beforeOriginalPath = line.split("\t", 1)[0] ?? ""; + const parts = beforeOriginalPath.trim().split(/\s+/g); + const filePath = parts.at(-1) ?? ""; + return filePath.length > 0 ? filePath : null; + } + const tabIndex = line.indexOf("\t"); if (tabIndex >= 0) { const fromTab = line.slice(tabIndex + 1); @@ -158,6 +166,36 @@ function parsePorcelainPath(line: string): string | null { return filePath.length > 0 ? filePath : null; } +function parsePorcelainStatusCode(code: string): VcsWorkingTreeFileStatus { + if (code.includes("R")) return "renamed"; + if (code.includes("D")) return "deleted"; + if (code.includes("A")) return "added"; + return "modified"; +} + +function parsePorcelainStatus( + line: string, +): { readonly path: string; readonly status: VcsWorkingTreeFileStatus } | null { + if (line.startsWith("? ")) { + const path = parsePorcelainPath(line); + return path ? { path, status: "untracked" } : null; + } + + if (line.startsWith("! ")) { + const path = parsePorcelainPath(line); + return path ? { path, status: "ignored" } : null; + } + + if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { + return null; + } + + const parts = line.trim().split(/\s+/g); + const code = parts.at(1) ?? ""; + const path = parsePorcelainPath(line); + return path ? { path, status: parsePorcelainStatusCode(code) } : null; +} + function parseBranchLine(line: string): { name: string; current: boolean } | null { const trimmed = line.trim(); if (trimmed.length === 0) return null; @@ -1329,7 +1367,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let behindCount = 0; let aheadOfDefaultCount = 0; let hasWorkingTreeChanges = false; - const changedFilesWithoutNumstat = new Set(); + const changedFileStatuses = new Map(); for (const line of statusStdout.split(/\r?\n/g)) { if (line.startsWith("# branch.head ")) { @@ -1351,8 +1389,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (line.trim().length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; - const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); + const status = parsePorcelainStatus(line); + if (status) changedFileStatuses.set(status.path, status.status); } } @@ -1393,13 +1431,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .map(([filePath, stat]) => { insertions += stat.insertions; deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + return { + path: filePath, + insertions: stat.insertions, + deletions: stat.deletions, + status: changedFileStatuses.get(filePath) ?? "modified", + }; }) .toSorted((a, b) => a.path.localeCompare(b.path)); - for (const filePath of changedFilesWithoutNumstat) { + for (const [filePath, status] of changedFileStatuses) { if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); + files.push({ path: filePath, insertions: 0, deletions: 0, status }); } files.sort((a, b) => a.path.localeCompare(b.path)); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 7b25f169e72..0fd7bef3f1f 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -75,6 +75,12 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb return yield* workspaceEntries.search(input); }); +const listWorkspaceEntries = (input: { cwd: string }) => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + return yield* workspaceEntries.listEntries(input); + }); + const appendSeparator = (input: string) => Effect.map(HostProcessPlatform, (platform) => input.endsWith("/") || input.endsWith("\\") @@ -316,6 +322,27 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { ); }); + describe("listEntries", () => { + it.effect("includes gitignored paths for the project tree", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-list-gitignore-", git: true }); + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\nignored-dir/\n"); + yield* writeTextFile(cwd, "src/keep.ts", "export {};"); + yield* writeTextFile(cwd, "ignored.txt", "ignore me"); + yield* writeTextFile(cwd, "ignored-dir/file.ts", "export {};"); + + const result = yield* listWorkspaceEntries({ cwd }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/keep.ts"); + expect(paths).toContain("ignored.txt"); + expect(paths).toContain("ignored-dir"); + expect(paths).toContain("ignored-dir/file.ts"); + }), + ); + }); + describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 2903fd3a8e4..d5a781b4d31 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -306,8 +306,12 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndexFromFilesystem = Effect.fn( "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", - )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); + )(function* ( + cwd: string, + options?: { readonly filterGitIgnored?: boolean }, + ): Effect.fn.Return { + const shouldFilterWithGitIgnore = + options?.filterGitIgnored === false ? false : yield* isInsideVcsWorkTree(cwd); let pendingDirectories: string[] = [""]; const entries: SearchableWorkspaceEntry[] = []; @@ -413,6 +417,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () { Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, }, ); + const workspaceListEntriesCache = yield* Cache.makeWith< + string, + WorkspaceIndex, + WorkspaceEntriesError + >((cwd) => buildWorkspaceIndexFromFilesystem(cwd, { filterGitIgnored: false }), { + capacity: WORKSPACE_CACHE_MAX_KEYS, + timeToLive: (exit) => + Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, + }); const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, @@ -436,8 +449,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { Effect.orElseSucceed(() => cwd), ); yield* Cache.invalidate(workspaceIndexCache, cwd); + yield* Cache.invalidate(workspaceListEntriesCache, cwd); if (normalizedCwd !== cwd) { yield* Cache.invalidate(workspaceIndexCache, normalizedCwd); + yield* Cache.invalidate(workspaceListEntriesCache, normalizedCwd); } }, ); @@ -530,9 +545,24 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }, ); + const listEntries: WorkspaceEntriesShape["listEntries"] = Effect.fn( + "WorkspaceEntries.listEntries", + )(function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Cache.get(workspaceListEntriesCache, normalizedCwd).pipe( + Effect.map((index) => ({ + entries: index.entries.map( + ({ normalizedName: _normalizedName, normalizedPath: _normalizedPath, ...entry }) => entry, + ), + truncated: index.truncated, + })), + ); + }); + return { browse, invalidate, + listEntries, search, } satisfies WorkspaceEntriesShape; }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 9b93b1e863b..b04305fd4b9 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -53,7 +53,68 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const writeDirectorySymlink = Effect.fn("writeDirectorySymlink")(function* ( + cwd: string, + relativePath: string, + targetPath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.symlink(targetPath, absolutePath).pipe(Effect.orDie); +}); + it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { + describe("readFile", () => { + it.effect("reads files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "plans/effect-rpc.md", "# Plan\n"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "plans/effect-rpc.md", + }); + + expect(result).toEqual({ + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n", + }); + }), + ); + + it.effect("rejects symlinked paths that resolve outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const externalDir = `${cwd}-outside`; + yield* fileSystem.makeDirectory(externalDir, { recursive: true }).pipe(Effect.orDie); + yield* fileSystem + .writeFileString(path.join(externalDir, "secret.txt"), "secret\n") + .pipe(Effect.orDie); + yield* writeDirectorySymlink(cwd, "linked", externalDir); + + const error = yield* workspaceFileSystem + .readFile({ + cwd, + relativePath: "linked/secret.txt", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: linked/secret.txt", + ); + }), + ); + }); + describe("writeFile", () => { it.effect("writes files relative to the workspace root", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 9f53ade1bb9..5dd6430cc1d 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -9,7 +9,22 @@ import { type WorkspaceFileSystemShape, } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; +import { WorkspacePathOutsideRootError, WorkspacePaths } from "../Services/WorkspacePaths.ts"; + +const WORKSPACE_PREVIEW_MAX_BYTES = 1024 * 1024; +const WORKSPACE_PREVIEW_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); + +function isLikelyBinaryPreview(bytes: Uint8Array): boolean { + return bytes.subarray(0, Math.min(bytes.length, 8_192)).includes(0); +} + +function decodePreviewContents(bytes: Uint8Array): string | null { + try { + return WORKSPACE_PREVIEW_TEXT_DECODER.decode(bytes); + } catch { + return null; + } +} export const makeWorkspaceFileSystem = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -17,6 +32,120 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries; + const toWorkspaceFileSystemError = ( + input: { cwd: string; relativePath: string }, + operation: string, + ) => { + return (cause: unknown) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); + }; + + const ensureReadTargetWithinRealRoot = Effect.fn( + "WorkspaceFileSystem.ensureReadTargetWithinRealRoot", + )(function* (input: { cwd: string; relativePath: string; absolutePath: string }) { + const realWorkspaceRoot = yield* fileSystem + .realPath(input.cwd) + .pipe( + Effect.mapError(toWorkspaceFileSystemError(input, "workspaceFileSystem.readFile.realRoot")), + ); + const realTargetPath = yield* fileSystem + .realPath(input.absolutePath) + .pipe( + Effect.mapError( + toWorkspaceFileSystemError(input, "workspaceFileSystem.readFile.realTarget"), + ), + ); + const relativeToRealRoot = path + .relative(realWorkspaceRoot, realTargetPath) + .replaceAll("\\", "/"); + if ( + relativeToRealRoot.length === 0 || + relativeToRealRoot === "." || + relativeToRealRoot.startsWith("../") || + relativeToRealRoot === ".." || + path.isAbsolute(relativeToRealRoot) + ) { + return yield* new WorkspacePathOutsideRootError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + } + }); + + const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( + function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + yield* ensureReadTargetWithinRealRoot({ ...input, absolutePath: target.absolutePath }); + + const fileInfo = yield* fileSystem + .stat(target.absolutePath) + .pipe( + Effect.mapError(toWorkspaceFileSystemError(input, "workspaceFileSystem.readFile.stat")), + ); + if (fileInfo.type !== "File") { + return yield* new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.readFile.stat", + detail: "Only regular files can be previewed.", + }); + } + + const fileSize = + typeof fileInfo.size === "bigint" + ? Number(fileInfo.size) + : typeof fileInfo.size === "number" + ? fileInfo.size + : 0; + if (fileSize > WORKSPACE_PREVIEW_MAX_BYTES) { + return yield* new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.readFile.sizeLimit", + detail: `File is too large to preview (${fileSize} bytes). Limit is ${WORKSPACE_PREVIEW_MAX_BYTES} bytes.`, + }); + } + + const bytes = yield* fileSystem + .readFile(target.absolutePath) + .pipe( + Effect.mapError(toWorkspaceFileSystemError(input, "workspaceFileSystem.readFile.read")), + ); + if (isLikelyBinaryPreview(bytes)) { + return yield* new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.readFile.binaryCheck", + detail: "Binary files cannot be previewed.", + }); + } + + const contents = decodePreviewContents(bytes); + if (contents === null) { + return yield* new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.readFile.decode", + detail: "Only UTF-8 text files can be previewed.", + }); + } + + return { + relativePath: target.relativePath, + contents, + }; + }, + ); + const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { @@ -52,7 +181,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { yield* workspaceEntries.invalidate(input.cwd); return { relativePath: target.relativePath }; }); - return { writeFile } satisfies WorkspaceFileSystemShape; + return { readFile, writeFile } satisfies WorkspaceFileSystemShape; }); export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts index ba65da04f38..08ebe3c1049 100644 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -13,6 +13,8 @@ import type * as Effect from "effect/Effect"; import type { FilesystemBrowseInput, FilesystemBrowseResult, + ProjectListEntriesInput, + ProjectListEntriesResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; @@ -50,6 +52,10 @@ export interface WorkspaceEntriesShape { input: FilesystemBrowseInput, ) => Effect.Effect; + readonly listEntries: ( + input: ProjectListEntriesInput, + ) => Effect.Effect; + /** * Search indexed workspace entries for files and directories matching the * provided query. diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 16fcdf0b57f..97aee51f729 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -10,7 +10,12 @@ import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts"; +import type { + ProjectReadFileInput, + ProjectReadFileResult, + ProjectWriteFileInput, + ProjectWriteFileResult, +} from "@t3tools/contracts"; import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( @@ -28,6 +33,13 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass Effect.Effect< + ProjectReadFileResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + /** * Write a file relative to the workspace root. * diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 175bf3248b8..350b3c7d97d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,8 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + ProjectListEntriesError, + ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -160,7 +162,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], + [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], + [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], @@ -1172,6 +1176,36 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListEntries]: (input) => + observeRpcEffect( + WS_METHODS.projectsListEntries, + workspaceEntries.listEntries(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + message: `Failed to list workspace entries: ${cause.detail}`, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.projectsReadFile]: (input) => + observeRpcEffect( + WS_METHODS.projectsReadFile, + workspaceFileSystem.readFile(input).pipe( + Effect.mapError((cause) => { + const message = isWorkspacePathOutsideRootError(cause) + ? "Workspace file path must stay within the project root." + : cause.detail; + return new ProjectReadFileError({ + message, + cause, + }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, diff --git a/apps/web/package.json b/apps/web/package.json index cbf554a6679..7704365b0ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", + "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index a93a6d231fd..118c76cb726 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -6,12 +6,14 @@ import { render } from "vitest-browser-react"; const { contextMenuShowMock, + openFileInFilePreviewMock, openFileInPreviewMock, openInPreferredEditorMock, openUrlInPreviewMock, readLocalApiMock, } = vi.hoisted(() => ({ contextMenuShowMock: vi.fn(), + openFileInFilePreviewMock: vi.fn(), openFileInPreviewMock: vi.fn(async () => undefined), openInPreferredEditorMock: vi.fn(async () => "vscode"), openUrlInPreviewMock: vi.fn(async () => undefined), @@ -47,6 +49,11 @@ vi.mock("../browser/openFileInPreview", async (importOriginal) => ({ openUrlInPreview: openUrlInPreviewMock, })); +vi.mock("../filePreviewActions", async (importOriginal) => ({ + ...(await importOriginal()), + openFileInFilePreview: openFileInFilePreviewMock, +})); + import ChatMarkdown from "./ChatMarkdown"; import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -59,6 +66,7 @@ const threadRef = { describe("ChatMarkdown", () => { afterEach(() => { openInPreferredEditorMock.mockClear(); + openFileInFilePreviewMock.mockClear(); openFileInPreviewMock.mockClear(); openUrlInPreviewMock.mockClear(); contextMenuShowMock.mockReset(); @@ -248,6 +256,40 @@ describe("ChatMarkdown", () => { } }); + it("opens file links in the file preview from the context menu", async () => { + contextMenuShowMock.mockResolvedValue("open-in-file-preview"); + const filePath = "/repo/project/src/App.tsx"; + const screen = await render( + , + ); + + try { + const link = page.getByRole("link", { name: "App.tsx" }).element(); + link.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }), + ); + + await vi.waitFor(() => { + expect(contextMenuShowMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: "open-in-file-preview", + label: "Open in file preview", + }), + ]), + { x: 4, y: 8 }, + ); + expect(openFileInFilePreviewMock).toHaveBeenCalledWith( + threadRef, + filePath, + "/repo/project", + ); + }); + } finally { + await screen.unmount(); + } + }); + it("keeps a favicon with the leading segment of a wrapping URL", async () => { const url = "https://github.com/pingdotgg/t3code/pull/3017/changes"; const screen = await render( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3aba45249fa..fec1231206a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -68,6 +68,7 @@ import { openFileInPreview, openUrlInPreview, } from "../browser/openFileInPreview"; +import { openFileInFilePreview } from "../filePreviewActions"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -678,6 +679,7 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; + cwd?: string | undefined; className?: string | undefined; } @@ -1001,6 +1003,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ copyMarkdown, theme, threadRef, + cwd, className, }: MarkdownFileLinkProps) { const handleOpen = useCallback(() => { @@ -1037,6 +1040,21 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }); }, [iconPath, threadRef]); + const handleOpenInFilePreview = useCallback(() => { + if (!threadRef || !cwd) return; + try { + openFileInFilePreview(threadRef, iconPath, cwd); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to preview file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, [cwd, iconPath, threadRef]); + const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -1079,9 +1097,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const canOpenInBrowser = Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath); + const canOpenInFilePreview = Boolean(threadRef && cwd); const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, + ...(canOpenInFilePreview + ? ([{ id: "open-in-file-preview", label: "Open in file preview" }] as const) + : []), ...(canOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), @@ -1099,6 +1121,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "open-in-file-preview") { + handleOpenInFilePreview(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1107,7 +1133,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleCopy(targetPath, "Full path"); } }, - [displayPath, handleCopy, handleOpen, handleOpenInBrowser, iconPath, targetPath, threadRef], + [ + cwd, + displayPath, + handleCopy, + handleOpen, + handleOpenInBrowser, + handleOpenInFilePreview, + iconPath, + targetPath, + threadRef, + ], ); return ( @@ -1155,6 +1191,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef?.environmentId === next.threadRef?.environmentId && previous.threadRef?.threadId === next.threadRef?.threadId && + previous.cwd === next.cwd && previous.className === next.className ); } @@ -1278,6 +1315,7 @@ function ChatMarkdown({ copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} threadRef={threadRef} + cwd={cwd} className={props.className} /> ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a960ea90eb..4203b5bb650 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -111,6 +111,7 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const FilePreviewPanel = lazy(() => import("./FilePreviewPanel")); import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; @@ -262,6 +263,19 @@ function eventTargetElement(target: EventTarget | null): Element | null { return null; } +function eventComposedPathElements(event: Event): Element[] { + const path = event.composedPath?.() ?? []; + const elements: Element[] = []; + for (const node of path) { + if (node instanceof Element) { + elements.push(node); + } else if (node instanceof Node && node.parentElement) { + elements.push(node.parentElement); + } + } + return elements; +} + function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.defaultPrevented || event.isComposing) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; @@ -270,6 +284,10 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { const target = eventTargetElement(event.target); if (target?.closest(TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; if (target?.closest(TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; + for (const element of eventComposedPathElements(event)) { + if (element.closest(TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; + if (element.closest(TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; + } if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; return true; @@ -2257,6 +2275,18 @@ export default function ChatView(props: ChatViewProps) { onDiffPanelOpen, threadId, ]); + const addFilePreviewSurface = useCallback(() => { + if (!activeThreadRef || !activeProject) return; + if (diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + useRightPanelStore.getState().openFilePreview(activeThreadRef); + }, [activeProject, activeThreadRef, diffOpen, environmentId, navigate, threadId]); // Right-panel arbitration: // - The diff panel's openness is mirrored by the `?diff=1` URL search // param so it deep-links cleanly. The store still records preview/plan @@ -4739,8 +4769,10 @@ export default function ChatView(props: ChatViewProps) { onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddFilePreview={addFilePreviewSurface} browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} + filePreviewAvailable={Boolean(activeProject)} > {activeRightPanelSurface?.kind === "preview" ? ( @@ -4776,6 +4808,15 @@ export default function ChatView(props: ChatViewProps) { + ) : activeRightPanelSurface?.kind === "filePreview" ? ( + + + ) : null} ) : null} @@ -4793,8 +4834,10 @@ export default function ChatView(props: ChatViewProps) { onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddFilePreview={addFilePreviewSurface} browserAvailable={isPreviewSupportedInRuntime()} diffAvailable={isServerThread && isGitRepo} + filePreviewAvailable={Boolean(activeProject)} > {activeRightPanelSurface?.kind === "preview" ? ( @@ -4830,6 +4873,15 @@ export default function ChatView(props: ChatViewProps) { + ) : activeRightPanelSurface?.kind === "filePreview" ? ( + + + ) : activeRightPanelSurface?.kind === "plan" ? ( >(); +const FILE_PREVIEW_TREE_UNSAFE_CSS = ` +:host { + --trees-bg-override: transparent; + --trees-bg-muted-override: color-mix(in srgb, var(--accent) 10%, transparent); + --trees-border-color-override: var(--input); + --trees-focus-ring-color-override: transparent; + --trees-search-bg-override: var(--popover); + --trees-search-fg-override: var(--foreground); + --truncate-marker-background-color: transparent; +} + +[data-file-tree-virtualized-wrapper='true'], +[data-file-tree-virtualized-root='true'], +[data-file-tree-virtualized-scroll='true'] { + background: transparent; +} + +[data-file-tree-search-container][data-open='false'] { + display: none; +} + +[data-file-tree-search-container] { + padding-inline: 0; + margin-bottom: 0.5rem; +} + +[data-file-tree-search-input] { + height: 2rem; + border-radius: var(--radius-lg); + outline: none; + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent); +} + +[data-file-tree-search-input]:focus, +[data-file-tree-search-input]:focus-visible { + border-color: var(--input); + outline: none; + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent); +} + +button[data-type='item']:focus, +button[data-type='item']:focus-visible { + outline: none; + box-shadow: none; +} +`; +const FILE_PREVIEW_CODE_CSS = ` +.file-preview-shiki { + background-color: color-mix(in srgb, var(--card) 90%, var(--background)); + --file-preview-line-number-width: 2rem; + --file-preview-line-number-gap: 0.75rem; +} + +.file-preview-shiki pre { + margin: 0; + line-height: 0; +} + +.file-preview-shiki pre, +.file-preview-shiki code { + background: transparent !important; +} + +.file-preview-shiki code { + counter-reset: file-preview-line; + display: grid; + font-size: 11px; +} + +.file-preview-shiki .line { + display: block; + line-height: 1.25rem; + padding-left: calc(var(--file-preview-line-number-width) + var(--file-preview-line-number-gap)); + text-indent: calc(-1 * (var(--file-preview-line-number-width) + var(--file-preview-line-number-gap))); +} + +.file-preview-shiki .line::before { + counter-increment: file-preview-line; + content: counter(file-preview-line); + display: inline-block; + width: var(--file-preview-line-number-width); + margin-right: var(--file-preview-line-number-gap); + color: color-mix(in srgb, var(--muted-foreground) 85%, transparent); + text-align: right; + text-indent: 0; + user-select: none; +} + +`; + +function clampTreeWidth(width: number, maxWidth: number): number { + return Math.min(Math.max(width, FILE_PREVIEW_TREE_MIN_WIDTH), maxWidth); +} + +function extensionToLanguage(filePath: string): string { + const lowerPath = filePath.toLowerCase(); + const basename = lowerPath.split("/").at(-1) ?? lowerPath; + if (basename === "dockerfile") return "dockerfile"; + if (basename === ".gitignore") return "ini"; + if (basename.endsWith(".md")) return "markdown"; + if (basename.endsWith(".tsx")) return "tsx"; + if (basename.endsWith(".ts")) return "ts"; + if (basename.endsWith(".jsx")) return "jsx"; + if (basename.endsWith(".js")) return "js"; + if (basename.endsWith(".json")) return "json"; + if (basename.endsWith(".css")) return "css"; + if (basename.endsWith(".html")) return "html"; + if (basename.endsWith(".yml") || basename.endsWith(".yaml")) return "yaml"; + if (basename.endsWith(".sh")) return "bash"; + if (basename.endsWith(".py")) return "python"; + if (basename.endsWith(".rs")) return "rust"; + if (basename.endsWith(".go")) return "go"; + return "text"; +} + +function getHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); + if (cached) return cached; + + const promise = getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", + }).catch((err) => { + highlighterPromiseCache.delete(language); + if (language === "text") { + throw err; + } + return getHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} + +function toFileTreePath(entry: ProjectEntry): string { + return entry.kind === "directory" ? `${entry.path.replace(/\/+$/, "")}/` : entry.path; +} + +function fromFileTreePath(path: string): string { + return path.endsWith("/") ? path.slice(0, -1) : path; +} + +function ancestorDirectoryPaths(filePath: string): string[] { + const segments = filePath.split("/").filter((segment) => segment.length > 0); + const paths: string[] = []; + for (let index = 1; index < segments.length; index += 1) { + paths.push(`${segments.slice(0, index).join("/")}/`); + } + return paths; +} + +function parentPathOf(path: string): string | undefined { + const separatorIndex = path.lastIndexOf("/"); + return separatorIndex === -1 ? undefined : path.slice(0, separatorIndex); +} + +function toGitStatusEntries( + files: ReadonlyArray<{ + readonly path: string; + readonly status?: GitStatusEntry["status"] | undefined; + }>, +): ReadonlyArray { + return files.map((file) => ({ path: file.path, status: file.status ?? "modified" })); +} + +function filterChangedProjectEntries( + entries: ReadonlyArray, + changedFilePaths: ReadonlySet, +): ReadonlyArray { + if (changedFilePaths.size === 0) { + return []; + } + + const directoryPaths = new Set(); + for (const filePath of changedFilePaths) { + for (const directoryPath of ancestorDirectoryPaths(filePath)) { + directoryPaths.add(directoryPath.slice(0, -1)); + } + } + + const seenPaths = new Set(); + const filteredEntries = entries.filter((entry) => { + const visible = + entry.kind === "file" ? changedFilePaths.has(entry.path) : directoryPaths.has(entry.path); + if (visible) { + seenPaths.add(entry.path); + } + return visible; + }); + + for (const filePath of changedFilePaths) { + for (const directoryPath of ancestorDirectoryPaths(filePath)) { + const path = directoryPath.slice(0, -1); + if (seenPaths.has(path)) continue; + seenPaths.add(path); + filteredEntries.push({ + path, + kind: "directory", + ...(parentPathOf(path) ? { parentPath: parentPathOf(path) } : {}), + }); + } + if (seenPaths.has(filePath)) continue; + seenPaths.add(filePath); + filteredEntries.push({ + path: filePath, + kind: "file", + ...(parentPathOf(filePath) ? { parentPath: parentPathOf(filePath) } : {}), + }); + } + + return filteredEntries.toSorted((left, right) => + left.path.localeCompare(right.path, undefined, { numeric: true, sensitivity: "base" }), + ); +} + +function expandAncestors(model: ReturnType["model"], filePath: string): void { + for (const path of ancestorDirectoryPaths(filePath)) { + const item = model.getItem(path); + if (item && "expand" in item) { + item.expand(); + } + } +} + +interface FilePreviewTreeProps { + entries: ReadonlyArray; + expandAll: boolean; + gitStatus: ReadonlyArray; + searchOpen: boolean; + selectedFilePath: string | null; + onSearchOpenChange: (open: boolean) => void; + onSelectFile: (filePath: string | null) => void; +} + +function FilePreviewTree({ + entries, + expandAll, + gitStatus, + searchOpen, + selectedFilePath, + onSearchOpenChange, + onSelectFile, +}: FilePreviewTreeProps) { + const selectableFilePathsRef = useRef>(new Set()); + const selectedFilePathRef = useRef(selectedFilePath); + const treePaths = useMemo(() => entries.map(toFileTreePath), [entries]); + const preparedInput = useMemo(() => prepareFileTreeInput(treePaths), [treePaths]); + const selectableFilePaths = useMemo( + () => new Set(entries.filter((entry) => entry.kind === "file").map((entry) => entry.path)), + [entries], + ); + const selectedFileExpandedPaths = useMemo( + () => (selectedFilePath ? ancestorDirectoryPaths(selectedFilePath) : []), + [selectedFilePath], + ); + + useEffect(() => { + selectableFilePathsRef.current = selectableFilePaths; + }, [selectableFilePaths]); + + useEffect(() => { + selectedFilePathRef.current = selectedFilePath; + }, [selectedFilePath]); + + const { model } = useFileTree({ + paths: treePaths, + preparedInput, + flattenEmptyDirectories: true, + initialExpansion: expandAll ? "open" : "closed", + initialExpandedPaths: expandAll ? [] : selectedFileExpandedPaths, + density: "compact", + gitStatus, + search: true, + searchFakeFocus: false, + searchBlurBehavior: "retain", + unsafeCSS: FILE_PREVIEW_TREE_UNSAFE_CSS, + onSelectionChange: (selectedPaths) => { + const selectedPath = selectedPaths.at(-1); + if (!selectedPath) { + onSelectFile(null); + return; + } + const filePath = fromFileTreePath(selectedPath); + onSelectFile(selectableFilePathsRef.current.has(filePath) ? filePath : null); + }, + }); + const searchState = useFileTreeSearch(model); + const previousSearchOpenRef = useRef(searchOpen); + + useEffect(() => { + const selectedPath = selectedFilePathRef.current; + model.resetPaths(treePaths, { + preparedInput, + ...(expandAll + ? {} + : { initialExpandedPaths: selectedPath ? ancestorDirectoryPaths(selectedPath) : [] }), + }); + }, [expandAll, model, preparedInput, treePaths]); + + useEffect(() => { + model.setGitStatus(gitStatus); + }, [gitStatus, model]); + + useEffect(() => { + if (previousSearchOpenRef.current === searchOpen) { + return; + } + previousSearchOpenRef.current = searchOpen; + if (searchOpen) { + model.openSearch(); + } else { + model.closeSearch(); + } + }, [model, searchOpen]); + + useEffect(() => { + if (searchState.isOpen !== searchOpen && searchState.isOpen === model.isSearchOpen()) { + onSearchOpenChange(searchState.isOpen); + } + }, [model, onSearchOpenChange, searchOpen, searchState.isOpen]); + + useEffect(() => { + const selectedTreePath = selectedFilePath ? selectedFilePath : null; + for (const path of model.getSelectedPaths()) { + if (path !== selectedTreePath) { + model.getItem(path)?.deselect(); + } + } + if (selectedTreePath) { + expandAncestors(model, selectedTreePath); + model.getItem(selectedTreePath)?.select(); + model.scrollToPath(selectedTreePath, { focus: false, offset: "nearest" }); + } + }, [model, selectedFilePath]); + + return ( + + ); +} + +interface FilePreviewPanelProps { + mode?: DiffPanelMode; + threadRef: ScopedThreadRef; + visible: boolean; + initialFilePath?: string | undefined; +} + +export default function FilePreviewPanel({ + mode = "embedded", + threadRef, + visible, + initialFilePath, +}: FilePreviewPanelProps) { + const { resolvedTheme } = useTheme(); + const activeThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const activeProjectId = activeThread?.projectId ?? null; + const activeProject = useStore((store) => + activeThread && activeProjectId + ? selectProjectByRef(store, { + environmentId: activeThread.environmentId, + projectId: activeProjectId, + }) + : undefined, + ); + const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; + const gitStatusQuery = useVcsStatus({ + environmentId: activeThread?.environmentId ?? null, + cwd: activeCwd, + }); + const [selectedFilePath, setSelectedFilePath] = useState(initialFilePath ?? null); + const [treeSearchOpen, setTreeSearchOpen] = useState(false); + const [showModifiedOnly, setShowModifiedOnly] = useState(false); + const [treeWidth, setTreeWidth] = useState(FILE_PREVIEW_TREE_DEFAULT_WIDTH); + const [highlightedPreviewHtml, setHighlightedPreviewHtml] = useState(null); + const previewLayoutRef = useRef(null); + const previousVisibleRef = useRef(visible); + const previousProjectFilesRefreshKeyRef = useRef(null); + const previousSelectedFileRefreshKeyRef = useRef(null); + const treeResizeStateRef = useRef<{ + readonly startX: number; + readonly startWidth: number; + readonly maxWidth: number; + readonly previousCursor: string; + readonly previousUserSelect: string; + } | null>(null); + + useEffect(() => { + const stopResize = () => { + const state = treeResizeStateRef.current; + if (!state) return; + treeResizeStateRef.current = null; + document.body.style.cursor = state.previousCursor; + document.body.style.userSelect = state.previousUserSelect; + }; + const handlePointerMove = (event: PointerEvent) => { + const state = treeResizeStateRef.current; + if (!state) return; + const nextWidth = clampTreeWidth( + state.startWidth + event.clientX - state.startX, + state.maxWidth, + ); + setTreeWidth(nextWidth); + }; + const handlePointerUp = () => { + stopResize(); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + return () => { + stopResize(); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + }; + }, []); + + const maxTreeWidth = () => + Math.max( + FILE_PREVIEW_TREE_MIN_WIDTH, + Math.floor( + (previewLayoutRef.current?.getBoundingClientRect().width ?? + FILE_PREVIEW_TREE_DEFAULT_WIDTH) * FILE_PREVIEW_TREE_MAX_WIDTH_RATIO, + ), + ); + + useEffect(() => { + setSelectedFilePath(initialFilePath ?? null); + }, [activeThread?.environmentId, activeThread?.id, initialFilePath]); + + const latestProjectFilesRefreshKey = useMemo(() => { + const latestChangedSummary = (activeThread?.turnDiffSummaries ?? []) + .toReversed() + .find((summary) => summary.files.length > 0); + return latestChangedSummary + ? `${latestChangedSummary.turnId}:${latestChangedSummary.completedAt}` + : null; + }, [activeThread?.turnDiffSummaries]); + + const latestSelectedFileRefreshKey = useMemo(() => { + if (!selectedFilePath) return null; + const latestSelectedFileSummary = (activeThread?.turnDiffSummaries ?? []) + .toReversed() + .find((summary) => summary.files.some((file) => file.path === selectedFilePath)); + return latestSelectedFileSummary + ? `${latestSelectedFileSummary.turnId}:${latestSelectedFileSummary.completedAt}` + : null; + }, [activeThread?.turnDiffSummaries, selectedFilePath]); + + const projectFilesQuery = useQuery({ + queryKey: ["projects", "listEntries", activeThread?.environmentId ?? null, activeCwd], + queryFn: async () => { + if (!activeThread?.environmentId || !activeCwd) { + throw new Error("Project tree is unavailable."); + } + const api = ensureEnvironmentApi(activeThread.environmentId); + return api.projects.listEntries({ cwd: activeCwd }); + }, + enabled: Boolean(activeThread?.environmentId && activeCwd && visible), + retry: 1, + select: (result) => ({ + ...result, + entries: result.entries.toSorted((left, right) => + left.path.localeCompare(right.path, undefined, { numeric: true, sensitivity: "base" }), + ), + }), + }); + const projectFilesError = + projectFilesQuery.error instanceof Error + ? projectFilesQuery.error.message + : projectFilesQuery.error + ? "Failed to load the workspace tree." + : null; + const projectFilesTruncated = projectFilesQuery.data?.truncated === true; + const refetchProjectFiles = projectFilesQuery.refetch; + const gitStatusEntries = useMemo( + () => toGitStatusEntries(gitStatusQuery.data?.workingTree.files ?? []), + [gitStatusQuery.data?.workingTree.files], + ); + const changedFilePaths = useMemo( + () => new Set(gitStatusEntries.map((entry) => entry.path)), + [gitStatusEntries], + ); + const projectEntries = projectFilesQuery.data?.entries ?? []; + const visibleProjectEntries = useMemo( + () => + showModifiedOnly + ? filterChangedProjectEntries(projectEntries, changedFilePaths) + : projectEntries, + [changedFilePaths, projectEntries, showModifiedOnly], + ); + + useEffect(() => { + if (projectFilesQuery.data?.entries?.length === 0) { + setSelectedFilePath(null); + return; + } + if ( + selectedFilePath && + !changedFilePaths.has(selectedFilePath) && + !projectFilesQuery.data?.entries.some( + (file) => file.kind === "file" && file.path === selectedFilePath, + ) + ) { + setSelectedFilePath(null); + } + }, [changedFilePaths, projectFilesQuery.data?.entries, selectedFilePath]); + + useEffect(() => { + if (!visible) { + previousProjectFilesRefreshKeyRef.current = latestProjectFilesRefreshKey; + return; + } + if (previousProjectFilesRefreshKeyRef.current === null) { + previousProjectFilesRefreshKeyRef.current = latestProjectFilesRefreshKey; + return; + } + if (previousProjectFilesRefreshKeyRef.current === latestProjectFilesRefreshKey) { + return; + } + previousProjectFilesRefreshKeyRef.current = latestProjectFilesRefreshKey; + void refetchProjectFiles(); + }, [latestProjectFilesRefreshKey, refetchProjectFiles, visible]); + + useEffect(() => { + if (visible && !previousVisibleRef.current) { + setSelectedFilePath(null); + } + previousVisibleRef.current = visible; + }, [visible]); + + const selectedFileQuery = useQuery({ + queryKey: [ + "projects", + "readFile", + activeThread?.environmentId ?? null, + activeCwd, + selectedFilePath, + ], + queryFn: async () => { + if (!activeThread?.environmentId || !activeCwd || !selectedFilePath) { + throw new Error("File preview is unavailable."); + } + const api = ensureEnvironmentApi(activeThread.environmentId); + return api.projects.readFile({ + cwd: activeCwd, + relativePath: selectedFilePath, + }); + }, + enabled: Boolean(activeThread?.environmentId && activeCwd && selectedFilePath && visible), + retry: 1, + }); + const selectedFileData = + selectedFileQuery.data?.relativePath === selectedFilePath ? selectedFileQuery.data : null; + const selectedFileError = + selectedFileQuery.error instanceof Error + ? selectedFileQuery.error.message + : selectedFileQuery.error + ? "Failed to load file preview." + : null; + const refetchSelectedFile = selectedFileQuery.refetch; + + useEffect(() => { + if (!selectedFilePath || !visible) { + previousSelectedFileRefreshKeyRef.current = latestSelectedFileRefreshKey; + return; + } + if (previousSelectedFileRefreshKeyRef.current === null) { + previousSelectedFileRefreshKeyRef.current = latestSelectedFileRefreshKey; + return; + } + if (previousSelectedFileRefreshKeyRef.current === latestSelectedFileRefreshKey) { + return; + } + previousSelectedFileRefreshKeyRef.current = latestSelectedFileRefreshKey; + void refetchSelectedFile(); + }, [latestSelectedFileRefreshKey, refetchSelectedFile, selectedFilePath, visible]); + + useEffect(() => { + let cancelled = false; + const contents = selectedFileData?.contents ?? ""; + if (!selectedFilePath || contents.length === 0) { + setHighlightedPreviewHtml(null); + return; + } + + const language = extensionToLanguage(selectedFilePath); + getHighlighterPromise(language) + .then((highlighter) => { + const html = highlighter.codeToHtml(contents, { + lang: language, + theme: resolveDiffThemeName(resolvedTheme), + }); + if (!cancelled) { + setHighlightedPreviewHtml(html); + } + }) + .catch(() => { + if (!cancelled) { + setHighlightedPreviewHtml(null); + } + }); + + return () => { + cancelled = true; + }; + }, [resolvedTheme, selectedFileData?.contents, selectedFilePath]); + + const headerRow = ( + <> +
+
File Preview
+
+ Workspace tree + {projectFilesTruncated ? ( + + Truncated + + ) : null} +
+
+
+ { + setShowModifiedOnly(Boolean(pressed)); + }} + > + + + +
+ + ); + + return ( + + {!activeThread ? ( +
+ Select a thread to preview files. +
+ ) : projectFilesQuery.isLoading && !projectFilesQuery.data ? ( + + ) : projectFilesError && !projectFilesQuery.data ? ( +
+ {projectFilesError} +
+ ) : (projectFilesQuery.data?.entries.length ?? 0) === 0 ? ( +
+ No project files available yet. +
+ ) : ( +
+
+ {projectFilesError || projectFilesTruncated ? ( + + {projectFilesError ? ( + + ) : ( + + )} + + {projectFilesError ? "Workspace tree failed" : "Workspace tree is truncated"} + + + {projectFilesError ? ( + {projectFilesError} + ) : ( + Only the first indexed workspace entries are shown here. + )} + + + ) : null} + {visibleProjectEntries.length === 0 ? ( +
+ No changed files. +
+ ) : ( + + )} +
+
{ + event.preventDefault(); + treeResizeStateRef.current = { + startX: event.clientX, + startWidth: treeWidth, + maxWidth: maxTreeWidth(), + previousCursor: document.body.style.cursor, + previousUserSelect: document.body.style.userSelect, + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }} + onKeyDown={(event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + setTreeWidth((width) => + clampTreeWidth(width + (event.key === "ArrowRight" ? 16 : -16), maxTreeWidth()), + ); + }} + /> +
+ {!selectedFilePath ? ( +
+ Select a file to preview. +
+ ) : selectedFileQuery.isLoading && !selectedFileData ? ( + + ) : selectedFileError ? ( +
+ {selectedFileError} +
+ ) : ( +
+ +
+
+
+ {selectedFilePath} +
+
+ {highlightedPreviewHtml ? ( +
+ ) : ( +
+                      {selectedFileData?.contents ?? ""}
+                    
+ )} +
+
+ )} +
+
+ )} + + ); +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b4d5c9cbb45..b6170a15039 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,6 @@ import type { PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { ClipboardList, FileDiff, FileText, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type ReactNode, useState } from "react"; import { isElectron } from "~/env"; @@ -23,8 +23,10 @@ interface RightPanelTabsProps { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddFilePreview: () => void; browserAvailable: boolean; diffAvailable: boolean; + filePreviewAvailable: boolean; children: ReactNode; } @@ -32,10 +34,19 @@ function RightPanelEmptyState(props: { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddFilePreview: () => void; browserAvailable: boolean; diffAvailable: boolean; + filePreviewAvailable: boolean; }) { const actions = [ + { + label: "Files", + description: "Browse and preview workspace files.", + icon: FileText, + available: props.filePreviewAvailable, + onClick: props.onAddFilePreview, + }, { label: "Browser", description: "Open a local app or URL.", @@ -68,7 +79,7 @@ function RightPanelEmptyState(props: { Choose what to show in the right panel.

-
+
{actions.map((action) => { const Icon = action.icon; return ( @@ -101,6 +112,8 @@ function surfaceTitle( switch (surface.kind) { case "diff": return "Diff"; + case "filePreview": + return "Files"; case "terminal": return ( terminalLabelsById.get(surface.activeTerminalId) ?? @@ -152,6 +165,8 @@ function SurfaceIcon({ } case "diff": return ; + case "filePreview": + return ; case "terminal": return ; case "plan": @@ -233,6 +248,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { Diff + + + Files +
@@ -242,8 +261,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddBrowser={props.onAddBrowser} onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} + onAddFilePreview={props.onAddFilePreview} browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} + filePreviewAvailable={props.filePreviewAvailable} /> ) : ( props.children diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 49de33091a2..ae373ac94f9 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -19,6 +19,8 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { onMetadata: (callback, options) => rpcClient.terminal.onMetadata(callback, options), }, projects: { + listEntries: rpcClient.projects.listEntries, + readFile: rpcClient.projects.readFile, searchEntries: rpcClient.projects.searchEntries, writeFile: rpcClient.projects.writeFile, }, diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 392db299339..4650ba45fb9 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -78,6 +78,8 @@ function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { onMetadata: vi.fn(() => () => undefined), }, projects: { + listEntries: vi.fn(async () => ({ entries: [], truncated: false })), + readFile: vi.fn(async () => ({ relativePath: "README.md", contents: "" })), searchEntries: vi.fn(async () => []), writeFile: vi.fn(async () => undefined), }, diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index 592bc31e260..94a9bf8fab5 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -197,6 +197,8 @@ function createClient() { subscribePorts: vi.fn(() => () => undefined), }, projects: { + listEntries: vi.fn(async () => ({ entries: [], truncated: false })), + readFile: vi.fn(async () => ({ relativePath: "README.md", contents: "" })), searchEntries: vi.fn(async () => []), writeFile: vi.fn(async () => undefined), }, diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 2f223c4b1b4..8d7cec99043 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -121,6 +121,8 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { subscribePorts: vi.fn(() => () => undefined), }, projects: { + listEntries: vi.fn(), + readFile: vi.fn(), searchEntries: vi.fn(), writeFile: vi.fn(), }, diff --git a/apps/web/src/filePreviewActions.ts b/apps/web/src/filePreviewActions.ts new file mode 100644 index 00000000000..c207238d885 --- /dev/null +++ b/apps/web/src/filePreviewActions.ts @@ -0,0 +1,57 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { useRightPanelStore } from "./rightPanelStore"; +import { splitPathAndPosition } from "./terminal-links"; + +function normalizePathSeparators(path: string): string { + return path.replaceAll("\\", "/"); +} + +function canonicalizeWindowsDrivePath(path: string): string { + return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; +} + +function trimTrailingPathSeparators(path: string): string { + return path.replace(/[\\/]+$/, ""); +} + +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\"); +} + +function stripRelativePrefixes(path: string): string { + return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); +} + +export function resolveFilePreviewPath(filePathWithPosition: string, cwd: string): string | null { + const { path } = splitPathAndPosition(filePathWithPosition); + const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + const normalizedCwd = canonicalizeWindowsDrivePath( + normalizePathSeparators(trimTrailingPathSeparators(cwd)), + ); + const pathForCompare = normalizedPath.toLowerCase(); + const cwdForCompare = normalizedCwd.toLowerCase(); + + if (pathForCompare === cwdForCompare) { + return null; + } + if (pathForCompare.startsWith(`${cwdForCompare}/`)) { + return normalizedPath.slice(normalizedCwd.length + 1); + } + if (!isAbsolutePath(normalizedPath)) { + return stripRelativePrefixes(normalizedPath); + } + return null; +} + +export function openFileInFilePreview( + threadRef: ScopedThreadRef, + filePath: string, + cwd: string, +): void { + const previewPath = resolveFilePreviewPath(filePath, cwd); + if (!previewPath) { + throw new Error("File is outside the active workspace."); + } + useRightPanelStore.getState().openFilePreview(threadRef, previewPath); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index f81a7259c93..893a79da194 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -55,6 +55,8 @@ const rpcClientMock = { ), }, projects: { + listEntries: vi.fn(), + readFile: vi.fn(), searchEntries: vi.fn(), writeFile: vi.fn(), }, diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c111ec50e6b..ce6c4698f47 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -88,6 +88,22 @@ describe("rightPanelStore", () => { ).toHaveLength(2); }); + it("opens file preview with an initial file path", () => { + useRightPanelStore.getState().openFilePreview(refA, "src/App.tsx"); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "file-preview", + surfaces: [{ id: "file-preview", kind: "filePreview", initialFilePath: "src/App.tsx" }], + }); + + useRightPanelStore.getState().openFilePreview(refA); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "file-preview", + surfaces: [{ id: "file-preview", kind: "filePreview" }], + }); + }); + it("close hides the panel without clearing its selected surface", () => { useRightPanelStore.getState().open(refA, "plan"); useRightPanelStore.getState().close(refA); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 7bcf38fb1dd..c06b0a37bd9 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -14,7 +14,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview", "terminal"] as const; +export const RIGHT_PANEL_KINDS = ["plan", "diff", "filePreview", "preview", "terminal"] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -28,6 +28,7 @@ export type RightPanelSurface = activeTerminalId: string; splitDirection?: "horizontal" | "vertical"; } + | { id: "file-preview"; kind: "filePreview"; initialFilePath?: string } | { id: "diff"; kind: "diff" } | { id: "plan"; kind: "plan" }; @@ -43,6 +44,7 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; open: (ref: ScopedThreadRef, kind: Exclude) => void; + openFilePreview: (ref: ScopedThreadRef, initialFilePath?: string) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( @@ -75,6 +77,8 @@ const singletonSurface = ( switch (kind) { case "diff": return { id: "diff", kind }; + case "filePreview": + return { id: "file-preview", kind }; case "plan": return { id: "plan", kind }; } @@ -93,6 +97,12 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({ activeTerminalId: terminalId, }); +const filePreviewSurface = (initialFilePath?: string): RightPanelSurface => ({ + id: "file-preview", + kind: "filePreview", + ...(initialFilePath ? { initialFilePath } : {}), +}); + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -200,9 +210,24 @@ export const useRightPanelStore = create()( const existing = current.surfaces.find((surface) => surface.kind === "preview"); return upsertSurface(current, existing ?? browserSurface(null)); } + if (kind === "filePreview") { + return upsertSurface(current, filePreviewSurface()); + } return upsertSurface(current, singletonSurface(kind)); }), })), + openFilePreview: (ref, initialFilePath) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface( + { + ...current, + surfaces: current.surfaces.filter((surface) => surface.id !== "file-preview"), + }, + filePreviewSurface(initialFilePath), + ), + ), + })), openBrowser: (ref, tabId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 3e4e9f343ce..18a6559f315 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -96,6 +96,8 @@ export interface WsRpcClient { readonly subscribePorts: RpcStreamMethod; }; readonly projects: { + readonly listEntries: RpcUnaryMethod; + readonly readFile: RpcUnaryMethod; readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; }; @@ -267,6 +269,10 @@ export function createWsRpcClient( ), }, projects: { + listEntries: (input) => + transport.request((client) => client[WS_METHODS.projectsListEntries](input)), + readFile: (input) => + transport.request((client) => client[WS_METHODS.projectsReadFile](input)), searchEntries: (input) => transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), writeFile: (input) => diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..18cb8233150 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -195,6 +195,16 @@ const VcsStatusChangeRequest = Schema.Struct({ state: VcsStatusChangeRequestState, }); +export const VcsWorkingTreeFileStatus = Schema.Literals([ + "added", + "deleted", + "ignored", + "modified", + "renamed", + "untracked", +]); +export type VcsWorkingTreeFileStatus = typeof VcsWorkingTreeFileStatus.Type; + const VcsStatusLocalShape = { isRepo: Schema.Boolean, sourceControlProvider: Schema.optional(SourceControlProviderInfo), @@ -208,6 +218,7 @@ const VcsStatusLocalShape = { path: TrimmedNonEmptyStringSchema, insertions: NonNegativeInt, deletions: NonNegativeInt, + status: Schema.optional(VcsWorkingTreeFileStatus), }), ), insertions: NonNegativeInt, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 92f669c44f0..dce7c0709d3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -22,6 +22,10 @@ import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.t import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { + ProjectListEntriesInput, + ProjectListEntriesResult, + ProjectReadFileInput, + ProjectReadFileResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -1110,6 +1114,8 @@ export interface EnvironmentApi { ) => () => void; }; projects: { + listEntries: (input: ProjectListEntriesInput) => Promise; + readFile: (input: ProjectReadFileInput) => Promise; searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d9cb71fca47..a201f675ee6 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -34,6 +34,45 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( + "ProjectReadFileError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export const ProjectListEntriesInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, +}); +export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; + +export const ProjectListEntriesResult = Schema.Struct({ + entries: Schema.Array(ProjectEntry), + truncated: Schema.Boolean, +}); +export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; + +export class ProjectListEntriesError extends Schema.TaggedErrorClass()( + "ProjectListEntriesError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b1dc36651e3..73c3026f22c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -65,6 +65,12 @@ import { RelayClientStatusSchema, } from "./relayClient.ts"; import { + ProjectListEntriesError, + ProjectListEntriesInput, + ProjectListEntriesResult, + ProjectReadFileError, + ProjectReadFileInput, + ProjectReadFileResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -141,7 +147,9 @@ export const WS_METHODS = { projectsList: "projects.list", projectsAdd: "projects.add", projectsRemove: "projects.remove", + projectsListEntries: "projects.listEntries", projectsSearchEntries: "projects.searchEntries", + projectsReadFile: "projects.readFile", projectsWriteFile: "projects.writeFile", // Shell methods @@ -350,6 +358,18 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); +export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { + payload: ProjectListEntriesInput, + success: ProjectListEntriesResult, + error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), +}); + +export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { + payload: ProjectReadFileInput, + success: ProjectReadFileResult, + error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), +}); + export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, @@ -676,7 +696,9 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, + WsProjectsListEntriesRpc, WsProjectsSearchEntriesRpc, + WsProjectsReadFileRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 501422ec25d..404f6f82d18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,6 +483,9 @@ importers: '@pierre/diffs': specifier: 'catalog:' version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@pierre/trees': + specifier: 1.0.0-beta.4 + version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -3213,6 +3216,12 @@ packages: resolution: {integrity: sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==} engines: {vscode: ^1.0.0} + '@pierre/trees@1.0.0-beta.4': + resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==} + peerDependencies: + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -8078,6 +8087,14 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + preact-render-to-string@6.6.5: + resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + + preact@11.0.0-beta.0: + resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -12463,6 +12480,13 @@ snapshots: '@pierre/theme@0.0.28': {} + '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + preact: 11.0.0-beta.0 + preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@polka/url@1.0.0-next.29': {} '@preact/signals-core@1.14.2': {} @@ -17748,6 +17772,12 @@ snapshots: commander: 9.5.0 optional: true + preact-render-to-string@6.6.5(preact@11.0.0-beta.0): + dependencies: + preact: 11.0.0-beta.0 + + preact@11.0.0-beta.0: {} + prettier@3.8.3: {} pretty-cache-header@1.0.0: