@@ -20,6 +20,7 @@ import { createAgentServer } from "./agent.ts";
2020import { CANVASES , readJson } from "./boards.ts" ;
2121import {
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+ ! / ^ U n t i t l e d ( \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+ / ^ ( c o n | p r n | a u x | n u l | c o m \d | l p t \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+
112140export 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 }
0 commit comments