Skip to content

Commit fa11600

Browse files
authored
Merge pull request #196 from ReScienceLab/project-folder-name
An unnamed project's folder takes the name its agent gives it
2 parents 2eda5e9 + 22ce6d1 commit fa11600

8 files changed

Lines changed: 264 additions & 20 deletions

File tree

‎canvas/server/agent.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,16 @@ export function createAgentServer(options: {
4545
examplesDir: string;
4646
/** Every project by the name its address carries, `/p/<name>/`. */
4747
projects: () => Map<string, string>;
48+
/** Moves a project the agent has just named into a folder of that name (projects.ts). */
49+
named: (dir: string) => string | undefined;
50+
/** A project by name, or by the name it had before `named` moved it. */
51+
project: (name: string) => string | undefined;
4852
/** This plugin's checkout, whose skills the sessions are given. */
4953
repoRoot: string;
5054
/** `<projects dir>/.workspaces`: a folder per session, a record beside each, and the skills. */
5155
workspaces: string;
5256
}) {
53-
const { examplesDir, projects, repoRoot, workspaces } = options;
57+
const { examplesDir, named, project: projectDir, projects, repoRoot, workspaces } = options;
5458
// One process per message — Claude Code or Codex, by the panel's choice, looked up in
5559
// agents.ts — its output kept here and streamed to the page. The runs are held in memory, the
5660
// newest twenty, for the panel to follow and for the history to say which session is running,
@@ -68,6 +72,8 @@ export function createAgentServer(options: {
6872
child: ChildProcess;
6973
/** The Stop button was pressed. Windows ends a run by exit code 1, which says nothing. */
7074
stopped: boolean;
75+
/** The project it works on, if any. */
76+
dir: string | undefined;
7177
}
7278
>();
7379

@@ -368,7 +374,7 @@ export function createAgentServer(options: {
368374
// The project it was sent from, by the name its address carries. None from the home page
369375
// or an example, and then the agent has no project to write to.
370376
const dir =
371-
project === undefined ? undefined : projects().get(project);
377+
project === undefined ? undefined : projectDir(project);
372378
if (project !== undefined && dir === undefined)
373379
return send(404, "no such project");
374380
// The session the panel is in, or a new one. Its id names a folder and a file, so it has
@@ -562,11 +568,12 @@ export function createAgentServer(options: {
562568
// server's port.
563569
env: {
564570
...process.env,
565-
SP_PROJECT: project,
571+
SP_PROJECT: dir && path.basename(dir),
566572
SP_CANVAS_PORT: String(req.socket.localPort),
567573
},
568574
}),
569575
stopped: false,
576+
dir,
570577
});
571578
runs.set(run.id, run);
572579
record.runs.push(run.id);
@@ -676,6 +683,24 @@ export function createAgentServer(options: {
676683
finish(error.code === "ENOENT" ? def.missing : String(error));
677684
});
678685
run.child.on("close", (code, signal) => {
686+
// The turn that named an unnamed project is over, so its folder can take the name.
687+
// Not when another turn has already started on it: that one moves it when it ends.
688+
if (
689+
dir !== undefined &&
690+
code === 0 &&
691+
![...runs.values()].some((r) => r !== run && r.dir === dir && !ended(r))
692+
) {
693+
// Never in the way of the run ending: a throw here would leave it running for good.
694+
try {
695+
const moved = named(dir);
696+
if (moved)
697+
record.projects = record.projects.map((had) =>
698+
had === dir ? moved : had,
699+
);
700+
} catch (error) {
701+
console.error(`[agent] ${dir} could not be named: ${error}`);
702+
}
703+
}
679704
settle();
680705
if (ended(run)) return;
681706
const tail = stderr.trim().split("\n").slice(-5).join("\n");

‎canvas/server/projects.test.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net";
44
import os from "node:os";
55
import path from "node:path";
66
import { expect, it } from "vitest";
7-
import { createProjectsServer, withoutFiles } from "./projects.ts";
7+
import { createProjectsServer, namedFolder, withoutFiles } from "./projects.ts";
88

99
// One server for every project: `/` goes to the one opened, else home, each is at `/p/<name>/`, the
1010
// root has the examples and no project, and a page of the server's own can make one. The folder
@@ -202,6 +202,18 @@ it("serves every project at its own address and makes new ones", async () => {
202202
expect(
203203
JSON.parse((await ask("/__sp/projects", { name: " " })).text).url,
204204
).toBe(url);
205+
expect(
206+
JSON.parse(
207+
fs.readFileSync(path.join(tmp, "projects/Untitled/project.json"), "utf8"),
208+
).unnamed,
209+
).toBe(true);
210+
// Typed, "Untitled" is the person's name for it, so its folder is never renamed.
211+
await ask("/__sp/projects", { name: "Untitled 9" });
212+
expect(
213+
JSON.parse(
214+
fs.readFileSync(path.join(tmp, "projects/Untitled 9/project.json"), "utf8"),
215+
).unnamed,
216+
).toBeUndefined();
205217
write("projects/Untitled/project.json", JSON.stringify({ name: "Gamma" }));
206218
const titled = JSON.parse((await ask("/__sp/projects.json")).text);
207219
expect(titled.find((p: any) => p.name === "Untitled").title).toBe("Gamma");
@@ -248,3 +260,40 @@ it("keeps a community board it draws off file: addresses, after its doctype", ()
248260
);
249261
expect(withoutFiles("<p>")).toMatch(/^<meta [^>]+><p>$/);
250262
});
263+
264+
// A project made without a name takes the one its agent writes, and only then.
265+
it("names an Untitled project's folder from its project.json", () => {
266+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sp-named-"));
267+
const project = (folder: string, json: object) => {
268+
fs.mkdirSync(path.join(tmp, folder));
269+
fs.writeFileSync(
270+
path.join(tmp, folder, "project.json"),
271+
JSON.stringify({ unnamed: true, ...json }),
272+
);
273+
return path.join(tmp, folder);
274+
};
275+
expect(namedFolder(project("Untitled 10", { name: "Acme Inc. " }))).toBe(
276+
path.join(tmp, "Acme Inc"),
277+
);
278+
expect(namedFolder(project("Untitled 3", { name: " Kasra " }))).toBe(
279+
path.join(tmp, "Kasra"),
280+
);
281+
expect(namedFolder(project("Untitled", { format: 1 }))).toBeUndefined();
282+
expect(namedFolder(project("Mine", { name: "Other" }))).toBeUndefined();
283+
// An "Untitled" the person typed has no `unnamed`.
284+
expect(
285+
namedFolder(project("Untitled 11", { name: "Other", unnamed: undefined })),
286+
).toBeUndefined();
287+
expect(namedFolder(project("Untitled 2", { name: "Mine" }))).toBeUndefined();
288+
expect(namedFolder(project("Untitled 4", { name: "a/b" }))).toBeUndefined();
289+
expect(
290+
namedFolder(project("Untitled 5", { name: ".hidden" })),
291+
).toBeUndefined();
292+
for (const [folder, name] of [
293+
["Untitled 6", "a\\b"],
294+
["Untitled 7", "a: b"],
295+
["Untitled 8", "CON"],
296+
["Untitled 9", 42],
297+
] as const)
298+
expect(namedFolder(project(folder, { name }))).toBeUndefined();
299+
});

‎canvas/server/projects.ts‎

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createAgentServer } from "./agent.ts";
2020
import { CANVASES, readJson } from "./boards.ts";
2121
import {
2222
createSpServer,
23+
readProjectJson,
2324
reveal,
2425
SANDBOX,
2526
sameOrigin,
@@ -109,6 +110,33 @@ export function loopbackHost(host: string | undefined) {
109110
);
110111
}
111112

113+
/**
114+
* Where an "Untitled" project's folder goes once its agent has named it in project.json: a folder
115+
* of that name beside it. Undefined for a folder the person named, "Untitled" included, a name no
116+
* folder can have, and a name another project already has.
117+
*/
118+
export function namedFolder(dir: string) {
119+
const { name: given, unnamed } = readProjectJson(dir);
120+
// Held to what Windows allows on every platform, since a project is also opened there once
121+
// shared: it drops a trailing dot or space, so they go here too, and these characters and device
122+
// names cannot be a folder's.
123+
const name =
124+
typeof given === "string" ? given.trim().replace(/[. ]+$/, "") : "";
125+
if (
126+
unnamed !== true ||
127+
!/^Untitled( \d+)?$/.test(path.basename(dir)) ||
128+
!name ||
129+
name.startsWith(".") ||
130+
path.basename(name) !== name ||
131+
// oxlint-disable-next-line no-control-regex
132+
/[<>:"|?*\\\x00-\x1f]/.test(name) ||
133+
/^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i.test(name)
134+
)
135+
return undefined;
136+
const to = path.join(path.dirname(dir), name);
137+
return fs.existsSync(to) ? undefined : to;
138+
}
139+
112140
export function createProjectsServer(options: {
113141
/** Where every project is listed from, and where `POST /__sp/projects` makes one. */
114142
projectsDir: string;
@@ -157,10 +185,53 @@ export function createProjectsServer(options: {
157185
repoRoot,
158186
});
159187

188+
// A project made without a name is an "Untitled" folder until its agent writes one into
189+
// project.json (AppShell.tsx). Once that turn is over, with no process holding the old path,
190+
// the folder takes the name, and the pages open on it follow (canvasIndex.ts). A name no folder
191+
// can have, or one another project has, leaves it where it is. A folder the person named keeps
192+
// its name whatever project.json says. Answers the new folder.
193+
// A renamed project's old name, answered with its new folder: a page on the old address still
194+
// saves its last edits there as it leaves, and a message sent as the turn ended names it.
195+
const renamed = new Map<string, string>();
196+
const project = (name: string) => {
197+
const dir = projects().get(name) ?? renamed.get(name);
198+
return dir !== undefined && fs.existsSync(dir) ? dir : undefined;
199+
};
200+
const named = (dir: string) => {
201+
const to = namedFolder(dir);
202+
if (to === undefined) return undefined;
203+
const name = path.basename(to);
204+
// Its server goes first: Windows will not move a folder that is being watched. A page asking
205+
// again, after a move that failed, gets a new one.
206+
const sp = sps.get(dir);
207+
sps.delete(dir);
208+
sp?.unwatch();
209+
try {
210+
fs.renameSync(dir, to);
211+
} catch (error) {
212+
console.error(
213+
`[projects] ${dir} could not be renamed to “${name}”: ${error}`,
214+
);
215+
sp?.close();
216+
return undefined;
217+
}
218+
renamed.set(path.basename(dir), to);
219+
// Every open page, not only the project's: the window's tab for it moves whichever is in front.
220+
const moved = {
221+
from: `/p/${encodeURIComponent(path.basename(dir))}/`,
222+
to: `/p/${encodeURIComponent(name)}/`,
223+
};
224+
for (const other of [root, sp, ...sps.values()]) other?.moved(moved);
225+
sp?.close();
226+
return to;
227+
};
228+
160229
// The agent, once for the whole server: its sessions and the skills they read are kept in a dot
161230
// folder of the projects directory, which the list above skips.
162231
const agent = createAgentServer({
163232
examplesDir,
233+
named,
234+
project,
164235
projects,
165236
repoRoot,
166237
workspaces: path.join(projectsDir, ".workspaces"),
@@ -312,9 +383,15 @@ export function createProjectsServer(options: {
312383
// "Untitled", and its agent names it (sp.ts, PROJECT_JSON). It goes under the projects
313384
// folder, so there is no place to pick. The checks are the ones the dialog cannot make.
314385
let name = typeof parsed.name === "string" ? parsed.name.trim() : "";
315-
if (name === "") {
386+
const unnamed = name === "";
387+
if (unnamed) {
316388
name = "Untitled";
317-
for (let n = 2; fs.existsSync(path.join(projectsDir, name)); n++)
389+
// Nor a name a renamed project had, which still answers for it (`renamed`).
390+
for (
391+
let n = 2;
392+
fs.existsSync(path.join(projectsDir, name)) || renamed.has(name);
393+
n++
394+
)
318395
name = `Untitled ${n}`;
319396
}
320397
if (name.startsWith(".") || path.basename(name) !== name)
@@ -323,7 +400,8 @@ export function createProjectsServer(options: {
323400
"A name cannot start with a dot or have a slash in it.",
324401
);
325402
const dir = path.join(projectsDir, name);
326-
if (fs.existsSync(dir))
403+
// A renamed project's old name is still its own (`renamed`).
404+
if (fs.existsSync(dir) || renamed.has(name))
327405
return send(
328406
409,
329407
`You already have a project called “${name}”. Try another name.`,
@@ -334,9 +412,10 @@ export function createProjectsServer(options: {
334412
// nothing to copy in.
335413
fs.mkdirSync(path.join(dir, CANVASES), { recursive: true });
336414
// Its id is what a package of it is known by, whatever the folder is renamed to.
415+
// `unnamed` marks the folder as one the app named, so its agent's name can replace it.
337416
fs.writeFileSync(
338417
path.join(dir, "project.json"),
339-
`${JSON.stringify({ format: PROJECT_FORMAT, id: crypto.randomUUID() }, null, 2)}\n`,
418+
`${JSON.stringify({ format: PROJECT_FORMAT, id: crypto.randomUUID(), ...(unnamed && { unnamed }) }, null, 2)}\n`,
340419
);
341420
} catch (e) {
342421
// A new name does not fix an unwritable Documents, so say what failed.
@@ -441,12 +520,22 @@ export function createProjectsServer(options: {
441520
if (rest === undefined) return root.handle(req, res, next);
442521
let dir: string | undefined;
443522
try {
444-
dir = projects().get(decodeURIComponent(name));
523+
dir = project(decodeURIComponent(name));
445524
} catch {} // a broken escape is no project's name
446525
if (dir === undefined) {
447526
res.statusCode = 404;
448527
return res.end("no such project");
449528
}
529+
// A page asked for by the name the project had, from a card or a link made before it moved:
530+
// sent to its address now, so it opens once, under one tab.
531+
if (!projects().has(decodeURIComponent(name)) && req.method === "GET") {
532+
res.statusCode = 302;
533+
res.setHeader(
534+
"location",
535+
`/p/${encodeURIComponent(path.basename(dir))}${rest}`,
536+
);
537+
return res.end();
538+
}
450539
// Made by a newer app, which may keep it in a way this one would misread, and then write back.
451540
const format = (
452541
readJson(path.join(dir, "project.json")) as { format?: unknown }

‎canvas/server/sp.ts‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ export function canvasFile(canvasesDir: string, examplesDir: string, name: strin
9797
/**
9898
* The project's own settings, beside its canvases: which cover it chose, and the name it is shown
9999
* by when that is not its folder's. A project made without a name is an "Untitled" folder whose
100-
* agent writes `name` here (AppShell.tsx), since renaming the folder would move every address the
101-
* open tab and the chat hold.
100+
* agent writes `name` here (AppShell.tsx). The folder takes that name once the turn that wrote it
101+
* is over (projects.ts, `named`), since during it the agent holds the folder's path.
102102
*/
103103
const PROJECT_JSON = "project.json";
104104

@@ -277,10 +277,11 @@ export function shoot(board: string, size: number[], res: ServerResponse) {
277277
}
278278

279279
/** A project's project.json, or nothing in it when it has none or it does not parse. */
280-
const readProjectJson = (dir: string) =>
280+
export const readProjectJson = (dir: string) =>
281281
(readJson(path.join(dir, PROJECT_JSON)) ?? {}) as {
282282
cover?: ChosenCover;
283283
name?: string;
284+
unnamed?: boolean;
284285
};
285286

286287
export function createSpServer(options: {
@@ -344,7 +345,7 @@ export function createSpServer(options: {
344345
// edited. canvasIndex.ts listens.
345346
const pages = new Set<ServerResponse>();
346347
const broadcast = (
347-
event: "reload" | "index" | "layout" | "docs" | "content",
348+
event: "reload" | "index" | "layout" | "docs" | "content" | "moved",
348349
data: unknown,
349350
) => {
350351
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -1312,12 +1313,18 @@ export function createSpServer(options: {
13121313
docWatcher.unref();
13131314
}
13141315

1316+
const unwatch = () => {
1317+
watcher?.close();
1318+
docWatcher?.close();
1319+
clearTimeout(docBatch);
1320+
};
13151321
return {
13161322
handle,
1323+
unwatch,
1324+
/** Tells the pages a project's address changed with its folder's name (projects.ts). */
1325+
moved: (moved: { from: string; to: string }) => broadcast("moved", moved),
13171326
close() {
1318-
watcher?.close();
1319-
docWatcher?.close();
1320-
clearTimeout(docBatch);
1327+
unwatch();
13211328
for (const page of pages) page.end();
13221329
pages.clear();
13231330
},

0 commit comments

Comments
 (0)