diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts new file mode 100644 index 000000000000..77bd09e7092b --- /dev/null +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -0,0 +1,17 @@ +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; + +import { preferences } from "./handlers.ts"; + +describe("environment handlers", () => { + it("includes worktreesDirectory in environment preferences", () => { + const prefs = preferences(DEFAULT_SERVER_SETTINGS); + assert.equal(prefs.worktreesDirectory, ""); + + const customPrefs = preferences({ + ...DEFAULT_SERVER_SETTINGS, + worktreesDirectory: "/Volumes/MySSD/t3-worktrees", + }); + assert.equal(customPrefs.worktreesDirectory, "/Volumes/MySSD/t3-worktrees"); + }); +}); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index 1aa3851111f9..3f7fa7025d3a 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -11,6 +11,7 @@ export function preferences(settings: ServerSettings) { const { defaultThreadEnvMode, newWorktreesStartFromOrigin, + worktreesDirectory, enableProviderUpdateChecks, backgroundActivity, sourceControlWritingStyle, @@ -19,6 +20,7 @@ export function preferences(settings: ServerSettings) { return { defaultThreadEnvMode, newWorktreesStartFromOrigin, + worktreesDirectory, enableProviderUpdateChecks, backgroundActivity: { profile: backgroundActivity.profile }, sourceControlWritingStyle: { diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index e441174900ce..7704bd07ee3f 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -17,6 +17,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const PreferenceFields = { defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode, newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin, + worktreesDirectory: ServerSettings.fields.worktreesDirectory, enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks, backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }), sourceControlWritingStyle: Schema.Struct({ @@ -58,6 +59,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update" parameters: Schema.Struct({ defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode, newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin, + worktreesDirectory: ServerSettingsPatch.fields.worktreesDirectory, enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks, backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })), sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 82d070f87cd0..c4a1c078acd2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3017,6 +3017,89 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ) .pipe(Effect.flip); assert.match(rootError.detail, /not a drive root/); + + const nestedSubdir = pathService.join(worktreesDirectory, "nested", "custom-path"); + const nestedCreated = yield* driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: "feature/nested-dir" }, + { worktreesDirectory: nestedSubdir }, + ); + const expectedNested = pathService.join( + nestedSubdir, + pathService.basename(cwd), + "feature-nested-dir", + ); + assert.equal(nestedCreated.worktree.path, expectedNested); + assert.equal(yield* fileSystem.exists(expectedNested), true); + }), + ); + + it.effect("resolves relative worktree paths against the repository directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const relativePath = path.join(`relative-${path.basename(cwd)}`, "nested-worktree"); + const wrongParent = path.resolve(process.cwd(), path.dirname(relativePath)); + + yield* Effect.addFinalizer(() => + fileSystem + .remove(wrongParent, { recursive: true }) + .pipe(Effect.orElseSucceed(() => undefined)), + ); + + const created = yield* driver.createWorktree({ + cwd, + path: relativePath, + refName: initialBranch, + newRefName: "feature/relative-path", + }); + const expectedPath = path.resolve(cwd, relativePath); + + assert.equal(created.worktree.path, expectedPath); + assert.equal(yield* fileSystem.exists(expectedPath), true); + assert.equal(yield* fileSystem.exists(wrongParent), false); + }), + ); + + it.effect("keeps worktree directory errors bounded and preserves their cause", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const blocker = path.join(yield* makeTmpDir(), "file-not-directory"); + yield* fileSystem.writeFileString(blocker, "file"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const configuredError = yield* driver + .createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: "feature/configured-failure" }, + { worktreesDirectory: path.join(blocker, "worktrees") }, + ) + .pipe(Effect.flip); + assert.equal( + configuredError.detail, + "Could not create the configured worktree storage directory. Check that the location is accessible and writable, or update Settings → Storage.", + ); + assert.isDefined(configuredError.cause); + assert.notInclude(configuredError.detail, blocker); + + const explicitError = yield* driver + .createWorktree( + { + cwd, + path: path.join(blocker, "explicit-worktree"), + refName: initialBranch, + newRefName: "feature/explicit-failure", + }, + { worktreesDirectory: path.join(blocker, "worktrees") }, + ) + .pipe(Effect.flip); + assert.equal(explicitError.detail, "Could not prepare the requested worktree directory."); + assert.isDefined(explicitError.cause); + assert.notInclude(explicitError.detail, blocker); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 92f7478fa34b..132fa88ac914 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3351,7 +3351,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); - let worktreePath = input.path; + let worktreePath = input.path == null ? null : path.resolve(input.cwd, input.path); if (worktreePath == null) { const parentDir = resolveWorktreesDirectory( options?.worktreesDirectory ?? "", @@ -3368,6 +3368,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } worktreePath = path.join(parentDir, repoName, sanitizedBranch); } + const customConfigured = input.path == null && Boolean(options?.worktreesDirectory?.trim()); + yield* fileSystem.makeDirectory(path.dirname(worktreePath), { recursive: true }).pipe( + Effect.mapError((cause) => { + return new GitCommandError({ + operation: "GitVcsDriver.createWorktree", + command: "mkdir -p", + cwd: input.cwd, + detail: customConfigured + ? "Could not create the configured worktree storage directory. Check that the location is accessible and writable, or update Settings → Storage." + : "Could not prepare the requested worktree directory.", + cause, + }); + }), + ); const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; diff --git a/apps/server/src/worktreesDirectory.test.ts b/apps/server/src/worktreesDirectory.test.ts new file mode 100644 index 000000000000..80488d20ca8e --- /dev/null +++ b/apps/server/src/worktreesDirectory.test.ts @@ -0,0 +1,112 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as NodeOS from "node:os"; + +import { managedWorktreesDirectories, resolveWorktreesDirectory } from "./worktreesDirectory.ts"; + +describe("worktreesDirectory", () => { + it.effect("resolves default directory when setting is empty or whitespace", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const defaultDir = path.resolve("/Users/test/.t3/worktrees"); + + assert.equal(resolveWorktreesDirectory("", defaultDir, path), defaultDir); + assert.equal(resolveWorktreesDirectory(" ", defaultDir, path), defaultDir); + assert.equal(resolveWorktreesDirectory("\t\n", defaultDir, path), defaultDir); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("resolves tilde to user home directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const defaultDir = path.resolve("/Users/test/.t3/worktrees"); + const home = NodeOS.homedir(); + + const result = resolveWorktreesDirectory("~/custom-worktrees", defaultDir, path); + assert.equal(result, path.resolve(home, "custom-worktrees")); + + const resultWithTrailing = resolveWorktreesDirectory("~/custom-worktrees/", defaultDir, path); + assert.equal(resultWithTrailing, path.resolve(home, "custom-worktrees")); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("resolves valid absolute custom path and normalizes trailing slashes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const defaultDir = path.resolve("/Users/test/.t3/worktrees"); + + const result = resolveWorktreesDirectory("/Volumes/External/worktrees/", defaultDir, path); + assert.equal(result, path.resolve("/Volumes/External/worktrees")); + + const resultWithSpaces = resolveWorktreesDirectory( + " /Volumes/External/worktrees ", + defaultDir, + path, + ); + assert.equal(resultWithSpaces, path.resolve("/Volumes/External/worktrees")); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects relative paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const defaultDir = path.resolve("/Users/test/.t3/worktrees"); + + assert.isNull(resolveWorktreesDirectory("relative/path", defaultDir, path)); + assert.isNull(resolveWorktreesDirectory("./relative/path", defaultDir, path)); + assert.isNull(resolveWorktreesDirectory("../relative/path", defaultDir, path)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects filesystem root paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const defaultDir = path.resolve("/Users/test/.t3/worktrees"); + + assert.isNull(resolveWorktreesDirectory("/", defaultDir, path)); + assert.isNull(resolveWorktreesDirectory("///", defaultDir, path)); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("manages worktrees directories including current and previous directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const defaultDir = path.resolve("/Users/test/.t3/worktrees"); + + const dirs = managedWorktreesDirectories( + { + worktreesDirectory: "/Volumes/External/worktrees", + previousWorktreesDirectories: ["/Volumes/OldDisk/worktrees", "/Users/test/.t3/worktrees"], + }, + defaultDir, + path, + ); + + assert.deepEqual(dirs, [ + defaultDir, + path.resolve("/Volumes/External/worktrees"), + path.resolve("/Volumes/OldDisk/worktrees"), + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("filters invalid paths from managed worktrees directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const defaultDir = path.resolve("/Users/test/.t3/worktrees"); + + const dirs = managedWorktreesDirectories( + { + worktreesDirectory: "/", // root -> invalid + previousWorktreesDirectories: ["relative-path", "/Volumes/Valid/worktrees"], + }, + defaultDir, + path, + ); + + assert.deepEqual(dirs, [defaultDir, path.resolve("/Volumes/Valid/worktrees")]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts index 5d4652393747..98d5e46ceb28 100644 --- a/apps/server/src/worktreesDirectory.ts +++ b/apps/server/src/worktreesDirectory.ts @@ -14,8 +14,9 @@ export function resolveWorktreesDirectory( defaultDir: string, path: Path.Path, ): string | null { - if (setting === "") return defaultDir; - const expanded = expandHomePathWith(setting, path); + const trimmed = setting.trim(); + if (trimmed === "") return defaultDir; + const expanded = expandHomePathWith(trimmed, path); if (!path.isAbsolute(expanded)) return null; const resolved = path.resolve(expanded); return isFilesystemRoot(resolved, path) ? null : resolved; diff --git a/apps/web/src/components/settings/StorageSettings.test.tsx b/apps/web/src/components/settings/StorageSettings.test.tsx new file mode 100644 index 000000000000..f7eed2dbc6da --- /dev/null +++ b/apps/web/src/components/settings/StorageSettings.test.tsx @@ -0,0 +1,412 @@ +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ScopedSettingsPatch } from "./scopedSettings"; +import { WorktreesDirectoryRow } from "./StorageSettings"; + +const state = vi.hoisted(() => ({ + settings: { worktreesDirectory: "" } as unknown as typeof DEFAULT_UNIFIED_SETTINGS, + mixed: false, + connectedEnvironments: [] as Array>, + targets: [] as Array>, + primaryEnvironmentId: "env-primary", + desktopLocalBootstraps: [] as Array>, + updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(), + pickFolder: vi.fn<(options?: Record) => Promise>(), +})); + +vi.mock("./useScopedSettings", () => ({ + useScopedSettings: () => state.settings, + useScopedSettingsMixed: () => state.mixed, + useUpdateScopedSettings: () => state.updateSettings, + useClearScopedSettings: () => vi.fn(), +})); + +vi.mock("./SettingsScopeContext", () => ({ + useSettingsScope: () => ({ + scope: { kind: "all", environmentIds: [] }, + environment: null, + connectedEnvironments: state.connectedEnvironments, + targets: state.targets, + }), +})); + +vi.mock("../../connection/useDesktopLocalBootstraps", () => ({ + useDesktopLocalBootstraps: () => state.desktopLocalBootstraps, +})); + +vi.mock("../../state/environments", () => ({ + usePrimaryEnvironmentId: () => state.primaryEnvironmentId, +})); + +vi.mock("../../localApi", () => ({ + ensureLocalApi: () => ({ + dialogs: { + pickFolder: state.pickFolder, + }, + }), +})); + +vi.mock("./settingsSearch", () => ({ + searchableSetting: (id: string) => ({ id, title: "Worktree Storage Directory" }), +})); + +vi.mock("./settingsLayout", () => ({ + SettingResetButton: ({ label, onClick }: { label: string; onClick: () => void }) => ( + + ), + SettingsSection: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsRow: ({ + title, + description, + control, + resetAction, + }: { + title: ReactNode; + description?: ReactNode; + control?: ReactNode; + resetAction?: ReactNode; + }) => ( +
+ {title} + {description ? {description} : null} + {control} + {resetAction} +
+ ), + SettingsPageContainer: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("./SettingsScopeNotice", () => ({ + SettingsScopeNotice: () => null, +})); + +function setDesktopBridge(bridge: unknown) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { desktopBridge: bridge }, + }); +} + +describe("StorageSettings Worktree Storage Directory", () => { + let renderer: ReactTestRenderer | null = null; + + beforeEach(() => { + state.settings = { ...DEFAULT_UNIFIED_SETTINGS, worktreesDirectory: "" }; + state.mixed = false; + state.primaryEnvironmentId = "env-primary"; + state.connectedEnvironments = [ + { + environmentId: "env-wsl", + displayUrl: "http://wsl.local", + entry: { + target: { + _tag: "BearerConnectionTarget", + connectionId: "local:wsl:Ubuntu", + }, + }, + serverConfig: { + environment: { + capabilities: { worktreesDirectory: true }, + platform: { os: "linux" }, + }, + }, + }, + ]; + state.targets = [{ environmentId: "env-wsl", settings: state.settings }]; + state.desktopLocalBootstraps = [{ id: "wsl:Ubuntu", httpBaseUrl: "http://wsl.local" }]; + state.updateSettings.mockClear(); + state.pickFolder.mockReset(); + setDesktopBridge({ + getLocalEnvironmentBootstraps: () => [ + { id: "primary", httpBaseUrl: "http://primary.local" }, + { id: "wsl:Ubuntu", httpBaseUrl: "http://wsl.local" }, + ], + getWslState: async () => ({ + enabled: false, + distro: null, + available: true, + wslOnly: false, + distros: [], + preflightError: null, + }), + }); + }); + + it("renders Worktree Storage Directory with placeholder and description", () => { + act(() => { + renderer = create( + + + , + ); + }); + + const root = renderer!.root; + const input = root.findByProps({ "aria-label": "Worktree Storage Directory" }); + expect(input.props.placeholder).toBe("~/.t3/worktrees (default)"); + expect(input.props.defaultValue).toBe(""); + + const desc = root.findAllByProps({ "data-testid": "row-desc" }); + const hasWorktreeDesc = desc.some((d) => + d.children.includes( + "Directory where git worktrees for threads are created. Point this to an external drive to save internal disk space.", + ), + ); + expect(hasWorktreeDesc).toBe(true); + }); + + it("updates worktree directory on blur", () => { + act(() => { + renderer = create( + + + , + ); + }); + + const root = renderer!.root; + const input = root.findByProps({ "aria-label": "Worktree Storage Directory" }); + + act(() => { + input.props.onChange(); + input.props.onBlur({ target: { value: "/Volumes/MySSD/t3-worktrees" } }); + }); + + expect(state.updateSettings).toHaveBeenCalledWith({ + worktreesDirectory: "/Volumes/MySSD/t3-worktrees", + }); + }); + + it("allows picking folder via folder picker button", async () => { + state.pickFolder.mockResolvedValue("/Volumes/External/worktrees"); + + act(() => { + renderer = create( + + + , + ); + }); + + const root = renderer!.root; + const browseButton = root.findByProps({ "aria-label": "Choose worktree directory" }); + + await act(async () => { + await browseButton.props.onClick(); + }); + + expect(state.pickFolder).toHaveBeenCalledWith({ + initialPath: null, + targetEnvironmentId: "wsl:Ubuntu", + }); + expect(state.updateSettings).toHaveBeenCalledWith({ + worktreesDirectory: "/Volumes/External/worktrees", + }); + }); + + it("hides folder browsing when no desktop picker is available", () => { + setDesktopBridge(undefined); + + act(() => { + renderer = create( + + + , + ); + }); + + expect( + renderer!.root.findAllByProps({ "aria-label": "Choose worktree directory" }), + ).toHaveLength(0); + expect( + renderer!.root.findByProps({ "aria-label": "Worktree Storage Directory" }), + ).toBeDefined(); + }); + + it("hides folder browsing for a scope spanning multiple environments", () => { + state.connectedEnvironments = [ + ...state.connectedEnvironments, + { + environmentId: "env-other", + displayUrl: "http://other.local", + entry: { target: { _tag: "BearerConnectionTarget", connectionId: "remote:other" } }, + serverConfig: { + environment: { + capabilities: { worktreesDirectory: true }, + platform: { os: "linux" }, + }, + }, + }, + ]; + state.targets = [...state.targets, { environmentId: "env-other", settings: state.settings }]; + + act(() => { + renderer = create( + + + , + ); + }); + + expect( + renderer!.root.findAllByProps({ "aria-label": "Choose worktree directory" }), + ).toHaveLength(0); + }); + + it("keeps primary desktop browsing on the primary filesystem", async () => { + state.primaryEnvironmentId = "env-primary"; + state.connectedEnvironments = [ + { + environmentId: "env-primary", + displayUrl: "http://primary.local", + entry: { target: { _tag: "PrimaryConnectionTarget" } }, + serverConfig: { + environment: { + capabilities: { worktreesDirectory: true }, + platform: { os: "darwin" }, + }, + }, + }, + ]; + state.targets = [{ environmentId: "env-primary", settings: state.settings }]; + state.desktopLocalBootstraps = []; + + act(() => { + renderer = create( + + + , + ); + }); + + await act(async () => { + await renderer!.root + .findByProps({ "aria-label": "Choose worktree directory" }) + .props.onClick(); + }); + + expect(state.pickFolder).toHaveBeenCalledWith({ initialPath: null }); + }); + + it("routes a WSL-only primary picker to its WSL backend", async () => { + state.primaryEnvironmentId = "env-primary"; + state.connectedEnvironments = [ + { + environmentId: "env-primary", + displayUrl: "http://primary.local", + entry: { target: { _tag: "PrimaryConnectionTarget" } }, + serverConfig: { + environment: { + capabilities: { worktreesDirectory: true }, + platform: { os: "linux" }, + }, + }, + }, + ]; + state.targets = [{ environmentId: "env-primary", settings: state.settings }]; + state.desktopLocalBootstraps = []; + setDesktopBridge({ + getLocalEnvironmentBootstraps: () => [{ id: "primary", httpBaseUrl: "http://primary.local" }], + getWslState: async () => ({ + enabled: true, + distro: "Ubuntu", + available: true, + wslOnly: true, + distros: [{ name: "Ubuntu", isDefault: true }], + preflightError: null, + }), + }); + + act(() => { + renderer = create( + + + , + ); + }); + + await act(async () => { + await renderer!.root + .findByProps({ "aria-label": "Choose worktree directory" }) + .props.onClick(); + }); + + expect(state.pickFolder).toHaveBeenCalledWith({ + initialPath: null, + targetEnvironmentId: "wsl:Ubuntu", + }); + }); + + it("does not open the picker when WSL state lookup fails", async () => { + state.primaryEnvironmentId = "env-primary"; + state.connectedEnvironments = [ + { + environmentId: "env-primary", + displayUrl: "http://primary.local", + entry: { target: { _tag: "PrimaryConnectionTarget" } }, + serverConfig: { + environment: { + capabilities: { worktreesDirectory: true }, + platform: { os: "linux" }, + }, + }, + }, + ]; + state.targets = [{ environmentId: "env-primary", settings: state.settings }]; + state.desktopLocalBootstraps = []; + setDesktopBridge({ + getLocalEnvironmentBootstraps: () => [{ id: "primary", httpBaseUrl: "http://primary.local" }], + getWslState: async () => { + throw new Error("WSL state unavailable"); + }, + }); + + act(() => { + renderer = create( + + + , + ); + }); + + await act(async () => { + await renderer!.root + .findByProps({ "aria-label": "Choose worktree directory" }) + .props.onClick(); + }); + + expect(state.pickFolder).not.toHaveBeenCalled(); + }); + + it("renders reset button when custom directory is configured and resets to empty string", () => { + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + worktreesDirectory: "/Volumes/MySSD/t3-worktrees", + }; + + act(() => { + renderer = create( + + + , + ); + }); + + const root = renderer!.root; + const resetButton = root.findByProps({ "data-testid": "reset-button" }); + expect(resetButton).toBeDefined(); + + act(() => { + resetButton.props.onClick(); + }); + + expect(state.updateSettings).toHaveBeenCalledWith({ + worktreesDirectory: "", + }); + }); +}); diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 0106acc7cebe..ca18b90a1341 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -1,7 +1,18 @@ -import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts"; +import { + PRIMARY_LOCAL_ENVIRONMENT_ID, + type StorageCleanupSettings, + type WorktreeCleanupRules, +} from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { useRef, useState } from "react"; +import { FolderOpenIcon } from "lucide-react"; +import { ensureLocalApi } from "../../localApi"; +import { desktopLocalBackendId } from "../../connection/desktopLocal"; +import { useDesktopLocalBootstraps } from "../../connection/useDesktopLocalBootstraps"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { resolveProjectPickerTarget } from "../../wslPaths"; +import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; @@ -29,8 +40,25 @@ import { useUpdateScopedSettings, } from "./useScopedSettings"; -function WorktreesDirectoryRow() { +function hasDesktopPrimaryBootstrap(displayUrl: string | null): boolean { + const bridge = typeof window === "undefined" ? undefined : window.desktopBridge; + if (!bridge || displayUrl === null) return false; + try { + return bridge + .getLocalEnvironmentBootstraps() + .some( + (bootstrap) => + bootstrap.id === PRIMARY_LOCAL_ENVIRONMENT_ID && bootstrap.httpBaseUrl === displayUrl, + ); + } catch { + return false; + } +} + +export function WorktreesDirectoryRow() { const { connectedEnvironments, targets } = useSettingsScope(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const desktopLocalBootstraps = useDesktopLocalBootstraps(); const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const mixed = useScopedSettingsMixed(["worktreesDirectory"]); @@ -43,41 +71,118 @@ function WorktreesDirectoryRow() { ) return null; const scopeKey = targets.map((target) => target.environmentId).join(","); + const targetEnvironmentIds = new Set(targets.map((target) => target.environmentId)); + const pickerEnvironmentId = + targetEnvironmentIds.size === 1 ? targetEnvironmentIds.values().next().value : undefined; + const pickerEnvironment = + pickerEnvironmentId === undefined + ? null + : (connectedEnvironments.find( + (environment) => environment.environmentId === pickerEnvironmentId, + ) ?? null); + const pickerBackendId = + pickerEnvironment === null ? null : desktopLocalBackendId(pickerEnvironment.entry.target); + const isDesktopPrimary = + pickerEnvironment !== null && + pickerEnvironment.environmentId === primaryEnvironmentId && + hasDesktopPrimaryBootstrap(pickerEnvironment.displayUrl); + const isDesktopWslBackend = + pickerEnvironment !== null && + pickerBackendId?.startsWith("wsl:") === true && + pickerEnvironment.displayUrl !== null && + desktopLocalBootstraps.some( + (bootstrap) => + bootstrap.id === pickerBackendId && bootstrap.httpBaseUrl === pickerEnvironment.displayUrl, + ); + const canPickFolder = + typeof window !== "undefined" && + window.desktopBridge !== undefined && + (isDesktopPrimary || isDesktopWslBackend); + + const handlePickFolder = async () => { + if (!canPickFolder || pickerEnvironment === null) return; + try { + const api = ensureLocalApi(); + let targetEnvironmentId = pickerBackendId?.startsWith("wsl:") ? pickerBackendId : null; + if (isDesktopPrimary && pickerEnvironment.serverConfig?.environment.platform.os === "linux") { + const wslConfiguration = await window.desktopBridge?.getWslState(); + targetEnvironmentId = resolveProjectPickerTarget({ + browseEnvironmentId: pickerEnvironment.environmentId, + primaryEnvironmentId, + desktopInstanceId: null, + wslConfiguration: wslConfiguration ?? null, + }); + } + const picked = await api.dialogs.pickFolder({ + initialPath: settings.worktreesDirectory || null, + ...(targetEnvironmentId === null ? {} : { targetEnvironmentId }), + }); + if (picked) { + updateSettings({ worktreesDirectory: picked.trim() }); + } + } catch { + // Ignore picker failures / cancellations + } + }; return ( updateSettings({ worktreesDirectory: "" })} /> ) : null } control={ - { - edited.current = true; - }} - onBlur={(event) => { - const value = event.target.value.trim(); - if (edited.current && (mixed || value !== settings.worktreesDirectory)) - updateSettings({ worktreesDirectory: value }); - edited.current = false; - }} - /> +
+ { + edited.current = true; + }} + onBlur={(event) => { + const value = event.target.value.trim(); + if (edited.current && (mixed || value !== settings.worktreesDirectory)) + updateSettings({ worktreesDirectory: value }); + edited.current = false; + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + const value = event.currentTarget.value.trim(); + if (mixed || value !== settings.worktreesDirectory) { + updateSettings({ worktreesDirectory: value }); + } + edited.current = false; + event.currentTarget.blur(); + } + }} + /> + {canPickFolder ? ( + + ) : null} +
} /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4797c9b64dde..83e019db368d 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -145,10 +145,10 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "storage-worktrees-location", - title: "Worktree location", + title: "Worktree Storage Directory", to: "/settings/storage", scope: "environment-defaults", - searchTerms: ["worktree location folder directory path drive external disk"], + searchTerms: ["worktree location folder directory path drive external disk ssd storage"], }, { id: "storage-artifacts",