diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 7d22b2e27eac8..4c00529389764 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -69,7 +69,7 @@ vi.mock("../threads/thread-settings-options", () => ({ withManagedRuntimeModeLimits: (choices: readonly unknown[]) => choices, })); vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children })); -vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); +vi.mock("../../components/ui/AppText", () => ({ AppText: Children, AppTextInput: () => null })); vi.mock("../../components/ui/Button", () => ({ Button: ({ label, diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 5026a90e13b9a..7a9ad6c4074e4 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -3,7 +3,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { AuthSettingsWriteScope } from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; -import { AppText as Text } from "../../components/ui/AppText"; +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; import { type ResponseStreamingMode, type ServerSettings, @@ -13,7 +13,7 @@ import { PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectScopedServerSettingKey, } from "@t3tools/contracts"; -import { useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -209,6 +209,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const isMixed = (key: keyof ServerSettings) => reference === null || displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); + const uniformDirectory = (key: "newProjectDirectory" | "addProjectBaseDirectory") => { + const saved = uniform(key); + return typeof saved === "string" ? saved : ""; + }; const writeSettings = useSettingsWriter(); const write = (patch: ServerSettingsPatch) => { if (writeInFlight.current) { @@ -242,6 +246,26 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { if (queued?.key === targetsKey) write({ ...patch, ...queued.patch }); }); }; + // Save on exit too: Android Back hides the keyboard without ending editing. + type DirectoryKey = "newProjectDirectory" | "addProjectBaseDirectory"; + const directoryDrafts = useRef>>({}); + const takeDirectoryDraft = (key: DirectoryKey): ServerSettingsPatch => { + const draft = directoryDrafts.current[key]; + delete directoryDrafts.current[key]; + // A draft typed over an older saved value is stale: the new value already remounted its input. + if (draft === undefined || draft.over !== uniformDirectory(key)) return {}; + const text = draft.text.trim(); + return text === draft.over ? {} : { [key]: text }; + }; + const saveDirectoryDrafts = useEffectEvent(() => { + const patch = { + ...takeDirectoryDraft("newProjectDirectory"), + ...takeDirectoryDraft("addProjectBaseDirectory"), + }; + if (Object.keys(patch).length > 0) write(patch); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- saves unsent directory drafts to server settings when the screen unmounts + useEffect(() => () => saveDirectoryDrafts(), []); const clearProjectOverrides = () => { if ( writeInFlight.current || @@ -400,6 +424,47 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null} + {props.page === "threads" ? ( + + {(["newProjectDirectory", "addProjectBaseDirectory"] as const).map((key) => { + const value = uniformDirectory(key); + const label = + key === "newProjectDirectory" + ? "New project location" + : "Add project starts in"; + return ( + + {label} + + {key === "newProjectDirectory" + ? "Leave empty to use T3 home's projects folder." + : "Leave empty to start in your home folder."} + + { + directoryDrafts.current[key] = { text, over: value }; + }} + onEndEditing={(event) => { + delete directoryDrafts.current[key]; + const text = event.nativeEvent.text.trim(); + if (text === value) return; + write({ [key]: text }); + }} + /> + + ); + })} + + ) : null} + {props.page === "source-control" ? ( <> {projectSelected ? null : ( diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 07da49c732539..e1abaef10af1b 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -95,6 +95,7 @@ const withScratch = ( }) => Effect.Effect< A, E, + | ServerSettings.ServerSettingsService | ManagedProjectFolders.ManagedProjectFolders | ProjectService.ProjectService | NodeServices.NodeServices @@ -433,8 +434,20 @@ it.effect("starts a named project as a committed repository, and suffixes a take const projects = yield* ProjectService.ProjectService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = path.resolve(baseDir, "projects"); - assert.equal(folders.namedProjectsRoot, root); + assert.equal(folders.namedProjectsRoot, path.resolve(baseDir, "projects")); + const root = path.resolve(baseDir, "custom-projects"); + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ newProjectDirectory: root }); + assert.equal(yield* folders.resolveNamedProjectsRoot(root), root); + const home = yield* HostProcess.HomeDirectory; + assert.equal( + yield* folders.resolveNamedProjectsRoot("~/custom-projects"), + path.resolve(home, "custom-projects"), + ); + assert.equal( + yield* folders.resolveNamedProjectsRoot("custom-projects"), + path.resolve(home, "custom-projects"), + ); const attributes = path.join(baseDir, "crlf.gitattributes"); const crlfConfig = path.join(baseDir, "crlf.gitconfig"); yield* fileSystem.writeFileString(attributes, "* text=auto eol=crlf\n"); @@ -447,6 +460,12 @@ it.effect("starts a named project as a committed repository, and suffixes a take assert.equal(first.workspaceRoot, path.join(root, "pinball-stats")); assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2")); + yield* settings.updateSettings({ newProjectDirectory: "" }); + const restored = yield* folders.createNamedProject({ name: "Default location" }); + assert.equal( + restored.workspaceRoot, + path.join(folders.namedProjectsRoot, "default-location"), + ); assert.isUndefined(first.commitError); const project = Option.getOrThrow(yield* projects.getById(first.projectId)); assert.equal(project.title, "Pinball Stats"); diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index d526c381fb404..7be7134067a8a 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -6,6 +6,9 @@ import { type ThreadId, VcsProcessSpawnError, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as ServerSettings from "../serverSettings.ts"; import { foldLatinDiacritics, newProjectFolderName } from "@t3tools/shared/path"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -120,6 +123,7 @@ export class ManagedProjectFolders extends Context.Service< readonly text: string; }) => Effect.Effect, ScratchFolderError>; readonly namedProjectsRoot: string; + readonly resolveNamedProjectsRoot: (directory: string) => Effect.Effect; /** Removes failed/cancelled creations' folders only while no project owns them. */ readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect< { @@ -231,6 +235,8 @@ const make = Effect.gen(function* () { const git = yield* GitVcsDriver.GitVcsDriver; const projects = yield* ProjectService.ProjectService; const random = yield* SecureRandom.SecureRandom; + const settings = yield* ServerSettings.ServerSettingsService; + const home = yield* HostProcess.HomeDirectory; // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. const claimFreeFolder = Effect.fnUntraced(function* ( @@ -392,34 +398,38 @@ const make = Effect.gen(function* () { const namedProjectsRoot = path.resolve(config.baseDir, "projects"); + const resolveNamedProjectsRoot = Effect.fn("ManagedProjectFolders.resolveNamedProjectsRoot")( + (directory: string) => + Effect.succeed( + directory.trim() + ? path.resolve(home, expandHomePath(directory.trim(), home)) + : namedProjectsRoot, + ), + ); + const claimNamedFolder = Effect.fn("ManagedProjectFolders.claimNamedFolder")(function* ( name: string, ) { + const currentSettings = yield* settings.getSettings.pipe( + Effect.mapError((cause) => new NamedProjectFolderError({ folder: namedProjectsRoot, cause })), + ); + const root = yield* resolveNamedProjectsRoot(currentSettings.newProjectDirectory); yield* fileSystem - .makeDirectory(namedProjectsRoot, { recursive: true }) - .pipe( - Effect.mapError( - (cause) => new NamedProjectFolderError({ folder: namedProjectsRoot, cause }), - ), - ); + .makeDirectory(root, { recursive: true }) + .pipe(Effect.mapError((cause) => new NamedProjectFolderError({ folder: root, cause }))); const folderName = newProjectFolderName(name); const claimed = yield* claimFreeFolder( (attempt) => Effect.succeed( attempt > MAX_NAMED_FOLDER_ATTEMPTS ? Option.none() - : Option.some( - path.join( - namedProjectsRoot, - attempt === 1 ? folderName : `${folderName}-${attempt}`, - ), - ), + : Option.some(path.join(root, 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(root, folderName), cause: `Every folder name for "${folderName}" is taken.`, }); }); @@ -514,6 +524,7 @@ const make = Effect.gen(function* () { isScratchProject, folderForThread, namedProjectsRoot, + resolveNamedProjectsRoot, createNamedProject, }); }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 660548c1dd68b..8f13d2b11930f 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -974,6 +974,7 @@ it.effect.each([ Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), + resolveNamedProjectsRoot: () => Effect.succeed(process.cwd()), }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ @@ -1355,6 +1356,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), + resolveNamedProjectsRoot: () => Effect.succeed(process.cwd()), }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 09c6d86c050fd..48a3ebdde1b4d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -397,7 +397,7 @@ export const withLateConfigDiscovery = ( case "providerStatuses": return [{ ...current, providers: event.payload.providers }, [event]]; case "settingsUpdated": - return [{ ...current, settings: event.payload.settings }, [event]]; + return [{ ...current, ...event.payload }, [event]]; // Themes and usage-limit sources survive snapshots because snapshots never include them. default: return [current, [event]]; @@ -1887,7 +1887,9 @@ export const layerWsRpc = ( onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), }), - newProjectsRoot: managedFolders.namedProjectsRoot, + newProjectsRoot: yield* managedFolders.resolveNamedProjectsRoot( + settings.newProjectDirectory, + ), ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), serverTime: DateTime.formatIso(yield* DateTime.now), }; @@ -3407,11 +3409,15 @@ export const layerWsRpc = ( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings, settingsClient), ), - Stream.map((settings) => ({ - version: 1 as const, - type: "settingsUpdated" as const, - payload: { settings }, - })), + Stream.mapEffect((settings) => + managedFolders.resolveNamedProjectsRoot(settings.newProjectDirectory).pipe( + Effect.map((newProjectsRoot) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings, newProjectsRoot }, + })), + ), + ), ); const liveUpdates = Stream.merge( diff --git a/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx new file mode 100644 index 0000000000000..c68ae80eb5638 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx @@ -0,0 +1,28 @@ +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ mixedDirectory: false })); +vi.mock(import("./useScopedSettings"), async (original) => ({ + ...(await original()), + useScopedSettings: (() => DEFAULT_UNIFIED_SETTINGS) as never, + useScopedSettingsMixed: (keys) => keys.includes("newProjectDirectory") && state.mixedDirectory, + useUpdateScopedSettings: () => vi.fn(), +})); + +import { useSettingsRestore } from "./SettingsPanels"; + +function RestoreSummary() { + const { changedSettingLabels } = useSettingsRestore(); + return {changedSettingLabels.join(", ")}; +} + +it("includes a custom project location on another environment in restore confirmation", async () => { + state.mixedDirectory = false; + await using screen = await render(); + await expect.element(screen.getByRole("status")).not.toHaveTextContent("New project location"); + state.mixedDirectory = true; + await screen.rerender(); + await expect.element(screen.getByRole("status")).toHaveTextContent("New project location"); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 633e18a567b2f..4e2132b0460f4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -451,6 +451,7 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); + const mixedNewProjectDirectory = useScopedSettingsMixed(["newProjectDirectory"]); const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), @@ -574,6 +575,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), + ...(mixedNewProjectDirectory || + settings.newProjectDirectory !== DEFAULT_UNIFIED_SETTINGS.newProjectDirectory + ? ["New project location"] + : []), ...(settings.ghqCloneLayout !== DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout ? ["Clone into host folders"] : []), @@ -694,6 +699,7 @@ export function useSettingsRestore(onRestored?: () => void) { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + newProjectDirectory: DEFAULT_UNIFIED_SETTINGS.newProjectDirectory, ghqCloneLayout: DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -1775,12 +1781,45 @@ export function ThreadsSettingsPanel() { textGenerationModelInstanceEntries, true, ); + const mixedNewProjectDirectory = useScopedSettingsMixed(["newProjectDirectory"]); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); return ( + + updateSettings({ + newProjectDirectory: DEFAULT_UNIFIED_SETTINGS.newProjectDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ newProjectDirectory: next })} + normalizeDraft={resolvePastedPath} + placeholder={mixedNewProjectDirectory ? "Mixed" : "Default projects folder"} + spellCheck={false} + aria-label="New project location" + /> + } + /> + Threads > New projects > New project location to choose where New project +creates repositories. Leave it empty to use T3 home's projects folder. Add project starts in +controls the folder browser's initial location. These settings belong to each environment, +including when you connect remotely. diff --git a/packages/client-runtime/src/state/serverConfigProjection.test.ts b/packages/client-runtime/src/state/serverConfigProjection.test.ts index d144972be4a82..38515a7541166 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.test.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.test.ts @@ -77,6 +77,13 @@ describe("applyServerConfigProjection settingsUpdated", () => { payload: { settings: decode() }, } as ServerConfigStreamEvent); expect(Option.getOrThrow(next).config).toBe(Option.getOrThrow(current).config); + const moved = applyServerConfigProjection(next, { + version: 1, + type: "settingsUpdated", + payload: { settings: decode(), newProjectsRoot: "/work/projects" }, + } as unknown as ServerConfigStreamEvent); + expect(Option.getOrThrow(moved).config.newProjectsRoot).toBe("/work/projects"); + expect(Option.getOrThrow(moved).config.settings).toBe(settings); }); }); diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts index 5c04a704c2a5b..27176532df54b 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -153,11 +153,17 @@ export function applyServerConfigProjection( case "settingsUpdated": return Option.map(current, (projection) => { const settings = reuseUnchanged(projection.config.settings, event.payload.settings); + const newProjectsRoot = event.payload.newProjectsRoot ?? projection.config.newProjectsRoot; return { config: - settings === projection.config.settings + settings === projection.config.settings && + newProjectsRoot === projection.config.newProjectsRoot ? projection.config - : { ...projection.config, settings }, + : { + ...projection.config, + settings, + ...(newProjectsRoot === undefined ? {} : { newProjectsRoot }), + }, latestEvent: event, source: "live", }; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e2662bca07463..d83173188927c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -810,6 +810,7 @@ export type ServerConfigProviderStatusDeltasPayload = export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ settings: ServerSettings, + newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7bbb03595d4bb..3d2e95ab05910 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1519,6 +1519,7 @@ export const ServerSettings = Schema.Struct({ worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + newProjectDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), /** Clones default to `///`, like ghq. */ ghqCloneLayout: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1926,6 +1927,7 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), + newProjectDirectory: Schema.optionalKey(TrimmedString), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), ghqCloneLayout: Schema.optionalKey(Schema.Boolean), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),