diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index be7f322a371..86e2dc5ee10 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { join } from 'node:path' -import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' +import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceDir } from '../../shared/constants' const { handleMock, @@ -33,7 +33,8 @@ const { addRepo: vi.fn(), removeProject: vi.fn(), getRepo: vi.fn(), - updateRepo: vi.fn() + updateRepo: vi.fn(), + getSettings: vi.fn() }, mkdirMock: vi.fn(), accessMock: vi.fn(), @@ -107,6 +108,8 @@ describe('repos:create', () => { } const tmpPath = (...segments: string[]): string => join('/tmp', ...segments) const defaultProjectParent = join('/Users/alice', 'orca', 'projects') + // The value a fresh install seeds Settings -> Workspace Directory with. + const defaultWorkspaceDir = getDefaultWorkspaceDir('/Users/alice') const callCreate = (args: CreateArgs): Promise => { const handler = handlers.get('repos:create') @@ -132,6 +135,7 @@ describe('repos:create', () => { removeHandlerMock.mockReset() mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.addRepo.mockReset() + mockStore.getSettings.mockReset().mockReturnValue({ workspaceDir: defaultWorkspaceDir }) mockWindow.webContents.send.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) @@ -156,6 +160,52 @@ describe('repos:create', () => { await expect(callDefaultCreateProjectParent()).resolves.toBe(defaultProjectParent) }) + // ── create-project default parent (orca#14767) ──────────────────── + + it('defaults new projects to a configured Workspace Directory', async () => { + mockStore.getSettings.mockReturnValue({ workspaceDir: 'J:\\PROJECTS' }) + await expect(callDefaultCreateProjectParent()).resolves.toBe('J:\\PROJECTS') + }) + + it('prefers the local host override over the client-default workspace directory', async () => { + mockStore.getSettings.mockReturnValue({ + workspaceDir: 'J:\\PROJECTS', + hostSettingOverrides: { local: { defaultWorktreeLocation: 'D:\\code' } } + }) + await expect(callDefaultCreateProjectParent()).resolves.toBe('D:\\code') + }) + + it.each([undefined, '', ' '])( + 'falls back to ~/orca/projects for a blank workspace directory: %p', + async (workspaceDir) => { + mockStore.getSettings.mockReturnValue({ workspaceDir }) + await expect(callDefaultCreateProjectParent()).resolves.toBe(defaultProjectParent) + } + ) + + // Why: workspaceDir is seeded, never blank, so the seeded value is not a user + // choice. Honouring it would move every existing user's new projects into the + // worktree root, where each project would host its own worktrees inside itself. + it('ignores the seeded workspace directory that the user never changed', async () => { + mockStore.getSettings.mockReturnValue({ workspaceDir: defaultWorkspaceDir }) + await expect(callDefaultCreateProjectParent()).resolves.toBe(defaultProjectParent) + }) + + it('ignores the seeded workspace directory spelled with a trailing separator', async () => { + mockStore.getSettings.mockReturnValue({ workspaceDir: `${defaultWorkspaceDir}/` }) + await expect(callDefaultCreateProjectParent()).resolves.toBe(defaultProjectParent) + }) + + it('ignores a Windows seeded workspace directory regardless of drive-letter case', async () => { + homedirMock.mockReturnValue('C:\\Users\\alice') + mockStore.getSettings.mockReturnValue({ + workspaceDir: 'c:\\users\\alice\\orca\\workspaces' + }) + await expect(callDefaultCreateProjectParent()).resolves.toBe( + join('C:\\Users\\alice', 'orca', 'projects') + ) + }) + it('unregisters any previously-registered repos:create handler', () => { // registerRepoHandlers must call removeHandler('repos:create') before // ipcMain.handle to avoid the "second handler for same channel" throw diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index f0ab4f91b08..35dfab5d7ee 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -6,7 +6,10 @@ import { homedir } from 'node:os' import { isAbsolute, join } from 'node:path' import type { Store } from '../../persistence' import type { Repo } from '../../../shared/repo-types' -import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' +import { DEFAULT_REPO_BADGE_COLOR, getDefaultWorkspaceDir } from '../../../shared/constants' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { getEffectiveHostSetting } from '../../../shared/host-setting-overrides' import { gitExecFileAsync } from '../../git/runner' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' @@ -31,13 +34,37 @@ async function isGitAvailable(): Promise { } } -function getDefaultCreateProjectParent(): string { - return join(homedir(), 'orca', 'projects') +/** + * Where the "Create new project" Location field starts. Settings -> Workspace + * Directory owns this once the user has actually set it, including a per-host + * override for the local host, which is the only scope this handler answers for. + * + * Why the untouched default does not count: `workspaceDir` is never blank -- new + * installs seed it with `~/orca/workspaces`. Treating that seeded value as a choice + * would silently relocate every existing user's projects into the worktree root, + * where each project would then host its own worktrees inside its working tree. + */ +function getDefaultCreateProjectParent(store: Store): string { + const home = homedir() + const settings = store.getSettings() + const configured = getEffectiveHostSetting( + settings, + LOCAL_EXECUTION_HOST_ID, + 'defaultWorktreeLocation', + settings.workspaceDir ?? '' + ).trim() + const isUntouchedDefault = + normalizeRuntimePathForComparison(configured) === + normalizeRuntimePathForComparison(getDefaultWorkspaceDir(home)) + if (configured && !isUntouchedDefault) { + return configured + } + return join(home, 'orca', 'projects') } export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: Store): void { ipcMain.handle('repos:isGitAvailable', () => isGitAvailable()) - ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent()) + ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent(store)) ipcMain.handle( 'repos:add', diff --git a/src/renderer/src/components/sidebar/create-project-defaults.test.ts b/src/renderer/src/components/sidebar/create-project-defaults.test.ts index 6b1a7204fb8..6b1035feea2 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.test.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.test.ts @@ -79,6 +79,18 @@ describe('create project defaults', () => { defaultParent: '/Users/alice/orca/projects' }) ).toBe('~/orca/projects') + expect( + formatCreateProjectParentSummary({ + parent: '/home/alice/orca/projects', + defaultParent: '/home/alice/orca/projects' + }) + ).toBe('~/orca/projects') + expect( + formatCreateProjectParentSummary({ + parent: 'C:\\Users\\alice\\orca\\projects', + defaultParent: 'C:\\Users\\alice\\orca\\projects' + }) + ).toBe('~/orca/projects') expect( formatCreateProjectParentSummary({ parent: '', @@ -101,4 +113,25 @@ describe('create project defaults', () => { }) ).toBe('host folder not selected') }) + + it('keeps a configured Workspace Directory verbatim in the summary', () => { + expect( + formatCreateProjectParentSummary({ + parent: 'J:\\PROJECTS', + defaultParent: 'J:\\PROJECTS' + }) + ).toBe('J:\\PROJECTS') + expect( + formatCreateProjectParentSummary({ + parent: '/data/orca/projects', + defaultParent: '/data/orca/projects' + }) + ).toBe('/data/orca/projects') + expect( + formatCreateProjectParentSummary({ + parent: 'D:\\code\\orca\\projects', + defaultParent: 'D:\\code\\orca\\projects' + }) + ).toBe('D:\\code\\orca\\projects') + }) }) diff --git a/src/renderer/src/components/sidebar/create-project-defaults.ts b/src/renderer/src/components/sidebar/create-project-defaults.ts index 9197a8a7d91..f5f65c853c3 100644 --- a/src/renderer/src/components/sidebar/create-project-defaults.ts +++ b/src/renderer/src/components/sidebar/create-project-defaults.ts @@ -4,6 +4,15 @@ function pathSeparatorFor(pathValue: string): '/' | '\\' { return pathValue.includes('\\') ? '\\' : '/' } +/** True only for `{home}/orca/projects` on the usual OS home layouts. A configured + * directory that merely ends in `orca/projects` (e.g. `/data/orca/projects`) must + * stay verbatim — the `~` shorthand would otherwise lie. */ +function isHomeProjectsFallback(pathValue: string): boolean { + return /^(?:\/(?:Users|home)\/[^/]+|[A-Za-z]:[\\/]Users[\\/][^\\/]+)[\\/]orca[\\/]projects$/.test( + pathValue + ) +} + function trimTrailingSeparators(pathValue: string): string { const trimmed = pathValue.replace(/[\\/]+$/, '') if (trimmed === '' && pathValue.startsWith('/')) { @@ -81,7 +90,13 @@ export function formatCreateProjectParentSummary({ if (!trimmedParent) { return runtimeEnvironmentId || isRemoteHost ? missingServerLocationLabel : missingLocationLabel } - if (defaultParent && trimmedParent === defaultParent && !runtimeEnvironmentId && !isRemoteHost) { + if ( + defaultParent && + trimmedParent === defaultParent && + !runtimeEnvironmentId && + !isRemoteHost && + isHomeProjectsFallback(trimmedParent) + ) { return '~/orca/projects' } return trimmedParent diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5a8fae51a07..4683c93c004 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -162,7 +162,9 @@ export function getDefaultOnboardingState(): OnboardingState { } } -function getDefaultWorkspaceDir(homeDir: string): string { +/** The stock worktree root. Exported so callers can tell an untouched default apart + * from a workspace directory the user actually chose. */ +export function getDefaultWorkspaceDir(homeDir: string): string { const separator = homeDir.includes('\\') ? '\\' : '/' const trimmedHomeDir = homeDir.replace(/[\\/]+$/, '') return [trimmedHomeDir, 'orca', 'workspaces'].join(separator)