From 0ce7212842698b6aef5bf50d720ba1226f28b183 Mon Sep 17 00:00:00 2001 From: Marc Neuhaus Date: Thu, 8 Oct 2026 12:16:37 +0200 Subject: [PATCH] feat(server,web): choose where new projects are created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects started from just a name always landed in `/projects`. A new `projectsDirectory` server setting (Settings → General → New project location) moves them, for example to `~/Projects`. It follows the worktree location rules: `~` expands, and the folder must be absolute on that machine and not a drive root; otherwise the create fails with a message that names the setting. The server reads it at create time, so the palette, the mobile Add Project screen and the t3_project_create MCP tool all follow it. Clients read the folder through resolveNewProjectsRoot, which prefers the live setting over the config snapshot's default folder and expands `~` with the server's home (new optional ServerConfig.homeDirectory). The palette and mobile previews, the first-thread-in-the-project-folder rule for new projects (web and mobile), and clones from a prompt link all use it. --- .../features/projects/AddProjectScreen.tsx | 3 +- .../threads/new-task-flow-provider.tsx | 7 ++- .../src/environment/ServerEnvironment.ts | 1 + .../src/project/ManagedProjectFolders.test.ts | 52 ++++++++++++++++++- .../src/project/ManagedProjectFolders.ts | 49 +++++++++++++---- apps/server/src/ws.ts | 2 + apps/web/src/components/CommandPalette.tsx | 6 ++- .../promptLinks/NewThreadLinkView.tsx | 7 ++- .../components/settings/SettingsPanels.tsx | 42 +++++++++++++++ .../src/components/settings/settingsSearch.ts | 7 +++ apps/web/src/hooks/useHandleNewThread.test.ts | 3 ++ apps/web/src/hooks/useHandleNewThread.ts | 5 +- docs/user/source-control.md | 7 +-- .../src/operations/projects.test.ts | 33 ++++++++++++ .../client-runtime/src/operations/projects.ts | 25 +++++++++ packages/contracts/src/environment.ts | 1 + packages/contracts/src/server.ts | 3 ++ packages/contracts/src/settings.ts | 3 ++ 18 files changed, 234 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 6de42ccd7a1b..f132c4aa10a8 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -25,6 +25,7 @@ import { normalizePastedCloneUrl, parseAddProjectRemoteSource, resolveAddProjectPath, + resolveNewProjectsRoot, sortAddProjectProviderSources, type AddProjectRemoteSource, } from "@t3tools/client-runtime/operations/projects"; @@ -388,7 +389,7 @@ function useEnvironmentOptions(): ReadonlyArray { platform: platformFromOs(config?.environment.platform.os ?? null), machine: resolveEnvironmentMachineKind(config ?? null), baseDirectory: config?.settings.addProjectBaseDirectory ?? null, - newProjectsRoot: config?.newProjectsRoot ?? null, + newProjectsRoot: resolveNewProjectsRoot(config), connectionState: runtime?.connectionState ?? "available", connectionError: runtime?.connectionError ?? null, connectionErrorTraceId: runtime?.connectionErrorTraceId ?? null, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 7218e89f50c4..00bab3a34ddc 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -97,7 +97,10 @@ import { useRemoteConnectionStatus, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + availableScratchWorkspaceRoot, + resolveNewProjectsRoot, +} from "@t3tools/client-runtime/operations/projects"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -525,7 +528,7 @@ export function NewTaskFlowProvider( ? resolveNewThreadEnvMode({ projectSettings, workspaceRoot: selectedProject?.workspaceRoot ?? null, - newProjectsRoot: selectedEnvironmentServerConfig?.newProjectsRoot, + newProjectsRoot: resolveNewProjectsRoot(selectedEnvironmentServerConfig), projectHasThreads: selectedProjectHasThreads, }) : "local"; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2bd56e30e9dc..3b581cae4629 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () { settledWorktreeCleanup: true, worktreesDirectory: true, threadAutoSettlementScope: true, + projectsDirectory: true, threadRestartContinuation: true, projectSettingsOverrides: true, projectProviderInstanceOverrides: true, diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 86dbcc5beb61..a049a2410091 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -56,6 +57,8 @@ interface HarnessOptions { readonly projects?: ( real: ProjectService.ProjectService["Service"], ) => ProjectService.ProjectService["Service"]; + /** The `projectsDirectory` setting, given the data dir. */ + readonly projectsDirectory?: (baseDir: string) => string; } const layer = (baseDir: string, options?: HarnessOptions) => @@ -74,7 +77,9 @@ const layer = (baseDir: string, options?: HarnessOptions) => Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)), Layer.provideMerge(options?.git ?? layerRealGit), Layer.provideMerge(SqlitePersistence.layerMemory), - Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge( + ServerSettings.layerTest({ projectsDirectory: options?.projectsDirectory?.(baseDir) ?? "" }), + ), Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -533,3 +538,48 @@ it.effect("removes the folder when the create is cancelled before the project ex ); }), ); + +it.effect("starts a named project under the projectsDirectory setting", () => + withScratch( + ({ baseDir }) => + withGitEnv( + TEST_IDENTITY, + Effect.gen(function* () { + const folders = yield* ManagedProjectFolders.ManagedProjectFolders; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const created = yield* folders + .createNamedProject({ name: "Pinball Stats" }) + .pipe(Effect.provideService(HostProcess.HomeDirectory, baseDir)); + + // `~` is the server's home; the folder is created on first use, and + // the default stays the fallback. + assert.equal(created.workspaceRoot, path.join(baseDir, "elsewhere", "pinball-stats")); + assert.equal(folders.namedProjectsRoot, path.resolve(baseDir, "projects")); + assert.isFalse(yield* fileSystem.exists(path.resolve(baseDir, "projects"))); + }), + ), + { projectsDirectory: () => "~/elsewhere" }, + ), +); + +it.effect("refuses a projectsDirectory that is not an absolute folder", () => + withScratch( + ({ baseDir }) => + Effect.gen(function* () { + const folders = yield* ManagedProjectFolders.ManagedProjectFolders; + const projects = yield* ProjectService.ProjectService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const failure = yield* Effect.flip(folders.createNamedProject({ name: "Pinball Stats" })); + + assert.equal(failure._tag, "NamedProjectLocationError"); + assert.include(failure.message, `"projects/mine"`); + assert.isFalse(yield* fileSystem.exists(path.resolve(baseDir, "projects"))); + assert.deepEqual((yield* projects.snapshot).projects, []); + }), + { projectsDirectory: () => "projects/mine" }, + ), +); diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 1e1fab92562e..2135f66a2a42 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -5,6 +5,7 @@ import { type ThreadId, VcsProcessSpawnError, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { newProjectFolderName } from "@t3tools/shared/path"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -22,7 +23,9 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; import * as ProjectService from "./ProjectService.ts"; export class ScratchUnavailableError extends Schema.TaggedError()( @@ -84,7 +87,21 @@ export class NamedProjectCreateError extends Schema.TaggedError()( + "NamedProjectLocationError", + { + location: Schema.String, + }, +) { + override get message(): string { + return `The project location "${this.location}" must be an absolute folder on this machine, not a drive root. Change it in Settings → General.`; + } +} + +export type NamedProjectError = + | NamedProjectLocationError + | NamedProjectFolderError + | NamedProjectCreateError; export class ManagedProjectFolders extends Context.Service< ManagedProjectFolders, @@ -97,6 +114,7 @@ export class ManagedProjectFolders extends Context.Service< readonly threadId: ThreadId; readonly text: string; }) => Effect.Effect, ScratchFolderError>; + /** Default folder for name-only projects; a non-empty `projectsDirectory` setting replaces it. */ readonly namedProjectsRoot: string; /** Removes failed/cancelled creations' folders only while no project owns them. */ readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect< @@ -208,6 +226,7 @@ const make = Effect.gen(function* () { const gitWorkflow = yield* GitWorkflow.GitWorkflowService; const git = yield* GitVcsDriver.GitVcsDriver; const projects = yield* ProjectService.ProjectService; + const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. @@ -342,16 +361,29 @@ const make = Effect.gen(function* () { }); const namedProjectsRoot = path.resolve(config.baseDir, "projects"); + // Best effort: a settings read failure falls back to the default folder. + const readProjectsDirectory = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.projectsDirectory), + Effect.orElseSucceed(() => ""), + ); const claimNamedFolder = Effect.fn("ManagedProjectFolders.claimNamedFolder")(function* ( name: string, ) { + const location = yield* readProjectsDirectory; + // Same rules as the worktree location: `~` expands, and the folder must + // be absolute on this machine and not a drive root. + const projectsRoot = resolveWorktreesDirectory( + location, + namedProjectsRoot, + path, + yield* HostProcess.HomeDirectory, + ); + if (projectsRoot === null) return yield* new NamedProjectLocationError({ location }); yield* fileSystem - .makeDirectory(namedProjectsRoot, { recursive: true }) + .makeDirectory(projectsRoot, { recursive: true }) .pipe( - Effect.mapError( - (cause) => new NamedProjectFolderError({ folder: namedProjectsRoot, cause }), - ), + Effect.mapError((cause) => new NamedProjectFolderError({ folder: projectsRoot, cause })), ); const folderName = newProjectFolderName(name); const claimed = yield* claimFreeFolder( @@ -360,17 +392,14 @@ const make = Effect.gen(function* () { attempt > MAX_NAMED_FOLDER_ATTEMPTS ? Option.none() : Option.some( - path.join( - namedProjectsRoot, - attempt === 1 ? folderName : `${folderName}-${attempt}`, - ), + path.join(projectsRoot, attempt === 1 ? folderName : `${folderName}-${attempt}`), ), ), (folder, cause) => new NamedProjectFolderError({ folder, cause }), ); if (Option.isSome(claimed)) return claimed.value; return yield* new NamedProjectFolderError({ - folder: path.join(namedProjectsRoot, folderName), + folder: path.join(projectsRoot, folderName), cause: `Every folder name for "${folderName}" is taken.`, }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a03176a455ea..0de004689062 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -100,6 +100,7 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { HttpRouter, HttpServerRequest, @@ -1793,6 +1794,7 @@ export const layerWsRpc = ( onSome: (root) => ({ scratchWorkspaceRoot: root }), }), newProjectsRoot: managedFolders.namedProjectsRoot, + homeDirectory: yield* HostProcess.HomeDirectory, ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), }; }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7951655a2daf..c99c929db457 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -16,6 +16,7 @@ import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, + resolveNewProjectsRoot, sortAddProjectProviderSources, type AddProjectRemoteSource, type AddProjectRemoteSourceReadiness, @@ -986,8 +987,9 @@ function OpenCommandPaletteDialog(props: { }; const newProjectsRootFor = (environmentId: EnvironmentId | null): string | null => - environments.find((environment) => environment.environmentId === environmentId)?.serverConfig - ?.newProjectsRoot ?? null; + resolveNewProjectsRoot( + environments.find((environment) => environment.environmentId === environmentId)?.serverConfig, + ); const startNewProject = ( environmentId: EnvironmentId, diff --git a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx index 16f1932dd155..c33d88c4a3b3 100644 --- a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx +++ b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx @@ -1,5 +1,8 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { getCloneDestinationPath } from "@t3tools/client-runtime/operations/projects"; +import { + getCloneDestinationPath, + resolveNewProjectsRoot, +} from "@t3tools/client-runtime/operations/projects"; import { findPromptLinkProjects, findPromptLinkPullRequestThread, @@ -198,7 +201,7 @@ export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) { const cloneRepository = async (repository: PromptLinkRepository) => { const environmentId = lastActiveEnvironmentId(); const environment = environments.find((entry) => entry.environmentId === environmentId); - const newProjectsRoot = environment?.serverConfig?.newProjectsRoot ?? null; + const newProjectsRoot = resolveNewProjectsRoot(environment?.serverConfig); if ( environmentId === null || newProjectsRoot === null || diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index adb29f178221..6d9290d9861c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -814,6 +814,11 @@ export function GeneralSettingsPanel() { connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true, ); + const supportsProjectsDirectory = + connectedEnvironments.length > 0 && + connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.projectsDirectory === true, + ); const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, @@ -855,6 +860,11 @@ export function GeneralSettingsPanel() { textGenerationModelInstanceEntries, ); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); + const mixedProjectsDirectory = useScopedSettingsMixed(["projectsDirectory"]); + // One machine shows its own default folder; several may each have a different one. + const projectsDirectoryPlaceholder = mixedProjectsDirectory + ? "Mixed" + : ((isEnvironmentScope ? environment?.serverConfig?.newProjectsRoot : undefined) ?? "Default"); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); return ( @@ -1651,6 +1661,38 @@ export function GeneralSettingsPanel() { /> } /> + {supportsProjectsDirectory && ( + + updateSettings({ + projectsDirectory: DEFAULT_UNIFIED_SETTINGS.projectsDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ projectsDirectory: next })} + placeholder={projectsDirectoryPlaceholder} + spellCheck={false} + aria-label="New project location" + /> + } + /> + )} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 000ae5d4742e..7244d155f8f7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -524,6 +524,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment-defaults", searchTerms: ["base directory folder browser path home"], }, + { + id: "new-project-location", + title: "New project location", + to: "/settings/general", + scope: "environment-defaults", + searchTerms: ["new project location folder directory path name create projects"], + }, { id: "unpin-confirmation", title: "Unpin confirmation", diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 3a30af7e7138..385b89e1aba3 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -121,6 +121,9 @@ vi.mock("@t3tools/client-runtime/environment", () => ({ scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); +vi.mock("@t3tools/client-runtime/operations/projects", () => ({ + resolveNewProjectsRoot: () => null, +})); vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index c89b4b73a4d7..b4e0eef43fb9 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -3,6 +3,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; +import { resolveNewProjectsRoot } from "@t3tools/client-runtime/operations/projects"; import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { @@ -159,7 +160,9 @@ export function useNewThreadHandler() { projectFile, ), workspaceRoot: project?.workspaceRoot ?? null, - newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot, + newProjectsRoot: resolveNewProjectsRoot( + environmentServerConfigs.get(projectRef.environmentId), + ), projectHasThreads: readThreadShells().some( (thread) => diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 57d512dcb3bb..c6e3b7ada564 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -126,9 +126,10 @@ To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+ repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, an icon, and a first commit, then opens a new thread in it. Until the project has a thread, new threads work in its folder, even when your default is a new worktree. The folder is named after the project, -like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also -publish it. If Git has no name or email on that machine, the project is created without the -first commit. +like `pinball-stats` for "Pinball Stats". To put new projects somewhere else, such as +`~/Projects`, set **Settings → General → New project location** on that machine. Turn on +**Create private repository on GitHub** to also publish it. If Git has no name or email on that +machine, the project is created without the first commit. Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting provider or paste a Git URL, then choose where to save it. The project opens right away while the diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index c0a03c5f50a1..4fb265da74a4 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -7,10 +7,43 @@ import { getCloneDirectoryName, getDefaultCloneUrl, normalizePastedCloneUrl, + resolveNewProjectsRoot, } from "./projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; describe("add project shared logic", () => { + it("puts name-only projects in the projectsDirectory setting, else the server default", () => { + const newProjectsRoot = "/Users/julius/.t3/projects"; + expect(resolveNewProjectsRoot({ newProjectsRoot, settings: { projectsDirectory: "" } })).toBe( + newProjectsRoot, + ); + expect( + resolveNewProjectsRoot({ newProjectsRoot, settings: { projectsDirectory: "/srv/code" } }), + ).toBe("/srv/code"); + // `~` expands like the server expands it, so the folder matches project paths. + expect( + resolveNewProjectsRoot({ + newProjectsRoot, + homeDirectory: "/Users/julius", + settings: { projectsDirectory: "~/Projects" }, + }), + ).toBe("/Users/julius/Projects"); + expect( + resolveNewProjectsRoot({ + newProjectsRoot: "C:\\Users\\julius\\.t3\\projects", + homeDirectory: "C:\\Users\\julius", + settings: { projectsDirectory: "~\\Projects" }, + }), + ).toBe("C:\\Users\\julius\\Projects"); + // Without the server's home, `~` stays for the server to expand. + expect( + resolveNewProjectsRoot({ newProjectsRoot, settings: { projectsDirectory: "~/Projects" } }), + ).toBe("~/Projects"); + // A server that cannot start projects from a name offers none, setting or not. + expect(resolveNewProjectsRoot({ settings: { projectsDirectory: "~/Projects" } })).toBeNull(); + expect(resolveNewProjectsRoot(null)).toBeNull(); + }); + it("routes owner/repository shorthand to GitHub over HTTPS", () => { expect(normalizePastedCloneUrl("imputnet/helium")).toBe( "https://github.com/imputnet/helium.git", diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 98c7e00a7163..073e7471a78f 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -5,6 +5,7 @@ import type { ProjectMutation, ProjectId, ServerConfig, + ServerSettings, SourceControlDiscoveryResult, SourceControlRepositoryInfo, } from "@t3tools/contracts"; @@ -44,6 +45,30 @@ export function availableScratchWorkspaceRoot( : null; } +/** + * Name-only project folder: the `projectsDirectory` setting (`~` expanded to the server's home), + * else the server default; null when the server cannot start projects from a name. Read it from + * live settings so a change shows before the next config snapshot. + */ +export function resolveNewProjectsRoot( + serverConfig: + | (Pick & { + readonly settings: Pick; + }) + | null + | undefined, +): string | null { + if (serverConfig?.newProjectsRoot === undefined) return null; + const location = serverConfig.settings.projectsDirectory; + if (location === "") return serverConfig.newProjectsRoot; + const home = serverConfig.homeDirectory; + if (home === undefined) return location; + if (location === "~") return home; + return location.startsWith("~/") || location.startsWith("~\\") + ? getCloneDestinationPath(home, location.slice(2)) + : location; +} + export interface AddProjectRemoteSourceReadinessEntry { readonly ready: boolean; readonly hint: string | null; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 771ade1902f2..b9d95c216d0e 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -94,6 +94,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ settledWorktreeCleanup: Schema.optionalKey(Schema.Boolean), worktreesDirectory: Schema.optionalKey(Schema.Boolean), threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean), + projectsDirectory: Schema.optionalKey(Schema.Boolean), threadRestartContinuation: Schema.optionalKey(Schema.Boolean), projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), /** Hide the switch when absent; older servers silently drop per-project provider patches. */ diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c14b4b4b24ea..8f23928cac4f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -613,7 +613,10 @@ export const ServerConfig = Schema.Struct({ threadFind: Schema.optionalKey(Schema.Boolean), threadFindProgressive: Schema.optionalKey(Schema.Boolean), scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), + /** A non-empty `projectsDirectory` setting overrides it; clients read it through `resolveNewProjectsRoot`. */ newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), + /** The server user's home, so clients expand `~` in path settings like the server does. */ + homeDirectory: Schema.optionalKey(TrimmedNonEmptyString), /** * The latest stall long enough to notice, sent only for a few minutes after * it. A client that reconnects reads it to say why the host stopped diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e47ea801f3ee..01e0a504cf98 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1116,6 +1116,8 @@ export const ServerSettings = Schema.Struct({ previousWorktreesDirectories: Schema.Array(TrimmedString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), + /** Absolute folder for projects started from just a name, e.g. `~/Projects`; empty uses `/projects`. */ + projectsDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), responseStreamingMode: ResponseStreamingMode.pipe( Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), ), @@ -1471,6 +1473,7 @@ export const ServerSettingsPatch = Schema.Struct({ }), ), worktreesDirectory: Schema.optionalKey(TrimmedString), + projectsDirectory: Schema.optionalKey(TrimmedString), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),