fix(settings): use Workspace Directory for the Create-project default path (#14767) (#16583)

* fix(settings): use Workspace Directory for the Create-project default path

`repos:getDefaultCreateProjectParent` hardcoded `join(homedir(), 'orca',
'projects')` and never consulted the settings store, so Settings -> General ->
Workspace Directory had no effect on the Location field of "Create new project".
Users had to retype the path every time, or fake it with an NTFS junction.

Resolve the parent from the store instead, through the same rule the rest of the
app uses for a host preference: `host override ?? client default`, i.e.
`getEffectiveHostSetting(settings, LOCAL_EXECUTION_HOST_ID,
'defaultWorktreeLocation', settings.workspaceDir)`. This handler only ever
answers for the local host, and a local-host override previously could not win
either.

A seeded value is not a user choice. `workspaceDir` is never blank -- new
installs seed it with `~/orca/workspaces` -- so treating any non-blank value as
configured would silently relocate every existing user's new projects into the
worktree root. Worktrees nest at `<workspaceDir>/<repoName>/<branch>`, so such a
project would then host its own worktrees inside its own working tree. Compare
against `getDefaultWorkspaceDir(homedir())` (now exported) via
`normalizeRuntimePathForComparison`, and keep `~/orca/projects` for blank,
whitespace-only, and untouched-default values.

Also scope the `~/orca/projects` shorthand in `formatCreateProjectParentSummary`
to the fallback path itself. Otherwise a user with Workspace Directory set to
`J:\PROJECTS` saw the summary line claim `~/orca/projects` while the field held
`J:\PROJECTS`.

Fixes #14767

* fix(settings): keep configured orca/projects paths verbatim in the create summary

The collapsed Location summary used a tail match on orca/projects, so a
configured directory like /data/orca/projects rendered as ~/orca/projects.
Scope the shorthand to usual home layouts and pin the lookalike cases.
This commit is contained in:
Neil
2026-08-26 02:36:38 -07:00
committed by GitHub
parent a27c691fdd
commit 1fafccb26b
5 changed files with 135 additions and 8 deletions
+52 -2
View File
@@ -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<CreateResult> => {
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
+31 -4
View File
@@ -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<boolean> {
}
}
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',
@@ -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')
})
})
@@ -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
+3 -1
View File
@@ -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)