Skip to content

Commit 44de4a6

Browse files
committed
Add workspace file explorer and Monaco editor
Re-implements editor features against the new Effect RPC architecture with listEntries/readFile RPC methods, workspace service layers, file tree, tab management, and Vite-bundled Monaco workers.
1 parent e40e2b1 commit 44de4a6

22 files changed

Lines changed: 1154 additions & 27 deletions

apps/server/src/workspace/Layers/WorkspaceEntries.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,8 +494,34 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
494494
},
495495
);
496496

497+
const list: WorkspaceEntriesShape["list"] = Effect.fn("WorkspaceEntries.list")(function* (input) {
498+
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
499+
return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(
500+
Effect.map((index) => {
501+
const limit = Math.max(0, Math.floor(input.limit));
502+
const sorted = [...index.entries].toSorted((a, b) => {
503+
if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1;
504+
return a.path.localeCompare(b.path);
505+
});
506+
const entries = sorted.slice(0, limit).map((e) => {
507+
const result: { path: string; kind: "file" | "directory"; parentPath?: string } = {
508+
path: e.path,
509+
kind: e.kind,
510+
};
511+
if (e.parentPath) result.parentPath = e.parentPath;
512+
return result;
513+
});
514+
return {
515+
entries,
516+
truncated: index.truncated || sorted.length > limit,
517+
};
518+
}),
519+
);
520+
});
521+
497522
return {
498523
invalidate,
524+
list,
499525
search,
500526
} satisfies WorkspaceEntriesShape;
501527
});

apps/server/src/workspace/Layers/WorkspaceFileSystem.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,69 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () {
1414
const workspacePaths = yield* WorkspacePaths;
1515
const workspaceEntries = yield* WorkspaceEntries;
1616

17+
const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")(
18+
function* (input) {
19+
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
20+
workspaceRoot: input.cwd,
21+
relativePath: input.relativePath,
22+
});
23+
24+
const maxBytes = input.maxBytes ?? 1_048_576; // default 1 MB
25+
26+
const stat = yield* fileSystem.stat(target.absolutePath).pipe(
27+
Effect.mapError(
28+
(cause) =>
29+
new WorkspaceFileSystemError({
30+
cwd: input.cwd,
31+
relativePath: input.relativePath,
32+
operation: "workspaceFileSystem.stat",
33+
detail: cause.message,
34+
cause,
35+
}),
36+
),
37+
);
38+
const fileSize = Number(stat.size);
39+
40+
const bytes = yield* fileSystem.readFile(target.absolutePath).pipe(
41+
Effect.mapError(
42+
(cause) =>
43+
new WorkspaceFileSystemError({
44+
cwd: input.cwd,
45+
relativePath: input.relativePath,
46+
operation: "workspaceFileSystem.readFile",
47+
detail: cause.message,
48+
cause,
49+
}),
50+
),
51+
);
52+
53+
// Detect binary by checking for null bytes in first 8KB
54+
const checkLen = Math.min(bytes.length, 8192);
55+
let isBinary = false;
56+
for (let i = 0; i < checkLen; i++) {
57+
if (bytes[i] === 0) {
58+
isBinary = true;
59+
break;
60+
}
61+
}
62+
63+
const truncated = bytes.length > maxBytes;
64+
const slice = truncated ? bytes.slice(0, maxBytes) : bytes;
65+
const encoding = isBinary ? ("base64" as const) : ("utf8" as const);
66+
const contents = isBinary
67+
? Buffer.from(slice).toString("base64")
68+
: new TextDecoder("utf-8").decode(slice);
69+
70+
return {
71+
relativePath: target.relativePath,
72+
contents,
73+
encoding,
74+
size: fileSize,
75+
truncated,
76+
};
77+
},
78+
);
79+
1780
const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn(
1881
"WorkspaceFileSystem.writeFile",
1982
)(function* (input) {
@@ -49,7 +112,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () {
49112
yield* workspaceEntries.invalidate(input.cwd);
50113
return { relativePath: target.relativePath };
51114
});
52-
return { writeFile } satisfies WorkspaceFileSystemShape;
115+
return { readFile, writeFile } satisfies WorkspaceFileSystemShape;
53116
});
54117

55118
export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem);

apps/server/src/workspace/Services/WorkspaceEntries.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
import { Schema, ServiceMap } from "effect";
1010
import type { Effect } from "effect";
1111

12-
import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts";
12+
import type {
13+
ProjectListEntriesInput,
14+
ProjectListEntriesResult,
15+
ProjectSearchEntriesInput,
16+
ProjectSearchEntriesResult,
17+
} from "@t3tools/contracts";
1318

1419
export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()(
1520
"WorkspaceEntriesError",
@@ -34,6 +39,14 @@ export interface WorkspaceEntriesShape {
3439
input: ProjectSearchEntriesInput,
3540
) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>;
3641

42+
/**
43+
* List all workspace entries without a search query.
44+
* Directories are returned before files, both sorted alphabetically.
45+
*/
46+
readonly list: (
47+
input: ProjectListEntriesInput,
48+
) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>;
49+
3750
/**
3851
* Drop any cached workspace entries for the given workspace root.
3952
*/

apps/server/src/workspace/Services/WorkspaceFileSystem.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
import { Schema, ServiceMap } from "effect";
1010
import type { Effect } from "effect";
1111

12-
import type { ProjectWriteFileInput, ProjectWriteFileResult } from "@t3tools/contracts";
12+
import type {
13+
ProjectReadFileInput,
14+
ProjectReadFileResult,
15+
ProjectWriteFileInput,
16+
ProjectWriteFileResult,
17+
} from "@t3tools/contracts";
1318
import { WorkspacePathOutsideRootError } from "./WorkspacePaths.ts";
1419

1520
export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(
@@ -27,6 +32,19 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceF
2732
* WorkspaceFileSystemShape - Service API for workspace-relative file operations.
2833
*/
2934
export interface WorkspaceFileSystemShape {
35+
/**
36+
* Read a file relative to the workspace root.
37+
*
38+
* Returns file contents (utf8 or base64 for binary), size, and truncation
39+
* status. Rejects paths that escape the workspace root.
40+
*/
41+
readonly readFile: (
42+
input: ProjectReadFileInput,
43+
) => Effect.Effect<
44+
ProjectReadFileResult,
45+
WorkspaceFileSystemError | WorkspacePathOutsideRootError
46+
>;
47+
3048
/**
3149
* Write a file relative to the workspace root.
3250
*

apps/server/src/ws.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
OrchestrationGetSnapshotError,
1212
OrchestrationGetTurnDiffError,
1313
ORCHESTRATION_WS_METHODS,
14+
ProjectListEntriesError,
15+
ProjectReadFileError,
1416
ProjectSearchEntriesError,
1517
ProjectWriteFileError,
1618
OrchestrationReplayEventsError,
@@ -539,6 +541,36 @@ const WsRpcLayer = WsRpcGroup.toLayer(
539541
),
540542
{ "rpc.aggregate": "workspace" },
541543
),
544+
[WS_METHODS.projectsListEntries]: (input) =>
545+
observeRpcEffect(
546+
WS_METHODS.projectsListEntries,
547+
workspaceEntries.list(input).pipe(
548+
Effect.mapError(
549+
(cause) =>
550+
new ProjectListEntriesError({
551+
message: `Failed to list workspace entries: ${cause.detail}`,
552+
cause,
553+
}),
554+
),
555+
),
556+
{ "rpc.aggregate": "workspace" },
557+
),
558+
[WS_METHODS.projectsReadFile]: (input) =>
559+
observeRpcEffect(
560+
WS_METHODS.projectsReadFile,
561+
workspaceFileSystem.readFile(input).pipe(
562+
Effect.mapError((cause) => {
563+
const message = Schema.is(WorkspacePathOutsideRootError)(cause)
564+
? "File path must stay within the project root."
565+
: "Failed to read workspace file";
566+
return new ProjectReadFileError({
567+
message,
568+
cause,
569+
});
570+
}),
571+
),
572+
{ "rpc.aggregate": "workspace" },
573+
),
542574
[WS_METHODS.projectsWriteFile]: (input) =>
543575
observeRpcEffect(
544576
WS_METHODS.projectsWriteFile,

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"effect": "catalog:",
3636
"lexical": "^0.41.0",
3737
"lucide-react": "^0.564.0",
38+
"monaco-editor": "^0.55.1",
3839
"react": "^19.0.0",
3940
"react-dom": "^19.0.0",
4041
"react-markdown": "^10.1.0",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { lazy, memo, Suspense, useCallback, useState } from "react";
2+
import { useQuery } from "@tanstack/react-query";
3+
import { SearchIcon } from "lucide-react";
4+
import { fileTreeQueryOptions } from "~/lib/editorReactQuery";
5+
import {
6+
selectProjectEditorTabs,
7+
selectProjectActiveEditorTabId,
8+
useSidePanelStore,
9+
} from "~/sidePanelStore";
10+
import { FileTree } from "./editor/FileTree";
11+
12+
const MonacoEditor = lazy(() => import("./editor/MonacoEditor"));
13+
14+
const EditorPanel = memo(function EditorPanel({ cwd }: { cwd: string | null }) {
15+
const editorTabs = useSidePanelStore(selectProjectEditorTabs);
16+
const activeEditorTabId = useSidePanelStore(selectProjectActiveEditorTabId);
17+
const openEditorFile = useSidePanelStore((s) => s.openEditorFile);
18+
const pinEditorTab = useSidePanelStore((s) => s.pinEditorTab);
19+
20+
const fileTreeQuery = useQuery(fileTreeQueryOptions(cwd));
21+
const entries = fileTreeQuery.data?.entries ?? [];
22+
23+
const [searchQuery, setSearchQuery] = useState("");
24+
25+
const filteredEntries =
26+
searchQuery.length > 0
27+
? entries.filter((e) => e.path.toLowerCase().includes(searchQuery.toLowerCase()))
28+
: entries;
29+
30+
const activeTab = editorTabs.find((t) => t.id === activeEditorTabId) ?? null;
31+
const activeFilePath = activeTab?.relativePath ?? null;
32+
33+
const handleFileClick = useCallback(
34+
(relativePath: string) => {
35+
openEditorFile(relativePath);
36+
},
37+
[openEditorFile],
38+
);
39+
40+
const handleFileDoubleClick = useCallback(
41+
(relativePath: string) => {
42+
openEditorFile(relativePath);
43+
const state = useSidePanelStore.getState();
44+
const projectId = state.activeProjectId;
45+
if (!projectId) return;
46+
const editorState = state.editorStateByProjectId[projectId];
47+
const tab = editorState?.tabs.find((t) => t.relativePath === relativePath);
48+
if (tab) pinEditorTab(tab.id);
49+
},
50+
[openEditorFile, pinEditorTab],
51+
);
52+
53+
return (
54+
<div className="flex min-h-0 flex-1">
55+
{/* Left: File tree */}
56+
<div className="flex w-52 shrink-0 flex-col border-r border-border bg-card">
57+
<div className="p-1.5">
58+
<div className="relative">
59+
<SearchIcon className="absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
60+
<input
61+
type="text"
62+
value={searchQuery}
63+
onChange={(e) => setSearchQuery(e.target.value)}
64+
className="h-6 w-full rounded-md bg-accent/40 pl-7 pr-2 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring"
65+
placeholder="Search files..."
66+
/>
67+
</div>
68+
</div>
69+
<FileTree
70+
entries={filteredEntries}
71+
activeFilePath={activeFilePath}
72+
onFileClick={handleFileClick}
73+
onFileDoubleClick={handleFileDoubleClick}
74+
/>
75+
</div>
76+
77+
{/* Right: Monaco editor (no duplicate tab bar — it's in SidePanel top row) */}
78+
<div className="flex min-w-0 flex-1 flex-col">
79+
<Suspense
80+
fallback={
81+
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
82+
Loading editor...
83+
</div>
84+
}
85+
>
86+
<MonacoEditor cwd={cwd} relativePath={activeFilePath} />
87+
</Suspense>
88+
</div>
89+
</div>
90+
);
91+
});
92+
93+
export default EditorPanel;

0 commit comments

Comments
 (0)