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/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/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/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..901255e9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,11 +13,16 @@ import { OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, + OrchestrationGetWorkingTreeDiffError, ORCHESTRATION_WS_METHODS, ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, FilesystemBrowseError, + FilesystemReadFileError, + FilesystemListDirectoryError, + FilesystemMutationError, + GitFileStatusError, ThreadId, type TerminalEvent, WS_METHODS, @@ -48,6 +53,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 +153,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; @@ -639,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, @@ -820,6 +841,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, diff --git a/apps/web/package.json b/apps/web/package.json index b18defeb..f73f735d 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,12 +49,14 @@ "@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", "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/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/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c76059b..d96cdd4d 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), @@ -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; @@ -1489,11 +1493,29 @@ 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]); + 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, ]); @@ -3192,10 +3222,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 }; }, }); }, @@ -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 e6dbb57c..8eada715 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -21,11 +21,11 @@ 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"; -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]), @@ -220,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 @@ -349,8 +353,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 +364,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/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/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/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"} + + void; +} + +export function CodeEditor({ environmentId, cwd, activeFilePath }: CodeEditorProps) { + const editorContainerRef = useRef(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; + basicSetup: Extension; + oneDark: Extension; + keymap: typeof import("@codemirror/view").keymap; + } | null>(null); + + const { tabs, activeIndex, activeTab, openTab, closeTab, setActiveIndex, markDirty, markSaved } = + useEditorTabs(); + + const composerHandleRef = useComposerHandleContext(); + + // 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; + }; + }, []); + + // 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 currentTabs = tabsRef.current; + const existingIndex = currentTabs.findIndex((t) => t.relativePath === activeFilePath); + if (existingIndex >= 0) { + setActiveIndex(existingIndex); + 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]); + + // 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 compartment = wordWrapCompartmentRef.current; + + // Destroy previous view + if (editorViewRef.current) { + editorViewRef.current.destroy(); + editorViewRef.current = null; + } + + const extensions: Extension[] = [ + basicSetup, + oneDark, + compartment.of(wordWrap ? EditorView.lineWrapping : []), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + markDirty(currentTabPath, update.state.doc.toString()); + } + }), + keymap.of([ + { + key: "Mod-s", + 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; + }, + }, + ]), + EditorView.theme({ + "&": { 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({ + 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]); + + // 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 && ( + setWordWrap((prev) => !prev)} + /> + )} +
+
+ ); +} 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..e84d37ad --- /dev/null +++ b/apps/web/src/components/file-explorer/EditorBreadcrumb.tsx @@ -0,0 +1,60 @@ +import { ChevronRightIcon, WrapTextIcon } from "lucide-react"; +import { useMemo } from "react"; + +interface EditorBreadcrumbProps { + relativePath: string; + wordWrap: boolean; + onToggleWordWrap: () => void; +} + +interface BreadcrumbSegment { + readonly name: string; + readonly cumulativePath: string; + readonly isLast: boolean; + readonly isFirst: boolean; +} + +export function EditorBreadcrumb({ + relativePath, + wordWrap, + onToggleWordWrap, +}: 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..87989fde --- /dev/null +++ b/apps/web/src/components/file-explorer/EditorTabs.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef } from "react"; +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) { + 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) => ( + + ))} +
+ ); +} 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..dc2e599c --- /dev/null +++ b/apps/web/src/components/file-explorer/FileExplorer.tsx @@ -0,0 +1,124 @@ +import { useCallback, useState } from "react"; +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"; +import { useComposerHandleContext } from "../../composerHandleContext"; + +interface FileExplorerProps { + environmentId: EnvironmentId; + cwd: string; + theme: "light" | "dark"; +} + +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 composerHandleRef = useComposerHandleContext(); + + const handleSelectFile = useCallback((relativePath: string) => { + setActiveFilePath(relativePath); + }, []); + + 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": { + composerHandleRef?.current?.insertTextAtCursor(` @${entry.relativePath} `); + break; + } + } + }, + [environmentId, cwd], + ); + + return ( + <> +
+
+ +
+ {activeFilePath && ( +
+ +
+ )} +
+ setContextMenu(null)} + onAction={handleContextAction} + /> + + ); +} 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..edc35f11 --- /dev/null +++ b/apps/web/src/components/file-explorer/FileTree.tsx @@ -0,0 +1,138 @@ +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, rootLoadState, rootError, expandedDirs, loadRoot, toggleExpand } = + useFileTree({ + environmentId, + cwd, + }); + const { statusMap } = useGitFileStatus({ + environmentId, + cwd, + enabled: rootLoadState === "loaded", + }); + 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]); + + // Idle or loading: show loading indicator + if (rootLoadState === "idle" || rootLoadState === "loading") { + return ( +
+ +
+ Loading... +
+
+ ); + } + + // Error state: load was attempted and failed + if (rootLoadState === "error") { + return ( +
+ +
+ {rootError ?? "Failed to load files"} + +
+
+ ); + } + + 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/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) => ( + + ))} +
+ ); +} 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..5e45ff70 --- /dev/null +++ b/apps/web/src/components/file-explorer/FileTreeFilter.tsx @@ -0,0 +1,41 @@ +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..68491063 --- /dev/null +++ b/apps/web/src/components/file-explorer/FileTreeNode.tsx @@ -0,0 +1,75 @@ +import { memo } from "react"; +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 const FileTreeNode = memo(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/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/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/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/apps/web/src/components/file-explorer/useFileTree.ts b/apps/web/src/components/file-explorer/useFileTree.ts new file mode 100644 index 00000000..2564bcbe --- /dev/null +++ b/apps/web/src/components/file-explorer/useFileTree.ts @@ -0,0 +1,148 @@ +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 type RootLoadState = "idle" | "loading" | "loaded" | "error"; + +export function useFileTree({ environmentId, cwd }: UseFileTreeOptions) { + const [expandedDirs, setExpandedDirs] = useState>(new Map()); + const [rootEntries, setRootEntries] = useState(null); + 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) { + if (relativePath === ".") { + setRootLoadState("error"); + setRootError("Environment not connected"); + } + return; + } + + if (relativePath === ".") { + setRootLoadState("loading"); + setRootError(null); + } 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); + setRootLoadState("loaded"); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { entries: result.entries, isLoading: false }); + return next; + }); + } + loadedRef.current.add(relativePath); + } catch (err) { + if (relativePath === ".") { + setRootLoadState("error"); + setRootError(err instanceof Error ? err.message : "Failed to list directory"); + } else { + setExpandedDirs((prev) => { + const next = new Map(prev); + next.set(relativePath, { entries: [], isLoading: false }); + return next; + }); + } + } + }, + [environmentId, cwd], + ); + + 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; + } + + // 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], + ); + + 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, + rootLoadState, + rootError, + 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 }; +} 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 }); } 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/environmentApi.ts b/apps/web/src/environmentApi.ts index 64df079d..8e9edc62 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,11 +40,13 @@ 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, 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.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..08616136 --- /dev/null +++ b/apps/web/src/panelRouteSearch.ts @@ -0,0 +1,67 @@ +import { TurnId } from "@t3tools/contracts"; + +export type PanelTab = "files" | "diff"; + +export interface PanelRouteSearch { + panel?: PanelTab | undefined; + diffTurnId?: TurnId | undefined; + diffFilePath?: string | undefined; + diffWorkingTree?: boolean | 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, + diffWorkingTree: _diffWorkingTree, + ...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 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 ff20673e..240d1dca 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -12,23 +12,30 @@ 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"; +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"; +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 FileExplorer = lazy(() => + import("../components/file-explorer/FileExplorer").then((m) => ({ default: m.FileExplorer })), +); const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( @@ -48,94 +55,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({ @@ -164,53 +83,62 @@ 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 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,26 +161,67 @@ function ChatThreadRouteView() { return null; } - const shouldRenderDiffContent = diffOpen || hasOpenedDiff; + const shouldRenderDiffContent = diffOpen || hasOpenedPanel; 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} +
+
+ {activeCwd ? ( + + Loading file explorer... +
+ } + > + + + ) : ( +
+ No workspace available +
+ )} +
+ ); } @@ -263,21 +232,61 @@ function ChatThreadRouteView() { - - {shouldRenderDiffContent ? : null} + + { + if (tab === "diff") markPanelOpened(); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + search: (prev) => ({ ...stripPanelSearchParams(prev), panel: tab }), + }); + }} + /> +
+ {shouldRenderDiffContent ? : null} +
+
+ {activeCwd ? ( + + Loading file explorer... +
+ } + > + + + ) : ( +
+ No workspace available +
+ )} +
); } export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - validateSearch: (search) => parseDiffRouteSearch(search), + validateSearch: (search) => parsePanelRouteSearch(search), search: { - middlewares: [retainSearchParams(["diff"])], + middlewares: [retainSearchParams(["panel"])], }, component: ChatThreadRouteView, }); diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b67be32d..4b6b57fa 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; @@ -116,6 +122,7 @@ export interface WsRpcClient { readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; readonly getFullThreadDiff: RpcUnaryMethod; + readonly getWorkingTreeDiff: RpcUnaryMethod; readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; }; @@ -150,6 +157,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 +218,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]({})), @@ -239,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/bun.lock b/bun.lock index e9b7511e..a2936462 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,12 +112,14 @@ "@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", "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", @@ -317,6 +335,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 +607,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 +1089,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 +1115,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=="], @@ -1667,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=="], @@ -1805,6 +1899,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 +2097,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=="], 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..710a9b06 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -28,3 +28,116 @@ 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/ipc.ts b/packages/contracts/src/ipc.ts index a1abc0fa..20828dad 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -18,7 +18,19 @@ import type { GitStatusResult, GitCreateBranchResult, } from "./git.ts"; -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + FilesystemReadFileInput, + FilesystemReadFileResult, + FilesystemListDirectoryInput, + FilesystemListDirectoryResult, + FilesystemRenameInput, + FilesystemDeleteInput, + FilesystemCreateDirectoryInput, + FilesystemMutationResult, + GitFileStatusResult, +} from "./filesystem.ts"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -47,6 +59,8 @@ import type { OrchestrationGetFullThreadDiffResult, OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, + OrchestrationGetWorkingTreeDiffInput, + OrchestrationGetWorkingTreeDiffResult, OrchestrationShellStreamItem, OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, @@ -247,6 +261,11 @@ export interface EnvironmentApi { }; 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; }; git: { listBranches: (input: GitListBranchesInput) => Promise; @@ -260,6 +279,7 @@ export interface EnvironmentApi { input: GitPreparePullRequestThreadInput, ) => Promise; pull: (input: GitPullInput) => Promise; + fileStatus: (input: GitStatusInput) => Promise; refreshStatus: (input: GitStatusInput) => Promise; onStatus: ( input: GitStatusInput, @@ -275,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 5dec716a..5f997af8 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, @@ -44,6 +57,8 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, + OrchestrationGetWorkingTreeDiffError, + OrchestrationGetWorkingTreeDiffInput, OrchestrationReplayEventsError, OrchestrationReplayEventsInput, OrchestrationRpcSchemas, @@ -90,8 +105,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 +200,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, @@ -307,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, @@ -365,6 +431,12 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsFilesystemReadFileRpc, + WsFilesystemListDirectoryRpc, + WsFilesystemRenameRpc, + WsFilesystemDeleteRpc, + WsFilesystemCreateDirectoryRpc, + WsGitFileStatusRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, WsGitRefreshStatusRpc, @@ -390,6 +462,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, + WsOrchestrationGetWorkingTreeDiffRpc, WsOrchestrationReplayEventsRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc,