Skip to content

Commit 3ab124a

Browse files
committed
Simplify command palette project browse flow
1 parent 34890f3 commit 3ab124a

19 files changed

Lines changed: 673 additions & 243 deletions

apps/server/src/orchestration/commandInvariants.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
requireNonNegativeInteger,
1717
requireThread,
1818
requireThreadAbsent,
19+
requireProjectWorkspaceRootAvailable,
1920
} from "./commandInvariants.ts";
2021

2122
const now = new Date().toISOString();
@@ -178,6 +179,40 @@ describe("commandInvariants", () => {
178179
).rejects.toThrow("already exists");
179180
});
180181

182+
it("rejects duplicate active project workspace roots", async () => {
183+
await expect(
184+
Effect.runPromise(
185+
requireProjectWorkspaceRootAvailable({
186+
readModel,
187+
command: {
188+
type: "project.create",
189+
commandId: CommandId.makeUnsafe("cmd-project-dup-root"),
190+
projectId: ProjectId.makeUnsafe("project-c"),
191+
title: "Project C",
192+
workspaceRoot: "/tmp/project-a",
193+
defaultModel: "gpt-5-codex",
194+
createdAt: now,
195+
},
196+
workspaceRoot: "/tmp/project-a",
197+
}),
198+
),
199+
).rejects.toThrow("already used");
200+
201+
await Effect.runPromise(
202+
requireProjectWorkspaceRootAvailable({
203+
readModel,
204+
command: {
205+
type: "project.meta.update",
206+
commandId: CommandId.makeUnsafe("cmd-project-update-root"),
207+
projectId: ProjectId.makeUnsafe("project-a"),
208+
workspaceRoot: "/tmp/project-a",
209+
},
210+
workspaceRoot: "/tmp/project-a",
211+
ignoredProjectId: ProjectId.makeUnsafe("project-a"),
212+
}),
213+
);
214+
});
215+
181216
it("requires non-negative integers", async () => {
182217
await Effect.runPromise(
183218
requireNonNegativeInteger({

apps/server/src/orchestration/commandInvariants.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ export function listThreadsByProjectId(
3838
return readModel.threads.filter((thread) => thread.projectId === projectId);
3939
}
4040

41+
function findActiveProjectByWorkspaceRoot(
42+
readModel: OrchestrationReadModel,
43+
workspaceRoot: string,
44+
ignoredProjectId?: ProjectId,
45+
): OrchestrationProject | undefined {
46+
return readModel.projects.find(
47+
(project) =>
48+
project.deletedAt === null &&
49+
project.workspaceRoot === workspaceRoot &&
50+
project.id !== ignoredProjectId,
51+
);
52+
}
53+
4154
export function requireProject(input: {
4255
readonly readModel: OrchestrationReadModel;
4356
readonly command: OrchestrationCommand;
@@ -71,6 +84,28 @@ export function requireProjectAbsent(input: {
7184
);
7285
}
7386

87+
export function requireProjectWorkspaceRootAvailable(input: {
88+
readonly readModel: OrchestrationReadModel;
89+
readonly command: OrchestrationCommand;
90+
readonly workspaceRoot: string;
91+
readonly ignoredProjectId?: ProjectId;
92+
}): Effect.Effect<void, OrchestrationCommandInvariantError> {
93+
const existingProject = findActiveProjectByWorkspaceRoot(
94+
input.readModel,
95+
input.workspaceRoot,
96+
input.ignoredProjectId,
97+
);
98+
if (!existingProject) {
99+
return Effect.void;
100+
}
101+
return Effect.fail(
102+
invariantError(
103+
input.command.type,
104+
`Project workspace root '${input.workspaceRoot}' is already used by project '${existingProject.id}'.`,
105+
),
106+
);
107+
}
108+
74109
export function requireThread(input: {
75110
readonly readModel: OrchestrationReadModel;
76111
readonly command: OrchestrationCommand;

apps/server/src/orchestration/decider.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { OrchestrationCommandInvariantError } from "./Errors.ts";
99
import {
1010
requireProject,
1111
requireProjectAbsent,
12+
requireProjectWorkspaceRootAvailable,
1213
requireThread,
1314
requireThreadAbsent,
1415
} from "./commandInvariants.ts";
@@ -64,6 +65,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
6465
command,
6566
projectId: command.projectId,
6667
});
68+
yield* requireProjectWorkspaceRootAvailable({
69+
readModel,
70+
command,
71+
workspaceRoot: command.workspaceRoot,
72+
});
6773

6874
return {
6975
...withEventBase({
@@ -91,6 +97,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
9197
command,
9298
projectId: command.projectId,
9399
});
100+
if (command.workspaceRoot !== undefined) {
101+
yield* requireProjectWorkspaceRootAvailable({
102+
readModel,
103+
command,
104+
workspaceRoot: command.workspaceRoot,
105+
ignoredProjectId: command.projectId,
106+
});
107+
}
94108
const occurredAt = nowIso();
95109
return {
96110
...withEventBase({

apps/server/src/wsServer.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,53 @@ describe("WebSocket Server", () => {
16061606
});
16071607
});
16081608

1609+
it("resolves relative filesystem.browse paths against the provided cwd", async () => {
1610+
const workspace = makeTempDir("t3code-ws-filesystem-browse-relative-");
1611+
fs.mkdirSync(path.join(workspace, "apps"), { recursive: true });
1612+
fs.mkdirSync(path.join(workspace, "docs"), { recursive: true });
1613+
1614+
server = await createTestServer({ cwd: "/test" });
1615+
const addr = server.address();
1616+
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1617+
1618+
const [ws] = await connectAndAwaitWelcome(port);
1619+
connections.push(ws);
1620+
1621+
const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
1622+
partialPath: "../d",
1623+
cwd: path.join(workspace, "apps"),
1624+
});
1625+
1626+
expect(response.error).toBeUndefined();
1627+
expect(response.result).toEqual({
1628+
parentPath: workspace,
1629+
entries: [
1630+
{
1631+
name: "docs",
1632+
fullPath: path.join(workspace, "docs"),
1633+
},
1634+
],
1635+
});
1636+
});
1637+
1638+
it("rejects relative filesystem.browse paths without a cwd", async () => {
1639+
server = await createTestServer({ cwd: "/test" });
1640+
const addr = server.address();
1641+
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1642+
1643+
const [ws] = await connectAndAwaitWelcome(port);
1644+
connections.push(ws);
1645+
1646+
const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, {
1647+
partialPath: "./docs",
1648+
});
1649+
1650+
expect(response.result).toBeUndefined();
1651+
expect(response.error?.message).toContain(
1652+
"Relative filesystem browse paths require a current project.",
1653+
);
1654+
});
1655+
16091656
it("supports projects.writeFile within the workspace root", async () => {
16101657
const workspace = makeTempDir("t3code-ws-write-file-");
16111658

apps/server/src/wsServer.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ const isServerNotRunningError = (error: Error): boolean => {
110110
);
111111
};
112112

113+
function isExplicitRelativePath(value: string): boolean {
114+
return (
115+
value.startsWith("./") ||
116+
value.startsWith("../") ||
117+
value.startsWith(".\\") ||
118+
value.startsWith("..\\")
119+
);
120+
}
121+
122+
function resolveFilesystemBrowseInputPath(input: {
123+
cwd: string | undefined;
124+
path: Path.Path;
125+
partialPath: string;
126+
}): Effect.Effect<string | null, never, Path.Path> {
127+
return Effect.gen(function* () {
128+
if (!isExplicitRelativePath(input.partialPath)) {
129+
return input.path.resolve(yield* expandHomePath(input.partialPath));
130+
}
131+
if (!input.cwd) {
132+
return null;
133+
}
134+
const expandedCwd = yield* expandHomePath(input.cwd);
135+
return input.path.resolve(expandedCwd, input.partialPath);
136+
});
137+
}
138+
113139
function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void {
114140
socket.end(
115141
`HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` +
@@ -868,14 +894,30 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
868894

869895
case WS_METHODS.filesystemBrowse: {
870896
const body = stripRequestTag(request.body);
871-
const expanded = path.resolve(yield* expandHomePath(body.partialPath));
897+
const resolvedInputPath = yield* resolveFilesystemBrowseInputPath({
898+
cwd: body.cwd,
899+
path,
900+
partialPath: body.partialPath,
901+
});
902+
if (resolvedInputPath === null) {
903+
return yield* new RouteRequestError({
904+
message: "Relative filesystem browse paths require a current project.",
905+
});
906+
}
907+
908+
const expanded = resolvedInputPath;
872909
const endsWithSep = /[\\/]$/.test(body.partialPath) || body.partialPath === "~";
873910
const parentDir = endsWithSep ? expanded : path.dirname(expanded);
874911
const prefix = endsWithSep ? "" : path.basename(expanded);
875912

876-
const names = yield* fileSystem
877-
.readDirectory(parentDir)
878-
.pipe(Effect.catch(() => Effect.succeed([] as string[])));
913+
const names = yield* fileSystem.readDirectory(parentDir).pipe(
914+
Effect.mapError(
915+
(cause) =>
916+
new RouteRequestError({
917+
message: `Unable to browse '${parentDir}': ${Cause.pretty(Cause.fail(cause)).trim()}`,
918+
}),
919+
),
920+
);
879921

880922
const showHidden = prefix.startsWith(".");
881923
const lowerPrefix = prefix.toLowerCase();
@@ -889,18 +931,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
889931
const entries = yield* Effect.forEach(
890932
filtered,
891933
(name) =>
892-
fileSystem.stat(path.join(parentDir, name)).pipe(
893-
Effect.map((s) =>
894-
s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null,
934+
fileSystem
935+
.stat(path.join(parentDir, name))
936+
.pipe(
937+
Effect.map((s) =>
938+
s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null,
939+
),
895940
),
896-
Effect.catch(() => Effect.succeed(null)),
897-
),
898941
{ concurrency: 16 },
899942
);
900943

901944
return {
902945
parentPath: parentDir,
903-
entries: entries.filter(Boolean).slice(0, 50),
946+
entries: entries.filter(Boolean),
904947
};
905948
}
906949

0 commit comments

Comments
 (0)