From 7bba53481b1b641a0eb8a0d6f46654c437a3897c Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:20:31 -0300 Subject: [PATCH 01/13] feat(contracts): add filesystem CRUD and git.fileStatus RPC schemas --- packages/contracts/src/filesystem.test.ts | 83 ++++++++++++++ packages/contracts/src/filesystem.ts | 127 ++++++++++++++++++++++ packages/contracts/src/rpc.ts | 61 +++++++++++ 3 files changed, 271 insertions(+) create mode 100644 packages/contracts/src/filesystem.test.ts diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts new file mode 100644 index 00000000..d25fa91f --- /dev/null +++ b/packages/contracts/src/filesystem.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; +import { + FilesystemReadFileInput, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemRenameInput, + GitFileStatusResult, +} from "./filesystem.ts"; + +describe("FilesystemReadFileInput", () => { + it("accepts valid input", () => { + const result = Schema.decodeUnknownSync(FilesystemReadFileInput)({ + cwd: "/workspace", + relativePath: "src/index.ts", + }); + expect(result.cwd).toBe("/workspace"); + expect(result.relativePath).toBe("src/index.ts"); + }); + + it("rejects empty path", () => { + expect(() => + Schema.decodeUnknownSync(FilesystemReadFileInput)({ + cwd: "/workspace", + relativePath: "", + }), + ).toThrow(); + }); +}); + +describe("FilesystemListDirectoryInput", () => { + it("accepts valid input", () => { + const result = Schema.decodeUnknownSync(FilesystemListDirectoryInput)({ + cwd: "/workspace", + relativePath: "src", + }); + expect(result.relativePath).toBe("src"); + }); + + it("accepts root path", () => { + const result = Schema.decodeUnknownSync(FilesystemListDirectoryInput)({ + cwd: "/workspace", + relativePath: ".", + }); + expect(result.relativePath).toBe("."); + }); +}); + +describe("FilesystemListDirectoryResult", () => { + it("accepts valid entries", () => { + const result = Schema.decodeUnknownSync(FilesystemListDirectoryResult)({ + entries: [ + { name: "index.ts", kind: "file", relativePath: "src/index.ts" }, + { name: "components", kind: "directory", relativePath: "src/components" }, + ], + }); + expect(result.entries).toHaveLength(2); + expect(result.entries[0]!.kind).toBe("file"); + }); +}); + +describe("FilesystemRenameInput", () => { + it("accepts valid input", () => { + const result = Schema.decodeUnknownSync(FilesystemRenameInput)({ + cwd: "/workspace", + oldRelativePath: "src/old.ts", + newRelativePath: "src/new.ts", + }); + expect(result.oldRelativePath).toBe("src/old.ts"); + }); +}); + +describe("GitFileStatusResult", () => { + it("accepts valid git status output", () => { + const result = Schema.decodeUnknownSync(GitFileStatusResult)({ + files: [ + { relativePath: "src/index.ts", status: "modified" }, + { relativePath: "src/new.ts", status: "added" }, + ], + }); + expect(result.files).toHaveLength(2); + }); +}); diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index a518e2e9..b278db2b 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -28,3 +28,130 @@ export class FilesystemBrowseError extends Schema.TaggedErrorClass()( + "FilesystemReadFileError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export const FilesystemListDirectoryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH), + ), +}); +export type FilesystemListDirectoryInput = typeof FilesystemListDirectoryInput.Type; + +const DirectoryEntryKind = Schema.Literals(["file", "directory"]); + +export const DirectoryEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + kind: DirectoryEntryKind, + relativePath: TrimmedNonEmptyString, +}); +export type DirectoryEntry = typeof DirectoryEntry.Type; + +export const FilesystemListDirectoryResult = Schema.Struct({ + entries: Schema.Array(DirectoryEntry), +}); +export type FilesystemListDirectoryResult = typeof FilesystemListDirectoryResult.Type; + +export class FilesystemListDirectoryError extends Schema.TaggedErrorClass()( + "FilesystemListDirectoryError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export const FilesystemRenameInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + oldRelativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH), + ), + newRelativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH), + ), +}); +export type FilesystemRenameInput = typeof FilesystemRenameInput.Type; + +export const FilesystemDeleteInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH), + ), +}); +export type FilesystemDeleteInput = typeof FilesystemDeleteInput.Type; + +export const FilesystemCreateDirectoryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH), + ), +}); +export type FilesystemCreateDirectoryInput = typeof FilesystemCreateDirectoryInput.Type; + +export const FilesystemMutationResult = Schema.Struct({ + success: Schema.Boolean, +}); +export type FilesystemMutationResult = typeof FilesystemMutationResult.Type; + +export class FilesystemMutationError extends Schema.TaggedErrorClass()( + "FilesystemMutationError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +// --- Git Status for File Explorer --- + +export const GitFileStatus = Schema.Literals([ + "modified", + "added", + "deleted", + "untracked", + "renamed", + "conflicted", +]); +export type GitFileStatus = typeof GitFileStatus.Type; + +export const GitFileStatusEntry = Schema.Struct({ + relativePath: TrimmedNonEmptyString, + status: GitFileStatus, +}); +export type GitFileStatusEntry = typeof GitFileStatusEntry.Type; + +export const GitFileStatusResult = Schema.Struct({ + files: Schema.Array(GitFileStatusEntry), +}); +export type GitFileStatusResult = typeof GitFileStatusResult.Type; + +export class GitFileStatusError extends Schema.TaggedErrorClass()( + "GitFileStatusError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5dec716a..a2f5f487 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -8,6 +8,19 @@ import { FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError, + FilesystemReadFileInput, + FilesystemReadFileResult, + FilesystemReadFileError, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemListDirectoryError, + FilesystemRenameInput, + FilesystemDeleteInput, + FilesystemCreateDirectoryInput, + FilesystemMutationResult, + FilesystemMutationError, + GitFileStatusResult, + GitFileStatusError, } from "./filesystem.ts"; import { GitActionProgressEvent, @@ -90,8 +103,14 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + filesystemReadFile: "filesystem.readFile", + filesystemListDirectory: "filesystem.listDirectory", + filesystemRename: "filesystem.rename", + filesystemDelete: "filesystem.delete", + filesystemCreateDirectory: "filesystem.createDirectory", // Git methods + gitFileStatus: "git.fileStatus", gitPull: "git.pull", gitRefreshStatus: "git.refreshStatus", gitRunStackedAction: "git.runStackedAction", @@ -179,6 +198,42 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: FilesystemBrowseError, }); +export const WsFilesystemReadFileRpc = Rpc.make(WS_METHODS.filesystemReadFile, { + payload: FilesystemReadFileInput, + success: FilesystemReadFileResult, + error: FilesystemReadFileError, +}); + +export const WsFilesystemListDirectoryRpc = Rpc.make(WS_METHODS.filesystemListDirectory, { + payload: FilesystemListDirectoryInput, + success: FilesystemListDirectoryResult, + error: FilesystemListDirectoryError, +}); + +export const WsFilesystemRenameRpc = Rpc.make(WS_METHODS.filesystemRename, { + payload: FilesystemRenameInput, + success: FilesystemMutationResult, + error: FilesystemMutationError, +}); + +export const WsFilesystemDeleteRpc = Rpc.make(WS_METHODS.filesystemDelete, { + payload: FilesystemDeleteInput, + success: FilesystemMutationResult, + error: FilesystemMutationError, +}); + +export const WsFilesystemCreateDirectoryRpc = Rpc.make(WS_METHODS.filesystemCreateDirectory, { + payload: FilesystemCreateDirectoryInput, + success: FilesystemMutationResult, + error: FilesystemMutationError, +}); + +export const WsGitFileStatusRpc = Rpc.make(WS_METHODS.gitFileStatus, { + payload: GitStatusInput, + success: GitFileStatusResult, + error: GitFileStatusError, +}); + export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, success: GitStatusStreamEvent, @@ -365,6 +420,12 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsFilesystemReadFileRpc, + WsFilesystemListDirectoryRpc, + WsFilesystemRenameRpc, + WsFilesystemDeleteRpc, + WsFilesystemCreateDirectoryRpc, + WsGitFileStatusRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, WsGitRefreshStatusRpc, From 009d0cc9176b730890f56355f3968242ef8c70a7 Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:29:43 -0300 Subject: [PATCH 02/13] Add WorkspaceFileExplorer service and wire filesystem RPC handlers Implement server-side file explorer operations (readFile, listDirectory, rename, delete, createDirectory, gitFileStatus) as an Effect service following existing WorkspaceFileSystem patterns. Wire all six new RPC handlers in ws.ts and compose the layer in server.ts. --- apps/server/src/server.test.ts | 9 + apps/server/src/server.ts | 7 + .../workspace/Layers/WorkspaceFileExplorer.ts | 302 ++++++++++++++++++ .../Services/WorkspaceFileExplorer.ts | 86 +++++ apps/server/src/ws.ts | 85 +++++ 5 files changed, 489 insertions(+) create mode 100644 apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts create mode 100644 apps/server/src/workspace/Services/WorkspaceFileExplorer.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 47e159d3..14487a50 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -102,6 +102,7 @@ import { } from "./environment/Services/ServerEnvironment.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; +import { WorkspaceFileExplorerLive } from "./workspace/Layers/WorkspaceFileExplorer.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -200,6 +201,10 @@ const workspaceAndProjectServicesLayer = Layer.mergeAll( Layer.provide(WorkspacePathsLive), Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), ), + WorkspaceFileExplorerLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + ), ProjectFaviconResolverLive, ); @@ -394,6 +399,10 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provide(workspaceEntriesLayer), ), + WorkspaceFileExplorerLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(workspaceEntriesLayer), + ), ProjectFaviconResolverLive, ); const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f94bbb34..af64376a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -48,6 +48,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; +import { WorkspaceFileExplorerLive } from "./workspace/Layers/WorkspaceFileExplorer.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; @@ -208,10 +209,16 @@ const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( Layer.provide(WorkspaceEntriesLayerLive), ); +const WorkspaceFileExplorerLayerLive = WorkspaceFileExplorerLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLayerLive), +); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePathsLive, WorkspaceEntriesLayerLive, WorkspaceFileSystemLayerLive, + WorkspaceFileExplorerLayerLive, ); const AuthLayerLive = ServerAuthLive.pipe( diff --git a/apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts b/apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts new file mode 100644 index 00000000..f9b459fc --- /dev/null +++ b/apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts @@ -0,0 +1,302 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import { execFile } from "node:child_process"; + +import { + WorkspaceFileExplorer, + WorkspaceFileExplorerError, + type WorkspaceFileExplorerShape, +} from "../Services/WorkspaceFileExplorer.ts"; +import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; +import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; + +const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB +const BINARY_CHECK_BYTES = 8192; + +function hasBinaryContent(buffer: Uint8Array): boolean { + const limit = Math.min(buffer.length, BINARY_CHECK_BYTES); + for (let i = 0; i < limit; i++) { + if (buffer[i] === 0) return true; + } + return false; +} + +type GitStatusCode = "modified" | "added" | "deleted" | "untracked" | "renamed" | "conflicted"; + +function parseGitStatusCode(x: string, y: string): GitStatusCode | null { + if (x === "?" && y === "?") return "untracked"; + if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) + return "conflicted"; + if (x === "R" || y === "R") return "renamed"; + if (x === "A" || y === "A") return "added"; + if (x === "D" || y === "D") return "deleted"; + if (x === "M" || y === "M") return "modified"; + return null; +} + +export const makeWorkspaceFileExplorer = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; + const workspaceEntries = yield* WorkspaceEntries; + + const readFile: WorkspaceFileExplorerShape["readFile"] = Effect.fn( + "WorkspaceFileExplorer.readFile", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + const stat = yield* fileSystem.stat(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "readFile.stat", + detail: cause.message, + cause, + }), + ), + ); + + if (stat.type !== "File") { + return yield* new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "readFile", + detail: "Path is not a file", + }); + } + + if (stat.size > MAX_FILE_SIZE) { + return yield* new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "readFile", + detail: `File exceeds maximum size of ${MAX_FILE_SIZE} bytes`, + }); + } + + const bytes = yield* fileSystem.readFile(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "readFile.read", + detail: cause.message, + cause, + }), + ), + ); + + if (hasBinaryContent(bytes)) { + const base64 = Buffer.from(bytes).toString("base64"); + return { content: base64, encoding: "base64" as const }; + } + + return { content: new TextDecoder().decode(bytes), encoding: "utf-8" as const }; + }); + + const listDirectory: WorkspaceFileExplorerShape["listDirectory"] = Effect.fn( + "WorkspaceFileExplorer.listDirectory", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + const dirEntries = yield* fileSystem.readDirectory(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "listDirectory.readdir", + detail: cause.message, + cause, + }), + ), + ); + + // Filter dotfiles, stat each entry to determine kind + const visibleEntries = dirEntries.filter((name) => !name.startsWith(".")); + + const entries = yield* Effect.forEach( + visibleEntries, + (name) => + fileSystem.stat(path.join(target.absolutePath, name)).pipe( + Effect.map((stat) => ({ + name, + kind: (stat.type === "Directory" ? "directory" : "file") as "file" | "directory", + relativePath: input.relativePath === "." ? name : path.join(input.relativePath, name), + })), + Effect.catch(() => + Effect.succeed({ + name, + kind: "file" as const, + relativePath: input.relativePath === "." ? name : path.join(input.relativePath, name), + }), + ), + ), + { concurrency: 16 }, + ); + + // Sort: directories first, then alphabetical + const sorted = entries.toSorted((a, b) => { + if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return { entries: sorted }; + }); + + const rename: WorkspaceFileExplorerShape["rename"] = Effect.fn("WorkspaceFileExplorer.rename")( + function* (input) { + const oldTarget = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.oldRelativePath, + }); + const newTarget = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.newRelativePath, + }); + + yield* fileSystem + .makeDirectory(path.dirname(newTarget.absolutePath), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.newRelativePath, + operation: "rename.mkdirParent", + detail: cause.message, + cause, + }), + ), + ); + + yield* fileSystem.rename(oldTarget.absolutePath, newTarget.absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.oldRelativePath, + operation: "rename", + detail: cause.message, + cause, + }), + ), + ); + + yield* workspaceEntries.invalidate(input.cwd); + return { success: true }; + }, + ); + + const deleteOp: WorkspaceFileExplorerShape["delete"] = Effect.fn("WorkspaceFileExplorer.delete")( + function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + yield* fileSystem.remove(target.absolutePath, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "delete", + detail: cause.message, + cause, + }), + ), + ); + + yield* workspaceEntries.invalidate(input.cwd); + return { success: true }; + }, + ); + + const createDirectory: WorkspaceFileExplorerShape["createDirectory"] = Effect.fn( + "WorkspaceFileExplorer.createDirectory", + )(function* (input) { + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(target.absolutePath, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileExplorerError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "createDirectory", + detail: cause.message, + cause, + }), + ), + ); + + yield* workspaceEntries.invalidate(input.cwd); + return { success: true }; + }); + + const gitFileStatus: WorkspaceFileExplorerShape["gitFileStatus"] = Effect.fn( + "WorkspaceFileExplorer.gitFileStatus", + )(function* (cwd) { + const stdout = yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + execFile( + "git", + ["status", "--porcelain=v1", "-uall"], + { cwd, maxBuffer: 10 * 1024 * 1024 }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }, + ); + }), + catch: (error) => + new WorkspaceFileExplorerError({ + cwd, + operation: "gitFileStatus", + detail: error instanceof Error ? error.message : "Failed to run git status", + cause: error, + }), + }); + + const files: Array<{ relativePath: string; status: GitStatusCode }> = []; + for (const line of stdout.split("\n")) { + if (line.length < 4) continue; + const x = line[0]!; + const y = line[1]!; + const filePath = line.slice(3).split(" -> ").pop()!.trim(); + const status = parseGitStatusCode(x, y); + if (status !== null && filePath.length > 0) { + files.push({ relativePath: filePath, status }); + } + } + + return { files }; + }); + + return { + readFile, + listDirectory, + rename, + delete: deleteOp, + createDirectory, + gitFileStatus, + } satisfies WorkspaceFileExplorerShape; +}); + +export const WorkspaceFileExplorerLive = Layer.effect( + WorkspaceFileExplorer, + makeWorkspaceFileExplorer, +); diff --git a/apps/server/src/workspace/Services/WorkspaceFileExplorer.ts b/apps/server/src/workspace/Services/WorkspaceFileExplorer.ts new file mode 100644 index 00000000..6316f21b --- /dev/null +++ b/apps/server/src/workspace/Services/WorkspaceFileExplorer.ts @@ -0,0 +1,86 @@ +/** + * WorkspaceFileExplorer - Effect service contract for workspace file exploration. + * + * Owns workspace-root-relative file reading, directory listing, mutations + * (rename, delete, createDirectory), and git file status queries. + * + * @module WorkspaceFileExplorer + */ +import { Schema, Context } from "effect"; +import type { Effect } from "effect"; + +import type { + FilesystemReadFileInput, + FilesystemReadFileResult, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemRenameInput, + FilesystemDeleteInput, + FilesystemCreateDirectoryInput, + FilesystemMutationResult, + GitFileStatusResult, +} from "@t3tools/contracts"; +import type { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; + +export class WorkspaceFileExplorerError extends Schema.TaggedErrorClass()( + "WorkspaceFileExplorerError", + { + cwd: Schema.String, + relativePath: Schema.optional(Schema.String), + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +/** + * WorkspaceFileExplorerShape - Service API for workspace file exploration operations. + */ +export interface WorkspaceFileExplorerShape { + readonly readFile: ( + input: FilesystemReadFileInput, + ) => Effect.Effect< + FilesystemReadFileResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly listDirectory: ( + input: FilesystemListDirectoryInput, + ) => Effect.Effect< + FilesystemListDirectoryResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly rename: ( + input: FilesystemRenameInput, + ) => Effect.Effect< + FilesystemMutationResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly delete: ( + input: FilesystemDeleteInput, + ) => Effect.Effect< + FilesystemMutationResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly createDirectory: ( + input: FilesystemCreateDirectoryInput, + ) => Effect.Effect< + FilesystemMutationResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly gitFileStatus: ( + cwd: string, + ) => Effect.Effect; +} + +/** + * WorkspaceFileExplorer - Service tag for workspace file exploration operations. + */ +export class WorkspaceFileExplorer extends Context.Service< + WorkspaceFileExplorer, + WorkspaceFileExplorerShape +>()("t3/workspace/Services/WorkspaceFileExplorer") {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index aac716cf..1e5b806e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -18,6 +18,10 @@ import { ProjectWriteFileError, OrchestrationReplayEventsError, FilesystemBrowseError, + FilesystemReadFileError, + FilesystemListDirectoryError, + FilesystemMutationError, + GitFileStatusError, ThreadId, type TerminalEvent, WS_METHODS, @@ -48,6 +52,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { ServerSettingsService } from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import { WorkspaceFileExplorer } from "./workspace/Services/WorkspaceFileExplorer.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; @@ -147,6 +152,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const startup = yield* ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; + const workspaceFileExplorer = yield* WorkspaceFileExplorer; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; @@ -820,6 +826,85 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.filesystemReadFile]: (input) => + observeRpcEffect( + WS_METHODS.filesystemReadFile, + workspaceFileExplorer.readFile(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Filesystem path must stay within the project root." + : "Failed to read file"; + return new FilesystemReadFileError({ message, cause }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.filesystemListDirectory]: (input) => + observeRpcEffect( + WS_METHODS.filesystemListDirectory, + workspaceFileExplorer.listDirectory(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Filesystem path must stay within the project root." + : "Failed to list directory"; + return new FilesystemListDirectoryError({ message, cause }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.filesystemRename]: (input) => + observeRpcEffect( + WS_METHODS.filesystemRename, + workspaceFileExplorer.rename(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Filesystem path must stay within the project root." + : "Failed to rename"; + return new FilesystemMutationError({ message, cause }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.filesystemDelete]: (input) => + observeRpcEffect( + WS_METHODS.filesystemDelete, + workspaceFileExplorer.delete(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Filesystem path must stay within the project root." + : "Failed to delete"; + return new FilesystemMutationError({ message, cause }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.filesystemCreateDirectory]: (input) => + observeRpcEffect( + WS_METHODS.filesystemCreateDirectory, + workspaceFileExplorer.createDirectory(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Filesystem path must stay within the project root." + : "Failed to create directory"; + return new FilesystemMutationError({ message, cause }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.gitFileStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitFileStatus, + workspaceFileExplorer.gitFileStatus(input.cwd).pipe( + Effect.mapError( + (cause) => + new GitFileStatusError({ + message: "Failed to get git file status", + cause, + }), + ), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.subscribeGitStatus]: (input) => observeRpcStream( WS_METHODS.subscribeGitStatus, From e5d11a47eeb54e3446ceeb722bc202743b1c2bca Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:32:39 -0300 Subject: [PATCH 03/13] feat(web): wire filesystem CRUD and git.fileStatus RPC to client --- .../content/panel-layout.html | 456 +++ .../209025-1776951894/state/server-stopped | 1 + .../209025-1776951894/state/server.pid | 1 + apps/web/src/components/ChatView.browser.tsx | 15 + apps/web/src/environmentApi.ts | 6 + apps/web/src/rpc/wsRpcClient.ts | 15 + .../plans/2026-04-23-file-explorer.md | 3191 +++++++++++++++++ packages/contracts/src/filesystem.ts | 26 +- packages/contracts/src/ipc.ts | 20 +- 9 files changed, 3710 insertions(+), 21 deletions(-) create mode 100644 .superpowers/brainstorm/209025-1776951894/content/panel-layout.html create mode 100644 .superpowers/brainstorm/209025-1776951894/state/server-stopped create mode 100644 .superpowers/brainstorm/209025-1776951894/state/server.pid create mode 100644 docs/superpowers/plans/2026-04-23-file-explorer.md diff --git a/.superpowers/brainstorm/209025-1776951894/content/panel-layout.html b/.superpowers/brainstorm/209025-1776951894/content/panel-layout.html new file mode 100644 index 00000000..1aa70a0a --- /dev/null +++ b/.superpowers/brainstorm/209025-1776951894/content/panel-layout.html @@ -0,0 +1,456 @@ +

Right Panel Architecture: Tabbed Layout

+

How the file tree + editor integrates with the existing diff panel

+ +
+

Approach A: Unified Tabbed Right Panel (Recommended)

+

+ Single right panel with tab bar. Diff and File Explorer are tabs. Each tab preserves state when + switching. Same responsive behavior (inline sidebar on wide, sheet on narrow). +

+
+ +
+
+
File Explorer Tab Active
+
+ +
+
+ 📁 Files +
+
+ ± Diff +
+
+ + +
+ +
+ +
+ +
+ + +
+
+ + 📂 + apps +
+
+ + 📂 + server +
+
+ + 📂 + src +
+
+    + 📄 + package.json +
+
+ + 📂 + web +
+
+ + 📂 + src +
+
+    + ⚙️ + tsconfig.json +
+
+ + 📂 + packages +
+
+    + 📄 + package.json +
+
+    + 📝 + README.md + M +
+
+
+ + +
+ +
+
+ ⚙️ tsconfig.json + +
+
+ 📄 package.json + +
+
+ +
+ apps > web > tsconfig.json +
+ +
+
+ 1{ +
+
+ 2  "compilerOptions": { +
+
+ 3    "strict": true, +
+
+ 4    "target": "ES2020", +
+
+ 5    "module": "ES2020" +
+
+ 6  } +
+
+ 7} +
+
+
+
+
+
+ +
+
Diff Tab Active (same panel, different tab)
+
+ +
+
+ 📁 Files +
+
+ ± Diff +
+
+ + +
+
+
Turn #3 Changes
+
+ src/index.ts +12 -3 + README.md +5 -2 +
+
+
src/index.ts
+
+
+ 10 + const app = express(); +
+
+ 11 + + app.use(cors()); +
+
+ 12 + + app.use(helmet()); +
+
+ 13 + app.listen(3000); +
+
+
+
+
+
+ +
+

Key Design Points

+
    +
  • + Tab bar at panel top — switches between Files and Diff views, URL-driven + state +
  • +
  • + File tree + editor split — tree on left, CodeMirror editor on right, + resizable divider +
  • +
  • + Multiple editor tabs — open files shown as tabs with close buttons and + unsaved indicators +
  • +
  • + State preserved — switching tabs keeps scroll position, expanded folders, + open files +
  • +
  • + Same responsive behavior — inline sidebar on wide screens, sheet overlay on + narrow +
  • +
+
diff --git a/.superpowers/brainstorm/209025-1776951894/state/server-stopped b/.superpowers/brainstorm/209025-1776951894/state/server-stopped new file mode 100644 index 00000000..f739585b --- /dev/null +++ b/.superpowers/brainstorm/209025-1776951894/state/server-stopped @@ -0,0 +1 @@ +{"reason":"owner process exited","timestamp":1776955134285} diff --git a/.superpowers/brainstorm/209025-1776951894/state/server.pid b/.superpowers/brainstorm/209025-1776951894/state/server.pid new file mode 100644 index 00000000..5e5112c8 --- /dev/null +++ b/.superpowers/brainstorm/209025-1776951894/state/server.pid @@ -0,0 +1 @@ +209033 diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6857a51b..7183ac31 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -201,6 +201,21 @@ function createMockEnvironmentApi(input: { projects: {} as EnvironmentApi["projects"], filesystem: { browse: input.browse, + readFile: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["filesystem"]["readFile"], + listDirectory: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["filesystem"]["listDirectory"], + rename: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["filesystem"]["rename"], + delete: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["filesystem"]["delete"], + createDirectory: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["filesystem"]["createDirectory"], }, git: {} as EnvironmentApi["git"], orchestration: { diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 64df079d..e4a5cc3d 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -22,6 +22,11 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { }, filesystem: { browse: rpcClient.filesystem.browse, + readFile: rpcClient.filesystem.readFile, + listDirectory: rpcClient.filesystem.listDirectory, + rename: rpcClient.filesystem.rename, + delete: rpcClient.filesystem.delete, + createDirectory: rpcClient.filesystem.createDirectory, }, git: { pull: rpcClient.git.pull, @@ -35,6 +40,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, + fileStatus: rpcClient.git.fileStatus, }, orchestration: { dispatchCommand: rpcClient.orchestration.dispatchCommand, diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b67be32d..89eea5ae 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -70,6 +70,11 @@ export interface WsRpcClient { }; readonly filesystem: { readonly browse: RpcUnaryMethod; + readonly readFile: RpcUnaryMethod; + readonly listDirectory: RpcUnaryMethod; + readonly rename: RpcUnaryMethod; + readonly delete: RpcUnaryMethod; + readonly createDirectory: RpcUnaryMethod; }; readonly shell: { readonly openInEditor: (input: { @@ -99,6 +104,7 @@ export interface WsRpcClient { readonly preparePullRequestThread: RpcUnaryMethod< typeof WS_METHODS.gitPreparePullRequestThread >; + readonly fileStatus: RpcUnaryMethod; }; readonly server: { readonly getConfig: RpcUnaryNoArgMethod; @@ -150,6 +156,14 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { }, filesystem: { browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), + readFile: (input) => + transport.request((client) => client[WS_METHODS.filesystemReadFile](input)), + listDirectory: (input) => + transport.request((client) => client[WS_METHODS.filesystemListDirectory](input)), + rename: (input) => transport.request((client) => client[WS_METHODS.filesystemRename](input)), + delete: (input) => transport.request((client) => client[WS_METHODS.filesystemDelete](input)), + createDirectory: (input) => + transport.request((client) => client[WS_METHODS.filesystemCreateDirectory](input)), }, shell: { openInEditor: (input) => @@ -203,6 +217,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), preparePullRequestThread: (input) => transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), + fileStatus: (input) => transport.request((client) => client[WS_METHODS.gitFileStatus](input)), }, server: { getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), diff --git a/docs/superpowers/plans/2026-04-23-file-explorer.md b/docs/superpowers/plans/2026-04-23-file-explorer.md new file mode 100644 index 00000000..6d73631c --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-file-explorer.md @@ -0,0 +1,3191 @@ +# File Explorer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a file explorer tab to the right panel — a tree browser + CodeMirror editor — alongside the existing diff tab, with full CRUD, git status, and mention integration. + +**Architecture:** Refactor the current `DiffPanelInlineSidebar` into a generic `RightPanel` with a tab bar (Files | Diff). The Files tab renders a split-pane file tree + CodeMirror editor. New filesystem/git RPCs are added to `packages/contracts` and served from `apps/server`. The mention system is extended to support `@file:L10-L20` line-range references. + +**Tech Stack:** React 19, TanStack Router (URL state), CodeMirror 6, Effect Schema (contracts), custom `` component (resize), `vscode-icons.ts` (file icons), Lexical (mentions). + +--- + +### Task 1: New Filesystem & Git Status RPC Contracts + +**Files:** + +- Modify: `packages/contracts/src/filesystem.ts` +- Modify: `packages/contracts/src/rpc.ts` +- Modify: `packages/contracts/src/index.ts` +- Test: `packages/contracts/src/filesystem.test.ts` + +This task adds the new schemas and RPC definitions for `filesystem.readFile`, `filesystem.listDirectory`, `filesystem.rename`, `filesystem.delete`, `filesystem.createDirectory`, and `git.status`. + +- [ ] **Step 1: Write tests for new filesystem schemas** + +Create `packages/contracts/src/filesystem.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; +import { + FilesystemReadFileInput, + FilesystemReadFileResult, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemRenameInput, + FilesystemDeleteInput, + FilesystemCreateDirectoryInput, + FilesystemMutationResult, + GitFileStatus, + GitFileStatusResult, +} from "./filesystem.ts"; + +describe("FilesystemReadFileInput", () => { + it("accepts valid input", () => { + const result = Schema.decodeUnknownSync(FilesystemReadFileInput)({ + cwd: "/workspace", + relativePath: "src/index.ts", + }); + expect(result.cwd).toBe("/workspace"); + expect(result.relativePath).toBe("src/index.ts"); + }); + + it("rejects empty path", () => { + expect(() => + Schema.decodeUnknownSync(FilesystemReadFileInput)({ + cwd: "/workspace", + relativePath: "", + }), + ).toThrow(); + }); +}); + +describe("FilesystemListDirectoryInput", () => { + it("accepts valid input", () => { + const result = Schema.decodeUnknownSync(FilesystemListDirectoryInput)({ + cwd: "/workspace", + relativePath: "src", + }); + expect(result.relativePath).toBe("src"); + }); + + it("accepts root path", () => { + const result = Schema.decodeUnknownSync(FilesystemListDirectoryInput)({ + cwd: "/workspace", + relativePath: ".", + }); + expect(result.relativePath).toBe("."); + }); +}); + +describe("FilesystemListDirectoryResult", () => { + it("accepts valid entries", () => { + const result = Schema.decodeUnknownSync(FilesystemListDirectoryResult)({ + entries: [ + { name: "index.ts", kind: "file", relativePath: "src/index.ts" }, + { name: "components", kind: "directory", relativePath: "src/components" }, + ], + }); + expect(result.entries).toHaveLength(2); + expect(result.entries[0]!.kind).toBe("file"); + }); +}); + +describe("FilesystemRenameInput", () => { + it("accepts valid input", () => { + const result = Schema.decodeUnknownSync(FilesystemRenameInput)({ + cwd: "/workspace", + oldRelativePath: "src/old.ts", + newRelativePath: "src/new.ts", + }); + expect(result.oldRelativePath).toBe("src/old.ts"); + }); +}); + +describe("GitFileStatusResult", () => { + it("accepts valid git status output", () => { + const result = Schema.decodeUnknownSync(GitFileStatusResult)({ + files: [ + { relativePath: "src/index.ts", status: "modified" }, + { relativePath: "src/new.ts", status: "added" }, + ], + }); + expect(result.files).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun run test packages/contracts/src/filesystem.test.ts` +Expected: FAIL — schemas not yet defined. + +- [ ] **Step 3: Add new schemas to `packages/contracts/src/filesystem.ts`** + +Add after the existing `FilesystemBrowseError` class: + +```ts +// --- File Explorer RPCs --- + +const FILESYSTEM_RELATIVE_PATH_MAX_LENGTH = 512; + +export const FilesystemReadFileInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), + ), +}); +export type FilesystemReadFileInput = typeof FilesystemReadFileInput.Type; + +export const FilesystemReadFileResult = Schema.Struct({ + content: Schema.String, + encoding: Schema.Literals(["utf-8", "base64"]), +}); +export type FilesystemReadFileResult = typeof FilesystemReadFileResult.Type; + +export class FilesystemReadFileError extends Schema.TaggedErrorClass()( + "FilesystemReadFileError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export const FilesystemListDirectoryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), + ), +}); +export type FilesystemListDirectoryInput = typeof FilesystemListDirectoryInput.Type; + +const DirectoryEntryKind = Schema.Literals(["file", "directory"]); + +export const DirectoryEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + kind: DirectoryEntryKind, + relativePath: TrimmedNonEmptyString, +}); +export type DirectoryEntry = typeof DirectoryEntry.Type; + +export const FilesystemListDirectoryResult = Schema.Struct({ + entries: Schema.Array(DirectoryEntry), +}); +export type FilesystemListDirectoryResult = typeof FilesystemListDirectoryResult.Type; + +export class FilesystemListDirectoryError extends Schema.TaggedErrorClass()( + "FilesystemListDirectoryError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export const FilesystemRenameInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + oldRelativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), + ), + newRelativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), + ), +}); +export type FilesystemRenameInput = typeof FilesystemRenameInput.Type; + +export const FilesystemDeleteInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), + ), +}); +export type FilesystemDeleteInput = typeof FilesystemDeleteInput.Type; + +export const FilesystemCreateDirectoryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check( + Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), + ), +}); +export type FilesystemCreateDirectoryInput = typeof FilesystemCreateDirectoryInput.Type; + +export const FilesystemMutationResult = Schema.Struct({ + success: Schema.Boolean, +}); +export type FilesystemMutationResult = typeof FilesystemMutationResult.Type; + +export class FilesystemMutationError extends Schema.TaggedErrorClass()( + "FilesystemMutationError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + +// --- Git Status for File Explorer --- + +export const GitFileStatus = Schema.Literals([ + "modified", + "added", + "deleted", + "untracked", + "renamed", + "conflicted", +]); +export type GitFileStatus = typeof GitFileStatus.Type; + +export const GitFileStatusEntry = Schema.Struct({ + relativePath: TrimmedNonEmptyString, + status: GitFileStatus, +}); +export type GitFileStatusEntry = typeof GitFileStatusEntry.Type; + +export const GitFileStatusResult = Schema.Struct({ + files: Schema.Array(GitFileStatusEntry), +}); +export type GitFileStatusResult = typeof GitFileStatusResult.Type; + +export class GitFileStatusError extends Schema.TaggedErrorClass()( + "GitFileStatusError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} +``` + +- [ ] **Step 4: Add RPC definitions to `packages/contracts/src/rpc.ts`** + +Add imports at the top of `rpc.ts`: + +```ts +import { + FilesystemReadFileInput, + FilesystemReadFileResult, + FilesystemReadFileError, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemListDirectoryError, + FilesystemRenameInput, + FilesystemDeleteInput, + FilesystemCreateDirectoryInput, + FilesystemMutationResult, + FilesystemMutationError, + GitFileStatusResult, + GitFileStatusError, +} from "./filesystem.ts"; +``` + +Add to the `WS_METHODS` object: + +```ts + // Filesystem methods (existing) + filesystemBrowse: "filesystem.browse", + + // Filesystem methods (file explorer) + filesystemReadFile: "filesystem.readFile", + filesystemListDirectory: "filesystem.listDirectory", + filesystemRename: "filesystem.rename", + filesystemDelete: "filesystem.delete", + filesystemCreateDirectory: "filesystem.createDirectory", + + // Git methods (existing stay as-is) + ... + // Git methods (file explorer) + gitFileStatus: "git.fileStatus", +``` + +Add the Rpc definitions after the existing `WsFilesystemBrowseRpc`: + +```ts +export const WsFilesystemReadFileRpc = Rpc.make(WS_METHODS.filesystemReadFile, { + payload: FilesystemReadFileInput, + success: FilesystemReadFileResult, + error: FilesystemReadFileError, +}); + +export const WsFilesystemListDirectoryRpc = Rpc.make(WS_METHODS.filesystemListDirectory, { + payload: FilesystemListDirectoryInput, + success: FilesystemListDirectoryResult, + error: FilesystemListDirectoryError, +}); + +export const WsFilesystemRenameRpc = Rpc.make(WS_METHODS.filesystemRename, { + payload: FilesystemRenameInput, + success: FilesystemMutationResult, + error: FilesystemMutationError, +}); + +export const WsFilesystemDeleteRpc = Rpc.make(WS_METHODS.filesystemDelete, { + payload: FilesystemDeleteInput, + success: FilesystemMutationResult, + error: FilesystemMutationError, +}); + +export const WsFilesystemCreateDirectoryRpc = Rpc.make(WS_METHODS.filesystemCreateDirectory, { + payload: FilesystemCreateDirectoryInput, + success: FilesystemMutationResult, + error: FilesystemMutationError, +}); + +export const WsGitFileStatusRpc = Rpc.make(WS_METHODS.gitFileStatus, { + payload: GitStatusInput, + success: GitFileStatusResult, + error: GitFileStatusError, +}); +``` + +Add all six new RPCs to the `WsRpcGroup` call: + +```ts +export const WsRpcGroup = RpcGroup.make( + // ...existing RPCs... + WsFilesystemReadFileRpc, + WsFilesystemListDirectoryRpc, + WsFilesystemRenameRpc, + WsFilesystemDeleteRpc, + WsFilesystemCreateDirectoryRpc, + WsGitFileStatusRpc, + // ...rest... +); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun run test packages/contracts/src/filesystem.test.ts` +Expected: PASS + +- [ ] **Step 6: Run typecheck** + +Run: `bun typecheck` +Expected: Type errors in `apps/server/src/ws.ts` and `apps/web/src/rpc/wsRpcClient.ts` (new RPCs not yet handled). These are expected and will be resolved in subsequent tasks. The contracts package itself should type-check cleanly. + +- [ ] **Step 7: Commit** + +```bash +git add packages/contracts/src/filesystem.ts packages/contracts/src/filesystem.test.ts packages/contracts/src/rpc.ts +git commit -m "feat(contracts): add filesystem CRUD and git.fileStatus RPC schemas" +``` + +--- + +### Task 2: Server-Side Filesystem Service + +**Files:** + +- Create: `apps/server/src/workspace/Services/WorkspaceFileExplorer.ts` +- Create: `apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts` +- Create: `apps/server/src/workspace/Layers/WorkspaceFileExplorer.test.ts` + +This task adds the Effect service that implements `readFile`, `listDirectory`, `rename`, `delete`, and `createDirectory` with workspace-root path containment. + +- [ ] **Step 1: Write the service contract** + +Create `apps/server/src/workspace/Services/WorkspaceFileExplorer.ts`: + +```ts +/** + * WorkspaceFileExplorer - Effect service contract for workspace file explorer operations. + * + * Owns read, list, rename, delete, and create-directory operations scoped to + * workspace roots. All paths are validated to stay within the workspace root. + * + * @module WorkspaceFileExplorer + */ +import { Context } from "effect"; +import type { Effect } from "effect"; + +import type { + FilesystemReadFileInput, + FilesystemReadFileResult, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemRenameInput, + FilesystemDeleteInput, + FilesystemCreateDirectoryInput, + FilesystemMutationResult, + GitFileStatusResult, +} from "@t3tools/contracts"; +import type { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; + +export class WorkspaceFileExplorerError extends Error { + readonly _tag = "WorkspaceFileExplorerError"; + constructor( + readonly operation: string, + readonly detail: string, + readonly cwd: string, + override readonly cause?: unknown, + ) { + super(`WorkspaceFileExplorer.${operation}: ${detail}`); + } +} + +export interface WorkspaceFileExplorerShape { + readonly readFile: ( + input: FilesystemReadFileInput, + ) => Effect.Effect< + FilesystemReadFileResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly listDirectory: ( + input: FilesystemListDirectoryInput, + ) => Effect.Effect< + FilesystemListDirectoryResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly rename: ( + input: FilesystemRenameInput, + ) => Effect.Effect< + FilesystemMutationResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly delete: ( + input: FilesystemDeleteInput, + ) => Effect.Effect< + FilesystemMutationResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly createDirectory: ( + input: FilesystemCreateDirectoryInput, + ) => Effect.Effect< + FilesystemMutationResult, + WorkspaceFileExplorerError | WorkspacePathOutsideRootError + >; + + readonly gitFileStatus: ( + cwd: string, + ) => Effect.Effect; +} + +export class WorkspaceFileExplorer extends Context.Service< + WorkspaceFileExplorer, + WorkspaceFileExplorerShape +>()("t3/workspace/Services/WorkspaceFileExplorer") {} +``` + +- [ ] **Step 2: Write the implementation layer** + +Create `apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts`: + +```ts +import { Effect, Layer } from "effect"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as childProcess from "node:child_process"; + +import type { + FilesystemReadFileInput, + FilesystemReadFileResult, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemRenameInput, + FilesystemDeleteInput, + FilesystemCreateDirectoryInput, + FilesystemMutationResult, + GitFileStatus, + GitFileStatusResult, +} from "@t3tools/contracts"; + +import { + WorkspaceFileExplorer, + WorkspaceFileExplorerError, +} from "../Services/WorkspaceFileExplorer.ts"; +import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; + +const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // 2 MB + +function execGitStatus(cwd: string): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile( + "git", + ["status", "--porcelain=v1", "-uall"], + { cwd, maxBuffer: 1024 * 1024, timeout: 10_000 }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); +} + +function parseGitStatusCode(code: string): GitFileStatus { + const trimmed = code.trim(); + if (trimmed === "M" || trimmed === "MM" || trimmed === "AM") return "modified"; + if (trimmed === "A") return "added"; + if (trimmed === "D") return "deleted"; + if (trimmed === "??" || trimmed === "?") return "untracked"; + if (trimmed.startsWith("R")) return "renamed"; + if (trimmed === "UU" || trimmed === "AA" || trimmed === "DD") return "conflicted"; + return "modified"; // fallback +} + +export const WorkspaceFileExplorerLive = Layer.effect( + WorkspaceFileExplorer, + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + + return WorkspaceFileExplorer.of({ + readFile: (input: FilesystemReadFileInput) => + Effect.gen(function* () { + const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); + const stats = yield* Effect.tryPromise({ + try: () => fs.stat(resolvedPath), + catch: (error) => + new WorkspaceFileExplorerError( + "readFile", + `File not found: ${input.relativePath}`, + input.cwd, + error, + ), + }); + + if (!stats.isFile()) { + return yield* Effect.fail( + new WorkspaceFileExplorerError( + "readFile", + `Not a file: ${input.relativePath}`, + input.cwd, + ), + ); + } + + if (stats.size > MAX_FILE_SIZE_BYTES) { + return yield* Effect.fail( + new WorkspaceFileExplorerError( + "readFile", + `File too large (${stats.size} bytes, max ${MAX_FILE_SIZE_BYTES})`, + input.cwd, + ), + ); + } + + const buffer = yield* Effect.tryPromise({ + try: () => fs.readFile(resolvedPath), + catch: (error) => + new WorkspaceFileExplorerError( + "readFile", + `Failed to read: ${input.relativePath}`, + input.cwd, + error, + ), + }); + + // Detect binary content by checking for null bytes in first 8KB + const sample = buffer.subarray(0, 8192); + const isBinary = sample.includes(0); + + const result: FilesystemReadFileResult = isBinary + ? { content: buffer.toString("base64"), encoding: "base64" as const } + : { content: buffer.toString("utf-8"), encoding: "utf-8" as const }; + + return result; + }), + + listDirectory: (input: FilesystemListDirectoryInput) => + Effect.gen(function* () { + const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); + const dirents = yield* Effect.tryPromise({ + try: () => fs.readdir(resolvedPath, { withFileTypes: true }), + catch: (error) => + new WorkspaceFileExplorerError( + "listDirectory", + `Failed to list: ${input.relativePath}`, + input.cwd, + error, + ), + }); + + const entries = dirents + .filter((d) => !d.name.startsWith(".")) + .map((d) => ({ + name: d.name, + kind: d.isDirectory() ? ("directory" as const) : ("file" as const), + relativePath: path.join(input.relativePath === "." ? "" : input.relativePath, d.name), + })) + .sort((a, b) => { + // Directories first, then alphabetical + if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + const result: FilesystemListDirectoryResult = { entries }; + return result; + }), + + rename: (input: FilesystemRenameInput) => + Effect.gen(function* () { + const oldResolved = yield* workspacePaths.resolve(input.cwd, input.oldRelativePath); + const newResolved = yield* workspacePaths.resolve(input.cwd, input.newRelativePath); + + // Ensure parent directory for new path exists + yield* Effect.tryPromise({ + try: () => fs.mkdir(path.dirname(newResolved), { recursive: true }), + catch: (error) => + new WorkspaceFileExplorerError( + "rename", + `Failed to create parent directory`, + input.cwd, + error, + ), + }); + + yield* Effect.tryPromise({ + try: () => fs.rename(oldResolved, newResolved), + catch: (error) => + new WorkspaceFileExplorerError( + "rename", + `Failed to rename ${input.oldRelativePath} to ${input.newRelativePath}`, + input.cwd, + error, + ), + }); + + const result: FilesystemMutationResult = { success: true }; + return result; + }), + + delete: (input: FilesystemDeleteInput) => + Effect.gen(function* () { + const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); + + yield* Effect.tryPromise({ + try: () => fs.rm(resolvedPath, { recursive: true }), + catch: (error) => + new WorkspaceFileExplorerError( + "delete", + `Failed to delete: ${input.relativePath}`, + input.cwd, + error, + ), + }); + + const result: FilesystemMutationResult = { success: true }; + return result; + }), + + createDirectory: (input: FilesystemCreateDirectoryInput) => + Effect.gen(function* () { + const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); + + yield* Effect.tryPromise({ + try: () => fs.mkdir(resolvedPath, { recursive: true }), + catch: (error) => + new WorkspaceFileExplorerError( + "createDirectory", + `Failed to create directory: ${input.relativePath}`, + input.cwd, + error, + ), + }); + + const result: FilesystemMutationResult = { success: true }; + return result; + }), + + gitFileStatus: (cwd: string) => + Effect.gen(function* () { + const stdout = yield* Effect.tryPromise({ + try: () => execGitStatus(cwd), + catch: (error) => + new WorkspaceFileExplorerError( + "gitFileStatus", + "Failed to run git status", + cwd, + error, + ), + }); + + const files = stdout + .split("\n") + .filter((line) => line.length >= 4) + .map((line) => ({ + relativePath: line.slice(3).trim(), + status: parseGitStatusCode(line.slice(0, 2)), + })); + + const result: GitFileStatusResult = { files }; + return result; + }), + }); + }), +); +``` + +- [ ] **Step 3: Read `WorkspacePaths` service to verify the `resolve` method signature** + +The implementation above uses `workspacePaths.resolve(cwd, relativePath)`. Before proceeding, verify that `WorkspacePaths` has a `.resolve()` method. Read `apps/server/src/workspace/Services/WorkspacePaths.ts` and check the interface. If the method is named differently (e.g., `resolvePath` or uses a different signature), update the `WorkspaceFileExplorer` layer implementation to match. + +- [ ] **Step 4: Run typecheck** + +Run: `bun typecheck` +Expected: Server-side types should be clean for the new service files. WebSocket handler errors remain (addressed in Task 3). + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/workspace/Services/WorkspaceFileExplorer.ts apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts +git commit -m "feat(server): add WorkspaceFileExplorer service for filesystem CRUD and git status" +``` + +--- + +### Task 3: Wire Server-Side RPC Handlers + +**Files:** + +- Modify: `apps/server/src/ws.ts` + +This task wires the new RPCs to the `WorkspaceFileExplorer` service in the WebSocket handler map. + +- [ ] **Step 1: Add import for `WorkspaceFileExplorer` at top of `ws.ts`** + +Add near the other workspace imports: + +```ts +import { WorkspaceFileExplorer } from "./workspace/Services/WorkspaceFileExplorer.ts"; +``` + +Also add the new error/type imports from contracts: + +```ts +import { + // ...existing imports... + FilesystemReadFileError, + FilesystemListDirectoryError, + FilesystemMutationError, + GitFileStatusError, +} from "@t3tools/contracts"; +``` + +- [ ] **Step 2: Yield the `WorkspaceFileExplorer` service in the handler setup** + +In the `Effect.gen` function that creates the handler map, add: + +```ts +const workspaceFileExplorer = yield * WorkspaceFileExplorer; +``` + +This should be near the existing `const workspaceEntries = yield* WorkspaceEntries;` line. + +- [ ] **Step 3: Add RPC handlers to the handler map** + +Add these entries to the handler record (alongside the existing `[WS_METHODS.filesystemBrowse]` handler): + +```ts +[WS_METHODS.filesystemReadFile]: (input) => + observeRpcEffect( + WS_METHODS.filesystemReadFile, + workspaceFileExplorer.readFile(input).pipe( + Effect.mapError( + (cause) => + new FilesystemReadFileError({ + message: cause instanceof Error ? cause.message : "Failed to read file", + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), +[WS_METHODS.filesystemListDirectory]: (input) => + observeRpcEffect( + WS_METHODS.filesystemListDirectory, + workspaceFileExplorer.listDirectory(input).pipe( + Effect.mapError( + (cause) => + new FilesystemListDirectoryError({ + message: cause instanceof Error ? cause.message : "Failed to list directory", + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), +[WS_METHODS.filesystemRename]: (input) => + observeRpcEffect( + WS_METHODS.filesystemRename, + workspaceFileExplorer.rename(input).pipe( + Effect.mapError( + (cause) => + new FilesystemMutationError({ + message: cause instanceof Error ? cause.message : "Failed to rename", + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), +[WS_METHODS.filesystemDelete]: (input) => + observeRpcEffect( + WS_METHODS.filesystemDelete, + workspaceFileExplorer.delete(input).pipe( + Effect.mapError( + (cause) => + new FilesystemMutationError({ + message: cause instanceof Error ? cause.message : "Failed to delete", + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), +[WS_METHODS.filesystemCreateDirectory]: (input) => + observeRpcEffect( + WS_METHODS.filesystemCreateDirectory, + workspaceFileExplorer.createDirectory(input).pipe( + Effect.mapError( + (cause) => + new FilesystemMutationError({ + message: cause instanceof Error ? cause.message : "Failed to create directory", + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), +[WS_METHODS.gitFileStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitFileStatus, + workspaceFileExplorer.gitFileStatus(input.cwd).pipe( + Effect.mapError( + (cause) => + new GitFileStatusError({ + message: cause instanceof Error ? cause.message : "Failed to get git status", + cause, + }), + ), + ), + { "rpc.aggregate": "git" }, + ), +``` + +- [ ] **Step 4: Provide the `WorkspaceFileExplorer` layer in the server composition** + +Find where `WorkspaceEntries` layer is provided to the server (likely in the server's main composition file or in `ws.ts`'s layer setup). Add `WorkspaceFileExplorerLive` to the layer composition. + +Import in the composition file: + +```ts +import { WorkspaceFileExplorerLive } from "./workspace/Layers/WorkspaceFileExplorer.ts"; +``` + +Add to the layer: + +```ts +Layer.merge(WorkspaceFileExplorerLive); +``` + +- [ ] **Step 5: Run typecheck** + +Run: `bun typecheck` +Expected: Server should now compile cleanly. Client may still have type errors (resolved in Task 4). + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/ws.ts +git commit -m "feat(server): wire filesystem and git.fileStatus RPC handlers" +``` + +--- + +### Task 4: Client-Side RPC Wiring + +**Files:** + +- Modify: `apps/web/src/rpc/wsRpcClient.ts` +- Modify: `apps/web/src/environmentApi.ts` +- Modify: `packages/contracts/src/ipc.ts` + +This task exposes the new RPCs on the client-side `WsRpcClient` and `EnvironmentApi`. + +- [ ] **Step 1: Add new methods to `WsRpcClient` interface in `wsRpcClient.ts`** + +Extend the `filesystem` section of the `WsRpcClient` interface: + +```ts +readonly filesystem: { + readonly browse: RpcUnaryMethod; + readonly readFile: RpcUnaryMethod; + readonly listDirectory: RpcUnaryMethod; + readonly rename: RpcUnaryMethod; + readonly delete: RpcUnaryMethod; + readonly createDirectory: RpcUnaryMethod; +}; +``` + +Add to the `git` section: + +```ts +readonly fileStatus: RpcUnaryMethod; +``` + +- [ ] **Step 2: Add implementations in `createWsRpcClient`** + +In the `filesystem` section of the returned object: + +```ts +filesystem: { + browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), + readFile: (input) => + transport.request((client) => client[WS_METHODS.filesystemReadFile](input)), + listDirectory: (input) => + transport.request((client) => client[WS_METHODS.filesystemListDirectory](input)), + rename: (input) => + transport.request((client) => client[WS_METHODS.filesystemRename](input)), + delete: (input) => + transport.request((client) => client[WS_METHODS.filesystemDelete](input)), + createDirectory: (input) => + transport.request((client) => client[WS_METHODS.filesystemCreateDirectory](input)), +}, +``` + +In the `git` section, add: + +```ts +fileStatus: (input) => + transport.request((client) => client[WS_METHODS.gitFileStatus](input)), +``` + +- [ ] **Step 3: Extend `EnvironmentApi` interface in `packages/contracts/src/ipc.ts`** + +Add to the `filesystem` section of `EnvironmentApi`: + +```ts +filesystem: { + browse: (input: FilesystemBrowseInput) => Promise; + readFile: (input: FilesystemReadFileInput) => Promise; + listDirectory: (input: FilesystemListDirectoryInput) => Promise; + rename: (input: FilesystemRenameInput) => Promise; + delete: (input: FilesystemDeleteInput) => Promise; + createDirectory: (input: FilesystemCreateDirectoryInput) => Promise; +}; +``` + +Add to the `git` section: + +```ts +fileStatus: (input: GitStatusInput) => Promise; +``` + +Add the new type imports at the top of `ipc.ts`. + +- [ ] **Step 4: Wire the new methods in `environmentApi.ts`** + +Update the `filesystem` and `git` sections in `createEnvironmentApi`: + +```ts +filesystem: { + browse: rpcClient.filesystem.browse, + readFile: rpcClient.filesystem.readFile, + listDirectory: rpcClient.filesystem.listDirectory, + rename: rpcClient.filesystem.rename, + delete: rpcClient.filesystem.delete, + createDirectory: rpcClient.filesystem.createDirectory, +}, +``` + +In `git`, add: + +```ts +fileStatus: rpcClient.git.fileStatus, +``` + +- [ ] **Step 5: Run typecheck and lint** + +Run: `bun typecheck && bun lint` +Expected: PASS — full client/server type chain should be clean. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/rpc/wsRpcClient.ts apps/web/src/environmentApi.ts packages/contracts/src/ipc.ts +git commit -m "feat(web): wire filesystem CRUD and git.fileStatus RPC to client" +``` + +--- + +### Task 5: URL State — Replace `?diff=1` with `?panel=files|diff` + +**Files:** + +- Rename: `apps/web/src/diffRouteSearch.ts` → `apps/web/src/panelRouteSearch.ts` +- Modify: `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` +- Modify: any other files importing from `diffRouteSearch.ts` + +This task replaces the `?diff=1` URL param with `?panel=files` / `?panel=diff`. + +- [ ] **Step 1: Create `panelRouteSearch.ts` to replace `diffRouteSearch.ts`** + +Create `apps/web/src/panelRouteSearch.ts`: + +```ts +import { TurnId } from "@t3tools/contracts"; + +export type PanelTab = "files" | "diff"; + +export interface PanelRouteSearch { + panel?: PanelTab | undefined; + diffTurnId?: TurnId | undefined; + diffFilePath?: string | undefined; +} + +function isPanelTabValue(value: unknown): value is PanelTab { + return value === "files" || value === "diff"; +} + +function normalizeSearchString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +export function stripPanelSearchParams>( + params: T, +): Omit { + const { panel: _panel, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; + return rest as Omit; +} + +/** + * Backward-compatible parse: also accepts legacy `?diff=1` and normalizes to `panel=diff`. + */ +export function parsePanelRouteSearch(search: Record): PanelRouteSearch { + // Support new `?panel=files` / `?panel=diff` + let panel: PanelTab | undefined; + if (isPanelTabValue(search.panel)) { + panel = search.panel; + } else if (search.diff === "1" || search.diff === 1 || search.diff === true) { + // Backward compat: legacy `?diff=1` → `panel=diff` + panel = "diff"; + } + + const isDiff = panel === "diff"; + const diffTurnIdRaw = isDiff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; + const diffFilePath = + isDiff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; + + return { + ...(panel ? { panel } : {}), + ...(diffTurnId ? { diffTurnId } : {}), + ...(diffFilePath ? { diffFilePath } : {}), + }; +} +``` + +- [ ] **Step 2: Write tests for the new panel route search** + +Create `apps/web/src/panelRouteSearch.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { parsePanelRouteSearch, stripPanelSearchParams } from "./panelRouteSearch"; + +describe("parsePanelRouteSearch", () => { + it("parses panel=files", () => { + expect(parsePanelRouteSearch({ panel: "files" })).toEqual({ panel: "files" }); + }); + + it("parses panel=diff", () => { + expect(parsePanelRouteSearch({ panel: "diff" })).toEqual({ panel: "diff" }); + }); + + it("returns empty for no panel", () => { + expect(parsePanelRouteSearch({})).toEqual({}); + }); + + it("backward compat: diff=1 → panel=diff", () => { + expect(parsePanelRouteSearch({ diff: "1" })).toEqual({ panel: "diff" }); + }); + + it("preserves diffTurnId when panel=diff", () => { + const result = parsePanelRouteSearch({ panel: "diff", diffTurnId: "turn-1" }); + expect(result.panel).toBe("diff"); + expect(result.diffTurnId).toBeDefined(); + }); + + it("ignores diffTurnId when panel=files", () => { + const result = parsePanelRouteSearch({ panel: "files", diffTurnId: "turn-1" }); + expect(result.diffTurnId).toBeUndefined(); + }); + + it("rejects invalid panel values", () => { + expect(parsePanelRouteSearch({ panel: "invalid" })).toEqual({}); + }); +}); + +describe("stripPanelSearchParams", () => { + it("removes panel-related keys", () => { + const result = stripPanelSearchParams({ + panel: "diff", + diffTurnId: "x", + diffFilePath: "y", + other: "keep", + }); + expect(result).toEqual({ other: "keep" }); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `bun run test apps/web/src/panelRouteSearch.test.ts` +Expected: PASS + +- [ ] **Step 4: Update all imports from `diffRouteSearch` to `panelRouteSearch`** + +Search the codebase for all files importing from `diffRouteSearch` and update them. Key files: + +- `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` — change import and update usage +- Any other component files that import `DiffRouteSearch`, `parseDiffRouteSearch`, or `stripDiffSearchParams` + +In the route file, update: + +```ts +// Before +import { + type DiffRouteSearch, + parseDiffRouteSearch, + stripDiffSearchParams, +} from "../diffRouteSearch"; + +// After +import { + type PanelRouteSearch, + type PanelTab, + parsePanelRouteSearch, + stripPanelSearchParams, +} from "../panelRouteSearch"; +``` + +Update route `validateSearch`: + +```ts +// Before +validateSearch: (search) => parseDiffRouteSearch(search), +search: { middlewares: [retainSearchParams(["diff"])] }, + +// After +validateSearch: (search) => parsePanelRouteSearch(search), +search: { middlewares: [retainSearchParams(["panel"])] }, +``` + +Update the `diffOpen` / navigation logic in `ChatThreadRouteView`: + +```ts +// Before +const diffOpen = search.diff === "1"; + +// After +const panelTab = search.panel; // "files" | "diff" | undefined +const diffOpen = panelTab === "diff"; +const filesOpen = panelTab === "files"; +const panelOpen = panelTab !== undefined; +``` + +Update `closeDiff`: + +```ts +// Before +search: { + diff: undefined; +} + +// After +search: { + panel: undefined; +} +``` + +Update `openDiff`: + +```ts +// Before +search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }); + +// After +search: (previous) => ({ ...stripPanelSearchParams(previous), panel: "diff" as const }); +``` + +Add `openFiles` and `closePanel` navigation helpers: + +```ts +const openFiles = useCallback(() => { + if (!threadRef) return; + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (previous) => ({ + ...stripPanelSearchParams(previous), + panel: "files" as const, + }), + }); +}, [navigate, threadRef]); + +const closePanel = useCallback(() => { + if (!threadRef) return; + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: { panel: undefined }, + }); +}, [navigate, threadRef]); +``` + +- [ ] **Step 5: Update ChatView.tsx diff toggle references** + +In `apps/web/src/components/ChatView.tsx`, find where `rawSearch.diff === "1"` is referenced and update to `rawSearch.panel === "diff"`. Also update the toggle function to use `panel: "diff"` / `panel: undefined`. + +- [ ] **Step 6: Delete the old `diffRouteSearch.ts` file** + +Once all references are updated, delete `apps/web/src/diffRouteSearch.ts`. + +- [ ] **Step 7: Run typecheck, lint, and tests** + +Run: `bun typecheck && bun lint && bun run test` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(web): replace ?diff=1 with ?panel=files|diff URL state" +``` + +--- + +### Task 6: Refactor DiffPanelInlineSidebar → RightPanel with Tab Bar + +**Files:** + +- Create: `apps/web/src/components/RightPanel.tsx` +- Create: `apps/web/src/components/RightPanelTabBar.tsx` +- Modify: `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` +- Modify: `apps/web/src/components/RightPanelSheet.tsx` +- Modify: `apps/web/src/rightPanelLayout.ts` + +This task extracts the inline sidebar into a generic `RightPanel` that renders a tab bar and switches between Files/Diff content. + +- [ ] **Step 1: Create `RightPanelTabBar.tsx`** + +Create `apps/web/src/components/RightPanelTabBar.tsx`: + +```tsx +import type { PanelTab } from "../panelRouteSearch"; +import { FilesIcon, GitCompareArrowsIcon } from "lucide-react"; + +interface RightPanelTabBarProps { + activeTab: PanelTab; + onTabChange: (tab: PanelTab) => void; +} + +export function RightPanelTabBar({ activeTab, onTabChange }: RightPanelTabBarProps) { + return ( +
+ + +
+ ); +} +``` + +- [ ] **Step 2: Create `RightPanel.tsx`** + +Create `apps/web/src/components/RightPanel.tsx`: + +```tsx +import { useCallback, type ReactNode } from "react"; +import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import type { PanelTab } from "../panelRouteSearch"; +import { RightPanelTabBar } from "./RightPanelTabBar"; + +const SIDEBAR_WIDTH_STORAGE_KEY = "chat_right_panel_width"; + +// Files tab is wider because it has a tree + editor split +const DEFAULT_WIDTH_BY_TAB: Record = { + files: "clamp(36rem,52vw,56rem)", + diff: "clamp(28rem,48vw,44rem)", +}; + +const MIN_WIDTH_BY_TAB: Record = { + files: 36 * 16, // 576px + diff: 26 * 16, // 416px +}; + +const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; + +interface RightPanelProps { + activeTab: PanelTab; + open: boolean; + onTabChange: (tab: PanelTab) => void; + onClose: () => void; + onOpen: () => void; + children: ReactNode; +} + +export function RightPanel({ + activeTab, + open, + onTabChange, + onClose, + onOpen, + children, +}: RightPanelProps) { + const onOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + onOpen(); + return; + } + onClose(); + }, + [onClose, onOpen], + ); + + const shouldAcceptWidth = useCallback( + ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { + const composerForm = document.querySelector("[data-chat-composer-form='true']"); + if (!composerForm) return true; + const composerViewport = composerForm.parentElement; + if (!composerViewport) return true; + const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); + wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); + + const viewportStyle = window.getComputedStyle(composerViewport); + const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; + const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; + const viewportContentWidth = Math.max( + 0, + composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, + ); + const formRect = composerForm.getBoundingClientRect(); + const composerFooter = composerForm.querySelector( + "[data-chat-composer-footer='true']", + ); + const composerRightActions = composerForm.querySelector( + "[data-chat-composer-actions='right']", + ); + const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; + const composerFooterGap = composerFooter + ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || + Number.parseFloat(window.getComputedStyle(composerFooter).gap) || + 0 + : 0; + const minimumComposerWidth = + COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; + const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; + const overflowsViewport = formRect.width > viewportContentWidth + 0.5; + const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; + + if (previousSidebarWidth.length > 0) { + wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); + } else { + wrapper.style.removeProperty("--sidebar-width"); + } + + return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; + }, + [], + ); + + return ( + + + + {children} + + + + ); +} +``` + +- [ ] **Step 3: Update the route file to use `RightPanel`** + +In `apps/web/src/routes/_chat.$environmentId.$threadId.tsx`, replace `DiffPanelInlineSidebar` with the new `RightPanel`: + +```tsx +import { RightPanel } from "../components/RightPanel"; +import { RightPanelTabBar } from "../components/RightPanelTabBar"; +``` + +Remove the `DiffPanelInlineSidebar` component definition entirely. + +Update the inline-sidebar rendering branch of `ChatThreadRouteView`: + +```tsx +if (!shouldUseDiffSheet) { + return ( + <> + + + + { + if (tab === "diff") { + markDiffOpened(); + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (prev) => ({ ...stripPanelSearchParams(prev), panel: tab }), + }); + }} + onClose={closePanel} + onOpen={openDiff} + > + {/* Diff tab content: stays mounted once opened, hidden when not active */} +
+ {shouldRenderDiffContent ? : null} +
+ {/* Files tab content: placeholder for now, implemented in Task 8 */} +
+
+ File explorer coming soon +
+
+
+ + ); +} +``` + +- [ ] **Step 4: Update the sheet branch for narrow viewports** + +Update the sheet rendering branch to include the tab bar: + +```tsx +return ( + <> + + + + + { + if (tab === "diff") markDiffOpened(); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (prev) => ({ ...stripPanelSearchParams(prev), panel: tab }), + }); + }} + /> +
+ {shouldRenderDiffContent ? : null} +
+
+
+ File explorer coming soon +
+
+
+ +); +``` + +- [ ] **Step 5: Clean up unused imports and old constants** + +Remove the now-unused constants from the route file: + +```ts +// Remove these (now in RightPanel.tsx): +const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = ... +const DIFF_INLINE_DEFAULT_WIDTH = ... +const DIFF_INLINE_SIDEBAR_MIN_WIDTH = ... +const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = ... +``` + +- [ ] **Step 6: Run typecheck, lint, and format** + +Run: `bun typecheck && bun lint && bun fmt` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor(web): extract RightPanel with tab bar from DiffPanelInlineSidebar" +``` + +--- + +### Task 7: File Tree Component (Left Pane) + +**Files:** + +- Create: `apps/web/src/components/file-explorer/FileTree.tsx` +- Create: `apps/web/src/components/file-explorer/FileTreeNode.tsx` +- Create: `apps/web/src/components/file-explorer/FileTreeFilter.tsx` +- Create: `apps/web/src/components/file-explorer/useFileTree.ts` +- Create: `apps/web/src/components/file-explorer/useGitFileStatus.ts` +- Create: `apps/web/src/components/file-explorer/types.ts` + +This task builds the expand-on-demand directory tree with git status badges and quick filter. + +- [ ] **Step 1: Create shared types** + +Create `apps/web/src/components/file-explorer/types.ts`: + +```ts +import type { DirectoryEntry, GitFileStatus } from "@t3tools/contracts"; + +export interface TreeNode { + readonly entry: DirectoryEntry; + readonly depth: number; + readonly isExpanded: boolean; + readonly isLoading: boolean; + readonly children: readonly TreeNode[] | null; // null = not loaded yet + readonly gitStatus?: GitFileStatus | undefined; +} + +export interface FileTreeState { + readonly rootPath: string; + readonly nodes: ReadonlyMap; + readonly expandedPaths: ReadonlySet; + readonly rootEntries: readonly DirectoryEntry[]; +} +``` + +- [ ] **Step 2: Create the `useFileTree` hook** + +Create `apps/web/src/components/file-explorer/useFileTree.ts`: + +```ts +import { useCallback, useRef, useState } from "react"; +import type { + DirectoryEntry, + EnvironmentId, + FilesystemListDirectoryResult, +} from "@t3tools/contracts"; +import { readEnvironmentApi } from "../../environmentApi"; + +interface UseFileTreeOptions { + environmentId: EnvironmentId; + cwd: string; +} + +interface ExpandedDir { + entries: readonly DirectoryEntry[]; + isLoading: boolean; +} + +export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { + const [expandedDirs, setExpandedDirs] = useState>(new Map()); + const [rootEntries, setRootEntries] = useState(null); + const [rootLoading, setRootLoading] = useState(false); + const loadedRef = useRef(new Set()); + + const loadDirectory = useCallback( + async (relativePath: string) => { + const api = readEnvironmentApi(environmentId); + if (!api) return; + + if (relativePath === ".") { + setRootLoading(true); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { + entries: prev.get(relativePath)?.entries ?? [], + isLoading: true, + }); + return next; + }); + } + + try { + const result: FilesystemListDirectoryResult = await api.filesystem.listDirectory({ + cwd, + relativePath, + }); + + if (relativePath === ".") { + setRootEntries(result.entries); + setRootLoading(false); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { entries: result.entries, isLoading: false }); + return next; + }); + } + loadedRef.current.add(relativePath); + } catch { + if (relativePath === ".") { + setRootLoading(false); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { entries: [], isLoading: false }); + return next; + }); + } + } + }, + [environmentId, cwd], + ); + + const toggleExpand = useCallback( + (relativePath: string) => { + setExpandedDirs((prev) => { + if (prev.has(relativePath)) { + const next = new Map(prev); + next.delete(relativePath); + return next; + } + return prev; + }); + + // If not loaded yet, load it + if (!loadedRef.current.has(relativePath)) { + void loadDirectory(relativePath); + } else { + // Re-expand: just put it back + setExpandedDirs((prev) => { + if (prev.has(relativePath)) return prev; + // Re-load to get fresh data + void loadDirectory(relativePath); + return prev; + }); + } + }, + [loadDirectory], + ); + + const collapseDir = useCallback((relativePath: string) => { + setExpandedDirs((prev) => { + if (!prev.has(relativePath)) return prev; + const next = new Map(prev); + next.delete(relativePath); + return next; + }); + }, []); + + const expandDir = useCallback( + (relativePath: string) => { + if (!loadedRef.current.has(relativePath)) { + void loadDirectory(relativePath); + } else { + setExpandedDirs((prev) => { + if (prev.has(relativePath)) return prev; + void loadDirectory(relativePath); + return prev; + }); + } + }, + [loadDirectory], + ); + + const loadRoot = useCallback(() => { + void loadDirectory("."); + }, [loadDirectory]); + + return { + rootEntries, + rootLoading, + expandedDirs, + loadRoot, + toggleExpand, + expandDir, + collapseDir, + }; +} +``` + +- [ ] **Step 3: Create the `useGitFileStatus` hook** + +Create `apps/web/src/components/file-explorer/useGitFileStatus.ts`: + +```ts +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EnvironmentId, GitFileStatusResult, GitFileStatus } from "@t3tools/contracts"; +import { readEnvironmentApi } from "../../environmentApi"; + +const POLL_INTERVAL_MS = 3_000; + +interface UseGitFileStatusOptions { + environmentId: EnvironmentId; + cwd: string; + enabled: boolean; +} + +export function useGitFileStatus({ environmentId, cwd, enabled }: UseGitFileStatusOptions) { + const [statusMap, setStatusMap] = useState>(new Map()); + const timerRef = useRef | null>(null); + + const fetchStatus = useCallback(async () => { + const api = readEnvironmentApi(environmentId); + if (!api) return; + + try { + const result: GitFileStatusResult = await api.git.fileStatus({ cwd }); + const next = new Map(); + for (const file of result.files) { + next.set(file.relativePath, file.status); + } + setStatusMap(next); + } catch { + // Silently ignore — git status polling is best-effort + } + }, [environmentId, cwd]); + + useEffect(() => { + if (!enabled) return; + + void fetchStatus(); + timerRef.current = setInterval(() => void fetchStatus(), POLL_INTERVAL_MS); + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [enabled, fetchStatus]); + + return { statusMap, refresh: fetchStatus }; +} +``` + +- [ ] **Step 4: Create `FileTreeFilter.tsx`** + +Create `apps/web/src/components/file-explorer/FileTreeFilter.tsx`: + +```tsx +import { SearchIcon, XIcon } from "lucide-react"; +import { useCallback, useRef } from "react"; + +interface FileTreeFilterProps { + value: string; + onChange: (value: string) => void; +} + +export function FileTreeFilter({ value, onChange }: FileTreeFilterProps) { + const inputRef = useRef(null); + + const handleClear = useCallback(() => { + onChange(""); + inputRef.current?.focus(); + }, [onChange]); + + return ( +
+ + onChange(e.target.value)} + placeholder="Filter files..." + className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + {value.length > 0 && ( + + )} +
+ ); +} +``` + +- [ ] **Step 5: Create `FileTreeNode.tsx`** + +Create `apps/web/src/components/file-explorer/FileTreeNode.tsx`: + +```tsx +import { ChevronRightIcon } from "lucide-react"; +import type { DirectoryEntry, GitFileStatus } from "@t3tools/contracts"; +import { getVscodeIconUrlForEntry } from "../../vscode-icons"; + +const GIT_STATUS_LABELS: Record = { + modified: { label: "M", className: "text-yellow-500" }, + added: { label: "A", className: "text-green-500" }, + deleted: { label: "D", className: "text-red-500" }, + untracked: { label: "?", className: "text-gray-400" }, + renamed: { label: "R", className: "text-blue-500" }, + conflicted: { label: "U", className: "text-orange-500" }, +}; + +interface FileTreeNodeProps { + entry: DirectoryEntry; + depth: number; + isExpanded: boolean; + isLoading: boolean; + gitStatus: GitFileStatus | undefined; + theme: "light" | "dark"; + onToggleExpand: (relativePath: string) => void; + onSelectFile: (relativePath: string) => void; + onContextMenu: (event: React.MouseEvent, entry: DirectoryEntry) => void; +} + +export function FileTreeNode({ + entry, + depth, + isExpanded, + isLoading, + gitStatus, + theme, + onToggleExpand, + onSelectFile, + onContextMenu, +}: FileTreeNodeProps) { + const isDir = entry.kind === "directory"; + const iconUrl = getVscodeIconUrlForEntry( + entry.relativePath, + isDir && isExpanded ? "directory" : entry.kind, + theme, + ); + const statusInfo = gitStatus ? GIT_STATUS_LABELS[gitStatus] : undefined; + + const handleClick = () => { + if (isDir) { + onToggleExpand(entry.relativePath); + } else { + onSelectFile(entry.relativePath); + } + }; + + return ( + + ); +} +``` + +- [ ] **Step 6: Create `FileTree.tsx`** + +Create `apps/web/src/components/file-explorer/FileTree.tsx`: + +```tsx +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { DirectoryEntry, EnvironmentId, GitFileStatus } from "@t3tools/contracts"; +import { useFileTree } from "./useFileTree"; +import { useGitFileStatus } from "./useGitFileStatus"; +import { FileTreeFilter } from "./FileTreeFilter"; +import { FileTreeNode } from "./FileTreeNode"; + +interface FileTreeProps { + environmentId: EnvironmentId; + cwd: string; + theme: "light" | "dark"; + onSelectFile: (relativePath: string) => void; + onContextMenu: (event: React.MouseEvent, entry: DirectoryEntry) => void; +} + +interface FlatEntry { + entry: DirectoryEntry; + depth: number; + isExpanded: boolean; + isLoading: boolean; +} + +export function FileTree({ + environmentId, + cwd, + theme, + onSelectFile, + onContextMenu, +}: FileTreeProps) { + const { rootEntries, rootLoading, expandedDirs, loadRoot, toggleExpand } = useFileTree({ + environmentId, + cwd, + }); + const { statusMap } = useGitFileStatus({ environmentId, cwd, enabled: true }); + const [filter, setFilter] = useState(""); + + useEffect(() => { + loadRoot(); + }, [loadRoot]); + + // Flatten tree for rendering + const flatEntries = useMemo(() => { + if (!rootEntries) return []; + const result: FlatEntry[] = []; + const lowerFilter = filter.toLowerCase(); + + function walk(entries: readonly DirectoryEntry[], depth: number) { + for (const entry of entries) { + const matchesFilter = + lowerFilter.length === 0 || entry.name.toLowerCase().includes(lowerFilter); + const dirData = + entry.kind === "directory" ? expandedDirs.get(entry.relativePath) : undefined; + const isExpanded = dirData !== undefined; + const isLoading = dirData?.isLoading ?? false; + const children = dirData?.entries ?? []; + + // For directories: show if matches filter OR has matching children + // For files: show if matches filter + if (entry.kind === "directory") { + if (matchesFilter || lowerFilter.length === 0) { + result.push({ entry, depth, isExpanded, isLoading }); + } + if (isExpanded) { + walk(children, depth + 1); + } + } else if (matchesFilter) { + result.push({ entry, depth, isExpanded: false, isLoading: false }); + } + } + } + + walk(rootEntries, 0); + return result; + }, [rootEntries, expandedDirs, filter]); + + if (rootLoading && !rootEntries) { + return ( +
+ +
+ Loading... +
+
+ ); + } + + return ( +
+ +
+ {flatEntries.map((item) => ( + + ))} + {flatEntries.length === 0 && rootEntries && ( +
+ {filter.length > 0 ? "No matching files" : "Empty directory"} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 7: Run typecheck and lint** + +Run: `bun typecheck && bun lint && bun fmt` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/components/file-explorer/ +git commit -m "feat(web): add FileTree component with expand-on-demand, git status, and filter" +``` + +--- + +### Task 8: CodeMirror Editor Component (Right Pane) + +**Files:** + +- Create: `apps/web/src/components/file-explorer/CodeEditor.tsx` +- Create: `apps/web/src/components/file-explorer/EditorTabs.tsx` +- Create: `apps/web/src/components/file-explorer/EditorBreadcrumb.tsx` +- Create: `apps/web/src/components/file-explorer/useEditorTabs.ts` +- Create: `apps/web/src/components/file-explorer/languageExtensions.ts` + +This task builds the CodeMirror-powered code editor with multi-tab support. + +- [ ] **Step 1: Install CodeMirror dependencies** + +Run: + +```bash +cd apps/web && bun add codemirror @codemirror/view @codemirror/state @codemirror/language @codemirror/lang-javascript @codemirror/lang-css @codemirror/lang-html @codemirror/lang-json @codemirror/lang-markdown @codemirror/lang-python @codemirror/lang-rust @codemirror/lang-cpp @codemirror/lang-java @codemirror/lang-xml @codemirror/lang-sql @codemirror/lang-yaml @codemirror/theme-one-dark +``` + +- [ ] **Step 2: Create `languageExtensions.ts`** + +Create `apps/web/src/components/file-explorer/languageExtensions.ts`: + +```ts +import type { Extension } from "@codemirror/state"; + +const EXTENSION_MAP: Record Promise> = { + ts: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })), + tsx: () => + import("@codemirror/lang-javascript").then((m) => + m.javascript({ typescript: true, jsx: true }), + ), + js: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), + jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })), + mjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), + cjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), + css: () => import("@codemirror/lang-css").then((m) => m.css()), + html: () => import("@codemirror/lang-html").then((m) => m.html()), + json: () => import("@codemirror/lang-json").then((m) => m.json()), + md: () => import("@codemirror/lang-markdown").then((m) => m.markdown()), + markdown: () => import("@codemirror/lang-markdown").then((m) => m.markdown()), + py: () => import("@codemirror/lang-python").then((m) => m.python()), + rs: () => import("@codemirror/lang-rust").then((m) => m.rust()), + cpp: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), + c: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), + h: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), + java: () => import("@codemirror/lang-java").then((m) => m.java()), + xml: () => import("@codemirror/lang-xml").then((m) => m.xml()), + svg: () => import("@codemirror/lang-xml").then((m) => m.xml()), + sql: () => import("@codemirror/lang-sql").then((m) => m.sql()), + yaml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()), + yml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()), +}; + +export function getLanguageExtension(filePath: string): (() => Promise) | undefined { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + return EXTENSION_MAP[ext]; +} +``` + +- [ ] **Step 3: Create `useEditorTabs.ts`** + +Create `apps/web/src/components/file-explorer/useEditorTabs.ts`: + +```ts +import { useCallback, useState } from "react"; + +const MAX_TABS = 10; + +export interface EditorTab { + readonly relativePath: string; + readonly isDirty: boolean; + readonly originalContent: string; + readonly currentContent: string; +} + +interface EditorTabsState { + readonly tabs: readonly EditorTab[]; + readonly activeIndex: number; +} + +export function useEditorTabs() { + const [state, setState] = useState({ + tabs: [], + activeIndex: -1, + }); + + const activeTab = state.activeIndex >= 0 ? (state.tabs[state.activeIndex] ?? null) : null; + + const openTab = useCallback((relativePath: string, content: string) => { + setState((prev) => { + const existingIndex = prev.tabs.findIndex((t) => t.relativePath === relativePath); + if (existingIndex >= 0) { + return { ...prev, activeIndex: existingIndex }; + } + + let tabs = [...prev.tabs]; + + // LRU eviction if at max capacity + if (tabs.length >= MAX_TABS) { + // Find the oldest non-dirty, non-active tab to evict + let evictIndex = -1; + for (let i = 0; i < tabs.length; i++) { + if (i !== prev.activeIndex && !tabs[i]!.isDirty) { + evictIndex = i; + break; + } + } + if (evictIndex >= 0) { + tabs.splice(evictIndex, 1); + } else { + // All non-active tabs are dirty — don't evict, just deny + return prev; + } + } + + const newTab: EditorTab = { + relativePath, + isDirty: false, + originalContent: content, + currentContent: content, + }; + + tabs = [...tabs, newTab]; + return { tabs, activeIndex: tabs.length - 1 }; + }); + }, []); + + const closeTab = useCallback((index: number) => { + setState((prev) => { + const tab = prev.tabs[index]; + if (!tab) return prev; + + // Guard against closing dirty tabs + if (tab.isDirty) { + const confirmed = window.confirm(`${tab.relativePath} has unsaved changes. Close anyway?`); + if (!confirmed) return prev; + } + + const tabs = prev.tabs.filter((_, i) => i !== index); + let activeIndex = prev.activeIndex; + if (activeIndex >= tabs.length) { + activeIndex = tabs.length - 1; + } else if (index < activeIndex) { + activeIndex -= 1; + } else if (index === activeIndex && activeIndex >= tabs.length) { + activeIndex = tabs.length - 1; + } + + return { tabs, activeIndex }; + }); + }, []); + + const setActiveIndex = useCallback((index: number) => { + setState((prev) => ({ ...prev, activeIndex: index })); + }, []); + + const markDirty = useCallback((relativePath: string, content: string) => { + setState((prev) => ({ + ...prev, + tabs: prev.tabs.map((t) => + t.relativePath === relativePath + ? { ...t, currentContent: content, isDirty: content !== t.originalContent } + : t, + ), + })); + }, []); + + const markSaved = useCallback((relativePath: string, savedContent: string) => { + setState((prev) => ({ + ...prev, + tabs: prev.tabs.map((t) => + t.relativePath === relativePath + ? { ...t, originalContent: savedContent, currentContent: savedContent, isDirty: false } + : t, + ), + })); + }, []); + + return { + tabs: state.tabs, + activeIndex: state.activeIndex, + activeTab, + openTab, + closeTab, + setActiveIndex, + markDirty, + markSaved, + }; +} +``` + +- [ ] **Step 4: Create `EditorTabs.tsx`** + +Create `apps/web/src/components/file-explorer/EditorTabs.tsx`: + +```tsx +import { XIcon } from "lucide-react"; +import type { EditorTab } from "./useEditorTabs"; +import { basenameOfPath } from "../../vscode-icons"; + +interface EditorTabsProps { + tabs: readonly EditorTab[]; + activeIndex: number; + onSelect: (index: number) => void; + onClose: (index: number) => void; +} + +export function EditorTabs({ tabs, activeIndex, onSelect, onClose }: EditorTabsProps) { + if (tabs.length === 0) return null; + + return ( +
+ {tabs.map((tab, index) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 5: Create `EditorBreadcrumb.tsx`** + +Create `apps/web/src/components/file-explorer/EditorBreadcrumb.tsx`: + +```tsx +import { ChevronRightIcon } from "lucide-react"; + +interface EditorBreadcrumbProps { + relativePath: string; +} + +export function EditorBreadcrumb({ relativePath }: EditorBreadcrumbProps) { + const parts = relativePath.split("/"); + + return ( +
+ {parts.map((part, i) => ( + + {i > 0 && } + {part} + + ))} +
+ ); +} +``` + +- [ ] **Step 6: Create `CodeEditor.tsx`** + +Create `apps/web/src/components/file-explorer/CodeEditor.tsx`: + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { readEnvironmentApi } from "../../environmentApi"; +import { useEditorTabs } from "./useEditorTabs"; +import { EditorTabs } from "./EditorTabs"; +import { EditorBreadcrumb } from "./EditorBreadcrumb"; +import { getLanguageExtension } from "./languageExtensions"; + +interface CodeEditorProps { + environmentId: EnvironmentId; + cwd: string; + activeFilePath: string | null; + onOpenFile: (relativePath: string) => void; +} + +export function CodeEditor({ environmentId, cwd, activeFilePath, onOpenFile }: CodeEditorProps) { + const editorContainerRef = useRef(null); + const editorViewRef = useRef(null); + const [cmModules, setCmModules] = useState<{ + EditorView: typeof import("@codemirror/view").EditorView; + EditorState: typeof import("@codemirror/state").EditorState; + basicSetup: import("@codemirror/state").Extension; + oneDark: import("@codemirror/state").Extension; + keymap: typeof import("@codemirror/view").keymap; + } | null>(null); + + const { tabs, activeIndex, activeTab, openTab, closeTab, setActiveIndex, markDirty, markSaved } = + useEditorTabs(); + + // Lazy-load CodeMirror on first mount + useEffect(() => { + let cancelled = false; + void Promise.all([ + import("@codemirror/view"), + import("@codemirror/state"), + import("codemirror"), + import("@codemirror/theme-one-dark"), + ]).then(([viewMod, stateMod, cmMod, themeMod]) => { + if (cancelled) return; + setCmModules({ + EditorView: viewMod.EditorView, + EditorState: stateMod.EditorState, + basicSetup: cmMod.basicSetup, + oneDark: themeMod.oneDark, + keymap: viewMod.keymap, + }); + }); + return () => { + cancelled = true; + }; + }, []); + + // Load file content when activeFilePath changes + useEffect(() => { + if (!activeFilePath) return; + + const existingTab = tabs.find((t) => t.relativePath === activeFilePath); + if (existingTab) { + const idx = tabs.indexOf(existingTab); + setActiveIndex(idx); + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) return; + + let cancelled = false; + void api.filesystem.readFile({ cwd, relativePath: activeFilePath }).then((result) => { + if (cancelled) return; + if (result.encoding === "utf-8") { + openTab(activeFilePath, result.content); + } + }); + + return () => { + cancelled = true; + }; + }, [activeFilePath, environmentId, cwd, openTab, setActiveIndex, tabs]); + + // Create/update EditorView when active tab changes + useEffect(() => { + if (!cmModules || !editorContainerRef.current || !activeTab) return; + + const { EditorView, EditorState, basicSetup, oneDark, keymap } = cmModules; + const container = editorContainerRef.current; + + // Save handler + const saveFile = async () => { + if (!activeTab.isDirty) return; + const api = readEnvironmentApi(environmentId); + if (!api) return; + try { + await api.projects.writeFile({ + cwd, + relativePath: activeTab.relativePath, + contents: activeTab.currentContent, + }); + markSaved(activeTab.relativePath, activeTab.currentContent); + } catch { + // TODO: toast error + } + }; + + // Destroy previous view + if (editorViewRef.current) { + editorViewRef.current.destroy(); + editorViewRef.current = null; + } + + const extensions = [ + basicSetup, + oneDark, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + markDirty(activeTab.relativePath, update.state.doc.toString()); + } + }), + keymap.of([ + { + key: "Mod-s", + run: () => { + void saveFile(); + return true; + }, + }, + ]), + EditorView.theme({ + "&": { height: "100%", fontSize: "13px" }, + ".cm-scroller": { overflow: "auto" }, + }), + ]; + + // Load language extension async + const langLoader = getLanguageExtension(activeTab.relativePath); + + const state = EditorState.create({ + doc: activeTab.currentContent, + extensions, + }); + + const view = new EditorView({ state, parent: container }); + editorViewRef.current = view; + + if (langLoader) { + void langLoader().then((langExt) => { + view.dispatch({ + effects: import("@codemirror/state") + .then(({ StateEffect }) => + // Use reconfigure compartment pattern or just dispatch + // For simplicity, reconfigure via state effect + StateEffect.appendConfig.of(langExt), + ) + .then((effect) => ({ effects: effect })), + }); + }); + } + + return () => { + view.destroy(); + editorViewRef.current = null; + }; + }, [cmModules, activeTab?.relativePath, activeTab?.originalContent]); + + if (!activeTab) { + return ( +
+ Select a file to view +
+ ); + } + + return ( +
+ + {activeTab && } +
+
+ ); +} +``` + +- [ ] **Step 7: Run typecheck and lint** + +Run: `bun typecheck && bun lint && bun fmt` +Expected: PASS (or minor fixable lint issues) + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/components/file-explorer/CodeEditor.tsx apps/web/src/components/file-explorer/EditorTabs.tsx apps/web/src/components/file-explorer/EditorBreadcrumb.tsx apps/web/src/components/file-explorer/useEditorTabs.ts apps/web/src/components/file-explorer/languageExtensions.ts +git commit -m "feat(web): add CodeMirror editor with multi-tab support and language detection" +``` + +--- + +### Task 9: File Explorer Tab Assembly (Tree + Editor Split) + +**Files:** + +- Create: `apps/web/src/components/file-explorer/FileExplorer.tsx` +- Modify: `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` + +This task wires the FileTree and CodeEditor into a resizable split pane and integrates it into the RightPanel. + +- [ ] **Step 1: Install `react-resizable-panels`** + +Run: + +```bash +cd apps/web && bun add react-resizable-panels +``` + +- [ ] **Step 2: Create `FileExplorer.tsx`** + +Create `apps/web/src/components/file-explorer/FileExplorer.tsx`: + +```tsx +import { useCallback, useState } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import type { DirectoryEntry, EnvironmentId } from "@t3tools/contracts"; +import { FileTree } from "./FileTree"; +import { CodeEditor } from "./CodeEditor"; + +interface FileExplorerProps { + environmentId: EnvironmentId; + cwd: string; + theme: "light" | "dark"; +} + +export function FileExplorer({ environmentId, cwd, theme }: FileExplorerProps) { + const [activeFilePath, setActiveFilePath] = useState(null); + + const handleSelectFile = useCallback((relativePath: string) => { + setActiveFilePath(relativePath); + }, []); + + const handleContextMenu = useCallback((_event: React.MouseEvent, _entry: DirectoryEntry) => { + // Context menu implementation in Task 10 + }, []); + + return ( + + + + + + + + + + ); +} +``` + +- [ ] **Step 3: Replace Files tab placeholder in route file** + +In `apps/web/src/routes/_chat.$environmentId.$threadId.tsx`, replace the "File explorer coming soon" placeholder: + +```tsx +import { lazy, Suspense } from "react"; + +const FileExplorer = lazy(() => + import("../components/file-explorer/FileExplorer").then((m) => ({ default: m.FileExplorer })), +); +``` + +Replace the files placeholder content in the inline sidebar branch: + +```tsx +
+ + Loading file explorer... +
+ } + > + + +
+``` + +For the `cwd` value: look up how the existing code gets the workspace root for the current environment. It's typically the project's `cwd` from the store. Use the store selector for the active project's cwd: + +```tsx +const projectCwd = useStore((store) => { + const envState = selectEnvironmentState(store, threadRef.environmentId); + return envState.projectCwd; +}); +``` + +If `projectCwd` isn't directly available on the env state, check what `GitStatusInput.cwd` uses — typically the same workspace root. Wire this through. + +- [ ] **Step 4: Do the same for the sheet branch** + +Replace the sheet's files placeholder with the same lazy-loaded `FileExplorer`. + +- [ ] **Step 5: Run typecheck, lint, and format** + +Run: `bun typecheck && bun lint && bun fmt` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(web): assemble FileExplorer with tree/editor split pane in RightPanel" +``` + +--- + +### Task 10: Context Menus (Tree + Editor) + +**Files:** + +- Create: `apps/web/src/components/file-explorer/FileTreeContextMenu.tsx` +- Create: `apps/web/src/components/file-explorer/EditorContextMenu.tsx` +- Modify: `apps/web/src/components/file-explorer/FileExplorer.tsx` +- Modify: `apps/web/src/components/file-explorer/FileTree.tsx` + +This task adds right-click context menus for the file tree (New File, New Folder, Rename, Delete, Copy Path, Mention in Chat) and the editor selection (Copy, Mention Selection in Chat). + +- [ ] **Step 1: Create `FileTreeContextMenu.tsx`** + +Create `apps/web/src/components/file-explorer/FileTreeContextMenu.tsx`: + +```tsx +import { useCallback, useState, useRef, useEffect } from "react"; +import type { DirectoryEntry, EnvironmentId, FilesystemMutationResult } from "@t3tools/contracts"; +import { readEnvironmentApi } from "../../environmentApi"; + +export type TreeContextAction = + | "newFile" + | "newFolder" + | "rename" + | "delete" + | "copyPath" + | "mentionInChat"; + +interface ContextMenuState { + x: number; + y: number; + entry: DirectoryEntry; +} + +interface FileTreeContextMenuProps { + state: ContextMenuState | null; + onClose: () => void; + environmentId: EnvironmentId; + cwd: string; + onAction: (action: TreeContextAction, entry: DirectoryEntry) => void; +} + +const MENU_ITEMS: { action: TreeContextAction; label: string; dirOnly?: boolean }[] = [ + { action: "newFile", label: "New File", dirOnly: true }, + { action: "newFolder", label: "New Folder", dirOnly: true }, + { action: "rename", label: "Rename" }, + { action: "delete", label: "Delete" }, + { action: "copyPath", label: "Copy Path" }, + { action: "mentionInChat", label: "Mention in Chat" }, +]; + +export function FileTreeContextMenu({ + state, + onClose, + environmentId, + cwd, + onAction, +}: FileTreeContextMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + if (!state) return; + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [state, onClose]); + + if (!state) return null; + + const isDir = state.entry.kind === "directory"; + const items = MENU_ITEMS.filter((item) => !item.dirOnly || isDir); + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 2: Wire context menu into `FileExplorer.tsx`** + +Update `FileExplorer.tsx` to manage context menu state: + +```tsx +import { FileTreeContextMenu, type TreeContextAction } from "./FileTreeContextMenu"; + +// Inside FileExplorer component: +const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + entry: DirectoryEntry; +} | null>(null); + +const handleContextMenu = useCallback((event: React.MouseEvent, entry: DirectoryEntry) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, entry }); +}, []); + +const handleContextAction = useCallback( + async (action: TreeContextAction, entry: DirectoryEntry) => { + const api = readEnvironmentApi(environmentId); + if (!api) return; + + switch (action) { + case "newFile": { + const name = window.prompt("File name:"); + if (!name) return; + const relativePath = `${entry.relativePath}/${name}`; + await api.projects.writeFile({ cwd, relativePath, contents: "" }); + setActiveFilePath(relativePath); + break; + } + case "newFolder": { + const name = window.prompt("Folder name:"); + if (!name) return; + await api.filesystem.createDirectory({ + cwd, + relativePath: `${entry.relativePath}/${name}`, + }); + break; + } + case "rename": { + const newName = window.prompt("New name:", entry.name); + if (!newName || newName === entry.name) return; + const parentPath = entry.relativePath.split("/").slice(0, -1).join("/"); + const newRelativePath = parentPath ? `${parentPath}/${newName}` : newName; + await api.filesystem.rename({ + cwd, + oldRelativePath: entry.relativePath, + newRelativePath, + }); + break; + } + case "delete": { + const confirmed = window.confirm(`Delete ${entry.relativePath}?`); + if (!confirmed) return; + await api.filesystem.delete({ cwd, relativePath: entry.relativePath }); + break; + } + case "copyPath": { + await navigator.clipboard.writeText(entry.relativePath); + break; + } + case "mentionInChat": { + // Implemented in Task 11 + break; + } + } + }, + [environmentId, cwd], +); + +// In the JSX, add before the closing : + setContextMenu(null)} + environmentId={environmentId} + cwd={cwd} + onAction={handleContextAction} +/>; +``` + +- [ ] **Step 3: Run typecheck, lint, and format** + +Run: `bun typecheck && bun lint && bun fmt` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/file-explorer/FileTreeContextMenu.tsx apps/web/src/components/file-explorer/FileExplorer.tsx +git commit -m "feat(web): add file tree context menu with CRUD operations" +``` + +--- + +### Task 11: Mention System Extensions + +**Files:** + +- Modify: `apps/web/src/composer-editor-mentions.ts` +- Modify: `apps/web/src/composer-editor-mentions.test.ts` +- Modify: `apps/web/src/components/file-explorer/FileExplorer.tsx` + +This task extends the mention system to support `@filepath:L-L` line-range references. + +- [ ] **Step 1: Write failing tests for line-range mentions** + +Add to `apps/web/src/composer-editor-mentions.test.ts`: + +```ts +describe("line-range mentions", () => { + it("parses @path:L10-L20 as a mention with lineRange", () => { + const segments = splitPromptIntoComposerSegments("Check @src/index.ts:L10-L20 here"); + expect(segments).toEqual([ + { type: "text", text: "Check " }, + { type: "mention", path: "src/index.ts", lineRange: { start: 10, end: 20 } }, + { type: "text", text: " here" }, + ]); + }); + + it("parses plain @path without line range", () => { + const segments = splitPromptIntoComposerSegments("Check @src/index.ts here"); + expect(segments).toEqual([ + { type: "text", text: "Check " }, + { type: "mention", path: "src/index.ts" }, + { type: "text", text: " here" }, + ]); + }); + + it("handles line range at end of string", () => { + const segments = splitPromptIntoComposerSegments("Look at @src/app.tsx:L1-L5 "); + expect(segments).toEqual([ + { type: "text", text: "Look at " }, + { type: "mention", path: "src/app.tsx", lineRange: { start: 1, end: 5 } }, + { type: "text", text: " " }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun run test apps/web/src/composer-editor-mentions.test.ts` +Expected: FAIL — `lineRange` not yet in the mention segment type. + +- [ ] **Step 3: Update `ComposerPromptSegment` type** + +In `apps/web/src/composer-editor-mentions.ts`, update the mention variant: + +```ts +export type MentionLineRange = { + readonly start: number; + readonly end: number; +}; + +export type ComposerPromptSegment = + | { + type: "text"; + text: string; + } + | { + type: "mention"; + path: string; + lineRange?: MentionLineRange | undefined; + } + | { + type: "skill"; + name: string; + } + | { + type: "terminal-context"; + context: TerminalContextDraft | null; + }; +``` + +- [ ] **Step 4: Update the mention regex** + +Update `MENTION_TOKEN_REGEX` to capture an optional `:L-L` suffix: + +```ts +const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+?)(?::L(\d+)-L(\d+))?(?=\s)/g; +``` + +- [ ] **Step 5: Update `collectInlineTokenMatches` to extract line ranges** + +Update the `InlineTokenMatch` type and mention matching: + +```ts +type InlineTokenMatch = + | { + type: "mention"; + value: string; + lineRange?: MentionLineRange; + start: number; + end: number; + } + | { + type: "skill"; + value: string; + start: number; + end: number; + }; +``` + +In the mention `matchAll` loop: + +```ts +for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const path = match[2] ?? ""; + const lineStart = match[3]; + const lineEnd = match[4]; + const matchIndex = match.index ?? 0; + const start = matchIndex + prefix.length; + const end = start + fullMatch.length - prefix.length; + if (path.length > 0) { + const lineRange = + lineStart !== undefined && lineEnd !== undefined + ? { start: Number.parseInt(lineStart, 10), end: Number.parseInt(lineEnd, 10) } + : undefined; + matches.push({ type: "mention", value: path, lineRange, start, end }); + } +} +``` + +- [ ] **Step 6: Update `splitPromptTextIntoComposerSegments` to pass through lineRange** + +In the mention branch: + +```ts +if (match.type === "mention") { + segments.push({ + type: "mention", + path: match.value, + ...(match.lineRange ? { lineRange: match.lineRange } : {}), + }); +} +``` + +- [ ] **Step 7: Run tests** + +Run: `bun run test apps/web/src/composer-editor-mentions.test.ts` +Expected: PASS + +- [ ] **Step 8: Run full test suite, typecheck, and lint** + +Run: `bun run test && bun typecheck && bun lint` +Expected: PASS. Verify existing mention tests still pass. + +- [ ] **Step 9: Commit** + +```bash +git add apps/web/src/composer-editor-mentions.ts apps/web/src/composer-editor-mentions.test.ts +git commit -m "feat(web): extend mention system to support @file:L-L line ranges" +``` + +--- + +### Task 12: Wire "Mention in Chat" Actions + +**Files:** + +- Modify: `apps/web/src/components/file-explorer/FileExplorer.tsx` + +This task wires the "Mention in Chat" context menu action to insert `@path` mentions, and the "Mention Selection in Chat" editor action to insert `@path:L-L` mentions into the Lexical composer. + +- [ ] **Step 1: Research how the composer accepts programmatic mention insertion** + +Before implementing, read the Lexical composer component to understand how to programmatically insert text/mentions. Find: + +- The Lexical editor ref or command dispatch pattern +- How existing mentions are inserted (e.g., from autocomplete) +- What API is available for external code to trigger insertion + +The approach will likely involve dispatching a custom command to the Lexical editor or using a shared store/callback that the composer listens to. + +- [ ] **Step 2: Implement the insertion bridge** + +Based on findings from Step 1, create the bridge. A common pattern is a zustand store or a ref-based command channel: + +```ts +// In a shared location (e.g., apps/web/src/composerInsertionStore.ts) +import { create } from "zustand"; + +interface ComposerInsertionStore { + pendingInsert: string | null; + insertText: (text: string) => void; + consumeInsert: () => string | null; +} + +export const useComposerInsertionStore = create((set, get) => ({ + pendingInsert: null, + insertText: (text) => set({ pendingInsert: text }), + consumeInsert: () => { + const text = get().pendingInsert; + set({ pendingInsert: null }); + return text; + }, +})); +``` + +Then in the Lexical composer component, add an effect that watches `pendingInsert` and inserts it into the editor. + +- [ ] **Step 3: Wire "Mention in Chat" in FileExplorer context menu** + +In `FileExplorer.tsx`, update the `mentionInChat` case: + +```ts +case "mentionInChat": { + useComposerInsertionStore.getState().insertText(` @${entry.relativePath} `); + break; +} +``` + +- [ ] **Step 4: Wire "Mention Selection in Chat" in CodeEditor** + +Add a context menu handler to the CodeMirror editor that, when the user right-clicks with a selection, offers "Mention Selection in Chat". On click, it reads the selection range and inserts `@path:L-L`: + +```ts +const sel = view.state.selection.main; +if (!sel.empty) { + const startLine = view.state.doc.lineAt(sel.from).number; + const endLine = view.state.doc.lineAt(sel.to).number; + useComposerInsertionStore + .getState() + .insertText(` @${activeTab.relativePath}:L${startLine}-L${endLine} `); +} +``` + +- [ ] **Step 5: Run typecheck and lint** + +Run: `bun typecheck && bun lint && bun fmt` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(web): wire mention-in-chat actions from file explorer to composer" +``` + +--- + +### Task 13: Final Integration, Polish, and Verification + +**Files:** + +- Various files across the codebase + +This task performs final integration testing, cleanup, and verification. + +- [ ] **Step 1: Run the full test suite** + +Run: `bun run test` +Expected: All tests PASS. + +- [ ] **Step 2: Run typecheck** + +Run: `bun typecheck` +Expected: PASS — zero type errors. + +- [ ] **Step 3: Run lint and format** + +Run: `bun lint && bun fmt` +Expected: PASS — zero lint errors, formatting clean. + +- [ ] **Step 4: Delete the old `diffRouteSearch.ts` if not already deleted** + +Verify `apps/web/src/diffRouteSearch.ts` has been deleted. If not, delete it and verify no remaining imports. + +- [ ] **Step 5: Verify all new exports are in contract index** + +Check `packages/contracts/src/index.ts` exports `filesystem.ts` (already does). Verify the new types are accessible: + +```ts +import { + FilesystemReadFileInput, + FilesystemListDirectoryResult, + GitFileStatusResult, + // etc. +} from "@t3tools/contracts"; +``` + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "chore: final cleanup and integration verification for file explorer" +``` diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index b278db2b..710a9b06 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -31,13 +31,9 @@ export class FilesystemBrowseError extends Schema.TaggedErrorClass Promise; + readFile: (input: FilesystemReadFileInput) => Promise; + listDirectory: (input: FilesystemListDirectoryInput) => Promise; + rename: (input: FilesystemRenameInput) => Promise; + delete: (input: FilesystemDeleteInput) => Promise; + createDirectory: (input: FilesystemCreateDirectoryInput) => Promise; }; git: { listBranches: (input: GitListBranchesInput) => Promise; @@ -260,6 +277,7 @@ export interface EnvironmentApi { input: GitPreparePullRequestThreadInput, ) => Promise; pull: (input: GitPullInput) => Promise; + fileStatus: (input: GitStatusInput) => Promise; refreshStatus: (input: GitStatusInput) => Promise; onStatus: ( input: GitStatusInput, From 8a00401ac63bc5619473addf311aceaf1de063b8 Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:37:27 -0300 Subject: [PATCH 04/13] refactor(web): replace ?diff=1 with ?panel=files|diff URL state --- apps/web/src/components/ChatView.tsx | 16 ++-- apps/web/src/components/DiffPanel.tsx | 17 +++-- apps/web/src/diffRouteSearch.test.ts | 74 ------------------- apps/web/src/diffRouteSearch.ts | 39 ---------- apps/web/src/panelRouteSearch.test.ts | 47 ++++++++++++ apps/web/src/panelRouteSearch.ts | 54 ++++++++++++++ .../routes/_chat.$environmentId.$threadId.tsx | 56 +++++++------- 7 files changed, 147 insertions(+), 156 deletions(-) delete mode 100644 apps/web/src/diffRouteSearch.test.ts delete mode 100644 apps/web/src/diffRouteSearch.ts create mode 100644 apps/web/src/panelRouteSearch.test.ts create mode 100644 apps/web/src/panelRouteSearch.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c76059b..e8642d2b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -38,7 +38,7 @@ import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { parsePanelRouteSearch, stripPanelSearchParams } from "../panelRouteSearch"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -618,7 +618,7 @@ export default function ChatView(props: ChatViewProps) { const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, - select: (params) => parseDiffRouteSearch(params), + select: (params) => parsePanelRouteSearch(params), }); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -790,7 +790,7 @@ export default function ChatView(props: ChatViewProps) { composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; + const diffOpen = rawSearch.panel === "diff"; const activeThreadId = activeThread?.id ?? null; const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), @@ -1489,8 +1489,8 @@ export default function ChatView(props: ChatViewProps) { }, replace: true, search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + const rest = stripPanelSearchParams(previous); + return diffOpen ? { ...rest, panel: undefined } : { ...rest, panel: "diff" as const }; }, }); }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); @@ -3192,10 +3192,10 @@ export default function ChatView(props: ChatViewProps) { threadId, }, search: (previous) => { - const rest = stripDiffSearchParams(previous); + const rest = stripPanelSearchParams(previous); return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; + ? { ...rest, panel: "diff" as const, diffTurnId: turnId, diffFilePath: filePath } + : { ...rest, panel: "diff" as const, diffTurnId: turnId }; }, }); }, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e6dbb57c..1aaf4798 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -25,7 +25,7 @@ import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { resolvePathLinkTarget } from "../terminal-links"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { parsePanelRouteSearch, stripPanelSearchParams } from "../panelRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; @@ -181,8 +181,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { strict: false, select: (params) => resolveThreadRouteRef(params), }); - const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const diffOpen = diffSearch.diff === "1"; + const diffSearch = useSearch({ + strict: false, + select: (search) => parsePanelRouteSearch(search), + }); + const diffOpen = diffSearch.panel === "diff"; const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useStore( useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), @@ -349,8 +352,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; + const rest = stripPanelSearchParams(previous); + return { ...rest, panel: "diff" as const, diffTurnId: turnId }; }, }); }; @@ -360,8 +363,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; + const rest = stripPanelSearchParams(previous); + return { ...rest, panel: "diff" as const }; }, }); }; diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts deleted file mode 100644 index ef00874b..00000000 --- a/apps/web/src/diffRouteSearch.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseDiffRouteSearch } from "./diffRouteSearch"; - -describe("parseDiffRouteSearch", () => { - it("parses valid diff search values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - }); - - it("treats numeric and boolean diff toggles as open", () => { - expect( - parseDiffRouteSearch({ - diff: 1, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - - expect( - parseDiffRouteSearch({ - diff: true, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - }); - - it("drops turn and file values when diff is closed", () => { - const parsed = parseDiffRouteSearch({ - diff: "0", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({}); - }); - - it("drops file value when turn is not selected", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); - - it("normalizes whitespace-only values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: " ", - diffFilePath: " ", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); -}); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts deleted file mode 100644 index d9b072f2..00000000 --- a/apps/web/src/diffRouteSearch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TurnId } from "@t3tools/contracts"; - -export interface DiffRouteSearch { - diff?: "1" | undefined; - diffTurnId?: TurnId | undefined; - diffFilePath?: string | undefined; -} - -function isDiffOpenValue(value: unknown): boolean { - return value === "1" || value === 1 || value === true; -} - -function normalizeSearchString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function stripDiffSearchParams>( - params: T, -): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; -} - -export function parseDiffRouteSearch(search: Record): DiffRouteSearch { - const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; - const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; - - return { - ...(diff ? { diff } : {}), - ...(diffTurnId ? { diffTurnId } : {}), - ...(diffFilePath ? { diffFilePath } : {}), - }; -} diff --git a/apps/web/src/panelRouteSearch.test.ts b/apps/web/src/panelRouteSearch.test.ts new file mode 100644 index 00000000..d46d66be --- /dev/null +++ b/apps/web/src/panelRouteSearch.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { parsePanelRouteSearch, stripPanelSearchParams } from "./panelRouteSearch"; + +describe("parsePanelRouteSearch", () => { + it("parses panel=files", () => { + expect(parsePanelRouteSearch({ panel: "files" })).toEqual({ panel: "files" }); + }); + + it("parses panel=diff", () => { + expect(parsePanelRouteSearch({ panel: "diff" })).toEqual({ panel: "diff" }); + }); + + it("returns empty for no panel", () => { + expect(parsePanelRouteSearch({})).toEqual({}); + }); + + it("backward compat: diff=1 → panel=diff", () => { + expect(parsePanelRouteSearch({ diff: "1" })).toEqual({ panel: "diff" }); + }); + + it("preserves diffTurnId when panel=diff", () => { + const result = parsePanelRouteSearch({ panel: "diff", diffTurnId: "turn-1" }); + expect(result.panel).toBe("diff"); + expect(result.diffTurnId).toBeDefined(); + }); + + it("ignores diffTurnId when panel=files", () => { + const result = parsePanelRouteSearch({ panel: "files", diffTurnId: "turn-1" }); + expect(result.diffTurnId).toBeUndefined(); + }); + + it("rejects invalid panel values", () => { + expect(parsePanelRouteSearch({ panel: "invalid" })).toEqual({}); + }); +}); + +describe("stripPanelSearchParams", () => { + it("removes panel-related keys", () => { + const result = stripPanelSearchParams({ + panel: "diff", + diffTurnId: "x", + diffFilePath: "y", + other: "keep", + }); + expect(result).toEqual({ other: "keep" }); + }); +}); diff --git a/apps/web/src/panelRouteSearch.ts b/apps/web/src/panelRouteSearch.ts new file mode 100644 index 00000000..a9562544 --- /dev/null +++ b/apps/web/src/panelRouteSearch.ts @@ -0,0 +1,54 @@ +import { TurnId } from "@t3tools/contracts"; + +export type PanelTab = "files" | "diff"; + +export interface PanelRouteSearch { + panel?: PanelTab | undefined; + diffTurnId?: TurnId | undefined; + diffFilePath?: string | undefined; +} + +function isPanelTabValue(value: unknown): value is PanelTab { + return value === "files" || value === "diff"; +} + +function normalizeSearchString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +export function stripPanelSearchParams>( + params: T, +): Omit { + const { panel: _panel, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; + return rest as Omit; +} + +/** + * Backward-compatible parse: also accepts legacy `?diff=1` and normalizes to `panel=diff`. + */ +export function parsePanelRouteSearch(search: Record): PanelRouteSearch { + // Support new `?panel=files` / `?panel=diff` + let panel: PanelTab | undefined; + if (isPanelTabValue(search.panel)) { + panel = search.panel; + } else if (search.diff === "1" || search.diff === 1 || search.diff === true) { + // Backward compat: legacy `?diff=1` → `panel=diff` + panel = "diff"; + } + + const isDiff = panel === "diff"; + const diffTurnIdRaw = isDiff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; + const diffFilePath = + isDiff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; + + return { + ...(panel ? { panel } : {}), + ...(diffTurnId ? { diffTurnId } : {}), + ...(diffFilePath ? { diffFilePath } : {}), + }; +} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index ff20673e..7cbadfe7 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -12,10 +12,10 @@ import { } from "../components/DiffPanelShell"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { - type DiffRouteSearch, - parseDiffRouteSearch, - stripDiffSearchParams, -} from "../diffRouteSearch"; + type PanelRouteSearch, + parsePanelRouteSearch, + stripPanelSearchParams, +} from "../panelRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; @@ -165,52 +165,52 @@ function ChatThreadRouteView() { const routeThreadExists = threadExists || draftThreadExists; const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; - const diffOpen = search.diff === "1"; + const panelTab = search.panel; + const diffOpen = panelTab === "diff"; + const panelOpen = panelTab !== undefined; const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; - const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ + const [panelMountState, setPanelMountState] = useState(() => ({ threadKey: currentThreadKey, - hasOpenedDiff: diffOpen, + hasOpenedPanel: panelOpen, })); - const hasOpenedDiff = - diffPanelMountState.threadKey === currentThreadKey - ? diffPanelMountState.hasOpenedDiff - : diffOpen; - const markDiffOpened = useCallback(() => { - setDiffPanelMountState((previous) => { - if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) { + const hasOpenedPanel = + panelMountState.threadKey === currentThreadKey ? panelMountState.hasOpenedPanel : panelOpen; + const markPanelOpened = useCallback(() => { + setPanelMountState((previous) => { + if (previous.threadKey === currentThreadKey && previous.hasOpenedPanel) { return previous; } return { threadKey: currentThreadKey, - hasOpenedDiff: true, + hasOpenedPanel: true, }; }); }, [currentThreadKey]); - const closeDiff = useCallback(() => { + const closePanel = useCallback(() => { if (!threadRef) { return; } void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), - search: { diff: undefined }, + search: { panel: undefined }, }); }, [navigate, threadRef]); const openDiff = useCallback(() => { if (!threadRef) { return; } - markDiffOpened(); + markPanelOpened(); void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; + const rest = stripPanelSearchParams(previous); + return { ...rest, panel: "diff" as const }; }, }); - }, [markDiffOpened, navigate, threadRef]); + }, [markPanelOpened, navigate, threadRef]); useEffect(() => { if (!threadRef || !bootstrapComplete) { @@ -233,7 +233,7 @@ function ChatThreadRouteView() { return null; } - const shouldRenderDiffContent = diffOpen || hasOpenedDiff; + const shouldRenderDiffContent = diffOpen || hasOpenedPanel; if (!shouldUseDiffSheet) { return ( @@ -242,14 +242,14 @@ function ChatThreadRouteView() { @@ -263,11 +263,11 @@ function ChatThreadRouteView() { - + {shouldRenderDiffContent ? : null} @@ -275,9 +275,9 @@ function ChatThreadRouteView() { } export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - validateSearch: (search) => parseDiffRouteSearch(search), + validateSearch: (search) => parsePanelRouteSearch(search), search: { - middlewares: [retainSearchParams(["diff"])], + middlewares: [retainSearchParams(["panel"])], }, component: ChatThreadRouteView, }); From 751b7b91613c1ec5711c5d7d8cbb0220185d1531 Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:40:32 -0300 Subject: [PATCH 05/13] refactor(web): extract RightPanel with tab bar from DiffPanelInlineSidebar --- apps/web/src/components/RightPanel.tsx | 118 ++++++++++++++ apps/web/src/components/RightPanelTabBar.tsx | 38 +++++ .../routes/_chat.$environmentId.$threadId.tsx | 153 ++++++------------ 3 files changed, 206 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/components/RightPanel.tsx create mode 100644 apps/web/src/components/RightPanelTabBar.tsx diff --git a/apps/web/src/components/RightPanel.tsx b/apps/web/src/components/RightPanel.tsx new file mode 100644 index 00000000..a57dc766 --- /dev/null +++ b/apps/web/src/components/RightPanel.tsx @@ -0,0 +1,118 @@ +import { useCallback, type ReactNode } from "react"; +import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import type { PanelTab } from "../panelRouteSearch"; +import { RightPanelTabBar } from "./RightPanelTabBar"; + +const SIDEBAR_WIDTH_STORAGE_KEY = "chat_right_panel_width"; + +const DEFAULT_WIDTH_BY_TAB: Record = { + files: "clamp(36rem,52vw,56rem)", + diff: "clamp(28rem,48vw,44rem)", +}; + +const MIN_WIDTH_BY_TAB: Record = { + files: 36 * 16, + diff: 26 * 16, +}; + +const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; + +interface RightPanelProps { + activeTab: PanelTab; + open: boolean; + onTabChange: (tab: PanelTab) => void; + onClose: () => void; + onOpen: () => void; + children: ReactNode; +} + +export function RightPanel({ + activeTab, + open, + onTabChange, + onClose, + onOpen, + children, +}: RightPanelProps) { + const onOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + onOpen(); + return; + } + onClose(); + }, + [onClose, onOpen], + ); + + const shouldAcceptWidth = useCallback( + ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { + const composerForm = document.querySelector("[data-chat-composer-form='true']"); + if (!composerForm) return true; + const composerViewport = composerForm.parentElement; + if (!composerViewport) return true; + const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); + wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); + + const viewportStyle = window.getComputedStyle(composerViewport); + const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; + const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; + const viewportContentWidth = Math.max( + 0, + composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, + ); + const formRect = composerForm.getBoundingClientRect(); + const composerFooter = composerForm.querySelector( + "[data-chat-composer-footer='true']", + ); + const composerRightActions = composerForm.querySelector( + "[data-chat-composer-actions='right']", + ); + const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; + const composerFooterGap = composerFooter + ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || + Number.parseFloat(window.getComputedStyle(composerFooter).gap) || + 0 + : 0; + const minimumComposerWidth = + COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; + const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; + const overflowsViewport = formRect.width > viewportContentWidth + 0.5; + const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; + + if (previousSidebarWidth.length > 0) { + wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); + } else { + wrapper.style.removeProperty("--sidebar-width"); + } + + return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; + }, + [], + ); + + return ( + + + + {children} + + + + ); +} diff --git a/apps/web/src/components/RightPanelTabBar.tsx b/apps/web/src/components/RightPanelTabBar.tsx new file mode 100644 index 00000000..24850578 --- /dev/null +++ b/apps/web/src/components/RightPanelTabBar.tsx @@ -0,0 +1,38 @@ +import { FilesIcon, GitCompareArrowsIcon } from "lucide-react"; +import type { PanelTab } from "../panelRouteSearch"; + +interface RightPanelTabBarProps { + activeTab: PanelTab; + onTabChange: (tab: PanelTab) => void; +} + +export function RightPanelTabBar({ activeTab, onTabChange }: RightPanelTabBarProps) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 7cbadfe7..ce73deed 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -21,14 +21,12 @@ import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; +import { RightPanel } from "../components/RightPanel"; +import { RightPanelTabBar } from "../components/RightPanelTabBar"; import { RightPanelSheet } from "../components/RightPanelSheet"; -import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import { SidebarInset } from "~/components/ui/sidebar"; const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; -const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; -const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; -const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( @@ -48,94 +46,6 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { ); }; -const DiffPanelInlineSidebar = (props: { - diffOpen: boolean; - onCloseDiff: () => void; - onOpenDiff: () => void; - renderDiffContent: boolean; -}) => { - const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props; - const onOpenChange = useCallback( - (open: boolean) => { - if (open) { - onOpenDiff(); - return; - } - onCloseDiff(); - }, - [onCloseDiff, onOpenDiff], - ); - const shouldAcceptInlineSidebarWidth = useCallback( - ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { - const composerForm = document.querySelector("[data-chat-composer-form='true']"); - if (!composerForm) return true; - const composerViewport = composerForm.parentElement; - if (!composerViewport) return true; - const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); - wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); - - const viewportStyle = window.getComputedStyle(composerViewport); - const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; - const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; - const viewportContentWidth = Math.max( - 0, - composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, - ); - const formRect = composerForm.getBoundingClientRect(); - const composerFooter = composerForm.querySelector( - "[data-chat-composer-footer='true']", - ); - const composerRightActions = composerForm.querySelector( - "[data-chat-composer-actions='right']", - ); - const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; - const composerFooterGap = composerFooter - ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || - Number.parseFloat(window.getComputedStyle(composerFooter).gap) || - 0 - : 0; - const minimumComposerWidth = - COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; - const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; - const overflowsViewport = formRect.width > viewportContentWidth + 0.5; - const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; - - if (previousSidebarWidth.length > 0) { - wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); - } else { - wrapper.style.removeProperty("--sidebar-width"); - } - - return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; - }, - [], - ); - - return ( - - - {renderDiffContent ? : null} - - - - ); -}; - function ChatThreadRouteView() { const navigate = useNavigate(); const threadRef = Route.useParams({ @@ -238,21 +148,40 @@ function ChatThreadRouteView() { if (!shouldUseDiffSheet) { return ( <> - + - + { + if (tab === "diff") { + markPanelOpened(); + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (prev) => ({ ...stripPanelSearchParams(prev), panel: tab }), + }); + }} + onClose={closePanel} + onOpen={openDiff} + > +
+ {shouldRenderDiffContent ? : null} +
+
+
+ File explorer coming soon +
+
+
); } @@ -267,8 +196,26 @@ function ChatThreadRouteView() { routeKind="server" />
- - {shouldRenderDiffContent ? : null} + + { + if (tab === "diff") markPanelOpened(); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (prev) => ({ ...stripPanelSearchParams(prev), panel: tab }), + }); + }} + /> +
+ {shouldRenderDiffContent ? : null} +
+
+
+ File explorer coming soon +
+
); From 65f3904e9c16c8f71c06709ceff3bdb49b10acae Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:44:25 -0300 Subject: [PATCH 06/13] feat(web): add FileTree component with expand-on-demand, git status, and filter --- .../src/components/file-explorer/FileTree.tsx | 113 ++++++++++++++ .../file-explorer/FileTreeFilter.tsx | 39 +++++ .../components/file-explorer/FileTreeNode.tsx | 74 ++++++++++ .../web/src/components/file-explorer/types.ts | 17 +++ .../components/file-explorer/useFileTree.ts | 139 ++++++++++++++++++ .../file-explorer/useGitFileStatus.ts | 48 ++++++ 6 files changed, 430 insertions(+) create mode 100644 apps/web/src/components/file-explorer/FileTree.tsx create mode 100644 apps/web/src/components/file-explorer/FileTreeFilter.tsx create mode 100644 apps/web/src/components/file-explorer/FileTreeNode.tsx create mode 100644 apps/web/src/components/file-explorer/types.ts create mode 100644 apps/web/src/components/file-explorer/useFileTree.ts create mode 100644 apps/web/src/components/file-explorer/useGitFileStatus.ts diff --git a/apps/web/src/components/file-explorer/FileTree.tsx b/apps/web/src/components/file-explorer/FileTree.tsx new file mode 100644 index 00000000..31332a77 --- /dev/null +++ b/apps/web/src/components/file-explorer/FileTree.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from "react"; +import type { DirectoryEntry, EnvironmentId } from "@t3tools/contracts"; +import { useFileTree } from "./useFileTree"; +import { useGitFileStatus } from "./useGitFileStatus"; +import { FileTreeFilter } from "./FileTreeFilter"; +import { FileTreeNode } from "./FileTreeNode"; + +interface FileTreeProps { + environmentId: EnvironmentId; + cwd: string; + theme: "light" | "dark"; + onSelectFile: (relativePath: string) => void; + onContextMenu: (event: React.MouseEvent, entry: DirectoryEntry) => void; +} + +interface FlatEntry { + entry: DirectoryEntry; + depth: number; + isExpanded: boolean; + isLoading: boolean; +} + +export function FileTree({ + environmentId, + cwd, + theme, + onSelectFile, + onContextMenu, +}: FileTreeProps) { + const { rootEntries, rootLoading, expandedDirs, loadRoot, toggleExpand } = useFileTree({ + environmentId, + cwd, + }); + const { statusMap } = useGitFileStatus({ environmentId, cwd, enabled: true }); + const [filter, setFilter] = useState(""); + + useEffect(() => { + loadRoot(); + }, [loadRoot]); + + // Flatten tree for rendering + const flatEntries = useMemo(() => { + if (!rootEntries) return []; + const result: FlatEntry[] = []; + const lowerFilter = filter.toLowerCase(); + + function walk(entries: readonly DirectoryEntry[], depth: number) { + for (const entry of entries) { + const matchesFilter = + lowerFilter.length === 0 || entry.name.toLowerCase().includes(lowerFilter); + const dirData = + entry.kind === "directory" ? expandedDirs.get(entry.relativePath) : undefined; + const isExpanded = dirData !== undefined; + const isLoading = dirData?.isLoading ?? false; + const children = dirData?.entries ?? []; + + // For directories: show if matches filter OR has matching children + // For files: show if matches filter + if (entry.kind === "directory") { + if (matchesFilter || lowerFilter.length === 0) { + result.push({ entry, depth, isExpanded, isLoading }); + } + if (isExpanded) { + walk(children, depth + 1); + } + } else if (matchesFilter) { + result.push({ entry, depth, isExpanded: false, isLoading: false }); + } + } + } + + walk(rootEntries, 0); + return result; + }, [rootEntries, expandedDirs, filter]); + + if (rootLoading && !rootEntries) { + return ( +
+ +
+ Loading... +
+
+ ); + } + + return ( +
+ +
+ {flatEntries.map((item) => ( + + ))} + {flatEntries.length === 0 && rootEntries && ( +
+ {filter.length > 0 ? "No matching files" : "Empty directory"} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/file-explorer/FileTreeFilter.tsx b/apps/web/src/components/file-explorer/FileTreeFilter.tsx new file mode 100644 index 00000000..195dc7be --- /dev/null +++ b/apps/web/src/components/file-explorer/FileTreeFilter.tsx @@ -0,0 +1,39 @@ +import { SearchIcon, XIcon } from "lucide-react"; +import { useCallback, useRef } from "react"; + +interface FileTreeFilterProps { + value: string; + onChange: (value: string) => void; +} + +export function FileTreeFilter({ value, onChange }: FileTreeFilterProps) { + const inputRef = useRef(null); + + const handleClear = useCallback(() => { + onChange(""); + inputRef.current?.focus(); + }, [onChange]); + + return ( +
+ + onChange(e.target.value)} + placeholder="Filter files..." + className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + {value.length > 0 && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/file-explorer/FileTreeNode.tsx b/apps/web/src/components/file-explorer/FileTreeNode.tsx new file mode 100644 index 00000000..db570df5 --- /dev/null +++ b/apps/web/src/components/file-explorer/FileTreeNode.tsx @@ -0,0 +1,74 @@ +import { ChevronRightIcon } from "lucide-react"; +import type { DirectoryEntry, GitFileStatus } from "@t3tools/contracts"; +import { getVscodeIconUrlForEntry } from "../../vscode-icons"; + +const GIT_STATUS_LABELS: Record = { + modified: { label: "M", className: "text-yellow-500" }, + added: { label: "A", className: "text-green-500" }, + deleted: { label: "D", className: "text-red-500" }, + untracked: { label: "?", className: "text-gray-400" }, + renamed: { label: "R", className: "text-blue-500" }, + conflicted: { label: "U", className: "text-orange-500" }, +}; + +interface FileTreeNodeProps { + entry: DirectoryEntry; + depth: number; + isExpanded: boolean; + isLoading: boolean; + gitStatus: GitFileStatus | undefined; + theme: "light" | "dark"; + onToggleExpand: (relativePath: string) => void; + onSelectFile: (relativePath: string) => void; + onContextMenu: (event: React.MouseEvent, entry: DirectoryEntry) => void; +} + +export function FileTreeNode({ + entry, + depth, + isExpanded, + isLoading, + gitStatus, + theme, + onToggleExpand, + onSelectFile, + onContextMenu, +}: FileTreeNodeProps) { + const isDir = entry.kind === "directory"; + const iconUrl = getVscodeIconUrlForEntry(entry.relativePath, entry.kind, theme); + const statusInfo = gitStatus ? GIT_STATUS_LABELS[gitStatus] : undefined; + + const handleClick = () => { + if (isDir) { + onToggleExpand(entry.relativePath); + } else { + onSelectFile(entry.relativePath); + } + }; + + return ( + + ); +} diff --git a/apps/web/src/components/file-explorer/types.ts b/apps/web/src/components/file-explorer/types.ts new file mode 100644 index 00000000..42ce97fe --- /dev/null +++ b/apps/web/src/components/file-explorer/types.ts @@ -0,0 +1,17 @@ +import type { DirectoryEntry, GitFileStatus } from "@t3tools/contracts"; + +export interface TreeNode { + readonly entry: DirectoryEntry; + readonly depth: number; + readonly isExpanded: boolean; + readonly isLoading: boolean; + readonly children: readonly TreeNode[] | null; // null = not loaded yet + readonly gitStatus?: GitFileStatus | undefined; +} + +export interface FileTreeState { + readonly rootPath: string; + readonly nodes: ReadonlyMap; + readonly expandedPaths: ReadonlySet; + readonly rootEntries: readonly DirectoryEntry[]; +} diff --git a/apps/web/src/components/file-explorer/useFileTree.ts b/apps/web/src/components/file-explorer/useFileTree.ts new file mode 100644 index 00000000..7a48783d --- /dev/null +++ b/apps/web/src/components/file-explorer/useFileTree.ts @@ -0,0 +1,139 @@ +import { useCallback, useRef, useState } from "react"; +import type { + DirectoryEntry, + EnvironmentId, + FilesystemListDirectoryResult, +} from "@t3tools/contracts"; +import { readEnvironmentApi } from "../../environmentApi"; + +interface UseFileTreeOptions { + environmentId: EnvironmentId; + cwd: string; +} + +interface ExpandedDir { + entries: readonly DirectoryEntry[]; + isLoading: boolean; +} + +export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { + const [expandedDirs, setExpandedDirs] = useState>(new Map()); + const [rootEntries, setRootEntries] = useState(null); + const [rootLoading, setRootLoading] = useState(false); + const loadedRef = useRef(new Set()); + + const loadDirectory = useCallback( + async (relativePath: string) => { + const api = readEnvironmentApi(environmentId); + if (!api) return; + + if (relativePath === ".") { + setRootLoading(true); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { + entries: prev.get(relativePath)?.entries ?? [], + isLoading: true, + }); + return next; + }); + } + + try { + const result: FilesystemListDirectoryResult = await api.filesystem.listDirectory({ + cwd, + relativePath, + }); + + if (relativePath === ".") { + setRootEntries(result.entries); + setRootLoading(false); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { entries: result.entries, isLoading: false }); + return next; + }); + } + loadedRef.current.add(relativePath); + } catch { + if (relativePath === ".") { + setRootLoading(false); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { entries: [], isLoading: false }); + return next; + }); + } + } + }, + [environmentId, cwd], + ); + + const toggleExpand = useCallback( + (relativePath: string) => { + setExpandedDirs((prev) => { + if (prev.has(relativePath)) { + const next = new Map(prev); + next.delete(relativePath); + return next; + } + return prev; + }); + + // If not loaded yet, load it + if (!loadedRef.current.has(relativePath)) { + void loadDirectory(relativePath); + } else { + // Re-expand: just put it back + setExpandedDirs((prev) => { + if (prev.has(relativePath)) return prev; + // Re-load to get fresh data + void loadDirectory(relativePath); + return prev; + }); + } + }, + [loadDirectory], + ); + + const collapseDir = useCallback((relativePath: string) => { + setExpandedDirs((prev) => { + if (!prev.has(relativePath)) return prev; + const next = new Map(prev); + next.delete(relativePath); + return next; + }); + }, []); + + const expandDir = useCallback( + (relativePath: string) => { + if (!loadedRef.current.has(relativePath)) { + void loadDirectory(relativePath); + } else { + setExpandedDirs((prev) => { + if (prev.has(relativePath)) return prev; + void loadDirectory(relativePath); + return prev; + }); + } + }, + [loadDirectory], + ); + + const loadRoot = useCallback(() => { + void loadDirectory("."); + }, [loadDirectory]); + + return { + rootEntries, + rootLoading, + expandedDirs, + loadRoot, + toggleExpand, + expandDir, + collapseDir, + }; +} diff --git a/apps/web/src/components/file-explorer/useGitFileStatus.ts b/apps/web/src/components/file-explorer/useGitFileStatus.ts new file mode 100644 index 00000000..cb33ff94 --- /dev/null +++ b/apps/web/src/components/file-explorer/useGitFileStatus.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EnvironmentId, GitFileStatus } from "@t3tools/contracts"; +import { readEnvironmentApi } from "../../environmentApi"; + +const POLL_INTERVAL_MS = 3_000; + +interface UseGitFileStatusOptions { + environmentId: EnvironmentId; + cwd: string; + enabled: boolean; +} + +export function useGitFileStatus({ environmentId, cwd, enabled }: UseGitFileStatusOptions) { + const [statusMap, setStatusMap] = useState>(new Map()); + const timerRef = useRef | null>(null); + + const fetchStatus = useCallback(async () => { + const api = readEnvironmentApi(environmentId); + if (!api) return; + + try { + const result = await api.git.fileStatus({ cwd }); + const next = new Map(); + for (const file of result.files) { + next.set(file.relativePath, file.status); + } + setStatusMap(next); + } catch { + // Silently ignore — git status polling is best-effort + } + }, [environmentId, cwd]); + + useEffect(() => { + if (!enabled) return; + + void fetchStatus(); + timerRef.current = setInterval(() => void fetchStatus(), POLL_INTERVAL_MS); + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [enabled, fetchStatus]); + + return { statusMap, refresh: fetchStatus }; +} From c655b4b5cf52f54d4642edb55a422ecbf54d6833 Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:48:59 -0300 Subject: [PATCH 07/13] feat(web): add CodeMirror editor with multi-tab support and language detection --- apps/web/package.json | 17 ++ .../components/file-explorer/CodeEditor.tsx | 184 ++++++++++++++++++ .../file-explorer/EditorBreadcrumb.tsx | 40 ++++ .../components/file-explorer/EditorTabs.tsx | 53 +++++ .../file-explorer/languageExtensions.ts | 35 ++++ .../components/file-explorer/useEditorTabs.ts | 120 ++++++++++++ bun.lock | 95 +++++++++ 7 files changed, 544 insertions(+) create mode 100644 apps/web/src/components/file-explorer/CodeEditor.tsx create mode 100644 apps/web/src/components/file-explorer/EditorBreadcrumb.tsx create mode 100644 apps/web/src/components/file-explorer/EditorTabs.tsx create mode 100644 apps/web/src/components/file-explorer/languageExtensions.ts create mode 100644 apps/web/src/components/file-explorer/useEditorTabs.ts diff --git a/apps/web/package.json b/apps/web/package.json index b18defeb..880a2f66 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,22 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.41.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -33,6 +49,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", + "codemirror": "^6.0.2", "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", diff --git a/apps/web/src/components/file-explorer/CodeEditor.tsx b/apps/web/src/components/file-explorer/CodeEditor.tsx new file mode 100644 index 00000000..31f74be5 --- /dev/null +++ b/apps/web/src/components/file-explorer/CodeEditor.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { StateEffect, type Extension } from "@codemirror/state"; +import { readEnvironmentApi } from "../../environmentApi"; +import { useEditorTabs } from "./useEditorTabs"; +import { EditorTabs } from "./EditorTabs"; +import { EditorBreadcrumb } from "./EditorBreadcrumb"; +import { getLanguageExtension } from "./languageExtensions"; + +interface CodeEditorProps { + environmentId: EnvironmentId; + cwd: string; + activeFilePath: string | null; + /** Reserved for future use (e.g. "go to definition") */ + onOpenFile: (relativePath: string) => void; +} + +export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorProps) { + const editorContainerRef = useRef(null); + const editorViewRef = useRef(null); + const [cmModules, setCmModules] = useState<{ + EditorView: typeof import("@codemirror/view").EditorView; + EditorState: typeof import("@codemirror/state").EditorState; + basicSetup: Extension; + oneDark: Extension; + keymap: typeof import("@codemirror/view").keymap; + } | null>(null); + + const { tabs, activeIndex, activeTab, openTab, closeTab, setActiveIndex, markDirty, markSaved } = + useEditorTabs(); + + // Lazy-load CodeMirror on first mount + useEffect(() => { + let cancelled = false; + void Promise.all([ + import("@codemirror/view"), + import("@codemirror/state"), + import("codemirror"), + import("@codemirror/theme-one-dark"), + ]).then(([viewMod, stateMod, cmMod, themeMod]) => { + if (cancelled) return; + setCmModules({ + EditorView: viewMod.EditorView, + EditorState: stateMod.EditorState, + basicSetup: cmMod.basicSetup, + oneDark: themeMod.oneDark, + keymap: viewMod.keymap, + }); + }); + return () => { + cancelled = true; + }; + }, []); + + // Load file content when activeFilePath changes + useEffect(() => { + if (!activeFilePath) return; + + const existingTab = tabs.find((t) => t.relativePath === activeFilePath); + if (existingTab) { + const idx = tabs.indexOf(existingTab); + setActiveIndex(idx); + return; + } + + const api = readEnvironmentApi(environmentId); + if (!api) return; + + let cancelled = false; + void api.filesystem.readFile({ cwd, relativePath: activeFilePath }).then((result) => { + if (cancelled) return; + if (result.encoding === "utf-8") { + openTab(activeFilePath, result.content); + } + }); + + return () => { + cancelled = true; + }; + }, [activeFilePath, environmentId, cwd, openTab, setActiveIndex, tabs]); + + // Create/update EditorView when active tab changes + useEffect(() => { + if (!cmModules || !editorContainerRef.current || !activeTab) return; + + const { EditorView, EditorState, basicSetup, oneDark, keymap } = cmModules; + const container = editorContainerRef.current; + const currentTabPath = activeTab.relativePath; + const currentTabContent = activeTab.currentContent; + + // Save handler + const saveFile = async () => { + const api = readEnvironmentApi(environmentId); + if (!api) return; + try { + await api.projects.writeFile({ + cwd, + relativePath: currentTabPath, + contents: currentTabContent, + }); + markSaved(currentTabPath, currentTabContent); + } catch { + // TODO: toast error + } + }; + + // Destroy previous view + if (editorViewRef.current) { + editorViewRef.current.destroy(); + editorViewRef.current = null; + } + + const extensions: Extension[] = [ + basicSetup, + oneDark, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + markDirty(currentTabPath, update.state.doc.toString()); + } + }), + keymap.of([ + { + key: "Mod-s", + run: () => { + void saveFile(); + return true; + }, + }, + ]), + EditorView.theme({ + "&": { height: "100%", fontSize: "13px" }, + ".cm-scroller": { overflow: "auto" }, + }), + ]; + + const state = EditorState.create({ + doc: activeTab.originalContent, + extensions, + }); + + const view = new EditorView({ state, parent: container }); + editorViewRef.current = view; + + // Load language extension async + const langLoader = getLanguageExtension(currentTabPath); + if (langLoader) { + void langLoader().then((langExt) => { + if (editorViewRef.current === view) { + view.dispatch({ + effects: StateEffect.appendConfig.of(langExt), + }); + } + }); + } + + return () => { + view.destroy(); + editorViewRef.current = null; + }; + // Only re-create editor when the tab identity changes or CM modules load + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cmModules, activeTab?.relativePath, activeTab?.originalContent]); + + if (!activeTab) { + return ( +
+ Select a file to view +
+ ); + } + + return ( +
+ + {activeTab && } +
+
+ ); +} diff --git a/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx b/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx new file mode 100644 index 00000000..2f8868dc --- /dev/null +++ b/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx @@ -0,0 +1,40 @@ +import { ChevronRightIcon } from "lucide-react"; +import { useMemo } from "react"; + +interface EditorBreadcrumbProps { + relativePath: string; +} + +interface BreadcrumbSegment { + readonly name: string; + readonly cumulativePath: string; + readonly isLast: boolean; + readonly isFirst: boolean; +} + +export function EditorBreadcrumb({ relativePath }: EditorBreadcrumbProps) { + const segments = useMemo((): BreadcrumbSegment[] => { + const parts = relativePath.split("/"); + let cumulative = ""; + return parts.map((part, idx) => { + cumulative = cumulative ? `${cumulative}/${part}` : part; + return { + name: part, + cumulativePath: cumulative, + isLast: idx === parts.length - 1, + isFirst: idx === 0, + }; + }); + }, [relativePath]); + + return ( +
+ {segments.map((segment) => ( + + {!segment.isFirst && } + {segment.name} + + ))} +
+ ); +} diff --git a/apps/web/src/components/file-explorer/EditorTabs.tsx b/apps/web/src/components/file-explorer/EditorTabs.tsx new file mode 100644 index 00000000..2548019a --- /dev/null +++ b/apps/web/src/components/file-explorer/EditorTabs.tsx @@ -0,0 +1,53 @@ +import { XIcon } from "lucide-react"; +import type { EditorTab } from "./useEditorTabs"; +import { basenameOfPath } from "../../vscode-icons"; + +interface EditorTabsProps { + tabs: readonly EditorTab[]; + activeIndex: number; + onSelect: (index: number) => void; + onClose: (index: number) => void; +} + +export function EditorTabs({ tabs, activeIndex, onSelect, onClose }: EditorTabsProps) { + if (tabs.length === 0) return null; + + return ( +
+ {tabs.map((tab, index) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/file-explorer/languageExtensions.ts b/apps/web/src/components/file-explorer/languageExtensions.ts new file mode 100644 index 00000000..1a2f753c --- /dev/null +++ b/apps/web/src/components/file-explorer/languageExtensions.ts @@ -0,0 +1,35 @@ +import type { Extension } from "@codemirror/state"; + +const EXTENSION_MAP: Record Promise> = { + ts: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })), + tsx: () => + import("@codemirror/lang-javascript").then((m) => + m.javascript({ typescript: true, jsx: true }), + ), + js: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), + jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })), + mjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), + cjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), + css: () => import("@codemirror/lang-css").then((m) => m.css()), + html: () => import("@codemirror/lang-html").then((m) => m.html()), + json: () => import("@codemirror/lang-json").then((m) => m.json()), + md: () => import("@codemirror/lang-markdown").then((m) => m.markdown()), + markdown: () => import("@codemirror/lang-markdown").then((m) => m.markdown()), + py: () => import("@codemirror/lang-python").then((m) => m.python()), + rs: () => import("@codemirror/lang-rust").then((m) => m.rust()), + cpp: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), + c: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), + h: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), + java: () => import("@codemirror/lang-java").then((m) => m.java()), + xml: () => import("@codemirror/lang-xml").then((m) => m.xml()), + svg: () => import("@codemirror/lang-xml").then((m) => m.xml()), + sql: () => import("@codemirror/lang-sql").then((m) => m.sql()), + yaml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()), + yml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()), +}; + +export function getLanguageExtension(filePath: string): (() => Promise) | undefined { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + return EXTENSION_MAP[ext]; +} diff --git a/apps/web/src/components/file-explorer/useEditorTabs.ts b/apps/web/src/components/file-explorer/useEditorTabs.ts new file mode 100644 index 00000000..050ef302 --- /dev/null +++ b/apps/web/src/components/file-explorer/useEditorTabs.ts @@ -0,0 +1,120 @@ +import { useCallback, useState } from "react"; + +const MAX_TABS = 10; + +export interface EditorTab { + readonly relativePath: string; + readonly isDirty: boolean; + readonly originalContent: string; + readonly currentContent: string; +} + +interface EditorTabsState { + readonly tabs: readonly EditorTab[]; + readonly activeIndex: number; +} + +export function useEditorTabs() { + const [state, setState] = useState({ + tabs: [], + activeIndex: -1, + }); + + const activeTab = state.activeIndex >= 0 ? (state.tabs[state.activeIndex] ?? null) : null; + + const openTab = useCallback((relativePath: string, content: string) => { + setState((prev) => { + const existingIndex = prev.tabs.findIndex((t) => t.relativePath === relativePath); + if (existingIndex >= 0) { + return { ...prev, activeIndex: existingIndex }; + } + + let tabs = [...prev.tabs]; + + // LRU eviction if at max capacity + if (tabs.length >= MAX_TABS) { + let evictIndex = -1; + for (let i = 0; i < tabs.length; i++) { + if (i !== prev.activeIndex && !tabs[i]!.isDirty) { + evictIndex = i; + break; + } + } + if (evictIndex >= 0) { + tabs.splice(evictIndex, 1); + } else { + return prev; + } + } + + const newTab: EditorTab = { + relativePath, + isDirty: false, + originalContent: content, + currentContent: content, + }; + + tabs = [...tabs, newTab]; + return { tabs, activeIndex: tabs.length - 1 }; + }); + }, []); + + const closeTab = useCallback((index: number) => { + setState((prev) => { + const tab = prev.tabs[index]; + if (!tab) return prev; + + if (tab.isDirty) { + const confirmed = window.confirm(`${tab.relativePath} has unsaved changes. Close anyway?`); + if (!confirmed) return prev; + } + + const tabs = prev.tabs.filter((_, i) => i !== index); + let activeIndex = prev.activeIndex; + if (activeIndex >= tabs.length) { + activeIndex = tabs.length - 1; + } else if (index < activeIndex) { + activeIndex -= 1; + } + + return { tabs, activeIndex }; + }); + }, []); + + const setActiveIndex = useCallback((index: number) => { + setState((prev) => ({ ...prev, activeIndex: index })); + }, []); + + const markDirty = useCallback((relativePath: string, content: string) => { + setState((prev) => ({ + ...prev, + tabs: prev.tabs.map((t) => + t.relativePath === relativePath + ? { ...t, currentContent: content, isDirty: content !== t.originalContent } + : t, + ), + })); + }, []); + + const markSaved = useCallback((relativePath: string, savedContent: string) => { + setState((prev) => ({ + ...prev, + tabs: prev.tabs.map((t) => + t.relativePath === relativePath + ? { ...t, originalContent: savedContent, currentContent: savedContent, isDirty: false } + : t, + ), + })); + }, []); + + return { + tabs: state.tabs, + activeIndex: state.activeIndex, + activeTab, + openTab, + closeTab, + setActiveIndex, + markDirty, + markSaved, + }; +} diff --git a/bun.lock b/bun.lock index e9b7511e..a5da20cc 100644 --- a/bun.lock +++ b/bun.lock @@ -78,6 +78,22 @@ "version": "0.0.20", "dependencies": { "@base-ui/react": "^1.2.0", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.41.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -96,6 +112,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", + "codemirror": "^6.0.2", "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", @@ -317,6 +334,46 @@ "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], + + "@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], + + "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="], + + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + + "@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="], + + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + + "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="], + + "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="], + + "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], + + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], + + "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + + "@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="], + + "@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -549,6 +606,36 @@ "@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + + "@lezer/cpp": ["@lezer/cpp@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw=="], + + "@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + + "@lezer/java": ["@lezer/java@1.1.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + + "@lezer/markdown": ["@lezer/markdown@1.6.3", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw=="], + + "@lezer/python": ["@lezer/python@1.1.18", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg=="], + + "@lezer/rust": ["@lezer/rust@1.0.2", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg=="], + + "@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="], + + "@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], @@ -1001,6 +1088,8 @@ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1025,6 +1114,8 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], @@ -1805,6 +1896,8 @@ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -2001,6 +2094,8 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], From d84afaf615d8e47c275ec4ee932a38f450cd08b1 Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:53:10 -0300 Subject: [PATCH 08/13] feat(web): assemble FileExplorer with tree/editor split pane in RightPanel --- apps/web/package.json | 1 + .../components/file-explorer/FileExplorer.tsx | 46 +++++++++++++ .../routes/_chat.$environmentId.$threadId.tsx | 64 +++++++++++++++++-- bun.lock | 3 + 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/file-explorer/FileExplorer.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 880a2f66..f73f735d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -56,6 +56,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.10.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" diff --git a/apps/web/src/components/file-explorer/FileExplorer.tsx b/apps/web/src/components/file-explorer/FileExplorer.tsx new file mode 100644 index 00000000..98e8e465 --- /dev/null +++ b/apps/web/src/components/file-explorer/FileExplorer.tsx @@ -0,0 +1,46 @@ +import { useCallback, useState } from "react"; +import { Group, Panel, Separator } from "react-resizable-panels"; +import type { DirectoryEntry, EnvironmentId } from "@t3tools/contracts"; +import { FileTree } from "./FileTree"; +import { CodeEditor } from "./CodeEditor"; + +interface FileExplorerProps { + environmentId: EnvironmentId; + cwd: string; + theme: "light" | "dark"; +} + +export function FileExplorer({ environmentId, cwd, theme }: FileExplorerProps) { + const [activeFilePath, setActiveFilePath] = useState(null); + + const handleSelectFile = useCallback((relativePath: string) => { + setActiveFilePath(relativePath); + }, []); + + const handleContextMenu = useCallback((_event: React.MouseEvent, _entry: DirectoryEntry) => { + // Context menu implementation in Task 10 + }, []); + + return ( + + + + + + + + + + ); +} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index ce73deed..7e3e5a7e 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -18,7 +18,13 @@ import { } from "../panelRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; -import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; +import { + selectEnvironmentState, + selectProjectByRef, + selectThreadExistsByRef, + useStore, +} from "../store"; +import { useTheme } from "../hooks/useTheme"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { RightPanel } from "../components/RightPanel"; @@ -27,6 +33,9 @@ import { RightPanelSheet } from "../components/RightPanelSheet"; import { SidebarInset } from "~/components/ui/sidebar"; const DiffPanel = lazy(() => import("../components/DiffPanel")); +const FileExplorer = lazy(() => + import("../components/file-explorer/FileExplorer").then((m) => ({ default: m.FileExplorer })), +); const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( @@ -74,6 +83,15 @@ function ChatThreadRouteView() { }); const routeThreadExists = threadExists || draftThreadExists; const serverThreadStarted = threadHasStarted(serverThread); + const activeProject = useStore((store) => { + if (!serverThread?.projectId || !threadRef) return undefined; + return selectProjectByRef(store, { + environmentId: threadRef.environmentId, + projectId: serverThread.projectId, + }); + }); + const activeCwd = serverThread?.worktreePath ?? activeProject?.cwd ?? null; + const { resolvedTheme } = useTheme(); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const panelTab = search.panel; const diffOpen = panelTab === "diff"; @@ -177,9 +195,25 @@ function ChatThreadRouteView() { {shouldRenderDiffContent ? : null}
-
- File explorer coming soon -
+ {activeCwd ? ( + + Loading file explorer... +
+ } + > + + + ) : ( +
+ No workspace available +
+ )} @@ -212,9 +246,25 @@ function ChatThreadRouteView() { {shouldRenderDiffContent ? : null}
-
- File explorer coming soon -
+ {activeCwd ? ( + + Loading file explorer... +
+ } + > + + + ) : ( +
+ No workspace available +
+ )}
diff --git a/bun.lock b/bun.lock index a5da20cc..a2936462 100644 --- a/bun.lock +++ b/bun.lock @@ -119,6 +119,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.10.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11", @@ -1758,6 +1759,8 @@ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-resizable-panels": ["react-resizable-panels@4.10.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-frjewRQt7TCv/vCH1pJfjZ7RxAhr5pKuqVQtVgzFq/vherxBFOWyC3xMbryx5Ti2wylViGUFc93Etg4rB3E0UA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "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=="], From 9b7f251b1c9b23606b55fb822ce014e7e7e9585f Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 16:56:15 -0300 Subject: [PATCH 09/13] feat(web): add file tree context menu with CRUD operations --- .../components/file-explorer/FileExplorer.tsx | 71 +++++++++++++++- .../file-explorer/FileTreeContextMenu.tsx | 80 +++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/file-explorer/FileTreeContextMenu.tsx diff --git a/apps/web/src/components/file-explorer/FileExplorer.tsx b/apps/web/src/components/file-explorer/FileExplorer.tsx index 98e8e465..831eff3c 100644 --- a/apps/web/src/components/file-explorer/FileExplorer.tsx +++ b/apps/web/src/components/file-explorer/FileExplorer.tsx @@ -3,6 +3,8 @@ import { Group, Panel, Separator } from "react-resizable-panels"; import type { DirectoryEntry, EnvironmentId } from "@t3tools/contracts"; import { FileTree } from "./FileTree"; import { CodeEditor } from "./CodeEditor"; +import { FileTreeContextMenu, type TreeContextAction } from "./FileTreeContextMenu"; +import { readEnvironmentApi } from "../../environmentApi"; interface FileExplorerProps { environmentId: EnvironmentId; @@ -12,15 +14,75 @@ interface FileExplorerProps { export function FileExplorer({ environmentId, cwd, theme }: FileExplorerProps) { const [activeFilePath, setActiveFilePath] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + entry: DirectoryEntry; + } | null>(null); const handleSelectFile = useCallback((relativePath: string) => { setActiveFilePath(relativePath); }, []); - const handleContextMenu = useCallback((_event: React.MouseEvent, _entry: DirectoryEntry) => { - // Context menu implementation in Task 10 + const handleContextMenu = useCallback((event: React.MouseEvent, entry: DirectoryEntry) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, entry }); }, []); + const handleContextAction = useCallback( + async (action: TreeContextAction, entry: DirectoryEntry) => { + const api = readEnvironmentApi(environmentId); + if (!api) return; + + switch (action) { + case "newFile": { + const name = window.prompt("File name:"); + if (!name) return; + const relativePath = `${entry.relativePath}/${name}`; + await api.projects.writeFile({ cwd, relativePath, contents: "" }); + setActiveFilePath(relativePath); + break; + } + case "newFolder": { + const name = window.prompt("Folder name:"); + if (!name) return; + await api.filesystem.createDirectory({ + cwd, + relativePath: `${entry.relativePath}/${name}`, + }); + break; + } + case "rename": { + const newName = window.prompt("New name:", entry.name); + if (!newName || newName === entry.name) return; + const parentPath = entry.relativePath.split("/").slice(0, -1).join("/"); + const newRelativePath = parentPath ? `${parentPath}/${newName}` : newName; + await api.filesystem.rename({ + cwd, + oldRelativePath: entry.relativePath, + newRelativePath, + }); + break; + } + case "delete": { + const confirmed = window.confirm(`Delete ${entry.relativePath}?`); + if (!confirmed) return; + await api.filesystem.delete({ cwd, relativePath: entry.relativePath }); + break; + } + case "copyPath": { + await navigator.clipboard.writeText(entry.relativePath); + break; + } + case "mentionInChat": { + // Implemented in Task 11 + break; + } + } + }, + [environmentId, cwd], + ); + return ( @@ -41,6 +103,11 @@ export function FileExplorer({ environmentId, cwd, theme }: FileExplorerProps) { onOpenFile={handleSelectFile} /> + setContextMenu(null)} + onAction={handleContextAction} + /> ); } diff --git a/apps/web/src/components/file-explorer/FileTreeContextMenu.tsx b/apps/web/src/components/file-explorer/FileTreeContextMenu.tsx new file mode 100644 index 00000000..c4655dd2 --- /dev/null +++ b/apps/web/src/components/file-explorer/FileTreeContextMenu.tsx @@ -0,0 +1,80 @@ +import { useRef, useEffect } from "react"; +import type { DirectoryEntry } from "@t3tools/contracts"; + +export type TreeContextAction = + | "newFile" + | "newFolder" + | "rename" + | "delete" + | "copyPath" + | "mentionInChat"; + +interface ContextMenuState { + x: number; + y: number; + entry: DirectoryEntry; +} + +interface FileTreeContextMenuProps { + state: ContextMenuState | null; + onClose: () => void; + onAction: (action: TreeContextAction, entry: DirectoryEntry) => void; +} + +const MENU_ITEMS: { action: TreeContextAction; label: string; dirOnly?: boolean }[] = [ + { action: "newFile", label: "New File", dirOnly: true }, + { action: "newFolder", label: "New Folder", dirOnly: true }, + { action: "rename", label: "Rename" }, + { action: "delete", label: "Delete" }, + { action: "copyPath", label: "Copy Path" }, + { action: "mentionInChat", label: "Mention in Chat" }, +]; + +export function FileTreeContextMenu({ state, onClose, onAction }: FileTreeContextMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + if (!state) return; + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [state, onClose]); + + if (!state) return null; + + const isDir = state.entry.kind === "directory"; + const items = MENU_ITEMS.filter((item) => !item.dirOnly || isDir); + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} From cf86afb8b9ae928b3bd4dbb490f3daea5f3becd8 Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 17:00:22 -0300 Subject: [PATCH 10/13] feat(web): extend mention system to support @file:L-L line ranges --- apps/web/src/composer-editor-mentions.test.ts | 29 +++++++++++++++++++ apps/web/src/composer-editor-mentions.ts | 23 +++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index d723114b..6f337673 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -126,3 +126,32 @@ describe("selectionTouchesMentionBoundary", () => { ).toBe(true); }); }); + +describe("line-range mentions", () => { + it("parses @path:L10-L20 as a mention with lineRange", () => { + const segments = splitPromptIntoComposerSegments("Check @src/index.ts:L10-L20 here"); + expect(segments).toEqual([ + { type: "text", text: "Check " }, + { type: "mention", path: "src/index.ts", lineRange: { start: 10, end: 20 } }, + { type: "text", text: " here" }, + ]); + }); + + it("parses plain @path without line range", () => { + const segments = splitPromptIntoComposerSegments("Check @src/index.ts here"); + expect(segments).toEqual([ + { type: "text", text: "Check " }, + { type: "mention", path: "src/index.ts" }, + { type: "text", text: " here" }, + ]); + }); + + it("handles line range at end of string followed by space", () => { + const segments = splitPromptIntoComposerSegments("Look at @src/app.tsx:L1-L5 "); + expect(segments).toEqual([ + { type: "text", text: "Look at " }, + { type: "mention", path: "src/app.tsx", lineRange: { start: 1, end: 5 } }, + { type: "text", text: " " }, + ]); + }); +}); diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 9f4492ca..a8a3e2ef 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -3,6 +3,11 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; +export type MentionLineRange = { + readonly start: number; + readonly end: number; +}; + export type ComposerPromptSegment = | { type: "text"; @@ -11,6 +16,7 @@ export type ComposerPromptSegment = | { type: "mention"; path: string; + lineRange?: MentionLineRange | undefined; } | { type: "skill"; @@ -21,7 +27,7 @@ export type ComposerPromptSegment = context: TerminalContextDraft | null; }; -const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; +const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+?)(?::L(\d+)-L(\d+))?(?=\s)/g; const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; function rangeIncludesIndex(start: number, end: number, index: number): boolean { @@ -42,6 +48,7 @@ type InlineTokenMatch = | { type: "mention"; value: string; + lineRange?: MentionLineRange | undefined; start: number; end: number; } @@ -59,11 +66,17 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] { const fullMatch = match[0]; const prefix = match[1] ?? ""; const path = match[2] ?? ""; + const lineStart = match[3]; + const lineEnd = match[4]; const matchIndex = match.index ?? 0; const start = matchIndex + prefix.length; const end = start + fullMatch.length - prefix.length; if (path.length > 0) { - matches.push({ type: "mention", value: path, start, end }); + const lineRange = + lineStart !== undefined && lineEnd !== undefined + ? { start: Number.parseInt(lineStart, 10), end: Number.parseInt(lineEnd, 10) } + : undefined; + matches.push({ type: "mention", value: path, lineRange, start, end }); } } @@ -178,7 +191,11 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen } if (match.type === "mention") { - segments.push({ type: "mention", path: match.value }); + segments.push({ + type: "mention", + path: match.value, + ...(match.lineRange ? { lineRange: match.lineRange } : {}), + }); } else { segments.push({ type: "skill", name: match.value }); } From f6af50ec350a85ac0a3cbfbc0d2d029a7e278dbf Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Thu, 23 Apr 2026 17:04:45 -0300 Subject: [PATCH 11/13] feat(web): wire mention-in-chat actions from file explorer to composer --- apps/web/src/components/chat/ChatComposer.tsx | 7 +++ .../components/file-explorer/CodeEditor.tsx | 48 +++++++++++++++++++ .../components/file-explorer/FileExplorer.tsx | 5 +- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3d3b081a..0f7dfe45 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -335,6 +335,8 @@ export interface ChatComposerHandle { }) => void; /** Insert a terminal context from the terminal drawer. */ addTerminalContext: (selection: TerminalContextSelection) => void; + /** Insert arbitrary text at the current cursor position (e.g. `@path ` from file explorer). */ + insertTextAtCursor: (text: string) => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; @@ -1648,6 +1650,10 @@ export const ChatComposer = memo( composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, + insertTextAtCursor: (text: string) => { + const snapshot = readComposerSnapshot(); + applyPromptReplacement(snapshot.cursor, snapshot.cursor, text); + }, getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, @@ -1671,6 +1677,7 @@ export const ChatComposer = memo( composerTerminalContextsRef, isComposerModelPickerOpen, readComposerSnapshot, + applyPromptReplacement, selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, diff --git a/apps/web/src/components/file-explorer/CodeEditor.tsx b/apps/web/src/components/file-explorer/CodeEditor.tsx index 31f74be5..1e1f1089 100644 --- a/apps/web/src/components/file-explorer/CodeEditor.tsx +++ b/apps/web/src/components/file-explorer/CodeEditor.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { StateEffect, type Extension } from "@codemirror/state"; import { readEnvironmentApi } from "../../environmentApi"; +import { useComposerHandleContext } from "../../composerHandleContext"; import { useEditorTabs } from "./useEditorTabs"; import { EditorTabs } from "./EditorTabs"; import { EditorBreadcrumb } from "./EditorBreadcrumb"; @@ -29,6 +30,8 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro const { tabs, activeIndex, activeTab, openTab, closeTab, setActiveIndex, markDirty, markSaved } = useEditorTabs(); + const composerHandleRef = useComposerHandleContext(); + // Lazy-load CodeMirror on first mount useEffect(() => { let cancelled = false; @@ -131,6 +134,51 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro "&": { height: "100%", fontSize: "13px" }, ".cm-scroller": { overflow: "auto" }, }), + EditorView.domEventHandlers({ + contextmenu: (event, view) => { + const sel = view.state.selection.main; + if (sel.empty) return false; + event.preventDefault(); + const startLine = view.state.doc.lineAt(sel.from).number; + const endLine = view.state.doc.lineAt(sel.to).number; + + // Create a simple context menu + const menu = document.createElement("div"); + menu.className = + "fixed z-50 min-w-[180px] rounded-md border border-border bg-popover py-1 shadow-md"; + menu.style.left = `${event.clientX}px`; + menu.style.top = `${event.clientY}px`; + + const btn = document.createElement("button"); + btn.className = + "flex w-full items-center px-3 py-1.5 text-xs text-popover-foreground hover:bg-accent hover:text-accent-foreground"; + btn.textContent = "Mention Selection in Chat"; + btn.onclick = () => { + composerHandleRef?.current?.insertTextAtCursor( + ` @${currentTabPath}:L${startLine}-L${endLine} `, + ); + menu.remove(); + }; + menu.appendChild(btn); + document.body.appendChild(menu); + + const dismiss = (e: MouseEvent) => { + if (!menu.contains(e.target as Node)) { + menu.remove(); + document.removeEventListener("mousedown", dismiss); + } + }; + const dismissOnEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { + menu.remove(); + document.removeEventListener("keydown", dismissOnEsc); + } + }; + document.addEventListener("mousedown", dismiss); + document.addEventListener("keydown", dismissOnEsc); + return true; + }, + }), ]; const state = EditorState.create({ diff --git a/apps/web/src/components/file-explorer/FileExplorer.tsx b/apps/web/src/components/file-explorer/FileExplorer.tsx index 831eff3c..8c50849e 100644 --- a/apps/web/src/components/file-explorer/FileExplorer.tsx +++ b/apps/web/src/components/file-explorer/FileExplorer.tsx @@ -5,6 +5,7 @@ import { FileTree } from "./FileTree"; import { CodeEditor } from "./CodeEditor"; import { FileTreeContextMenu, type TreeContextAction } from "./FileTreeContextMenu"; import { readEnvironmentApi } from "../../environmentApi"; +import { useComposerHandleContext } from "../../composerHandleContext"; interface FileExplorerProps { environmentId: EnvironmentId; @@ -20,6 +21,8 @@ export function FileExplorer({ environmentId, cwd, theme }: FileExplorerProps) { entry: DirectoryEntry; } | null>(null); + const composerHandleRef = useComposerHandleContext(); + const handleSelectFile = useCallback((relativePath: string) => { setActiveFilePath(relativePath); }, []); @@ -75,7 +78,7 @@ export function FileExplorer({ environmentId, cwd, theme }: FileExplorerProps) { break; } case "mentionInChat": { - // Implemented in Task 11 + composerHandleRef?.current?.insertTextAtCursor(` @${entry.relativePath} `); break; } } From 7b6f6faf1088c756c0f95c0531a571f44e05dab4 Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Fri, 24 Apr 2026 10:08:53 -0300 Subject: [PATCH 12/13] Add file explorer panel toggle and working tree diff support - Wire files panel as toggleable right panel (Mod+E) with header button - Add getWorkingTreeDiff to checkpoint system for uncommitted edits - Fix WorkspacePaths to resolve "." as valid workspace root - Polish editor: word wrap toggle, middle-click tab close, scroll-into-view - Replace resizable-panels with CSS layout, add error/retry state to tree - Memoize FileTreeNode, restyle borders and backgrounds --- .../Layers/CheckpointDiffQuery.ts | 66 +++++++++++++++++ .../checkpointing/Layers/CheckpointStore.ts | 34 +++++++++ .../Services/CheckpointDiffQuery.ts | 12 +++ .../checkpointing/Services/CheckpointStore.ts | 15 ++++ apps/server/src/keybindings.ts | 1 + .../workspace/Layers/WorkspacePaths.test.ts | 17 +++++ .../src/workspace/Layers/WorkspacePaths.ts | 9 ++- apps/server/src/ws.ts | 15 ++++ apps/web/src/components/ChatView.tsx | 33 +++++++++ apps/web/src/components/DiffPanel.tsx | 3 +- apps/web/src/components/chat/ChatHeader.tsx | 32 +++++++- .../components/file-explorer/CodeEditor.tsx | 73 ++++++++++++------- .../file-explorer/EditorBreadcrumb.tsx | 38 +++++++--- .../components/file-explorer/EditorTabs.tsx | 41 +++++++++-- .../components/file-explorer/FileExplorer.tsx | 50 +++++++------ .../src/components/file-explorer/FileTree.tsx | 31 +++++++- .../file-explorer/FileTreeFilter.tsx | 40 +++++----- .../components/file-explorer/FileTreeNode.tsx | 5 +- .../components/file-explorer/useFileTree.ts | 47 +++++++----- apps/web/src/environmentApi.ts | 1 + apps/web/src/index.css | 6 +- apps/web/src/keybindings.ts | 8 ++ apps/web/src/lib/providerReactQuery.ts | 39 ++++++++++ apps/web/src/panelRouteSearch.ts | 21 +++++- .../routes/_chat.$environmentId.$threadId.tsx | 20 ++++- apps/web/src/rpc/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 5 ++ packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/orchestration.ts | 25 +++++++ packages/contracts/src/rpc.ts | 12 +++ 30 files changed, 583 insertions(+), 120 deletions(-) diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts index 1c2edee4..adc5eb44 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts @@ -3,6 +3,8 @@ import { type OrchestrationGetFullThreadDiffInput, type OrchestrationGetFullThreadDiffResult, type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, + type OrchestrationGetWorkingTreeDiffInput, + type OrchestrationGetWorkingTreeDiffResult, } from "@t3tools/contracts"; import { Effect, Layer, Option, Schema } from "effect"; @@ -159,9 +161,73 @@ const make = Effect.gen(function* () { toTurnCount: input.toTurnCount, }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); + const getWorkingTreeDiff: CheckpointDiffQueryShape["getWorkingTreeDiff"] = Effect.fn( + "getWorkingTreeDiff", + )(function* (input: OrchestrationGetWorkingTreeDiffInput) { + const operation = "CheckpointDiffQuery.getWorkingTreeDiff"; + + const threadContext = yield* projectionSnapshotQuery.getThreadCheckpointContext(input.threadId); + + if (Option.isNone(threadContext)) { + return yield* new CheckpointInvariantError({ + operation, + detail: `Thread '${input.threadId}' not found.`, + }); + } + + const workspaceCwd = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; + if (!workspaceCwd) { + return yield* new CheckpointInvariantError({ + operation, + detail: `Workspace path missing for thread '${input.threadId}' when computing working tree diff.`, + }); + } + + // Find the latest checkpoint to diff against. + const maxTurnCount = threadContext.value.checkpoints.reduce( + (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), + 0, + ); + + // Use the latest checkpoint as the base, or fallback to checkpoint 0 (initial state). + const baseCheckpointRef = + maxTurnCount > 0 + ? (threadContext.value.checkpoints.find( + (checkpoint) => checkpoint.checkpointTurnCount === maxTurnCount, + )?.checkpointRef ?? checkpointRefForThreadTurn(input.threadId, 0)) + : checkpointRefForThreadTurn(input.threadId, 0); + + const refExists = yield* checkpointStore.hasCheckpointRef({ + cwd: workspaceCwd, + checkpointRef: baseCheckpointRef, + }); + + if (!refExists) { + // If no checkpoint exists yet, return an empty diff. + const emptyResult: OrchestrationGetWorkingTreeDiffResult = { + threadId: input.threadId, + diff: "", + }; + return emptyResult; + } + + const diff = yield* checkpointStore.diffCheckpointToWorkTree({ + cwd: workspaceCwd, + fromCheckpointRef: baseCheckpointRef, + fallbackFromToHead: true, + }); + + const result: OrchestrationGetWorkingTreeDiffResult = { + threadId: input.threadId, + diff, + }; + return result; + }); + return { getTurnDiff, getFullThreadDiff, + getWorkingTreeDiff, } satisfies CheckpointDiffQueryShape; }); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 211877e9..139a1f81 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -254,6 +254,39 @@ const makeCheckpointStore = Effect.gen(function* () { }, ); + const diffCheckpointToWorkTree: CheckpointStoreShape["diffCheckpointToWorkTree"] = Effect.fn( + "diffCheckpointToWorkTree", + )(function* (input) { + const operation = "CheckpointStore.diffCheckpointToWorkTree"; + + let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); + + if (!fromCommitOid && input.fallbackFromToHead === true) { + const headCommit = yield* resolveHeadCommit(input.cwd); + if (headCommit) { + fromCommitOid = headCommit; + } + } + + if (!fromCommitOid) { + return yield* new GitCommandError({ + operation, + command: "git diff", + cwd: input.cwd, + detail: "Checkpoint ref is unavailable for working tree diff operation.", + }); + } + + const result = yield* git.execute({ + operation, + cwd: input.cwd, + args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid], + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + }); + + return result.stdout; + }); + const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { @@ -278,6 +311,7 @@ const makeCheckpointStore = Effect.gen(function* () { hasCheckpointRef, restoreCheckpoint, diffCheckpoints, + diffCheckpointToWorkTree, deleteCheckpointRefs, } satisfies CheckpointStoreShape; }); diff --git a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts index d865256a..3e60ff60 100644 --- a/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts @@ -11,6 +11,8 @@ import type { OrchestrationGetFullThreadDiffResult, OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, + OrchestrationGetWorkingTreeDiffInput, + OrchestrationGetWorkingTreeDiffResult, } from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect } from "effect"; @@ -38,6 +40,16 @@ export interface CheckpointDiffQueryShape { readonly getFullThreadDiff: ( input: OrchestrationGetFullThreadDiffInput, ) => Effect.Effect; + + /** + * Read the working tree diff relative to the latest checkpoint. + * + * Diffs the latest checkpoint commit against the current working tree to + * capture uncommitted user edits. + */ + readonly getWorkingTreeDiff: ( + input: OrchestrationGetWorkingTreeDiffInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index d9a43fa4..928795cf 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -34,6 +34,12 @@ export interface DiffCheckpointsInput { readonly fallbackFromToHead?: boolean; } +export interface DiffCheckpointToWorkTreeInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly fallbackFromToHead?: boolean; +} + export interface DeleteCheckpointRefsInput { readonly cwd: string; readonly checkpointRefs: ReadonlyArray; @@ -82,6 +88,15 @@ export interface CheckpointStoreShape { input: DiffCheckpointsInput, ) => Effect.Effect; + /** + * Compute patch diff between a checkpoint ref and the current working tree. + * + * Can optionally treat missing checkpoint ref as `HEAD`. + */ + readonly diffCheckpointToWorkTree: ( + input: DiffCheckpointToWorkTreeInput, + ) => Effect.Effect; + /** * Delete the provided checkpoint refs. * diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 165b2ede..eaff0505 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -63,6 +63,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+e", command: "files.toggle", when: "!terminalFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts index 13658e9c..be8beee8 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.test.ts @@ -92,6 +92,23 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }); describe("resolveRelativePathWithinRoot", () => { + it.effect("resolves '.' to the workspace root itself", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths; + const cwd = yield* makeTempDir(); + + const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: cwd, + relativePath: ".", + }); + + expect(resolved).toEqual({ + absolutePath: cwd, + relativePath: ".", + }); + }), + ); + it.effect("resolves relative paths inside the workspace root", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index f19bb362..c8d26326 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -76,9 +76,14 @@ export const makeWorkspacePaths = Effect.gen(function* () { const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + + // "." resolves to the workspace root itself (relativeToRoot === ""). + // That is a valid target — e.g. listing the root directory. + if (relativeToRoot.length === 0 || relativeToRoot === ".") { + return { absolutePath, relativePath: "." }; + } + if ( - relativeToRoot.length === 0 || - relativeToRoot === "." || relativeToRoot.startsWith("../") || relativeToRoot === ".." || path.isAbsolute(relativeToRoot) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1e5b806e..901255e9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,6 +13,7 @@ import { OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, + OrchestrationGetWorkingTreeDiffError, ORCHESTRATION_WS_METHODS, ProjectSearchEntriesError, ProjectWriteFileError, @@ -645,6 +646,20 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getWorkingTreeDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getWorkingTreeDiff, + checkpointDiffQuery.getWorkingTreeDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetWorkingTreeDiffError({ + message: "Failed to load working tree diff", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.replayEvents, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e8642d2b..d96cdd4d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1474,6 +1474,10 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], ); + const filesPanelShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "files.toggle", nonTerminalShortcutLabelOptions), + [keybindings, nonTerminalShortcutLabelOptions], + ); const onToggleDiff = useCallback(() => { if (!isServerThread) { return; @@ -1494,6 +1498,24 @@ export default function ChatView(props: ChatViewProps) { }, }); }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); + const filesOpen = rawSearch.panel === "files"; + const onToggleFiles = useCallback(() => { + if (!isServerThread) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, + replace: true, + search: (previous) => { + const rest = stripPanelSearchParams(previous); + return filesOpen ? { ...rest, panel: undefined } : { ...rest, panel: "files" as const }; + }, + }); + }, [filesOpen, environmentId, isServerThread, navigate, threadId]); const envLocked = Boolean( activeThread && @@ -2285,6 +2307,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "files.toggle") { + event.preventDefault(); + event.stopPropagation(); + onToggleFiles(); + return; + } + if (command === "modelPicker.toggle") { event.preventDefault(); event.stopPropagation(); @@ -2314,6 +2343,7 @@ export default function ChatView(props: ChatViewProps) { splitTerminal, keybindings, onToggleDiff, + onToggleFiles, toggleTerminalVisibility, ]); @@ -3253,14 +3283,17 @@ export default function ChatView(props: ChatViewProps) { terminalOpen={terminalState.terminalOpen} terminalToggleShortcutLabel={terminalToggleShortcutLabel} diffToggleShortcutLabel={diffPanelShortcutLabel} + filesToggleShortcutLabel={filesPanelShortcutLabel} gitCwd={gitCwd} diffOpen={diffOpen} + filesOpen={filesOpen} onRunProjectScript={runProjectScript} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} onToggleTerminal={toggleTerminalVisibility} onToggleDiff={onToggleDiff} + onToggleFiles={onToggleFiles} /> diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1aaf4798..8eada715 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -21,7 +21,7 @@ import { } from "react"; import { openInPreferredEditor } from "../editorPreferences"; import { useGitStatus } from "~/lib/gitStatusState"; -import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; +import { checkpointDiffQueryOptions, workingTreeDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { resolvePathLinkTarget } from "../terminal-links"; @@ -223,6 +223,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ); const selectedTurnId = diffSearch.diffTurnId ?? null; + const isWorkingTreeSelected = diffSearch.diffWorkingTree === true; const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; const selectedTurn = selectedTurnId === null diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index cda0bb13..25127844 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -9,7 +9,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; -import { DiffIcon, TerminalSquareIcon } from "lucide-react"; +import { DiffIcon, FilesIcon, TerminalSquareIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; @@ -33,14 +33,17 @@ interface ChatHeaderProps { terminalOpen: boolean; terminalToggleShortcutLabel: string | null; diffToggleShortcutLabel: string | null; + filesToggleShortcutLabel: string | null; gitCwd: string | null; diffOpen: boolean; + filesOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; onToggleTerminal: () => void; onToggleDiff: () => void; + onToggleFiles: () => void; } export const ChatHeader = memo(function ChatHeader({ @@ -59,14 +62,17 @@ export const ChatHeader = memo(function ChatHeader({ terminalOpen, terminalToggleShortcutLabel, diffToggleShortcutLabel, + filesToggleShortcutLabel, gitCwd, diffOpen, + filesOpen, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, onToggleTerminal, onToggleDiff, + onToggleFiles, }: ChatHeaderProps) { return (
@@ -139,6 +145,30 @@ export const ChatHeader = memo(function ChatHeader({ : "Toggle terminal drawer"} + + + + + } + /> + + {!activeProjectName + ? "Files panel is unavailable until this thread has an active project." + : filesToggleShortcutLabel + ? `Toggle files panel (${filesToggleShortcutLabel})` + : "Toggle files panel"} + + (null); const editorViewRef = useRef(null); + const wordWrapCompartmentRef = useRef(new Compartment()); + const [wordWrap, setWordWrap] = useState(true); const [cmModules, setCmModules] = useState<{ EditorView: typeof import("@codemirror/view").EditorView; EditorState: typeof import("@codemirror/state").EditorState; @@ -55,14 +57,18 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro }; }, []); + // Track tabs in a ref so the file-load effect doesn't depend on the tabs array + const tabsRef = useRef(tabs); + tabsRef.current = tabs; + // Load file content when activeFilePath changes useEffect(() => { if (!activeFilePath) return; - const existingTab = tabs.find((t) => t.relativePath === activeFilePath); - if (existingTab) { - const idx = tabs.indexOf(existingTab); - setActiveIndex(idx); + const currentTabs = tabsRef.current; + const existingIndex = currentTabs.findIndex((t) => t.relativePath === activeFilePath); + if (existingIndex >= 0) { + setActiveIndex(existingIndex); return; } @@ -80,7 +86,7 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro return () => { cancelled = true; }; - }, [activeFilePath, environmentId, cwd, openTab, setActiveIndex, tabs]); + }, [activeFilePath, environmentId, cwd, openTab, setActiveIndex]); // Create/update EditorView when active tab changes useEffect(() => { @@ -89,23 +95,7 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro const { EditorView, EditorState, basicSetup, oneDark, keymap } = cmModules; const container = editorContainerRef.current; const currentTabPath = activeTab.relativePath; - const currentTabContent = activeTab.currentContent; - - // Save handler - const saveFile = async () => { - const api = readEnvironmentApi(environmentId); - if (!api) return; - try { - await api.projects.writeFile({ - cwd, - relativePath: currentTabPath, - contents: currentTabContent, - }); - markSaved(currentTabPath, currentTabContent); - } catch { - // TODO: toast error - } - }; + const compartment = wordWrapCompartmentRef.current; // Destroy previous view if (editorViewRef.current) { @@ -116,6 +106,7 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro const extensions: Extension[] = [ basicSetup, oneDark, + compartment.of(wordWrap ? EditorView.lineWrapping : []), EditorView.updateListener.of((update) => { if (update.docChanged) { markDirty(currentTabPath, update.state.doc.toString()); @@ -124,8 +115,18 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro keymap.of([ { key: "Mod-s", - run: () => { - void saveFile(); + run: (view) => { + // Read content directly from the view to avoid stale closures + const content = view.state.doc.toString(); + const api = readEnvironmentApi(environmentId); + if (api) { + void api.projects + .writeFile({ cwd, relativePath: currentTabPath, contents: content }) + .then(() => markSaved(currentTabPath, content)) + .catch(() => { + // TODO: toast error + }); + } return true; }, }, @@ -209,23 +210,39 @@ export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorPro // eslint-disable-next-line react-hooks/exhaustive-deps }, [cmModules, activeTab?.relativePath, activeTab?.originalContent]); + // Toggle word wrap dynamically without recreating the editor + useEffect(() => { + const view = editorViewRef.current; + if (!view || !cmModules) return; + const compartment = wordWrapCompartmentRef.current; + view.dispatch({ + effects: compartment.reconfigure(wordWrap ? cmModules.EditorView.lineWrapping : []), + }); + }, [wordWrap, cmModules]); + if (!activeTab) { return ( -
+
Select a file to view
); } return ( -
+
- {activeTab && } + {activeTab && ( + setWordWrap((prev) => !prev)} + /> + )}
); diff --git a/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx b/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx index 2f8868dc..e84d37ad 100644 --- a/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx +++ b/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx @@ -1,8 +1,10 @@ -import { ChevronRightIcon } from "lucide-react"; +import { ChevronRightIcon, WrapTextIcon } from "lucide-react"; import { useMemo } from "react"; interface EditorBreadcrumbProps { relativePath: string; + wordWrap: boolean; + onToggleWordWrap: () => void; } interface BreadcrumbSegment { @@ -12,7 +14,11 @@ interface BreadcrumbSegment { readonly isFirst: boolean; } -export function EditorBreadcrumb({ relativePath }: EditorBreadcrumbProps) { +export function EditorBreadcrumb({ + relativePath, + wordWrap, + onToggleWordWrap, +}: EditorBreadcrumbProps) { const segments = useMemo((): BreadcrumbSegment[] => { const parts = relativePath.split("/"); let cumulative = ""; @@ -28,13 +34,27 @@ export function EditorBreadcrumb({ relativePath }: EditorBreadcrumbProps) { }, [relativePath]); return ( -
- {segments.map((segment) => ( - - {!segment.isFirst && } - {segment.name} - - ))} +
+
+ {segments.map((segment) => ( + + {!segment.isFirst && } + {segment.name} + + ))} +
+
); } diff --git a/apps/web/src/components/file-explorer/EditorTabs.tsx b/apps/web/src/components/file-explorer/EditorTabs.tsx index 2548019a..87989fde 100644 --- a/apps/web/src/components/file-explorer/EditorTabs.tsx +++ b/apps/web/src/components/file-explorer/EditorTabs.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useRef } from "react"; import { XIcon } from "lucide-react"; import type { EditorTab } from "./useEditorTabs"; import { basenameOfPath } from "../../vscode-icons"; @@ -10,19 +11,49 @@ interface EditorTabsProps { } export function EditorTabs({ tabs, activeIndex, onSelect, onClose }: EditorTabsProps) { + const scrollRef = useRef(null); + const activeTabRef = useRef(null); + + // Scroll active tab into view when it changes + useEffect(() => { + activeTabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeIndex]); + + // Horizontal scroll with mouse wheel (shift not required) + const handleWheel = useCallback((e: React.WheelEvent) => { + const container = scrollRef.current; + if (!container) return; + // Scroll horizontally whether the wheel is vertical or horizontal + const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + container.scrollLeft += delta; + e.preventDefault(); + }, []); + if (tabs.length === 0) return null; return ( -
+
{tabs.map((tab, index) => ( +
+
+ ); + } + return (
diff --git a/apps/web/src/components/file-explorer/FileTreeFilter.tsx b/apps/web/src/components/file-explorer/FileTreeFilter.tsx index 195dc7be..5e45ff70 100644 --- a/apps/web/src/components/file-explorer/FileTreeFilter.tsx +++ b/apps/web/src/components/file-explorer/FileTreeFilter.tsx @@ -15,25 +15,27 @@ export function FileTreeFilter({ value, onChange }: FileTreeFilterProps) { }, [onChange]); return ( -
- - onChange(e.target.value)} - placeholder="Filter files..." - className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" - /> - {value.length > 0 && ( - - )} +
+
+ + onChange(e.target.value)} + placeholder="Filter files..." + className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" + /> + {value.length > 0 && ( + + )} +
); } diff --git a/apps/web/src/components/file-explorer/FileTreeNode.tsx b/apps/web/src/components/file-explorer/FileTreeNode.tsx index db570df5..68491063 100644 --- a/apps/web/src/components/file-explorer/FileTreeNode.tsx +++ b/apps/web/src/components/file-explorer/FileTreeNode.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { ChevronRightIcon } from "lucide-react"; import type { DirectoryEntry, GitFileStatus } from "@t3tools/contracts"; import { getVscodeIconUrlForEntry } from "../../vscode-icons"; @@ -23,7 +24,7 @@ interface FileTreeNodeProps { onContextMenu: (event: React.MouseEvent, entry: DirectoryEntry) => void; } -export function FileTreeNode({ +export const FileTreeNode = memo(function FileTreeNode({ entry, depth, isExpanded, @@ -71,4 +72,4 @@ export function FileTreeNode({ )} ); -} +}); diff --git a/apps/web/src/components/file-explorer/useFileTree.ts b/apps/web/src/components/file-explorer/useFileTree.ts index 7a48783d..2564bcbe 100644 --- a/apps/web/src/components/file-explorer/useFileTree.ts +++ b/apps/web/src/components/file-explorer/useFileTree.ts @@ -16,19 +16,29 @@ interface ExpandedDir { isLoading: boolean; } +export type RootLoadState = "idle" | "loading" | "loaded" | "error"; + export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { const [expandedDirs, setExpandedDirs] = useState>(new Map()); const [rootEntries, setRootEntries] = useState(null); - const [rootLoading, setRootLoading] = useState(false); + const [rootLoadState, setRootLoadState] = useState("idle"); + const [rootError, setRootError] = useState(null); const loadedRef = useRef(new Set()); const loadDirectory = useCallback( async (relativePath: string) => { const api = readEnvironmentApi(environmentId); - if (!api) return; + if (!api) { + if (relativePath === ".") { + setRootLoadState("error"); + setRootError("Environment not connected"); + } + return; + } if (relativePath === ".") { - setRootLoading(true); + setRootLoadState("loading"); + setRootError(null); } else { setExpandedDirs((prev) => { const next = new Map(prev); @@ -48,7 +58,7 @@ export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { if (relativePath === ".") { setRootEntries(result.entries); - setRootLoading(false); + setRootLoadState("loaded"); } else { setExpandedDirs((prev) => { const next = new Map(prev); @@ -57,9 +67,10 @@ export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { }); } loadedRef.current.add(relativePath); - } catch { + } catch (err) { if (relativePath === ".") { - setRootLoading(false); + setRootLoadState("error"); + setRootError(err instanceof Error ? err.message : "Failed to list directory"); } else { setExpandedDirs((prev) => { const next = new Map(prev); @@ -74,27 +85,24 @@ export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { const toggleExpand = useCallback( (relativePath: string) => { + // Collapse: if currently expanded, just remove from map and return setExpandedDirs((prev) => { if (prev.has(relativePath)) { const next = new Map(prev); next.delete(relativePath); return next; } - return prev; - }); - // If not loaded yet, load it - if (!loadedRef.current.has(relativePath)) { - void loadDirectory(relativePath); - } else { - // Re-expand: just put it back - setExpandedDirs((prev) => { - if (prev.has(relativePath)) return prev; - // Re-load to get fresh data + // Expand: if already loaded before, restore with a re-fetch + if (loadedRef.current.has(relativePath)) { void loadDirectory(relativePath); return prev; - }); - } + } + + // Never loaded: trigger the first load + void loadDirectory(relativePath); + return prev; + }); }, [loadDirectory], ); @@ -129,7 +137,8 @@ export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { return { rootEntries, - rootLoading, + rootLoadState, + rootError, expandedDirs, loadRoot, toggleExpand, diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index e4a5cc3d..8e9edc62 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -46,6 +46,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, + getWorkingTreeDiff: rpcClient.orchestration.getWorkingTreeDiff, subscribeShell: (callback, options) => rpcClient.orchestration.subscribeShell(callback, options), subscribeThread: (input, callback, options) => diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5c568ad5..5da684de 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -208,13 +208,15 @@ code { background: rgba(255, 255, 255, 0.18); } -.turn-chip-strip { +.turn-chip-strip, +.editor-tabs-scroll { scrollbar-width: none; -ms-overflow-style: none; overscroll-behavior-x: contain; } -.turn-chip-strip::-webkit-scrollbar { +.turn-chip-strip::-webkit-scrollbar, +.editor-tabs-scroll::-webkit-scrollbar { display: none; } diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index dbf2450f..0d57944e 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -379,6 +379,14 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isFilesToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "files.toggle", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 20007fc8..d57c3274 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, + OrchestrationGetWorkingTreeDiffInput, ThreadId, } from "@t3tools/contracts"; import { queryOptions } from "@tanstack/react-query"; @@ -29,6 +30,8 @@ export const providerQueryKeys = { input.toTurnCount, input.cacheScope ?? null, ] as const, + workingTreeDiff: (input: WorkingTreeDiffQueryInput) => + ["providers", "workingTreeDiff", input.environmentId ?? null, input.threadId] as const, }; function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { @@ -129,3 +132,39 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { : Math.min(1_000, 100 * 2 ** (attempt - 1)), }); } + +interface WorkingTreeDiffQueryInput { + environmentId: EnvironmentId | null; + threadId: ThreadId | null; + enabled?: boolean; +} + +export function workingTreeDiffQueryOptions(input: WorkingTreeDiffQueryInput) { + const decodedRequest = Schema.decodeUnknownOption(OrchestrationGetWorkingTreeDiffInput)({ + threadId: input.threadId, + }); + + return queryOptions({ + queryKey: providerQueryKeys.workingTreeDiff(input), + queryFn: async () => { + if (!input.environmentId || decodedRequest._tag === "None") { + throw new Error("Working tree diff is unavailable."); + } + const api = ensureEnvironmentApi(input.environmentId); + try { + return await api.orchestration.getWorkingTreeDiff(decodedRequest.value); + } catch (error) { + throw new Error(normalizeCheckpointErrorMessage(error), { cause: error }); + } + }, + enabled: (input.enabled ?? true) && !!input.environmentId && decodedRequest._tag === "Some", + staleTime: 5_000, + retry: (failureCount, error) => { + if (isCheckpointTemporarilyUnavailable(error)) { + return failureCount < 6; + } + return failureCount < 2; + }, + retryDelay: (attempt) => Math.min(2_000, 200 * 2 ** (attempt - 1)), + }); +} diff --git a/apps/web/src/panelRouteSearch.ts b/apps/web/src/panelRouteSearch.ts index a9562544..08616136 100644 --- a/apps/web/src/panelRouteSearch.ts +++ b/apps/web/src/panelRouteSearch.ts @@ -6,6 +6,7 @@ export interface PanelRouteSearch { panel?: PanelTab | undefined; diffTurnId?: TurnId | undefined; diffFilePath?: string | undefined; + diffWorkingTree?: boolean | undefined; } function isPanelTabValue(value: unknown): value is PanelTab { @@ -22,9 +23,15 @@ function normalizeSearchString(value: unknown): string | undefined { export function stripPanelSearchParams>( params: T, -): Omit { - const { panel: _panel, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; +): Omit { + const { + panel: _panel, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + diffWorkingTree: _diffWorkingTree, + ...rest + } = params; + return rest as Omit; } /** @@ -41,13 +48,19 @@ export function parsePanelRouteSearch(search: Record): PanelRou } const isDiff = panel === "diff"; - const diffTurnIdRaw = isDiff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffWorkingTree = + isDiff && (search.diffWorkingTree === true || search.diffWorkingTree === "1") + ? true + : undefined; + const diffTurnIdRaw = + isDiff && !diffWorkingTree ? normalizeSearchString(search.diffTurnId) : undefined; const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; const diffFilePath = isDiff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; return { ...(panel ? { panel } : {}), + ...(diffWorkingTree ? { diffWorkingTree } : {}), ...(diffTurnId ? { diffTurnId } : {}), ...(diffFilePath ? { diffFilePath } : {}), }; diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 7e3e5a7e..240d1dca 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -191,10 +191,16 @@ function ChatThreadRouteView() { onClose={closePanel} onOpen={openDiff} > -
+
{shouldRenderDiffContent ? : null}
-
+
{activeCwd ? ( -
+
{shouldRenderDiffContent ? : null}
-
+
{activeCwd ? ( ; readonly getTurnDiff: RpcUnaryMethod; readonly getFullThreadDiff: RpcUnaryMethod; + readonly getWorkingTreeDiff: RpcUnaryMethod; readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; }; @@ -254,6 +255,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), getFullThreadDiff: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), + getWorkingTreeDiff: (input) => + transport.request((client) => client[ORCHESTRATION_WS_METHODS.getWorkingTreeDiff](input)), subscribeShell: (listener, options) => transport.subscribe( (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 88eb72fd..20828dad 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -59,6 +59,8 @@ import type { OrchestrationGetFullThreadDiffResult, OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, + OrchestrationGetWorkingTreeDiffInput, + OrchestrationGetWorkingTreeDiffResult, OrchestrationShellStreamItem, OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, @@ -293,6 +295,9 @@ export interface EnvironmentApi { getFullThreadDiff: ( input: OrchestrationGetFullThreadDiffInput, ) => Promise; + getWorkingTreeDiff: ( + input: OrchestrationGetWorkingTreeDiffInput, + ) => Promise; subscribeShell: ( callback: (event: OrchestrationShellStreamItem) => void, options?: { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d3b85d1c..310c04a4 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -53,6 +53,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "files.toggle", "commandPalette.toggle", "chat.new", "chat.newLocal", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 087a6670..63152c02 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -25,6 +25,7 @@ export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", + getWorkingTreeDiff: "orchestration.getWorkingTreeDiff", replayEvents: "orchestration.replayEvents", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", @@ -1162,6 +1163,18 @@ export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThr export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff; export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type; +export const OrchestrationGetWorkingTreeDiffInput = Schema.Struct({ + threadId: ThreadId, +}); +export type OrchestrationGetWorkingTreeDiffInput = typeof OrchestrationGetWorkingTreeDiffInput.Type; + +export const OrchestrationGetWorkingTreeDiffResult = Schema.Struct({ + threadId: ThreadId, + diff: Schema.String, +}); +export type OrchestrationGetWorkingTreeDiffResult = + typeof OrchestrationGetWorkingTreeDiffResult.Type; + export const OrchestrationReplayEventsInput = Schema.Struct({ fromSequenceExclusive: NonNegativeInt, }); @@ -1183,6 +1196,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, }, + getWorkingTreeDiff: { + input: OrchestrationGetWorkingTreeDiffInput, + output: OrchestrationGetWorkingTreeDiffResult, + }, replayEvents: { input: OrchestrationReplayEventsInput, output: OrchestrationReplayEventsResult, @@ -1229,6 +1246,14 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass }, ) {} +export class OrchestrationGetWorkingTreeDiffError extends Schema.TaggedErrorClass()( + "OrchestrationGetWorkingTreeDiffError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} + export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass()( "OrchestrationReplayEventsError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a2f5f487..5f997af8 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -57,6 +57,8 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, + OrchestrationGetWorkingTreeDiffError, + OrchestrationGetWorkingTreeDiffInput, OrchestrationReplayEventsError, OrchestrationReplayEventsInput, OrchestrationRpcSchemas, @@ -362,6 +364,15 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( }, ); +export const WsOrchestrationGetWorkingTreeDiffRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getWorkingTreeDiff, + { + payload: OrchestrationGetWorkingTreeDiffInput, + success: OrchestrationRpcSchemas.getWorkingTreeDiff.output, + error: OrchestrationGetWorkingTreeDiffError, + }, +); + export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, { payload: OrchestrationReplayEventsInput, success: OrchestrationRpcSchemas.replayEvents.output, @@ -451,6 +462,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationGetWorkingTreeDiffRpc, WsOrchestrationReplayEventsRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, From 8fc5be69c73b72b13c04890e2ec9247866accbec Mon Sep 17 00:00:00 2001 From: Leandro Ciric Date: Fri, 24 Apr 2026 10:14:35 -0300 Subject: [PATCH 13/13] Remove brainstorm artifacts and implementation plan for file explorer - Delete .superpowers/brainstorm session files (HTML mockup, server state) - Delete docs/superpowers/plans/2026-04-23-file-explorer.md --- .../content/panel-layout.html | 456 --- .../209025-1776951894/state/server-stopped | 1 - .../209025-1776951894/state/server.pid | 1 - .../plans/2026-04-23-file-explorer.md | 3191 ----------------- 4 files changed, 3649 deletions(-) delete mode 100644 .superpowers/brainstorm/209025-1776951894/content/panel-layout.html delete mode 100644 .superpowers/brainstorm/209025-1776951894/state/server-stopped delete mode 100644 .superpowers/brainstorm/209025-1776951894/state/server.pid delete mode 100644 docs/superpowers/plans/2026-04-23-file-explorer.md diff --git a/.superpowers/brainstorm/209025-1776951894/content/panel-layout.html b/.superpowers/brainstorm/209025-1776951894/content/panel-layout.html deleted file mode 100644 index 1aa70a0a..00000000 --- a/.superpowers/brainstorm/209025-1776951894/content/panel-layout.html +++ /dev/null @@ -1,456 +0,0 @@ -

Right Panel Architecture: Tabbed Layout

-

How the file tree + editor integrates with the existing diff panel

- -
-

Approach A: Unified Tabbed Right Panel (Recommended)

-

- Single right panel with tab bar. Diff and File Explorer are tabs. Each tab preserves state when - switching. Same responsive behavior (inline sidebar on wide, sheet on narrow). -

-
- -
-
-
File Explorer Tab Active
-
- -
-
- 📁 Files -
-
- ± Diff -
-
- - -
- -
- -
- -
- - -
-
- - 📂 - apps -
-
- - 📂 - server -
-
- - 📂 - src -
-
-    - 📄 - package.json -
-
- - 📂 - web -
-
- - 📂 - src -
-
-    - ⚙️ - tsconfig.json -
-
- - 📂 - packages -
-
-    - 📄 - package.json -
-
-    - 📝 - README.md - M -
-
-
- - -
- -
-
- ⚙️ tsconfig.json - -
-
- 📄 package.json - -
-
- -
- apps > web > tsconfig.json -
- -
-
- 1{ -
-
- 2  "compilerOptions": { -
-
- 3    "strict": true, -
-
- 4    "target": "ES2020", -
-
- 5    "module": "ES2020" -
-
- 6  } -
-
- 7} -
-
-
-
-
-
- -
-
Diff Tab Active (same panel, different tab)
-
- -
-
- 📁 Files -
-
- ± Diff -
-
- - -
-
-
Turn #3 Changes
-
- src/index.ts +12 -3 - README.md +5 -2 -
-
-
src/index.ts
-
-
- 10 - const app = express(); -
-
- 11 - + app.use(cors()); -
-
- 12 - + app.use(helmet()); -
-
- 13 - app.listen(3000); -
-
-
-
-
-
- -
-

Key Design Points

-
    -
  • - Tab bar at panel top — switches between Files and Diff views, URL-driven - state -
  • -
  • - File tree + editor split — tree on left, CodeMirror editor on right, - resizable divider -
  • -
  • - Multiple editor tabs — open files shown as tabs with close buttons and - unsaved indicators -
  • -
  • - State preserved — switching tabs keeps scroll position, expanded folders, - open files -
  • -
  • - Same responsive behavior — inline sidebar on wide screens, sheet overlay on - narrow -
  • -
-
diff --git a/.superpowers/brainstorm/209025-1776951894/state/server-stopped b/.superpowers/brainstorm/209025-1776951894/state/server-stopped deleted file mode 100644 index f739585b..00000000 --- a/.superpowers/brainstorm/209025-1776951894/state/server-stopped +++ /dev/null @@ -1 +0,0 @@ -{"reason":"owner process exited","timestamp":1776955134285} diff --git a/.superpowers/brainstorm/209025-1776951894/state/server.pid b/.superpowers/brainstorm/209025-1776951894/state/server.pid deleted file mode 100644 index 5e5112c8..00000000 --- a/.superpowers/brainstorm/209025-1776951894/state/server.pid +++ /dev/null @@ -1 +0,0 @@ -209033 diff --git a/docs/superpowers/plans/2026-04-23-file-explorer.md b/docs/superpowers/plans/2026-04-23-file-explorer.md deleted file mode 100644 index 6d73631c..00000000 --- a/docs/superpowers/plans/2026-04-23-file-explorer.md +++ /dev/null @@ -1,3191 +0,0 @@ -# File Explorer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a file explorer tab to the right panel — a tree browser + CodeMirror editor — alongside the existing diff tab, with full CRUD, git status, and mention integration. - -**Architecture:** Refactor the current `DiffPanelInlineSidebar` into a generic `RightPanel` with a tab bar (Files | Diff). The Files tab renders a split-pane file tree + CodeMirror editor. New filesystem/git RPCs are added to `packages/contracts` and served from `apps/server`. The mention system is extended to support `@file:L10-L20` line-range references. - -**Tech Stack:** React 19, TanStack Router (URL state), CodeMirror 6, Effect Schema (contracts), custom `` component (resize), `vscode-icons.ts` (file icons), Lexical (mentions). - ---- - -### Task 1: New Filesystem & Git Status RPC Contracts - -**Files:** - -- Modify: `packages/contracts/src/filesystem.ts` -- Modify: `packages/contracts/src/rpc.ts` -- Modify: `packages/contracts/src/index.ts` -- Test: `packages/contracts/src/filesystem.test.ts` - -This task adds the new schemas and RPC definitions for `filesystem.readFile`, `filesystem.listDirectory`, `filesystem.rename`, `filesystem.delete`, `filesystem.createDirectory`, and `git.status`. - -- [ ] **Step 1: Write tests for new filesystem schemas** - -Create `packages/contracts/src/filesystem.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; -import { - FilesystemReadFileInput, - FilesystemReadFileResult, - FilesystemListDirectoryInput, - FilesystemListDirectoryResult, - FilesystemRenameInput, - FilesystemDeleteInput, - FilesystemCreateDirectoryInput, - FilesystemMutationResult, - GitFileStatus, - GitFileStatusResult, -} from "./filesystem.ts"; - -describe("FilesystemReadFileInput", () => { - it("accepts valid input", () => { - const result = Schema.decodeUnknownSync(FilesystemReadFileInput)({ - cwd: "/workspace", - relativePath: "src/index.ts", - }); - expect(result.cwd).toBe("/workspace"); - expect(result.relativePath).toBe("src/index.ts"); - }); - - it("rejects empty path", () => { - expect(() => - Schema.decodeUnknownSync(FilesystemReadFileInput)({ - cwd: "/workspace", - relativePath: "", - }), - ).toThrow(); - }); -}); - -describe("FilesystemListDirectoryInput", () => { - it("accepts valid input", () => { - const result = Schema.decodeUnknownSync(FilesystemListDirectoryInput)({ - cwd: "/workspace", - relativePath: "src", - }); - expect(result.relativePath).toBe("src"); - }); - - it("accepts root path", () => { - const result = Schema.decodeUnknownSync(FilesystemListDirectoryInput)({ - cwd: "/workspace", - relativePath: ".", - }); - expect(result.relativePath).toBe("."); - }); -}); - -describe("FilesystemListDirectoryResult", () => { - it("accepts valid entries", () => { - const result = Schema.decodeUnknownSync(FilesystemListDirectoryResult)({ - entries: [ - { name: "index.ts", kind: "file", relativePath: "src/index.ts" }, - { name: "components", kind: "directory", relativePath: "src/components" }, - ], - }); - expect(result.entries).toHaveLength(2); - expect(result.entries[0]!.kind).toBe("file"); - }); -}); - -describe("FilesystemRenameInput", () => { - it("accepts valid input", () => { - const result = Schema.decodeUnknownSync(FilesystemRenameInput)({ - cwd: "/workspace", - oldRelativePath: "src/old.ts", - newRelativePath: "src/new.ts", - }); - expect(result.oldRelativePath).toBe("src/old.ts"); - }); -}); - -describe("GitFileStatusResult", () => { - it("accepts valid git status output", () => { - const result = Schema.decodeUnknownSync(GitFileStatusResult)({ - files: [ - { relativePath: "src/index.ts", status: "modified" }, - { relativePath: "src/new.ts", status: "added" }, - ], - }); - expect(result.files).toHaveLength(2); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `bun run test packages/contracts/src/filesystem.test.ts` -Expected: FAIL — schemas not yet defined. - -- [ ] **Step 3: Add new schemas to `packages/contracts/src/filesystem.ts`** - -Add after the existing `FilesystemBrowseError` class: - -```ts -// --- File Explorer RPCs --- - -const FILESYSTEM_RELATIVE_PATH_MAX_LENGTH = 512; - -export const FilesystemReadFileInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - relativePath: TrimmedNonEmptyString.check( - Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), - ), -}); -export type FilesystemReadFileInput = typeof FilesystemReadFileInput.Type; - -export const FilesystemReadFileResult = Schema.Struct({ - content: Schema.String, - encoding: Schema.Literals(["utf-8", "base64"]), -}); -export type FilesystemReadFileResult = typeof FilesystemReadFileResult.Type; - -export class FilesystemReadFileError extends Schema.TaggedErrorClass()( - "FilesystemReadFileError", - { - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect), - }, -) {} - -export const FilesystemListDirectoryInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - relativePath: TrimmedNonEmptyString.check( - Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), - ), -}); -export type FilesystemListDirectoryInput = typeof FilesystemListDirectoryInput.Type; - -const DirectoryEntryKind = Schema.Literals(["file", "directory"]); - -export const DirectoryEntry = Schema.Struct({ - name: TrimmedNonEmptyString, - kind: DirectoryEntryKind, - relativePath: TrimmedNonEmptyString, -}); -export type DirectoryEntry = typeof DirectoryEntry.Type; - -export const FilesystemListDirectoryResult = Schema.Struct({ - entries: Schema.Array(DirectoryEntry), -}); -export type FilesystemListDirectoryResult = typeof FilesystemListDirectoryResult.Type; - -export class FilesystemListDirectoryError extends Schema.TaggedErrorClass()( - "FilesystemListDirectoryError", - { - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect), - }, -) {} - -export const FilesystemRenameInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - oldRelativePath: TrimmedNonEmptyString.check( - Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), - ), - newRelativePath: TrimmedNonEmptyString.check( - Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), - ), -}); -export type FilesystemRenameInput = typeof FilesystemRenameInput.Type; - -export const FilesystemDeleteInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - relativePath: TrimmedNonEmptyString.check( - Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), - ), -}); -export type FilesystemDeleteInput = typeof FilesystemDeleteInput.Type; - -export const FilesystemCreateDirectoryInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - relativePath: TrimmedNonEmptyString.check( - Schema.isMaxLength(FILESYSTEM_RELATIVE_PATH_MAX_LENGTH), - ), -}); -export type FilesystemCreateDirectoryInput = typeof FilesystemCreateDirectoryInput.Type; - -export const FilesystemMutationResult = Schema.Struct({ - success: Schema.Boolean, -}); -export type FilesystemMutationResult = typeof FilesystemMutationResult.Type; - -export class FilesystemMutationError extends Schema.TaggedErrorClass()( - "FilesystemMutationError", - { - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect), - }, -) {} - -// --- Git Status for File Explorer --- - -export const GitFileStatus = Schema.Literals([ - "modified", - "added", - "deleted", - "untracked", - "renamed", - "conflicted", -]); -export type GitFileStatus = typeof GitFileStatus.Type; - -export const GitFileStatusEntry = Schema.Struct({ - relativePath: TrimmedNonEmptyString, - status: GitFileStatus, -}); -export type GitFileStatusEntry = typeof GitFileStatusEntry.Type; - -export const GitFileStatusResult = Schema.Struct({ - files: Schema.Array(GitFileStatusEntry), -}); -export type GitFileStatusResult = typeof GitFileStatusResult.Type; - -export class GitFileStatusError extends Schema.TaggedErrorClass()( - "GitFileStatusError", - { - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect), - }, -) {} -``` - -- [ ] **Step 4: Add RPC definitions to `packages/contracts/src/rpc.ts`** - -Add imports at the top of `rpc.ts`: - -```ts -import { - FilesystemReadFileInput, - FilesystemReadFileResult, - FilesystemReadFileError, - FilesystemListDirectoryInput, - FilesystemListDirectoryResult, - FilesystemListDirectoryError, - FilesystemRenameInput, - FilesystemDeleteInput, - FilesystemCreateDirectoryInput, - FilesystemMutationResult, - FilesystemMutationError, - GitFileStatusResult, - GitFileStatusError, -} from "./filesystem.ts"; -``` - -Add to the `WS_METHODS` object: - -```ts - // Filesystem methods (existing) - filesystemBrowse: "filesystem.browse", - - // Filesystem methods (file explorer) - filesystemReadFile: "filesystem.readFile", - filesystemListDirectory: "filesystem.listDirectory", - filesystemRename: "filesystem.rename", - filesystemDelete: "filesystem.delete", - filesystemCreateDirectory: "filesystem.createDirectory", - - // Git methods (existing stay as-is) - ... - // Git methods (file explorer) - gitFileStatus: "git.fileStatus", -``` - -Add the Rpc definitions after the existing `WsFilesystemBrowseRpc`: - -```ts -export const WsFilesystemReadFileRpc = Rpc.make(WS_METHODS.filesystemReadFile, { - payload: FilesystemReadFileInput, - success: FilesystemReadFileResult, - error: FilesystemReadFileError, -}); - -export const WsFilesystemListDirectoryRpc = Rpc.make(WS_METHODS.filesystemListDirectory, { - payload: FilesystemListDirectoryInput, - success: FilesystemListDirectoryResult, - error: FilesystemListDirectoryError, -}); - -export const WsFilesystemRenameRpc = Rpc.make(WS_METHODS.filesystemRename, { - payload: FilesystemRenameInput, - success: FilesystemMutationResult, - error: FilesystemMutationError, -}); - -export const WsFilesystemDeleteRpc = Rpc.make(WS_METHODS.filesystemDelete, { - payload: FilesystemDeleteInput, - success: FilesystemMutationResult, - error: FilesystemMutationError, -}); - -export const WsFilesystemCreateDirectoryRpc = Rpc.make(WS_METHODS.filesystemCreateDirectory, { - payload: FilesystemCreateDirectoryInput, - success: FilesystemMutationResult, - error: FilesystemMutationError, -}); - -export const WsGitFileStatusRpc = Rpc.make(WS_METHODS.gitFileStatus, { - payload: GitStatusInput, - success: GitFileStatusResult, - error: GitFileStatusError, -}); -``` - -Add all six new RPCs to the `WsRpcGroup` call: - -```ts -export const WsRpcGroup = RpcGroup.make( - // ...existing RPCs... - WsFilesystemReadFileRpc, - WsFilesystemListDirectoryRpc, - WsFilesystemRenameRpc, - WsFilesystemDeleteRpc, - WsFilesystemCreateDirectoryRpc, - WsGitFileStatusRpc, - // ...rest... -); -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `bun run test packages/contracts/src/filesystem.test.ts` -Expected: PASS - -- [ ] **Step 6: Run typecheck** - -Run: `bun typecheck` -Expected: Type errors in `apps/server/src/ws.ts` and `apps/web/src/rpc/wsRpcClient.ts` (new RPCs not yet handled). These are expected and will be resolved in subsequent tasks. The contracts package itself should type-check cleanly. - -- [ ] **Step 7: Commit** - -```bash -git add packages/contracts/src/filesystem.ts packages/contracts/src/filesystem.test.ts packages/contracts/src/rpc.ts -git commit -m "feat(contracts): add filesystem CRUD and git.fileStatus RPC schemas" -``` - ---- - -### Task 2: Server-Side Filesystem Service - -**Files:** - -- Create: `apps/server/src/workspace/Services/WorkspaceFileExplorer.ts` -- Create: `apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts` -- Create: `apps/server/src/workspace/Layers/WorkspaceFileExplorer.test.ts` - -This task adds the Effect service that implements `readFile`, `listDirectory`, `rename`, `delete`, and `createDirectory` with workspace-root path containment. - -- [ ] **Step 1: Write the service contract** - -Create `apps/server/src/workspace/Services/WorkspaceFileExplorer.ts`: - -```ts -/** - * WorkspaceFileExplorer - Effect service contract for workspace file explorer operations. - * - * Owns read, list, rename, delete, and create-directory operations scoped to - * workspace roots. All paths are validated to stay within the workspace root. - * - * @module WorkspaceFileExplorer - */ -import { Context } from "effect"; -import type { Effect } from "effect"; - -import type { - FilesystemReadFileInput, - FilesystemReadFileResult, - FilesystemListDirectoryInput, - FilesystemListDirectoryResult, - FilesystemRenameInput, - FilesystemDeleteInput, - FilesystemCreateDirectoryInput, - FilesystemMutationResult, - GitFileStatusResult, -} from "@t3tools/contracts"; -import type { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts"; - -export class WorkspaceFileExplorerError extends Error { - readonly _tag = "WorkspaceFileExplorerError"; - constructor( - readonly operation: string, - readonly detail: string, - readonly cwd: string, - override readonly cause?: unknown, - ) { - super(`WorkspaceFileExplorer.${operation}: ${detail}`); - } -} - -export interface WorkspaceFileExplorerShape { - readonly readFile: ( - input: FilesystemReadFileInput, - ) => Effect.Effect< - FilesystemReadFileResult, - WorkspaceFileExplorerError | WorkspacePathOutsideRootError - >; - - readonly listDirectory: ( - input: FilesystemListDirectoryInput, - ) => Effect.Effect< - FilesystemListDirectoryResult, - WorkspaceFileExplorerError | WorkspacePathOutsideRootError - >; - - readonly rename: ( - input: FilesystemRenameInput, - ) => Effect.Effect< - FilesystemMutationResult, - WorkspaceFileExplorerError | WorkspacePathOutsideRootError - >; - - readonly delete: ( - input: FilesystemDeleteInput, - ) => Effect.Effect< - FilesystemMutationResult, - WorkspaceFileExplorerError | WorkspacePathOutsideRootError - >; - - readonly createDirectory: ( - input: FilesystemCreateDirectoryInput, - ) => Effect.Effect< - FilesystemMutationResult, - WorkspaceFileExplorerError | WorkspacePathOutsideRootError - >; - - readonly gitFileStatus: ( - cwd: string, - ) => Effect.Effect; -} - -export class WorkspaceFileExplorer extends Context.Service< - WorkspaceFileExplorer, - WorkspaceFileExplorerShape ->()("t3/workspace/Services/WorkspaceFileExplorer") {} -``` - -- [ ] **Step 2: Write the implementation layer** - -Create `apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts`: - -```ts -import { Effect, Layer } from "effect"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import * as childProcess from "node:child_process"; - -import type { - FilesystemReadFileInput, - FilesystemReadFileResult, - FilesystemListDirectoryInput, - FilesystemListDirectoryResult, - FilesystemRenameInput, - FilesystemDeleteInput, - FilesystemCreateDirectoryInput, - FilesystemMutationResult, - GitFileStatus, - GitFileStatusResult, -} from "@t3tools/contracts"; - -import { - WorkspaceFileExplorer, - WorkspaceFileExplorerError, -} from "../Services/WorkspaceFileExplorer.ts"; -import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; - -const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // 2 MB - -function execGitStatus(cwd: string): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile( - "git", - ["status", "--porcelain=v1", "-uall"], - { cwd, maxBuffer: 1024 * 1024, timeout: 10_000 }, - (error, stdout) => { - if (error) { - reject(error); - return; - } - resolve(stdout); - }, - ); - }); -} - -function parseGitStatusCode(code: string): GitFileStatus { - const trimmed = code.trim(); - if (trimmed === "M" || trimmed === "MM" || trimmed === "AM") return "modified"; - if (trimmed === "A") return "added"; - if (trimmed === "D") return "deleted"; - if (trimmed === "??" || trimmed === "?") return "untracked"; - if (trimmed.startsWith("R")) return "renamed"; - if (trimmed === "UU" || trimmed === "AA" || trimmed === "DD") return "conflicted"; - return "modified"; // fallback -} - -export const WorkspaceFileExplorerLive = Layer.effect( - WorkspaceFileExplorer, - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths; - - return WorkspaceFileExplorer.of({ - readFile: (input: FilesystemReadFileInput) => - Effect.gen(function* () { - const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); - const stats = yield* Effect.tryPromise({ - try: () => fs.stat(resolvedPath), - catch: (error) => - new WorkspaceFileExplorerError( - "readFile", - `File not found: ${input.relativePath}`, - input.cwd, - error, - ), - }); - - if (!stats.isFile()) { - return yield* Effect.fail( - new WorkspaceFileExplorerError( - "readFile", - `Not a file: ${input.relativePath}`, - input.cwd, - ), - ); - } - - if (stats.size > MAX_FILE_SIZE_BYTES) { - return yield* Effect.fail( - new WorkspaceFileExplorerError( - "readFile", - `File too large (${stats.size} bytes, max ${MAX_FILE_SIZE_BYTES})`, - input.cwd, - ), - ); - } - - const buffer = yield* Effect.tryPromise({ - try: () => fs.readFile(resolvedPath), - catch: (error) => - new WorkspaceFileExplorerError( - "readFile", - `Failed to read: ${input.relativePath}`, - input.cwd, - error, - ), - }); - - // Detect binary content by checking for null bytes in first 8KB - const sample = buffer.subarray(0, 8192); - const isBinary = sample.includes(0); - - const result: FilesystemReadFileResult = isBinary - ? { content: buffer.toString("base64"), encoding: "base64" as const } - : { content: buffer.toString("utf-8"), encoding: "utf-8" as const }; - - return result; - }), - - listDirectory: (input: FilesystemListDirectoryInput) => - Effect.gen(function* () { - const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); - const dirents = yield* Effect.tryPromise({ - try: () => fs.readdir(resolvedPath, { withFileTypes: true }), - catch: (error) => - new WorkspaceFileExplorerError( - "listDirectory", - `Failed to list: ${input.relativePath}`, - input.cwd, - error, - ), - }); - - const entries = dirents - .filter((d) => !d.name.startsWith(".")) - .map((d) => ({ - name: d.name, - kind: d.isDirectory() ? ("directory" as const) : ("file" as const), - relativePath: path.join(input.relativePath === "." ? "" : input.relativePath, d.name), - })) - .sort((a, b) => { - // Directories first, then alphabetical - if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - const result: FilesystemListDirectoryResult = { entries }; - return result; - }), - - rename: (input: FilesystemRenameInput) => - Effect.gen(function* () { - const oldResolved = yield* workspacePaths.resolve(input.cwd, input.oldRelativePath); - const newResolved = yield* workspacePaths.resolve(input.cwd, input.newRelativePath); - - // Ensure parent directory for new path exists - yield* Effect.tryPromise({ - try: () => fs.mkdir(path.dirname(newResolved), { recursive: true }), - catch: (error) => - new WorkspaceFileExplorerError( - "rename", - `Failed to create parent directory`, - input.cwd, - error, - ), - }); - - yield* Effect.tryPromise({ - try: () => fs.rename(oldResolved, newResolved), - catch: (error) => - new WorkspaceFileExplorerError( - "rename", - `Failed to rename ${input.oldRelativePath} to ${input.newRelativePath}`, - input.cwd, - error, - ), - }); - - const result: FilesystemMutationResult = { success: true }; - return result; - }), - - delete: (input: FilesystemDeleteInput) => - Effect.gen(function* () { - const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); - - yield* Effect.tryPromise({ - try: () => fs.rm(resolvedPath, { recursive: true }), - catch: (error) => - new WorkspaceFileExplorerError( - "delete", - `Failed to delete: ${input.relativePath}`, - input.cwd, - error, - ), - }); - - const result: FilesystemMutationResult = { success: true }; - return result; - }), - - createDirectory: (input: FilesystemCreateDirectoryInput) => - Effect.gen(function* () { - const resolvedPath = yield* workspacePaths.resolve(input.cwd, input.relativePath); - - yield* Effect.tryPromise({ - try: () => fs.mkdir(resolvedPath, { recursive: true }), - catch: (error) => - new WorkspaceFileExplorerError( - "createDirectory", - `Failed to create directory: ${input.relativePath}`, - input.cwd, - error, - ), - }); - - const result: FilesystemMutationResult = { success: true }; - return result; - }), - - gitFileStatus: (cwd: string) => - Effect.gen(function* () { - const stdout = yield* Effect.tryPromise({ - try: () => execGitStatus(cwd), - catch: (error) => - new WorkspaceFileExplorerError( - "gitFileStatus", - "Failed to run git status", - cwd, - error, - ), - }); - - const files = stdout - .split("\n") - .filter((line) => line.length >= 4) - .map((line) => ({ - relativePath: line.slice(3).trim(), - status: parseGitStatusCode(line.slice(0, 2)), - })); - - const result: GitFileStatusResult = { files }; - return result; - }), - }); - }), -); -``` - -- [ ] **Step 3: Read `WorkspacePaths` service to verify the `resolve` method signature** - -The implementation above uses `workspacePaths.resolve(cwd, relativePath)`. Before proceeding, verify that `WorkspacePaths` has a `.resolve()` method. Read `apps/server/src/workspace/Services/WorkspacePaths.ts` and check the interface. If the method is named differently (e.g., `resolvePath` or uses a different signature), update the `WorkspaceFileExplorer` layer implementation to match. - -- [ ] **Step 4: Run typecheck** - -Run: `bun typecheck` -Expected: Server-side types should be clean for the new service files. WebSocket handler errors remain (addressed in Task 3). - -- [ ] **Step 5: Commit** - -```bash -git add apps/server/src/workspace/Services/WorkspaceFileExplorer.ts apps/server/src/workspace/Layers/WorkspaceFileExplorer.ts -git commit -m "feat(server): add WorkspaceFileExplorer service for filesystem CRUD and git status" -``` - ---- - -### Task 3: Wire Server-Side RPC Handlers - -**Files:** - -- Modify: `apps/server/src/ws.ts` - -This task wires the new RPCs to the `WorkspaceFileExplorer` service in the WebSocket handler map. - -- [ ] **Step 1: Add import for `WorkspaceFileExplorer` at top of `ws.ts`** - -Add near the other workspace imports: - -```ts -import { WorkspaceFileExplorer } from "./workspace/Services/WorkspaceFileExplorer.ts"; -``` - -Also add the new error/type imports from contracts: - -```ts -import { - // ...existing imports... - FilesystemReadFileError, - FilesystemListDirectoryError, - FilesystemMutationError, - GitFileStatusError, -} from "@t3tools/contracts"; -``` - -- [ ] **Step 2: Yield the `WorkspaceFileExplorer` service in the handler setup** - -In the `Effect.gen` function that creates the handler map, add: - -```ts -const workspaceFileExplorer = yield * WorkspaceFileExplorer; -``` - -This should be near the existing `const workspaceEntries = yield* WorkspaceEntries;` line. - -- [ ] **Step 3: Add RPC handlers to the handler map** - -Add these entries to the handler record (alongside the existing `[WS_METHODS.filesystemBrowse]` handler): - -```ts -[WS_METHODS.filesystemReadFile]: (input) => - observeRpcEffect( - WS_METHODS.filesystemReadFile, - workspaceFileExplorer.readFile(input).pipe( - Effect.mapError( - (cause) => - new FilesystemReadFileError({ - message: cause instanceof Error ? cause.message : "Failed to read file", - cause, - }), - ), - ), - { "rpc.aggregate": "workspace" }, - ), -[WS_METHODS.filesystemListDirectory]: (input) => - observeRpcEffect( - WS_METHODS.filesystemListDirectory, - workspaceFileExplorer.listDirectory(input).pipe( - Effect.mapError( - (cause) => - new FilesystemListDirectoryError({ - message: cause instanceof Error ? cause.message : "Failed to list directory", - cause, - }), - ), - ), - { "rpc.aggregate": "workspace" }, - ), -[WS_METHODS.filesystemRename]: (input) => - observeRpcEffect( - WS_METHODS.filesystemRename, - workspaceFileExplorer.rename(input).pipe( - Effect.mapError( - (cause) => - new FilesystemMutationError({ - message: cause instanceof Error ? cause.message : "Failed to rename", - cause, - }), - ), - ), - { "rpc.aggregate": "workspace" }, - ), -[WS_METHODS.filesystemDelete]: (input) => - observeRpcEffect( - WS_METHODS.filesystemDelete, - workspaceFileExplorer.delete(input).pipe( - Effect.mapError( - (cause) => - new FilesystemMutationError({ - message: cause instanceof Error ? cause.message : "Failed to delete", - cause, - }), - ), - ), - { "rpc.aggregate": "workspace" }, - ), -[WS_METHODS.filesystemCreateDirectory]: (input) => - observeRpcEffect( - WS_METHODS.filesystemCreateDirectory, - workspaceFileExplorer.createDirectory(input).pipe( - Effect.mapError( - (cause) => - new FilesystemMutationError({ - message: cause instanceof Error ? cause.message : "Failed to create directory", - cause, - }), - ), - ), - { "rpc.aggregate": "workspace" }, - ), -[WS_METHODS.gitFileStatus]: (input) => - observeRpcEffect( - WS_METHODS.gitFileStatus, - workspaceFileExplorer.gitFileStatus(input.cwd).pipe( - Effect.mapError( - (cause) => - new GitFileStatusError({ - message: cause instanceof Error ? cause.message : "Failed to get git status", - cause, - }), - ), - ), - { "rpc.aggregate": "git" }, - ), -``` - -- [ ] **Step 4: Provide the `WorkspaceFileExplorer` layer in the server composition** - -Find where `WorkspaceEntries` layer is provided to the server (likely in the server's main composition file or in `ws.ts`'s layer setup). Add `WorkspaceFileExplorerLive` to the layer composition. - -Import in the composition file: - -```ts -import { WorkspaceFileExplorerLive } from "./workspace/Layers/WorkspaceFileExplorer.ts"; -``` - -Add to the layer: - -```ts -Layer.merge(WorkspaceFileExplorerLive); -``` - -- [ ] **Step 5: Run typecheck** - -Run: `bun typecheck` -Expected: Server should now compile cleanly. Client may still have type errors (resolved in Task 4). - -- [ ] **Step 6: Commit** - -```bash -git add apps/server/src/ws.ts -git commit -m "feat(server): wire filesystem and git.fileStatus RPC handlers" -``` - ---- - -### Task 4: Client-Side RPC Wiring - -**Files:** - -- Modify: `apps/web/src/rpc/wsRpcClient.ts` -- Modify: `apps/web/src/environmentApi.ts` -- Modify: `packages/contracts/src/ipc.ts` - -This task exposes the new RPCs on the client-side `WsRpcClient` and `EnvironmentApi`. - -- [ ] **Step 1: Add new methods to `WsRpcClient` interface in `wsRpcClient.ts`** - -Extend the `filesystem` section of the `WsRpcClient` interface: - -```ts -readonly filesystem: { - readonly browse: RpcUnaryMethod; - readonly readFile: RpcUnaryMethod; - readonly listDirectory: RpcUnaryMethod; - readonly rename: RpcUnaryMethod; - readonly delete: RpcUnaryMethod; - readonly createDirectory: RpcUnaryMethod; -}; -``` - -Add to the `git` section: - -```ts -readonly fileStatus: RpcUnaryMethod; -``` - -- [ ] **Step 2: Add implementations in `createWsRpcClient`** - -In the `filesystem` section of the returned object: - -```ts -filesystem: { - browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), - readFile: (input) => - transport.request((client) => client[WS_METHODS.filesystemReadFile](input)), - listDirectory: (input) => - transport.request((client) => client[WS_METHODS.filesystemListDirectory](input)), - rename: (input) => - transport.request((client) => client[WS_METHODS.filesystemRename](input)), - delete: (input) => - transport.request((client) => client[WS_METHODS.filesystemDelete](input)), - createDirectory: (input) => - transport.request((client) => client[WS_METHODS.filesystemCreateDirectory](input)), -}, -``` - -In the `git` section, add: - -```ts -fileStatus: (input) => - transport.request((client) => client[WS_METHODS.gitFileStatus](input)), -``` - -- [ ] **Step 3: Extend `EnvironmentApi` interface in `packages/contracts/src/ipc.ts`** - -Add to the `filesystem` section of `EnvironmentApi`: - -```ts -filesystem: { - browse: (input: FilesystemBrowseInput) => Promise; - readFile: (input: FilesystemReadFileInput) => Promise; - listDirectory: (input: FilesystemListDirectoryInput) => Promise; - rename: (input: FilesystemRenameInput) => Promise; - delete: (input: FilesystemDeleteInput) => Promise; - createDirectory: (input: FilesystemCreateDirectoryInput) => Promise; -}; -``` - -Add to the `git` section: - -```ts -fileStatus: (input: GitStatusInput) => Promise; -``` - -Add the new type imports at the top of `ipc.ts`. - -- [ ] **Step 4: Wire the new methods in `environmentApi.ts`** - -Update the `filesystem` and `git` sections in `createEnvironmentApi`: - -```ts -filesystem: { - browse: rpcClient.filesystem.browse, - readFile: rpcClient.filesystem.readFile, - listDirectory: rpcClient.filesystem.listDirectory, - rename: rpcClient.filesystem.rename, - delete: rpcClient.filesystem.delete, - createDirectory: rpcClient.filesystem.createDirectory, -}, -``` - -In `git`, add: - -```ts -fileStatus: rpcClient.git.fileStatus, -``` - -- [ ] **Step 5: Run typecheck and lint** - -Run: `bun typecheck && bun lint` -Expected: PASS — full client/server type chain should be clean. - -- [ ] **Step 6: Commit** - -```bash -git add apps/web/src/rpc/wsRpcClient.ts apps/web/src/environmentApi.ts packages/contracts/src/ipc.ts -git commit -m "feat(web): wire filesystem CRUD and git.fileStatus RPC to client" -``` - ---- - -### Task 5: URL State — Replace `?diff=1` with `?panel=files|diff` - -**Files:** - -- Rename: `apps/web/src/diffRouteSearch.ts` → `apps/web/src/panelRouteSearch.ts` -- Modify: `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` -- Modify: any other files importing from `diffRouteSearch.ts` - -This task replaces the `?diff=1` URL param with `?panel=files` / `?panel=diff`. - -- [ ] **Step 1: Create `panelRouteSearch.ts` to replace `diffRouteSearch.ts`** - -Create `apps/web/src/panelRouteSearch.ts`: - -```ts -import { TurnId } from "@t3tools/contracts"; - -export type PanelTab = "files" | "diff"; - -export interface PanelRouteSearch { - panel?: PanelTab | undefined; - diffTurnId?: TurnId | undefined; - diffFilePath?: string | undefined; -} - -function isPanelTabValue(value: unknown): value is PanelTab { - return value === "files" || value === "diff"; -} - -function normalizeSearchString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function stripPanelSearchParams>( - params: T, -): Omit { - const { panel: _panel, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; -} - -/** - * Backward-compatible parse: also accepts legacy `?diff=1` and normalizes to `panel=diff`. - */ -export function parsePanelRouteSearch(search: Record): PanelRouteSearch { - // Support new `?panel=files` / `?panel=diff` - let panel: PanelTab | undefined; - if (isPanelTabValue(search.panel)) { - panel = search.panel; - } else if (search.diff === "1" || search.diff === 1 || search.diff === true) { - // Backward compat: legacy `?diff=1` → `panel=diff` - panel = "diff"; - } - - const isDiff = panel === "diff"; - const diffTurnIdRaw = isDiff ? normalizeSearchString(search.diffTurnId) : undefined; - const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; - const diffFilePath = - isDiff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; - - return { - ...(panel ? { panel } : {}), - ...(diffTurnId ? { diffTurnId } : {}), - ...(diffFilePath ? { diffFilePath } : {}), - }; -} -``` - -- [ ] **Step 2: Write tests for the new panel route search** - -Create `apps/web/src/panelRouteSearch.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import { parsePanelRouteSearch, stripPanelSearchParams } from "./panelRouteSearch"; - -describe("parsePanelRouteSearch", () => { - it("parses panel=files", () => { - expect(parsePanelRouteSearch({ panel: "files" })).toEqual({ panel: "files" }); - }); - - it("parses panel=diff", () => { - expect(parsePanelRouteSearch({ panel: "diff" })).toEqual({ panel: "diff" }); - }); - - it("returns empty for no panel", () => { - expect(parsePanelRouteSearch({})).toEqual({}); - }); - - it("backward compat: diff=1 → panel=diff", () => { - expect(parsePanelRouteSearch({ diff: "1" })).toEqual({ panel: "diff" }); - }); - - it("preserves diffTurnId when panel=diff", () => { - const result = parsePanelRouteSearch({ panel: "diff", diffTurnId: "turn-1" }); - expect(result.panel).toBe("diff"); - expect(result.diffTurnId).toBeDefined(); - }); - - it("ignores diffTurnId when panel=files", () => { - const result = parsePanelRouteSearch({ panel: "files", diffTurnId: "turn-1" }); - expect(result.diffTurnId).toBeUndefined(); - }); - - it("rejects invalid panel values", () => { - expect(parsePanelRouteSearch({ panel: "invalid" })).toEqual({}); - }); -}); - -describe("stripPanelSearchParams", () => { - it("removes panel-related keys", () => { - const result = stripPanelSearchParams({ - panel: "diff", - diffTurnId: "x", - diffFilePath: "y", - other: "keep", - }); - expect(result).toEqual({ other: "keep" }); - }); -}); -``` - -- [ ] **Step 3: Run tests** - -Run: `bun run test apps/web/src/panelRouteSearch.test.ts` -Expected: PASS - -- [ ] **Step 4: Update all imports from `diffRouteSearch` to `panelRouteSearch`** - -Search the codebase for all files importing from `diffRouteSearch` and update them. Key files: - -- `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` — change import and update usage -- Any other component files that import `DiffRouteSearch`, `parseDiffRouteSearch`, or `stripDiffSearchParams` - -In the route file, update: - -```ts -// Before -import { - type DiffRouteSearch, - parseDiffRouteSearch, - stripDiffSearchParams, -} from "../diffRouteSearch"; - -// After -import { - type PanelRouteSearch, - type PanelTab, - parsePanelRouteSearch, - stripPanelSearchParams, -} from "../panelRouteSearch"; -``` - -Update route `validateSearch`: - -```ts -// Before -validateSearch: (search) => parseDiffRouteSearch(search), -search: { middlewares: [retainSearchParams(["diff"])] }, - -// After -validateSearch: (search) => parsePanelRouteSearch(search), -search: { middlewares: [retainSearchParams(["panel"])] }, -``` - -Update the `diffOpen` / navigation logic in `ChatThreadRouteView`: - -```ts -// Before -const diffOpen = search.diff === "1"; - -// After -const panelTab = search.panel; // "files" | "diff" | undefined -const diffOpen = panelTab === "diff"; -const filesOpen = panelTab === "files"; -const panelOpen = panelTab !== undefined; -``` - -Update `closeDiff`: - -```ts -// Before -search: { - diff: undefined; -} - -// After -search: { - panel: undefined; -} -``` - -Update `openDiff`: - -```ts -// Before -search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }); - -// After -search: (previous) => ({ ...stripPanelSearchParams(previous), panel: "diff" as const }); -``` - -Add `openFiles` and `closePanel` navigation helpers: - -```ts -const openFiles = useCallback(() => { - if (!threadRef) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: (previous) => ({ - ...stripPanelSearchParams(previous), - panel: "files" as const, - }), - }); -}, [navigate, threadRef]); - -const closePanel = useCallback(() => { - if (!threadRef) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: { panel: undefined }, - }); -}, [navigate, threadRef]); -``` - -- [ ] **Step 5: Update ChatView.tsx diff toggle references** - -In `apps/web/src/components/ChatView.tsx`, find where `rawSearch.diff === "1"` is referenced and update to `rawSearch.panel === "diff"`. Also update the toggle function to use `panel: "diff"` / `panel: undefined`. - -- [ ] **Step 6: Delete the old `diffRouteSearch.ts` file** - -Once all references are updated, delete `apps/web/src/diffRouteSearch.ts`. - -- [ ] **Step 7: Run typecheck, lint, and tests** - -Run: `bun typecheck && bun lint && bun run test` -Expected: PASS - -- [ ] **Step 8: Commit** - -```bash -git add -A -git commit -m "refactor(web): replace ?diff=1 with ?panel=files|diff URL state" -``` - ---- - -### Task 6: Refactor DiffPanelInlineSidebar → RightPanel with Tab Bar - -**Files:** - -- Create: `apps/web/src/components/RightPanel.tsx` -- Create: `apps/web/src/components/RightPanelTabBar.tsx` -- Modify: `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` -- Modify: `apps/web/src/components/RightPanelSheet.tsx` -- Modify: `apps/web/src/rightPanelLayout.ts` - -This task extracts the inline sidebar into a generic `RightPanel` that renders a tab bar and switches between Files/Diff content. - -- [ ] **Step 1: Create `RightPanelTabBar.tsx`** - -Create `apps/web/src/components/RightPanelTabBar.tsx`: - -```tsx -import type { PanelTab } from "../panelRouteSearch"; -import { FilesIcon, GitCompareArrowsIcon } from "lucide-react"; - -interface RightPanelTabBarProps { - activeTab: PanelTab; - onTabChange: (tab: PanelTab) => void; -} - -export function RightPanelTabBar({ activeTab, onTabChange }: RightPanelTabBarProps) { - return ( -
- - -
- ); -} -``` - -- [ ] **Step 2: Create `RightPanel.tsx`** - -Create `apps/web/src/components/RightPanel.tsx`: - -```tsx -import { useCallback, type ReactNode } from "react"; -import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; -import type { PanelTab } from "../panelRouteSearch"; -import { RightPanelTabBar } from "./RightPanelTabBar"; - -const SIDEBAR_WIDTH_STORAGE_KEY = "chat_right_panel_width"; - -// Files tab is wider because it has a tree + editor split -const DEFAULT_WIDTH_BY_TAB: Record = { - files: "clamp(36rem,52vw,56rem)", - diff: "clamp(28rem,48vw,44rem)", -}; - -const MIN_WIDTH_BY_TAB: Record = { - files: 36 * 16, // 576px - diff: 26 * 16, // 416px -}; - -const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; - -interface RightPanelProps { - activeTab: PanelTab; - open: boolean; - onTabChange: (tab: PanelTab) => void; - onClose: () => void; - onOpen: () => void; - children: ReactNode; -} - -export function RightPanel({ - activeTab, - open, - onTabChange, - onClose, - onOpen, - children, -}: RightPanelProps) { - const onOpenChange = useCallback( - (nextOpen: boolean) => { - if (nextOpen) { - onOpen(); - return; - } - onClose(); - }, - [onClose, onOpen], - ); - - const shouldAcceptWidth = useCallback( - ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { - const composerForm = document.querySelector("[data-chat-composer-form='true']"); - if (!composerForm) return true; - const composerViewport = composerForm.parentElement; - if (!composerViewport) return true; - const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); - wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); - - const viewportStyle = window.getComputedStyle(composerViewport); - const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; - const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; - const viewportContentWidth = Math.max( - 0, - composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, - ); - const formRect = composerForm.getBoundingClientRect(); - const composerFooter = composerForm.querySelector( - "[data-chat-composer-footer='true']", - ); - const composerRightActions = composerForm.querySelector( - "[data-chat-composer-actions='right']", - ); - const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; - const composerFooterGap = composerFooter - ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || - Number.parseFloat(window.getComputedStyle(composerFooter).gap) || - 0 - : 0; - const minimumComposerWidth = - COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; - const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; - const overflowsViewport = formRect.width > viewportContentWidth + 0.5; - const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; - - if (previousSidebarWidth.length > 0) { - wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); - } else { - wrapper.style.removeProperty("--sidebar-width"); - } - - return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; - }, - [], - ); - - return ( - - - - {children} - - - - ); -} -``` - -- [ ] **Step 3: Update the route file to use `RightPanel`** - -In `apps/web/src/routes/_chat.$environmentId.$threadId.tsx`, replace `DiffPanelInlineSidebar` with the new `RightPanel`: - -```tsx -import { RightPanel } from "../components/RightPanel"; -import { RightPanelTabBar } from "../components/RightPanelTabBar"; -``` - -Remove the `DiffPanelInlineSidebar` component definition entirely. - -Update the inline-sidebar rendering branch of `ChatThreadRouteView`: - -```tsx -if (!shouldUseDiffSheet) { - return ( - <> - - - - { - if (tab === "diff") { - markDiffOpened(); - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: (prev) => ({ ...stripPanelSearchParams(prev), panel: tab }), - }); - }} - onClose={closePanel} - onOpen={openDiff} - > - {/* Diff tab content: stays mounted once opened, hidden when not active */} -
- {shouldRenderDiffContent ? : null} -
- {/* Files tab content: placeholder for now, implemented in Task 8 */} -
-
- File explorer coming soon -
-
-
- - ); -} -``` - -- [ ] **Step 4: Update the sheet branch for narrow viewports** - -Update the sheet rendering branch to include the tab bar: - -```tsx -return ( - <> - - - - - { - if (tab === "diff") markDiffOpened(); - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: (prev) => ({ ...stripPanelSearchParams(prev), panel: tab }), - }); - }} - /> -
- {shouldRenderDiffContent ? : null} -
-
-
- File explorer coming soon -
-
-
- -); -``` - -- [ ] **Step 5: Clean up unused imports and old constants** - -Remove the now-unused constants from the route file: - -```ts -// Remove these (now in RightPanel.tsx): -const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = ... -const DIFF_INLINE_DEFAULT_WIDTH = ... -const DIFF_INLINE_SIDEBAR_MIN_WIDTH = ... -const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = ... -``` - -- [ ] **Step 6: Run typecheck, lint, and format** - -Run: `bun typecheck && bun lint && bun fmt` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "refactor(web): extract RightPanel with tab bar from DiffPanelInlineSidebar" -``` - ---- - -### Task 7: File Tree Component (Left Pane) - -**Files:** - -- Create: `apps/web/src/components/file-explorer/FileTree.tsx` -- Create: `apps/web/src/components/file-explorer/FileTreeNode.tsx` -- Create: `apps/web/src/components/file-explorer/FileTreeFilter.tsx` -- Create: `apps/web/src/components/file-explorer/useFileTree.ts` -- Create: `apps/web/src/components/file-explorer/useGitFileStatus.ts` -- Create: `apps/web/src/components/file-explorer/types.ts` - -This task builds the expand-on-demand directory tree with git status badges and quick filter. - -- [ ] **Step 1: Create shared types** - -Create `apps/web/src/components/file-explorer/types.ts`: - -```ts -import type { DirectoryEntry, GitFileStatus } from "@t3tools/contracts"; - -export interface TreeNode { - readonly entry: DirectoryEntry; - readonly depth: number; - readonly isExpanded: boolean; - readonly isLoading: boolean; - readonly children: readonly TreeNode[] | null; // null = not loaded yet - readonly gitStatus?: GitFileStatus | undefined; -} - -export interface FileTreeState { - readonly rootPath: string; - readonly nodes: ReadonlyMap; - readonly expandedPaths: ReadonlySet; - readonly rootEntries: readonly DirectoryEntry[]; -} -``` - -- [ ] **Step 2: Create the `useFileTree` hook** - -Create `apps/web/src/components/file-explorer/useFileTree.ts`: - -```ts -import { useCallback, useRef, useState } from "react"; -import type { - DirectoryEntry, - EnvironmentId, - FilesystemListDirectoryResult, -} from "@t3tools/contracts"; -import { readEnvironmentApi } from "../../environmentApi"; - -interface UseFileTreeOptions { - environmentId: EnvironmentId; - cwd: string; -} - -interface ExpandedDir { - entries: readonly DirectoryEntry[]; - isLoading: boolean; -} - -export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { - const [expandedDirs, setExpandedDirs] = useState>(new Map()); - const [rootEntries, setRootEntries] = useState(null); - const [rootLoading, setRootLoading] = useState(false); - const loadedRef = useRef(new Set()); - - const loadDirectory = useCallback( - async (relativePath: string) => { - const api = readEnvironmentApi(environmentId); - if (!api) return; - - if (relativePath === ".") { - setRootLoading(true); - } else { - setExpandedDirs((prev) => { - const next = new Map(prev); - next.set(relativePath, { - entries: prev.get(relativePath)?.entries ?? [], - isLoading: true, - }); - return next; - }); - } - - try { - const result: FilesystemListDirectoryResult = await api.filesystem.listDirectory({ - cwd, - relativePath, - }); - - if (relativePath === ".") { - setRootEntries(result.entries); - setRootLoading(false); - } else { - setExpandedDirs((prev) => { - const next = new Map(prev); - next.set(relativePath, { entries: result.entries, isLoading: false }); - return next; - }); - } - loadedRef.current.add(relativePath); - } catch { - if (relativePath === ".") { - setRootLoading(false); - } else { - setExpandedDirs((prev) => { - const next = new Map(prev); - next.set(relativePath, { entries: [], isLoading: false }); - return next; - }); - } - } - }, - [environmentId, cwd], - ); - - const toggleExpand = useCallback( - (relativePath: string) => { - setExpandedDirs((prev) => { - if (prev.has(relativePath)) { - const next = new Map(prev); - next.delete(relativePath); - return next; - } - return prev; - }); - - // If not loaded yet, load it - if (!loadedRef.current.has(relativePath)) { - void loadDirectory(relativePath); - } else { - // Re-expand: just put it back - setExpandedDirs((prev) => { - if (prev.has(relativePath)) return prev; - // Re-load to get fresh data - void loadDirectory(relativePath); - return prev; - }); - } - }, - [loadDirectory], - ); - - const collapseDir = useCallback((relativePath: string) => { - setExpandedDirs((prev) => { - if (!prev.has(relativePath)) return prev; - const next = new Map(prev); - next.delete(relativePath); - return next; - }); - }, []); - - const expandDir = useCallback( - (relativePath: string) => { - if (!loadedRef.current.has(relativePath)) { - void loadDirectory(relativePath); - } else { - setExpandedDirs((prev) => { - if (prev.has(relativePath)) return prev; - void loadDirectory(relativePath); - return prev; - }); - } - }, - [loadDirectory], - ); - - const loadRoot = useCallback(() => { - void loadDirectory("."); - }, [loadDirectory]); - - return { - rootEntries, - rootLoading, - expandedDirs, - loadRoot, - toggleExpand, - expandDir, - collapseDir, - }; -} -``` - -- [ ] **Step 3: Create the `useGitFileStatus` hook** - -Create `apps/web/src/components/file-explorer/useGitFileStatus.ts`: - -```ts -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EnvironmentId, GitFileStatusResult, GitFileStatus } from "@t3tools/contracts"; -import { readEnvironmentApi } from "../../environmentApi"; - -const POLL_INTERVAL_MS = 3_000; - -interface UseGitFileStatusOptions { - environmentId: EnvironmentId; - cwd: string; - enabled: boolean; -} - -export function useGitFileStatus({ environmentId, cwd, enabled }: UseGitFileStatusOptions) { - const [statusMap, setStatusMap] = useState>(new Map()); - const timerRef = useRef | null>(null); - - const fetchStatus = useCallback(async () => { - const api = readEnvironmentApi(environmentId); - if (!api) return; - - try { - const result: GitFileStatusResult = await api.git.fileStatus({ cwd }); - const next = new Map(); - for (const file of result.files) { - next.set(file.relativePath, file.status); - } - setStatusMap(next); - } catch { - // Silently ignore — git status polling is best-effort - } - }, [environmentId, cwd]); - - useEffect(() => { - if (!enabled) return; - - void fetchStatus(); - timerRef.current = setInterval(() => void fetchStatus(), POLL_INTERVAL_MS); - - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, [enabled, fetchStatus]); - - return { statusMap, refresh: fetchStatus }; -} -``` - -- [ ] **Step 4: Create `FileTreeFilter.tsx`** - -Create `apps/web/src/components/file-explorer/FileTreeFilter.tsx`: - -```tsx -import { SearchIcon, XIcon } from "lucide-react"; -import { useCallback, useRef } from "react"; - -interface FileTreeFilterProps { - value: string; - onChange: (value: string) => void; -} - -export function FileTreeFilter({ value, onChange }: FileTreeFilterProps) { - const inputRef = useRef(null); - - const handleClear = useCallback(() => { - onChange(""); - inputRef.current?.focus(); - }, [onChange]); - - return ( -
- - onChange(e.target.value)} - placeholder="Filter files..." - className="min-w-0 flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground outline-none" - /> - {value.length > 0 && ( - - )} -
- ); -} -``` - -- [ ] **Step 5: Create `FileTreeNode.tsx`** - -Create `apps/web/src/components/file-explorer/FileTreeNode.tsx`: - -```tsx -import { ChevronRightIcon } from "lucide-react"; -import type { DirectoryEntry, GitFileStatus } from "@t3tools/contracts"; -import { getVscodeIconUrlForEntry } from "../../vscode-icons"; - -const GIT_STATUS_LABELS: Record = { - modified: { label: "M", className: "text-yellow-500" }, - added: { label: "A", className: "text-green-500" }, - deleted: { label: "D", className: "text-red-500" }, - untracked: { label: "?", className: "text-gray-400" }, - renamed: { label: "R", className: "text-blue-500" }, - conflicted: { label: "U", className: "text-orange-500" }, -}; - -interface FileTreeNodeProps { - entry: DirectoryEntry; - depth: number; - isExpanded: boolean; - isLoading: boolean; - gitStatus: GitFileStatus | undefined; - theme: "light" | "dark"; - onToggleExpand: (relativePath: string) => void; - onSelectFile: (relativePath: string) => void; - onContextMenu: (event: React.MouseEvent, entry: DirectoryEntry) => void; -} - -export function FileTreeNode({ - entry, - depth, - isExpanded, - isLoading, - gitStatus, - theme, - onToggleExpand, - onSelectFile, - onContextMenu, -}: FileTreeNodeProps) { - const isDir = entry.kind === "directory"; - const iconUrl = getVscodeIconUrlForEntry( - entry.relativePath, - isDir && isExpanded ? "directory" : entry.kind, - theme, - ); - const statusInfo = gitStatus ? GIT_STATUS_LABELS[gitStatus] : undefined; - - const handleClick = () => { - if (isDir) { - onToggleExpand(entry.relativePath); - } else { - onSelectFile(entry.relativePath); - } - }; - - return ( - - ); -} -``` - -- [ ] **Step 6: Create `FileTree.tsx`** - -Create `apps/web/src/components/file-explorer/FileTree.tsx`: - -```tsx -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { DirectoryEntry, EnvironmentId, GitFileStatus } from "@t3tools/contracts"; -import { useFileTree } from "./useFileTree"; -import { useGitFileStatus } from "./useGitFileStatus"; -import { FileTreeFilter } from "./FileTreeFilter"; -import { FileTreeNode } from "./FileTreeNode"; - -interface FileTreeProps { - environmentId: EnvironmentId; - cwd: string; - theme: "light" | "dark"; - onSelectFile: (relativePath: string) => void; - onContextMenu: (event: React.MouseEvent, entry: DirectoryEntry) => void; -} - -interface FlatEntry { - entry: DirectoryEntry; - depth: number; - isExpanded: boolean; - isLoading: boolean; -} - -export function FileTree({ - environmentId, - cwd, - theme, - onSelectFile, - onContextMenu, -}: FileTreeProps) { - const { rootEntries, rootLoading, expandedDirs, loadRoot, toggleExpand } = useFileTree({ - environmentId, - cwd, - }); - const { statusMap } = useGitFileStatus({ environmentId, cwd, enabled: true }); - const [filter, setFilter] = useState(""); - - useEffect(() => { - loadRoot(); - }, [loadRoot]); - - // Flatten tree for rendering - const flatEntries = useMemo(() => { - if (!rootEntries) return []; - const result: FlatEntry[] = []; - const lowerFilter = filter.toLowerCase(); - - function walk(entries: readonly DirectoryEntry[], depth: number) { - for (const entry of entries) { - const matchesFilter = - lowerFilter.length === 0 || entry.name.toLowerCase().includes(lowerFilter); - const dirData = - entry.kind === "directory" ? expandedDirs.get(entry.relativePath) : undefined; - const isExpanded = dirData !== undefined; - const isLoading = dirData?.isLoading ?? false; - const children = dirData?.entries ?? []; - - // For directories: show if matches filter OR has matching children - // For files: show if matches filter - if (entry.kind === "directory") { - if (matchesFilter || lowerFilter.length === 0) { - result.push({ entry, depth, isExpanded, isLoading }); - } - if (isExpanded) { - walk(children, depth + 1); - } - } else if (matchesFilter) { - result.push({ entry, depth, isExpanded: false, isLoading: false }); - } - } - } - - walk(rootEntries, 0); - return result; - }, [rootEntries, expandedDirs, filter]); - - if (rootLoading && !rootEntries) { - return ( -
- -
- Loading... -
-
- ); - } - - return ( -
- -
- {flatEntries.map((item) => ( - - ))} - {flatEntries.length === 0 && rootEntries && ( -
- {filter.length > 0 ? "No matching files" : "Empty directory"} -
- )} -
-
- ); -} -``` - -- [ ] **Step 7: Run typecheck and lint** - -Run: `bun typecheck && bun lint && bun fmt` -Expected: PASS - -- [ ] **Step 8: Commit** - -```bash -git add apps/web/src/components/file-explorer/ -git commit -m "feat(web): add FileTree component with expand-on-demand, git status, and filter" -``` - ---- - -### Task 8: CodeMirror Editor Component (Right Pane) - -**Files:** - -- Create: `apps/web/src/components/file-explorer/CodeEditor.tsx` -- Create: `apps/web/src/components/file-explorer/EditorTabs.tsx` -- Create: `apps/web/src/components/file-explorer/EditorBreadcrumb.tsx` -- Create: `apps/web/src/components/file-explorer/useEditorTabs.ts` -- Create: `apps/web/src/components/file-explorer/languageExtensions.ts` - -This task builds the CodeMirror-powered code editor with multi-tab support. - -- [ ] **Step 1: Install CodeMirror dependencies** - -Run: - -```bash -cd apps/web && bun add codemirror @codemirror/view @codemirror/state @codemirror/language @codemirror/lang-javascript @codemirror/lang-css @codemirror/lang-html @codemirror/lang-json @codemirror/lang-markdown @codemirror/lang-python @codemirror/lang-rust @codemirror/lang-cpp @codemirror/lang-java @codemirror/lang-xml @codemirror/lang-sql @codemirror/lang-yaml @codemirror/theme-one-dark -``` - -- [ ] **Step 2: Create `languageExtensions.ts`** - -Create `apps/web/src/components/file-explorer/languageExtensions.ts`: - -```ts -import type { Extension } from "@codemirror/state"; - -const EXTENSION_MAP: Record Promise> = { - ts: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ typescript: true })), - tsx: () => - import("@codemirror/lang-javascript").then((m) => - m.javascript({ typescript: true, jsx: true }), - ), - js: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), - jsx: () => import("@codemirror/lang-javascript").then((m) => m.javascript({ jsx: true })), - mjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), - cjs: () => import("@codemirror/lang-javascript").then((m) => m.javascript()), - css: () => import("@codemirror/lang-css").then((m) => m.css()), - html: () => import("@codemirror/lang-html").then((m) => m.html()), - json: () => import("@codemirror/lang-json").then((m) => m.json()), - md: () => import("@codemirror/lang-markdown").then((m) => m.markdown()), - markdown: () => import("@codemirror/lang-markdown").then((m) => m.markdown()), - py: () => import("@codemirror/lang-python").then((m) => m.python()), - rs: () => import("@codemirror/lang-rust").then((m) => m.rust()), - cpp: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), - c: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), - h: () => import("@codemirror/lang-cpp").then((m) => m.cpp()), - java: () => import("@codemirror/lang-java").then((m) => m.java()), - xml: () => import("@codemirror/lang-xml").then((m) => m.xml()), - svg: () => import("@codemirror/lang-xml").then((m) => m.xml()), - sql: () => import("@codemirror/lang-sql").then((m) => m.sql()), - yaml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()), - yml: () => import("@codemirror/lang-yaml").then((m) => m.yaml()), -}; - -export function getLanguageExtension(filePath: string): (() => Promise) | undefined { - const ext = filePath.split(".").pop()?.toLowerCase(); - if (!ext) return undefined; - return EXTENSION_MAP[ext]; -} -``` - -- [ ] **Step 3: Create `useEditorTabs.ts`** - -Create `apps/web/src/components/file-explorer/useEditorTabs.ts`: - -```ts -import { useCallback, useState } from "react"; - -const MAX_TABS = 10; - -export interface EditorTab { - readonly relativePath: string; - readonly isDirty: boolean; - readonly originalContent: string; - readonly currentContent: string; -} - -interface EditorTabsState { - readonly tabs: readonly EditorTab[]; - readonly activeIndex: number; -} - -export function useEditorTabs() { - const [state, setState] = useState({ - tabs: [], - activeIndex: -1, - }); - - const activeTab = state.activeIndex >= 0 ? (state.tabs[state.activeIndex] ?? null) : null; - - const openTab = useCallback((relativePath: string, content: string) => { - setState((prev) => { - const existingIndex = prev.tabs.findIndex((t) => t.relativePath === relativePath); - if (existingIndex >= 0) { - return { ...prev, activeIndex: existingIndex }; - } - - let tabs = [...prev.tabs]; - - // LRU eviction if at max capacity - if (tabs.length >= MAX_TABS) { - // Find the oldest non-dirty, non-active tab to evict - let evictIndex = -1; - for (let i = 0; i < tabs.length; i++) { - if (i !== prev.activeIndex && !tabs[i]!.isDirty) { - evictIndex = i; - break; - } - } - if (evictIndex >= 0) { - tabs.splice(evictIndex, 1); - } else { - // All non-active tabs are dirty — don't evict, just deny - return prev; - } - } - - const newTab: EditorTab = { - relativePath, - isDirty: false, - originalContent: content, - currentContent: content, - }; - - tabs = [...tabs, newTab]; - return { tabs, activeIndex: tabs.length - 1 }; - }); - }, []); - - const closeTab = useCallback((index: number) => { - setState((prev) => { - const tab = prev.tabs[index]; - if (!tab) return prev; - - // Guard against closing dirty tabs - if (tab.isDirty) { - const confirmed = window.confirm(`${tab.relativePath} has unsaved changes. Close anyway?`); - if (!confirmed) return prev; - } - - const tabs = prev.tabs.filter((_, i) => i !== index); - let activeIndex = prev.activeIndex; - if (activeIndex >= tabs.length) { - activeIndex = tabs.length - 1; - } else if (index < activeIndex) { - activeIndex -= 1; - } else if (index === activeIndex && activeIndex >= tabs.length) { - activeIndex = tabs.length - 1; - } - - return { tabs, activeIndex }; - }); - }, []); - - const setActiveIndex = useCallback((index: number) => { - setState((prev) => ({ ...prev, activeIndex: index })); - }, []); - - const markDirty = useCallback((relativePath: string, content: string) => { - setState((prev) => ({ - ...prev, - tabs: prev.tabs.map((t) => - t.relativePath === relativePath - ? { ...t, currentContent: content, isDirty: content !== t.originalContent } - : t, - ), - })); - }, []); - - const markSaved = useCallback((relativePath: string, savedContent: string) => { - setState((prev) => ({ - ...prev, - tabs: prev.tabs.map((t) => - t.relativePath === relativePath - ? { ...t, originalContent: savedContent, currentContent: savedContent, isDirty: false } - : t, - ), - })); - }, []); - - return { - tabs: state.tabs, - activeIndex: state.activeIndex, - activeTab, - openTab, - closeTab, - setActiveIndex, - markDirty, - markSaved, - }; -} -``` - -- [ ] **Step 4: Create `EditorTabs.tsx`** - -Create `apps/web/src/components/file-explorer/EditorTabs.tsx`: - -```tsx -import { XIcon } from "lucide-react"; -import type { EditorTab } from "./useEditorTabs"; -import { basenameOfPath } from "../../vscode-icons"; - -interface EditorTabsProps { - tabs: readonly EditorTab[]; - activeIndex: number; - onSelect: (index: number) => void; - onClose: (index: number) => void; -} - -export function EditorTabs({ tabs, activeIndex, onSelect, onClose }: EditorTabsProps) { - if (tabs.length === 0) return null; - - return ( -
- {tabs.map((tab, index) => ( - - ))} -
- ); -} -``` - -- [ ] **Step 5: Create `EditorBreadcrumb.tsx`** - -Create `apps/web/src/components/file-explorer/EditorBreadcrumb.tsx`: - -```tsx -import { ChevronRightIcon } from "lucide-react"; - -interface EditorBreadcrumbProps { - relativePath: string; -} - -export function EditorBreadcrumb({ relativePath }: EditorBreadcrumbProps) { - const parts = relativePath.split("/"); - - return ( -
- {parts.map((part, i) => ( - - {i > 0 && } - {part} - - ))} -
- ); -} -``` - -- [ ] **Step 6: Create `CodeEditor.tsx`** - -Create `apps/web/src/components/file-explorer/CodeEditor.tsx`: - -```tsx -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { readEnvironmentApi } from "../../environmentApi"; -import { useEditorTabs } from "./useEditorTabs"; -import { EditorTabs } from "./EditorTabs"; -import { EditorBreadcrumb } from "./EditorBreadcrumb"; -import { getLanguageExtension } from "./languageExtensions"; - -interface CodeEditorProps { - environmentId: EnvironmentId; - cwd: string; - activeFilePath: string | null; - onOpenFile: (relativePath: string) => void; -} - -export function CodeEditor({ environmentId, cwd, activeFilePath, onOpenFile }: CodeEditorProps) { - const editorContainerRef = useRef(null); - const editorViewRef = useRef(null); - const [cmModules, setCmModules] = useState<{ - EditorView: typeof import("@codemirror/view").EditorView; - EditorState: typeof import("@codemirror/state").EditorState; - basicSetup: import("@codemirror/state").Extension; - oneDark: import("@codemirror/state").Extension; - keymap: typeof import("@codemirror/view").keymap; - } | null>(null); - - const { tabs, activeIndex, activeTab, openTab, closeTab, setActiveIndex, markDirty, markSaved } = - useEditorTabs(); - - // Lazy-load CodeMirror on first mount - useEffect(() => { - let cancelled = false; - void Promise.all([ - import("@codemirror/view"), - import("@codemirror/state"), - import("codemirror"), - import("@codemirror/theme-one-dark"), - ]).then(([viewMod, stateMod, cmMod, themeMod]) => { - if (cancelled) return; - setCmModules({ - EditorView: viewMod.EditorView, - EditorState: stateMod.EditorState, - basicSetup: cmMod.basicSetup, - oneDark: themeMod.oneDark, - keymap: viewMod.keymap, - }); - }); - return () => { - cancelled = true; - }; - }, []); - - // Load file content when activeFilePath changes - useEffect(() => { - if (!activeFilePath) return; - - const existingTab = tabs.find((t) => t.relativePath === activeFilePath); - if (existingTab) { - const idx = tabs.indexOf(existingTab); - setActiveIndex(idx); - return; - } - - const api = readEnvironmentApi(environmentId); - if (!api) return; - - let cancelled = false; - void api.filesystem.readFile({ cwd, relativePath: activeFilePath }).then((result) => { - if (cancelled) return; - if (result.encoding === "utf-8") { - openTab(activeFilePath, result.content); - } - }); - - return () => { - cancelled = true; - }; - }, [activeFilePath, environmentId, cwd, openTab, setActiveIndex, tabs]); - - // Create/update EditorView when active tab changes - useEffect(() => { - if (!cmModules || !editorContainerRef.current || !activeTab) return; - - const { EditorView, EditorState, basicSetup, oneDark, keymap } = cmModules; - const container = editorContainerRef.current; - - // Save handler - const saveFile = async () => { - if (!activeTab.isDirty) return; - const api = readEnvironmentApi(environmentId); - if (!api) return; - try { - await api.projects.writeFile({ - cwd, - relativePath: activeTab.relativePath, - contents: activeTab.currentContent, - }); - markSaved(activeTab.relativePath, activeTab.currentContent); - } catch { - // TODO: toast error - } - }; - - // Destroy previous view - if (editorViewRef.current) { - editorViewRef.current.destroy(); - editorViewRef.current = null; - } - - const extensions = [ - basicSetup, - oneDark, - EditorView.updateListener.of((update) => { - if (update.docChanged) { - markDirty(activeTab.relativePath, update.state.doc.toString()); - } - }), - keymap.of([ - { - key: "Mod-s", - run: () => { - void saveFile(); - return true; - }, - }, - ]), - EditorView.theme({ - "&": { height: "100%", fontSize: "13px" }, - ".cm-scroller": { overflow: "auto" }, - }), - ]; - - // Load language extension async - const langLoader = getLanguageExtension(activeTab.relativePath); - - const state = EditorState.create({ - doc: activeTab.currentContent, - extensions, - }); - - const view = new EditorView({ state, parent: container }); - editorViewRef.current = view; - - if (langLoader) { - void langLoader().then((langExt) => { - view.dispatch({ - effects: import("@codemirror/state") - .then(({ StateEffect }) => - // Use reconfigure compartment pattern or just dispatch - // For simplicity, reconfigure via state effect - StateEffect.appendConfig.of(langExt), - ) - .then((effect) => ({ effects: effect })), - }); - }); - } - - return () => { - view.destroy(); - editorViewRef.current = null; - }; - }, [cmModules, activeTab?.relativePath, activeTab?.originalContent]); - - if (!activeTab) { - return ( -
- Select a file to view -
- ); - } - - return ( -
- - {activeTab && } -
-
- ); -} -``` - -- [ ] **Step 7: Run typecheck and lint** - -Run: `bun typecheck && bun lint && bun fmt` -Expected: PASS (or minor fixable lint issues) - -- [ ] **Step 8: Commit** - -```bash -git add apps/web/src/components/file-explorer/CodeEditor.tsx apps/web/src/components/file-explorer/EditorTabs.tsx apps/web/src/components/file-explorer/EditorBreadcrumb.tsx apps/web/src/components/file-explorer/useEditorTabs.ts apps/web/src/components/file-explorer/languageExtensions.ts -git commit -m "feat(web): add CodeMirror editor with multi-tab support and language detection" -``` - ---- - -### Task 9: File Explorer Tab Assembly (Tree + Editor Split) - -**Files:** - -- Create: `apps/web/src/components/file-explorer/FileExplorer.tsx` -- Modify: `apps/web/src/routes/_chat.$environmentId.$threadId.tsx` - -This task wires the FileTree and CodeEditor into a resizable split pane and integrates it into the RightPanel. - -- [ ] **Step 1: Install `react-resizable-panels`** - -Run: - -```bash -cd apps/web && bun add react-resizable-panels -``` - -- [ ] **Step 2: Create `FileExplorer.tsx`** - -Create `apps/web/src/components/file-explorer/FileExplorer.tsx`: - -```tsx -import { useCallback, useState } from "react"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; -import type { DirectoryEntry, EnvironmentId } from "@t3tools/contracts"; -import { FileTree } from "./FileTree"; -import { CodeEditor } from "./CodeEditor"; - -interface FileExplorerProps { - environmentId: EnvironmentId; - cwd: string; - theme: "light" | "dark"; -} - -export function FileExplorer({ environmentId, cwd, theme }: FileExplorerProps) { - const [activeFilePath, setActiveFilePath] = useState(null); - - const handleSelectFile = useCallback((relativePath: string) => { - setActiveFilePath(relativePath); - }, []); - - const handleContextMenu = useCallback((_event: React.MouseEvent, _entry: DirectoryEntry) => { - // Context menu implementation in Task 10 - }, []); - - return ( - - - - - - - - - - ); -} -``` - -- [ ] **Step 3: Replace Files tab placeholder in route file** - -In `apps/web/src/routes/_chat.$environmentId.$threadId.tsx`, replace the "File explorer coming soon" placeholder: - -```tsx -import { lazy, Suspense } from "react"; - -const FileExplorer = lazy(() => - import("../components/file-explorer/FileExplorer").then((m) => ({ default: m.FileExplorer })), -); -``` - -Replace the files placeholder content in the inline sidebar branch: - -```tsx -
- - Loading file explorer... -
- } - > - - -
-``` - -For the `cwd` value: look up how the existing code gets the workspace root for the current environment. It's typically the project's `cwd` from the store. Use the store selector for the active project's cwd: - -```tsx -const projectCwd = useStore((store) => { - const envState = selectEnvironmentState(store, threadRef.environmentId); - return envState.projectCwd; -}); -``` - -If `projectCwd` isn't directly available on the env state, check what `GitStatusInput.cwd` uses — typically the same workspace root. Wire this through. - -- [ ] **Step 4: Do the same for the sheet branch** - -Replace the sheet's files placeholder with the same lazy-loaded `FileExplorer`. - -- [ ] **Step 5: Run typecheck, lint, and format** - -Run: `bun typecheck && bun lint && bun fmt` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(web): assemble FileExplorer with tree/editor split pane in RightPanel" -``` - ---- - -### Task 10: Context Menus (Tree + Editor) - -**Files:** - -- Create: `apps/web/src/components/file-explorer/FileTreeContextMenu.tsx` -- Create: `apps/web/src/components/file-explorer/EditorContextMenu.tsx` -- Modify: `apps/web/src/components/file-explorer/FileExplorer.tsx` -- Modify: `apps/web/src/components/file-explorer/FileTree.tsx` - -This task adds right-click context menus for the file tree (New File, New Folder, Rename, Delete, Copy Path, Mention in Chat) and the editor selection (Copy, Mention Selection in Chat). - -- [ ] **Step 1: Create `FileTreeContextMenu.tsx`** - -Create `apps/web/src/components/file-explorer/FileTreeContextMenu.tsx`: - -```tsx -import { useCallback, useState, useRef, useEffect } from "react"; -import type { DirectoryEntry, EnvironmentId, FilesystemMutationResult } from "@t3tools/contracts"; -import { readEnvironmentApi } from "../../environmentApi"; - -export type TreeContextAction = - | "newFile" - | "newFolder" - | "rename" - | "delete" - | "copyPath" - | "mentionInChat"; - -interface ContextMenuState { - x: number; - y: number; - entry: DirectoryEntry; -} - -interface FileTreeContextMenuProps { - state: ContextMenuState | null; - onClose: () => void; - environmentId: EnvironmentId; - cwd: string; - onAction: (action: TreeContextAction, entry: DirectoryEntry) => void; -} - -const MENU_ITEMS: { action: TreeContextAction; label: string; dirOnly?: boolean }[] = [ - { action: "newFile", label: "New File", dirOnly: true }, - { action: "newFolder", label: "New Folder", dirOnly: true }, - { action: "rename", label: "Rename" }, - { action: "delete", label: "Delete" }, - { action: "copyPath", label: "Copy Path" }, - { action: "mentionInChat", label: "Mention in Chat" }, -]; - -export function FileTreeContextMenu({ - state, - onClose, - environmentId, - cwd, - onAction, -}: FileTreeContextMenuProps) { - const menuRef = useRef(null); - - useEffect(() => { - if (!state) return; - const handleClick = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("mousedown", handleClick); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleClick); - document.removeEventListener("keydown", handleKey); - }; - }, [state, onClose]); - - if (!state) return null; - - const isDir = state.entry.kind === "directory"; - const items = MENU_ITEMS.filter((item) => !item.dirOnly || isDir); - - return ( -
- {items.map((item) => ( - - ))} -
- ); -} -``` - -- [ ] **Step 2: Wire context menu into `FileExplorer.tsx`** - -Update `FileExplorer.tsx` to manage context menu state: - -```tsx -import { FileTreeContextMenu, type TreeContextAction } from "./FileTreeContextMenu"; - -// Inside FileExplorer component: -const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - entry: DirectoryEntry; -} | null>(null); - -const handleContextMenu = useCallback((event: React.MouseEvent, entry: DirectoryEntry) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, entry }); -}, []); - -const handleContextAction = useCallback( - async (action: TreeContextAction, entry: DirectoryEntry) => { - const api = readEnvironmentApi(environmentId); - if (!api) return; - - switch (action) { - case "newFile": { - const name = window.prompt("File name:"); - if (!name) return; - const relativePath = `${entry.relativePath}/${name}`; - await api.projects.writeFile({ cwd, relativePath, contents: "" }); - setActiveFilePath(relativePath); - break; - } - case "newFolder": { - const name = window.prompt("Folder name:"); - if (!name) return; - await api.filesystem.createDirectory({ - cwd, - relativePath: `${entry.relativePath}/${name}`, - }); - break; - } - case "rename": { - const newName = window.prompt("New name:", entry.name); - if (!newName || newName === entry.name) return; - const parentPath = entry.relativePath.split("/").slice(0, -1).join("/"); - const newRelativePath = parentPath ? `${parentPath}/${newName}` : newName; - await api.filesystem.rename({ - cwd, - oldRelativePath: entry.relativePath, - newRelativePath, - }); - break; - } - case "delete": { - const confirmed = window.confirm(`Delete ${entry.relativePath}?`); - if (!confirmed) return; - await api.filesystem.delete({ cwd, relativePath: entry.relativePath }); - break; - } - case "copyPath": { - await navigator.clipboard.writeText(entry.relativePath); - break; - } - case "mentionInChat": { - // Implemented in Task 11 - break; - } - } - }, - [environmentId, cwd], -); - -// In the JSX, add before the closing : - setContextMenu(null)} - environmentId={environmentId} - cwd={cwd} - onAction={handleContextAction} -/>; -``` - -- [ ] **Step 3: Run typecheck, lint, and format** - -Run: `bun typecheck && bun lint && bun fmt` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/components/file-explorer/FileTreeContextMenu.tsx apps/web/src/components/file-explorer/FileExplorer.tsx -git commit -m "feat(web): add file tree context menu with CRUD operations" -``` - ---- - -### Task 11: Mention System Extensions - -**Files:** - -- Modify: `apps/web/src/composer-editor-mentions.ts` -- Modify: `apps/web/src/composer-editor-mentions.test.ts` -- Modify: `apps/web/src/components/file-explorer/FileExplorer.tsx` - -This task extends the mention system to support `@filepath:L-L` line-range references. - -- [ ] **Step 1: Write failing tests for line-range mentions** - -Add to `apps/web/src/composer-editor-mentions.test.ts`: - -```ts -describe("line-range mentions", () => { - it("parses @path:L10-L20 as a mention with lineRange", () => { - const segments = splitPromptIntoComposerSegments("Check @src/index.ts:L10-L20 here"); - expect(segments).toEqual([ - { type: "text", text: "Check " }, - { type: "mention", path: "src/index.ts", lineRange: { start: 10, end: 20 } }, - { type: "text", text: " here" }, - ]); - }); - - it("parses plain @path without line range", () => { - const segments = splitPromptIntoComposerSegments("Check @src/index.ts here"); - expect(segments).toEqual([ - { type: "text", text: "Check " }, - { type: "mention", path: "src/index.ts" }, - { type: "text", text: " here" }, - ]); - }); - - it("handles line range at end of string", () => { - const segments = splitPromptIntoComposerSegments("Look at @src/app.tsx:L1-L5 "); - expect(segments).toEqual([ - { type: "text", text: "Look at " }, - { type: "mention", path: "src/app.tsx", lineRange: { start: 1, end: 5 } }, - { type: "text", text: " " }, - ]); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `bun run test apps/web/src/composer-editor-mentions.test.ts` -Expected: FAIL — `lineRange` not yet in the mention segment type. - -- [ ] **Step 3: Update `ComposerPromptSegment` type** - -In `apps/web/src/composer-editor-mentions.ts`, update the mention variant: - -```ts -export type MentionLineRange = { - readonly start: number; - readonly end: number; -}; - -export type ComposerPromptSegment = - | { - type: "text"; - text: string; - } - | { - type: "mention"; - path: string; - lineRange?: MentionLineRange | undefined; - } - | { - type: "skill"; - name: string; - } - | { - type: "terminal-context"; - context: TerminalContextDraft | null; - }; -``` - -- [ ] **Step 4: Update the mention regex** - -Update `MENTION_TOKEN_REGEX` to capture an optional `:L-L` suffix: - -```ts -const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+?)(?::L(\d+)-L(\d+))?(?=\s)/g; -``` - -- [ ] **Step 5: Update `collectInlineTokenMatches` to extract line ranges** - -Update the `InlineTokenMatch` type and mention matching: - -```ts -type InlineTokenMatch = - | { - type: "mention"; - value: string; - lineRange?: MentionLineRange; - start: number; - end: number; - } - | { - type: "skill"; - value: string; - start: number; - end: number; - }; -``` - -In the mention `matchAll` loop: - -```ts -for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const path = match[2] ?? ""; - const lineStart = match[3]; - const lineEnd = match[4]; - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - if (path.length > 0) { - const lineRange = - lineStart !== undefined && lineEnd !== undefined - ? { start: Number.parseInt(lineStart, 10), end: Number.parseInt(lineEnd, 10) } - : undefined; - matches.push({ type: "mention", value: path, lineRange, start, end }); - } -} -``` - -- [ ] **Step 6: Update `splitPromptTextIntoComposerSegments` to pass through lineRange** - -In the mention branch: - -```ts -if (match.type === "mention") { - segments.push({ - type: "mention", - path: match.value, - ...(match.lineRange ? { lineRange: match.lineRange } : {}), - }); -} -``` - -- [ ] **Step 7: Run tests** - -Run: `bun run test apps/web/src/composer-editor-mentions.test.ts` -Expected: PASS - -- [ ] **Step 8: Run full test suite, typecheck, and lint** - -Run: `bun run test && bun typecheck && bun lint` -Expected: PASS. Verify existing mention tests still pass. - -- [ ] **Step 9: Commit** - -```bash -git add apps/web/src/composer-editor-mentions.ts apps/web/src/composer-editor-mentions.test.ts -git commit -m "feat(web): extend mention system to support @file:L-L line ranges" -``` - ---- - -### Task 12: Wire "Mention in Chat" Actions - -**Files:** - -- Modify: `apps/web/src/components/file-explorer/FileExplorer.tsx` - -This task wires the "Mention in Chat" context menu action to insert `@path` mentions, and the "Mention Selection in Chat" editor action to insert `@path:L-L` mentions into the Lexical composer. - -- [ ] **Step 1: Research how the composer accepts programmatic mention insertion** - -Before implementing, read the Lexical composer component to understand how to programmatically insert text/mentions. Find: - -- The Lexical editor ref or command dispatch pattern -- How existing mentions are inserted (e.g., from autocomplete) -- What API is available for external code to trigger insertion - -The approach will likely involve dispatching a custom command to the Lexical editor or using a shared store/callback that the composer listens to. - -- [ ] **Step 2: Implement the insertion bridge** - -Based on findings from Step 1, create the bridge. A common pattern is a zustand store or a ref-based command channel: - -```ts -// In a shared location (e.g., apps/web/src/composerInsertionStore.ts) -import { create } from "zustand"; - -interface ComposerInsertionStore { - pendingInsert: string | null; - insertText: (text: string) => void; - consumeInsert: () => string | null; -} - -export const useComposerInsertionStore = create((set, get) => ({ - pendingInsert: null, - insertText: (text) => set({ pendingInsert: text }), - consumeInsert: () => { - const text = get().pendingInsert; - set({ pendingInsert: null }); - return text; - }, -})); -``` - -Then in the Lexical composer component, add an effect that watches `pendingInsert` and inserts it into the editor. - -- [ ] **Step 3: Wire "Mention in Chat" in FileExplorer context menu** - -In `FileExplorer.tsx`, update the `mentionInChat` case: - -```ts -case "mentionInChat": { - useComposerInsertionStore.getState().insertText(` @${entry.relativePath} `); - break; -} -``` - -- [ ] **Step 4: Wire "Mention Selection in Chat" in CodeEditor** - -Add a context menu handler to the CodeMirror editor that, when the user right-clicks with a selection, offers "Mention Selection in Chat". On click, it reads the selection range and inserts `@path:L-L`: - -```ts -const sel = view.state.selection.main; -if (!sel.empty) { - const startLine = view.state.doc.lineAt(sel.from).number; - const endLine = view.state.doc.lineAt(sel.to).number; - useComposerInsertionStore - .getState() - .insertText(` @${activeTab.relativePath}:L${startLine}-L${endLine} `); -} -``` - -- [ ] **Step 5: Run typecheck and lint** - -Run: `bun typecheck && bun lint && bun fmt` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(web): wire mention-in-chat actions from file explorer to composer" -``` - ---- - -### Task 13: Final Integration, Polish, and Verification - -**Files:** - -- Various files across the codebase - -This task performs final integration testing, cleanup, and verification. - -- [ ] **Step 1: Run the full test suite** - -Run: `bun run test` -Expected: All tests PASS. - -- [ ] **Step 2: Run typecheck** - -Run: `bun typecheck` -Expected: PASS — zero type errors. - -- [ ] **Step 3: Run lint and format** - -Run: `bun lint && bun fmt` -Expected: PASS — zero lint errors, formatting clean. - -- [ ] **Step 4: Delete the old `diffRouteSearch.ts` if not already deleted** - -Verify `apps/web/src/diffRouteSearch.ts` has been deleted. If not, delete it and verify no remaining imports. - -- [ ] **Step 5: Verify all new exports are in contract index** - -Check `packages/contracts/src/index.ts` exports `filesystem.ts` (already does). Verify the new types are accessible: - -```ts -import { - FilesystemReadFileInput, - FilesystemListDirectoryResult, - GitFileStatusResult, - // etc. -} from "@t3tools/contracts"; -``` - -- [ ] **Step 6: Final commit** - -```bash -git add -A -git commit -m "chore: final cleanup and integration verification for file explorer" -```