Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
normalizePastedCloneUrl,
parseAddProjectRemoteSource,
resolveAddProjectPath,
resolveNewProjectsRoot,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
} from "@t3tools/client-runtime/operations/projects";
Expand Down Expand Up @@ -388,7 +389,7 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
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,
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -525,7 +528,7 @@ export function NewTaskFlowProvider(
? resolveNewThreadEnvMode({
projectSettings,
workspaceRoot: selectedProject?.workspaceRoot ?? null,
newProjectsRoot: selectedEnvironmentServerConfig?.newProjectsRoot,
newProjectsRoot: resolveNewProjectsRoot(selectedEnvironmentServerConfig),
projectHasThreads: selectedProjectHasThreads,
})
: "local";
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () {
settledWorktreeCleanup: true,
worktreesDirectory: true,
threadAutoSettlementScope: true,
projectsDirectory: true,
threadRestartContinuation: true,
projectSettingsOverrides: true,
projectProviderInstanceOverrides: true,
Expand Down
52 changes: 51 additions & 1 deletion apps/server/src/project/ManagedProjectFolders.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) =>
Expand All @@ -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),
);
Expand Down Expand Up @@ -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" },
),
);
49 changes: 39 additions & 10 deletions apps/server/src/project/ManagedProjectFolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<ScratchUnavailableError>()(
Expand Down Expand Up @@ -84,7 +87,21 @@ export class NamedProjectCreateError extends Schema.TaggedError<NamedProjectCrea
}
}

export type NamedProjectError = NamedProjectFolderError | NamedProjectCreateError;
export class NamedProjectLocationError extends Schema.TaggedError<NamedProjectLocationError>()(
"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,
Expand All @@ -97,6 +114,7 @@ export class ManagedProjectFolders extends Context.Service<
readonly threadId: ThreadId;
readonly text: string;
}) => Effect.Effect<Option.Option<string>, 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<
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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.`,
});
});
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import {
WS_METHODS,
WsRpcGroup,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import {
HttpRouter,
HttpServerRequest,
Expand Down Expand Up @@ -1793,6 +1794,7 @@ export const layerWsRpc = (
onSome: (root) => ({ scratchWorkspaceRoot: root }),
}),
newProjectsRoot: managedFolders.namedProjectsRoot,
homeDirectory: yield* HostProcess.HomeDirectory,
...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }),
};
});
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
addProjectRemoteSourceLabel,
addProjectRemoteSourcePathHint,
addProjectRemoteSourceProvider,
resolveNewProjectsRoot,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
type AddProjectRemoteSourceReadiness,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/promptLinks/NewThreadLinkView.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 ||
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -1651,6 +1661,38 @@ export function GeneralSettingsPanel() {
/>
}
/>
{supportsProjectsDirectory && (
<SettingsRow
serverScoped
settingKeys={["projectsDirectory"]}
{...searchableSetting("new-project-location")}
description="Folder where projects started from just a name are created, such as ~/Projects. Existing projects stay where they are. Leave empty to use the T3 home folder."
resetAction={
mixedProjectsDirectory ||
settings.projectsDirectory !== DEFAULT_UNIFIED_SETTINGS.projectsDirectory ? (
<SettingResetButton
label="new project location"
onClick={() =>
updateSettings({
projectsDirectory: DEFAULT_UNIFIED_SETTINGS.projectsDirectory,
})
}
/>
) : null
}
control={
<DraftInput
size="sm"
className="w-full sm:w-72"
value={mixedProjectsDirectory ? "" : settings.projectsDirectory}
onCommit={(next) => updateSettings({ projectsDirectory: next })}
placeholder={projectsDirectoryPlaceholder}
spellCheck={false}
aria-label="New project location"
/>
}
/>
)}
</SettingsSection>

<SettingsSection id="confirmations" title="Confirmations">
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/hooks/useHandleNewThread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) =>
Expand Down
Loading
Loading