diff --git a/src/main/home-path-expansion.ts b/src/main/home-path-expansion.ts new file mode 100644 index 00000000000..7b577239764 --- /dev/null +++ b/src/main/home-path-expansion.ts @@ -0,0 +1,11 @@ +import { join, sep } from 'node:path' + +export function expandLeadingHomePath(input: string, home: string): string { + if (input === '~') { + return home + } + if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) { + return join(home, input.slice(2)) + } + return input +} diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 305d8fb287d..3b1c1bdc1c8 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -5,29 +5,15 @@ import { getAppEnvironment } from '../../shared/app-environment' import type { GlobalSettings } from '../../shared/global-settings-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { Store } from '../persistence' +import { resolveFloatingWorkspacePath } from '../persistence/restoring-sessions/floating-workspace-normalization' type FloatingTerminalSettings = Pick const FLOATING_WORKSPACE_DIRNAME = 'floating-workspace' -function expandHomePath(input: string, home: string): string { - if (input === '~') { - return home - } - if (input.startsWith(`~${path.sep}`)) { - return path.join(home, input.slice(2)) - } - if (process.platform === 'win32' && input.startsWith('~/')) { - return path.join(home, input.slice(2)) - } - return input -} - function resolveFloatingWorkspaceInput(input: string): string { // Headless runtime uses this path resolver without an Electron app. - const home = getAppEnvironment().getPath('home') - const expanded = expandHomePath(input, home) - return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(home, expanded) + return resolveFloatingWorkspacePath(input, getAppEnvironment().getPath('home')) } async function canonicalizeAccessibleDirectory(dirPath: string): Promise { diff --git a/src/main/persistence/applying-settings/persistence-settings-update.test.ts b/src/main/persistence/applying-settings/persistence-settings-update.test.ts index 618095236b3..fc662402b61 100644 --- a/src/main/persistence/applying-settings/persistence-settings-update.test.ts +++ b/src/main/persistence/applying-settings/persistence-settings-update.test.ts @@ -12,7 +12,7 @@ import { makeTerminalTab } from '../persistence-session-fixtures' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { writeFileSync, rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultWorkspaceSession } from '../../../shared/constants' @@ -112,6 +112,18 @@ describe('Store', () => { ).toBe(false) }) + it('expands a leading ~ in the worktree location on load and every settings write', async () => { + writeDataFile({ settings: { workspaceDir: '~/source/.orca/worktrees' } }) + const store = await createStore() + + expect(store.getSettings().workspaceDir).toBe(join(homedir(), 'source/.orca/worktrees')) + expect(store.updateSettings({ workspaceDir: '~' }).workspaceDir).toBe(homedir()) + expect(store.updateSettings({ workspaceDir: '~/wt' }).workspaceDir).toBe(join(homedir(), 'wt')) + // Repo-relative locations and other names that start with ~ stay as written. + expect(store.updateSettings({ workspaceDir: '.worktrees' }).workspaceDir).toBe('.worktrees') + expect(store.updateSettings({ workspaceDir: '~wt' }).workspaceDir).toBe('~wt') + }) + it('normalizes bot-author overrides on load and every settings write', async () => { writeDataFile({ settings: { diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index bb2fa8e4abd..52adef6bfcf 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -42,6 +42,7 @@ import { normalizeNotificationSettings } from './onboarding-normalization' import { retireLegacyInstructionsForClearedTextActionRecipes } from './source-control-settings' import { buildWorkspaceDirHistoryForUpdate, + expandWorkspaceDirHome, stripRetiredGlobalSettings } from './terminal-settings-migrations' @@ -58,7 +59,7 @@ export function updateSettings( updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripRetiredGlobalSettings(updates) + const sanitizedUpdates = expandWorkspaceDirHome(stripRetiredGlobalSettings(updates)) const linkBehavior = updates.terminalLinkClickBehavior if (linkBehavior === 'actions' || linkBehavior === 'open' || linkBehavior === 'none') { // Loaded defaults are ambiguous; synchronize the legacy switch only on an explicit write. diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 48286f2c6af..06699874621 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -1,3 +1,4 @@ +import { homedir } from 'node:os' import type { GlobalSettings, OrcaWorkspaceLayout } from '../../../shared/global-settings-types' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { @@ -10,6 +11,14 @@ import { normalizeTuiAgentArgsRecord, normalizeTuiAgentEnvRecord } from '../../../shared/tui-agent-launch-defaults' +import { expandLeadingHomePath } from '../../home-path-expansion' + +/** Unexpanded, a leading ~ resolves inside each repo and worktrees land in /~ (#34944). */ +export function expandWorkspaceDirHome(settings: Partial): Partial { + return typeof settings.workspaceDir === 'string' + ? { ...settings, workspaceDir: expandLeadingHomePath(settings.workspaceDir, homedir()) } + : settings +} export function buildWorkspaceDirHistoryForUpdate( current: GlobalSettings, diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index 64466afc5b4..889e5001309 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -11,6 +11,7 @@ import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/s import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat/native-chat-shell-environment' import { publishedLegacyChatPermissionMode } from '../../../shared/agent-chat-permission-mode' import { + expandWorkspaceDirHome, savedChatUiWithTerminalDefaultView, stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' @@ -64,7 +65,7 @@ export function normalizeLoadedGlobalSettings( return { ...defaults.settings, // Legacy stored booleans cannot distinguish opt-in from the old default; retain explicit true. - ...stripRetiredGlobalSettings(parsed.settings), + ...expandWorkspaceDirHome(stripRetiredGlobalSettings(parsed.settings)), // Preserve terminal-first new tabs when retiring the old default-view switch. ...(savedChatUiWithTerminalDefaultView(parsed.settings) ? { experimentalNativeChat: false } diff --git a/src/main/persistence/restoring-sessions/floating-workspace-normalization.ts b/src/main/persistence/restoring-sessions/floating-workspace-normalization.ts index 593812eb97c..d83e5306cc7 100644 --- a/src/main/persistence/restoring-sessions/floating-workspace-normalization.ts +++ b/src/main/persistence/restoring-sessions/floating-workspace-normalization.ts @@ -1,18 +1,9 @@ import { realpathSync, statSync } from 'node:fs' -import { isAbsolute, join, resolve, sep } from 'node:path' - -export function expandFloatingWorkspaceHomePath(input: string, home: string): string { - if (input === '~') { - return home - } - if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) { - return join(home, input.slice(2)) - } - return input -} +import { isAbsolute, resolve } from 'node:path' +import { expandLeadingHomePath } from '../../home-path-expansion' export function resolveFloatingWorkspacePath(input: string, home: string): string { - const expanded = expandFloatingWorkspaceHomePath(input, home) + const expanded = expandLeadingHomePath(input, home) return isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded) }