diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 103f55b82..8dde69e77 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -4,11 +4,14 @@ import { chmodSync, cpSync, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, + readlinkSync, rmSync, statSync, + symlinkSync, writeFileSync } from 'node:fs' import net from 'node:net' @@ -136,7 +139,7 @@ function prepareMacDevElectronApp() { const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev' const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot - const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v3' + const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4' const hash = createHash('sha1') .update( `${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}` @@ -197,6 +200,7 @@ function prepareMacDevElectronApp() { // Why: Electron.framework uses relative symlinks for its bundle resources; // resolving them to pnpm-store absolutes breaks Chromium's bundle lookup. cpSync(sourceAppPath, appPath, { recursive: true, verbatimSymlinks: true }) + restoreElectronFrameworkSymlinks(appPath) const plistPath = path.join(appPath, 'Contents', 'Info.plist') setPlistValue(plistPath, 'CFBundleName', title) @@ -210,6 +214,53 @@ function prepareMacDevElectronApp() { process.env.ELECTRON_EXEC_PATH = executablePath } +function isSymlink(filePath) { + try { + return lstatSync(filePath).isSymbolicLink() + } catch { + return false + } +} + +function ensureRelativeSymlink(linkPath, target) { + if (isSymlink(linkPath)) { + try { + if (readlinkSync(linkPath) === target) { + return + } + } catch {} + } + + const targetPath = path.join(path.dirname(linkPath), target) + if (!existsSync(targetPath)) { + return + } + + rmSync(linkPath, { recursive: true, force: true }) + symlinkSync(target, linkPath) +} + +function restoreElectronFrameworkSymlinks(appPath) { + const frameworkPath = path.join( + appPath, + 'Contents', + 'Frameworks', + 'Electron Framework.framework' + ) + const versionsPath = path.join(frameworkPath, 'Versions') + if (!existsSync(path.join(versionsPath, 'A'))) { + return + } + + // Why: some Electron installs have framework symlinks flattened into + // duplicate directories. Recreate the relative bundle links after copying so + // Chromium resolves resources through the canonical macOS framework layout. + ensureRelativeSymlink(path.join(versionsPath, 'Current'), 'A') + for (const entry of ['Electron Framework', 'Resources', 'Libraries', 'Helpers']) { + ensureRelativeSymlink(path.join(frameworkPath, entry), `Versions/Current/${entry}`) + } +} + function getDevUserDataPath() { if (process.env.ORCA_DEV_USER_DATA_PATH) { return process.env.ORCA_DEV_USER_DATA_PATH diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 56854c931..a93074996 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -450,13 +450,11 @@ async function prepareWorktreePushTargetSsh( remoteCreated = true } } - await provider.exec( - [ - 'fetch', - remoteName, - `+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}` - ], - repoPath + await provider.fetchRemoteTrackingRef( + repoPath, + remoteName, + target.branchName, + `refs/remotes/${remoteName}/${target.branchName}` ) return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) } } @@ -671,14 +669,11 @@ export async function createRemoteWorktree( const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch) if (remoteTrackingBase) { try { - await provider.exec( - [ - 'fetch', - '--no-tags', - remoteTrackingBase.remote, - `+refs/heads/${remoteTrackingBase.branch}:${remoteTrackingBase.ref}` - ], - repo.path + await provider.fetchRemoteTrackingRef( + repo.path, + remoteTrackingBase.remote, + remoteTrackingBase.branch, + remoteTrackingBase.ref ) } catch { throw new Error( diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index e3f00f1fb..5cf596725 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -870,6 +870,7 @@ describe('registerWorktreeHandlers', () => { } return { stdout: '', stderr: '' } }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi.fn().mockResolvedValue([ { @@ -945,6 +946,7 @@ describe('registerWorktreeHandlers', () => { } return { stdout: '', stderr: '' } }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi.fn().mockResolvedValue([ { @@ -1030,6 +1032,7 @@ describe('registerWorktreeHandlers', () => { } return { stdout: '', stderr: '' } }), + fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')), addWorktree: vi.fn(), listWorktrees: vi.fn() } @@ -1052,6 +1055,12 @@ describe('registerWorktreeHandlers', () => { ) expect(provider.addWorktree).not.toHaveBeenCalled() + expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( + '/remote/repo', + 'origin', + 'main', + 'refs/remotes/origin/main' + ) }) it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => { diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 5bad2766f..c9aebff80 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -496,6 +496,22 @@ describe('SshGitProvider', () => { }) }) + it('fetchRemoteTrackingRef sends git.fetchRemoteTrackingRef request', async () => { + await provider.fetchRemoteTrackingRef( + '/home/user/repo', + 'origin', + 'main', + 'refs/remotes/origin/main' + ) + + expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', { + worktreePath: '/home/user/repo', + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main' + }) + }) + it('getBranchDiff sends git.branchDiff request', async () => { const diffs = [{ kind: 'text', originalContent: '', modifiedContent: 'new' }] mux.request.mockResolvedValue(diffs) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 627a7cd75..0ff95daa3 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -320,6 +320,20 @@ export class SshGitProvider implements IGitProvider { await this.mux.request('git.fetch', { worktreePath }) } + async fetchRemoteTrackingRef( + worktreePath: string, + remote: string, + branch: string, + ref: string + ): Promise { + await this.mux.request('git.fetchRemoteTrackingRef', { + worktreePath, + remote, + branch, + ref + }) + } + async getBranchDiff( worktreePath: string, baseRef: string, diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index fc63da22b..29cce7eb8 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -44,11 +44,16 @@ describe('client UI RPC methods', () => { const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) const response = await dispatcher.dispatch( - makeRequest('ui.set', { showActiveOnly: true, filterRepoIds: ['repo-1'] }) + makeRequest('ui.set', { + showActiveOnly: true, + hideSleepingWorkspaces: true, + filterRepoIds: ['repo-1'] + }) ) expect(runtime.updateUIState).toHaveBeenCalledWith({ showActiveOnly: true, + hideSleepingWorkspaces: true, filterRepoIds: ['repo-1'] }) expect(response).toMatchObject({ ok: true, result: { ui: updated } }) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 927f81170..6aba227c7 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -55,6 +55,7 @@ const UiUpdate = z showWorkspaceLineage: z.boolean().optional(), sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), showActiveOnly: z.boolean().optional(), + hideSleepingWorkspaces: z.boolean().optional(), showSleepingWorkspaces: z.boolean().optional(), showInactiveWorkspaces: z.boolean().optional(), hideDefaultBranchWorkspace: z.boolean().optional(), diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 3295f8304..c929c7014 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -51,6 +51,7 @@ describe('GitHandler', () => { expect(methods).toContain('git.branchCompare') expect(methods).toContain('git.upstreamStatus') expect(methods).toContain('git.fetch') + expect(methods).toContain('git.fetchRemoteTrackingRef') expect(methods).toContain('git.push') expect(methods).toContain('git.pull') expect(methods).toContain('git.branchDiff') @@ -630,6 +631,78 @@ describe('GitHandler', () => { } }) + it('refreshes one remote-tracking ref from a configured remote', async () => { + const bareDir = mkdtempSync(path.join(tmpdir(), 'relay-git-bare-')) + const producerParent = mkdtempSync(path.join(tmpdir(), 'relay-git-producer-')) + const producerDir = path.join(producerParent, 'repo') + try { + execFileSync('git', ['init', '--bare'], { cwd: bareDir, stdio: 'pipe' }) + + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + execFileSync('git', ['remote', 'add', 'origin', bareDir], { + cwd: tmpDir, + stdio: 'pipe' + }) + execFileSync('git', ['push', '--set-upstream', 'origin', branch], { + cwd: tmpDir, + stdio: 'pipe' + }) + + execFileSync('git', ['clone', bareDir, producerDir], { stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: producerDir, + stdio: 'pipe' + }) + execFileSync('git', ['config', 'user.name', 'Test'], { + cwd: producerDir, + stdio: 'pipe' + }) + writeFileSync(path.join(producerDir, 'base.txt'), 'updated') + gitCommit(producerDir, 'remote update') + execFileSync('git', ['push', 'origin', branch], { cwd: producerDir, stdio: 'pipe' }) + const expected = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: producerDir, + encoding: 'utf-8' + }).trim() + + await dispatcher.callRequest('git.fetchRemoteTrackingRef', { + worktreePath: tmpDir, + remote: 'origin', + branch, + ref: `refs/remotes/origin/${branch}` + }) + + const actual = execFileSync('git', ['rev-parse', `refs/remotes/origin/${branch}`], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + expect(actual).toBe(expected) + } finally { + await fs.rm(bareDir, { recursive: true, force: true }) + await fs.rm(producerParent, { recursive: true, force: true }) + } + }) + + it('rejects remote-tracking refreshes that target a different ref', async () => { + gitInit(tmpDir) + execFileSync('git', ['remote', 'add', 'origin', tmpDir], { cwd: tmpDir, stdio: 'pipe' }) + + await expect( + dispatcher.callRequest('git.fetchRemoteTrackingRef', { + worktreePath: tmpDir, + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/other' + }) + ).rejects.toThrow('Remote-tracking ref does not match the requested remote and branch.') + }) + it('rethrows upstreamStatus failures that are not "no upstream configured"', async () => { // Why: the handler's catch is narrowed to only swallow the expected // "no upstream" signal. A non-repo path should surface its error rather diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 5aa806553..aeda9049b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -58,6 +58,7 @@ export class GitHandler { this.dispatcher.onRequest('git.commitCompare', (p) => this.commitCompare(p)) this.dispatcher.onRequest('git.upstreamStatus', (p) => this.upstreamStatus(p)) this.dispatcher.onRequest('git.fetch', (p) => this.fetch(p)) + this.dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => this.fetchRemoteTrackingRef(p)) this.dispatcher.onRequest('git.push', (p) => this.push(p)) this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) @@ -339,6 +340,41 @@ export class GitHandler { } } + private async fetchRemoteTrackingRef(params: Record) { + const worktreePath = params.worktreePath as string + const remote = params.remote + const branch = params.branch + const ref = params.ref + if (typeof remote !== 'string' || typeof branch !== 'string' || typeof ref !== 'string') { + throw new Error('Invalid remote-tracking fetch request.') + } + if (remote.startsWith('-') || branch.startsWith('-')) { + throw new Error('Remote-tracking fetch inputs must not start with "-".') + } + if (ref !== `refs/remotes/${remote}/${branch}`) { + throw new Error('Remote-tracking ref does not match the requested remote and branch.') + } + + try { + const { stdout } = await this.git(['remote'], worktreePath) + const remotes = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + if (!remotes.includes(remote)) { + throw new Error(`Remote "${remote}" is not configured.`) + } + await this.git(['check-ref-format', `refs/heads/${branch}`], worktreePath) + await this.git(['check-ref-format', ref], worktreePath) + await this.git(['fetch', '--no-tags', remote, `+refs/heads/${branch}:${ref}`], worktreePath) + } catch (error) { + // Why: create-worktree needs a write-capable fetch, but generic git.exec + // intentionally rejects fetch. This narrow RPC keeps the relay allowlist + // tight while preserving the same safe error normalization as git.fetch. + throw new Error(normalizeGitErrorMessage(error, 'fetch')) + } + } + private async push(params: Record) { const worktreePath = params.worktreePath as string // Why: mirror src/main/git/remote.ts. Push to a configured upstream when diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1224d6cd1..ddcd6b370 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -878,6 +878,7 @@ function App(): React.JSX.Element { groupBy, sortBy, showActiveOnly: false, + hideSleepingWorkspaces: !showSleepingWorkspaces, showSleepingWorkspaces, hideDefaultBranchWorkspace, filterRepoIds, diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 0573ecfad..e705aaaa8 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -19,6 +19,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip import RepoDotLabel from '@/components/repo/RepoDotLabel' import { searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' +import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' type SidebarFilterProps = { preserveWorkspaceBoardOpen?: boolean @@ -88,9 +89,10 @@ const SidebarFilter = React.memo(function SidebarFilter({ }, [repos, filterRepoIds]) const selectedCount = selectedRepoIdSet.size const hasRepoFilter = selectedCount > 0 - const hasAnyFilter = showSleepingWorkspaces || hideDefaultBranchWorkspace || hasRepoFilter + const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES + const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter const activeFilterCount = - (showSleepingWorkspaces ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) @@ -106,7 +108,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ const allSelected = canFilterRepos && selectedCount === repos.length const clearAll = useCallback(() => { - setShowSleepingWorkspaces(false) + setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) setHideDefaultBranchWorkspace(false) setFilterRepoIds([]) }, [setShowSleepingWorkspaces, setHideDefaultBranchWorkspace, setFilterRepoIds]) @@ -161,9 +163,9 @@ const SidebarFilter = React.memo(function SidebarFilter({ > } - label="Show sleeping" - checked={showSleepingWorkspaces} - onChange={setShowSleepingWorkspaces} + label="Hide sleeping" + checked={!showSleepingWorkspaces} + onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} /> } diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 5b23fada7..16357cceb 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -16,9 +16,9 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter } - label="Show sleeping" - checked={showSleepingWorkspaces} - onChange={setShowSleepingWorkspaces} + label="Hide sleeping" + checked={!showSleepingWorkspaces} + onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} /> } diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index ead4b38e5..b59d9c980 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -18,6 +18,7 @@ import { import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { WorktreeCardProperty } from '../../../../shared/types' +import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection' import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection' @@ -99,9 +100,10 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM return count }, [repos, filterRepoIds]) const hasRepoFilter = selectedCount > 0 - const hasAnyFilter = showSleepingWorkspaces || hideDefaultBranchWorkspace || hasRepoFilter + const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES + const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hasRepoFilter const activeFilterCount = - (showSleepingWorkspaces ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount + (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort' const visiblePropertyCount = PROPERTY_OPTIONS.filter((opt) => diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index fcf064df2..8d03cc9b9 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -38,6 +38,12 @@ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' })) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index d41d9b730..90f00830a 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -34,6 +34,7 @@ import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types' +import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { buildWorktreeComparator } from './smart-sort' import { buildAttentionByWorktree, @@ -2842,7 +2843,7 @@ const WorktreeList = React.memo(function WorktreeList({ const clearFilters = useCallback(() => { const actions = computeClearFilterActions(filterState) if (actions.resetShowSleepingWorkspaces) { - setShowSleepingWorkspaces(false) + setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) } if (actions.resetFilterRepoIds) { setFilterRepoIds([]) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 7f838ac0b..5d5a095fc 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -90,7 +90,7 @@ type FilterState = Parameters[0] function filterState(overrides: Partial = {}): FilterState { return { - showSleepingWorkspaces: false, + showSleepingWorkspaces: true, filterRepoIds: [], hideDefaultBranchWorkspace: false, ...overrides @@ -382,8 +382,8 @@ describe('sidebarHasActiveFilters', () => { expect(sidebarHasActiveFilters(filterState({ hideDefaultBranchWorkspace: true }))).toBe(true) }) - it('returns true when only showSleepingWorkspaces is active', () => { - expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: true }))).toBe(true) + it('returns true when sleeping workspaces are hidden', () => { + expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true) }) it('returns true when only filterRepoIds is non-empty', () => { @@ -416,12 +416,11 @@ describe('computeClearFilterActions', () => { // in the common case where hide was never on. const actions = computeClearFilterActions( filterState({ - showSleepingWorkspaces: true, filterRepoIds: ['repo1'] }) ) expect(actions.resetHideDefaultBranchWorkspace).toBe(false) - expect(actions.resetShowSleepingWorkspaces).toBe(true) + expect(actions.resetShowSleepingWorkspaces).toBe(false) expect(actions.resetFilterRepoIds).toBe(true) }) @@ -429,7 +428,7 @@ describe('computeClearFilterActions', () => { expect( computeClearFilterActions( filterState({ - showSleepingWorkspaces: true, + showSleepingWorkspaces: false, filterRepoIds: ['repo1', 'repo2'], hideDefaultBranchWorkspace: true }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 762e695b3..bf333af54 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -3,6 +3,7 @@ import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { isInactiveWorkspace } from '@/lib/worktree-activity-state' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' +import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' /** * Whether a worktree represents the repo's default-branch row that the @@ -35,7 +36,7 @@ export type SidebarFilterState = { */ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { return ( - state.showSleepingWorkspaces || + state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES || state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace ) @@ -61,7 +62,7 @@ export type ClearFilterActions = { */ export function computeClearFilterActions(state: SidebarFilterState): ClearFilterActions { return { - resetShowSleepingWorkspaces: state.showSleepingWorkspaces, + resetShowSleepingWorkspaces: state.showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES, resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace } diff --git a/src/renderer/src/lib/connection-context.test.ts b/src/renderer/src/lib/connection-context.test.ts new file mode 100644 index 000000000..dba7fbbb0 --- /dev/null +++ b/src/renderer/src/lib/connection-context.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/types' +import { useAppStore } from '@/store' +import { getConnectionId } from './connection-context' + +const initialState = useAppStore.getInitialState() + +function makeRepo(overrides: Partial & { id: string }): Repo { + return { + path: '/home/neil/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +describe('getConnectionId', () => { + afterEach(() => { + useAppStore.setState(initialState, true) + }) + + it('resolves SSH targets from composite worktree IDs before worktree discovery completes', () => { + useAppStore.setState({ + repos: [ + makeRepo({ + id: 'repo-ssh', + connectionId: 'ssh-1' + }) + ], + worktreesByRepo: {} + }) + + expect(getConnectionId('repo-ssh::/home/neil/repo-feature')).toBe('ssh-1') + }) + + it('returns null for known local repos without a discovered worktree', () => { + useAppStore.setState({ + repos: [makeRepo({ id: 'repo-local' })], + worktreesByRepo: {} + }) + + expect(getConnectionId('repo-local::/Users/me/repo-feature')).toBeNull() + }) + + it('returns undefined when neither the worktree nor repo is known', () => { + useAppStore.setState({ + repos: [], + worktreesByRepo: {} + }) + + expect(getConnectionId('repo-missing::/tmp/repo-feature')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/connection-context.ts b/src/renderer/src/lib/connection-context.ts index 72f04db3b..280d02824 100644 --- a/src/renderer/src/lib/connection-context.ts +++ b/src/renderer/src/lib/connection-context.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' /** * Resolve the SSH connectionId for a worktree. Returns null for local repos, @@ -12,9 +13,12 @@ export function getConnectionId(worktreeId: string | null): string | null | unde const state = useAppStore.getState() const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat() const worktree = allWorktrees.find((w) => w.id === worktreeId) - if (!worktree) { + // Why: SSH worktrees can be restored from session IDs before relay discovery + // repopulates worktreesByRepo. The composite ID still carries the repo ID. + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos?.find((r) => r.id === repoId) + if (!repo) { return undefined } - const repo = state.repos?.find((r) => r.id === worktree.repoId) - return repo?.connectionId ?? null + return repo.connectionId ?? null } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 6ef216840..ce54dcdfe 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -166,6 +166,24 @@ describe('safeFit', () => { expect(activeBuffer.viewportY).toBe(42) }) + it('does not throw when xterm rejects scroll restoration during layout', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + expect(() => safeFit(pane)).not.toThrow() + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + it('still refits when a split-scroll lock is active and the grid changed', () => { const pane = createPane({ proposedCols: 100, diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 490bf3e8d..a95d29e33 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -73,7 +73,12 @@ export function safeFit(pane: ManagedPane): void { // Container may not have dimensions yet } finally { if (shouldRestoreScroll && scrollState) { - restoreScrollStateAfterLayout(pane.terminal, scrollState) + try { + restoreScrollStateAfterLayout(pane.terminal, scrollState) + } catch { + // Why: xterm can temporarily expose a terminal whose renderer has not + // initialized dimensions yet during SSH reattach/layout. Fit is best-effort. + } } } } diff --git a/src/renderer/src/lib/startup-ui-hydration.test.ts b/src/renderer/src/lib/startup-ui-hydration.test.ts index 6f2bee86b..8b577afe3 100644 --- a/src/renderer/src/lib/startup-ui-hydration.test.ts +++ b/src/renderer/src/lib/startup-ui-hydration.test.ts @@ -54,6 +54,8 @@ describe('startup UI hydration fallback', () => { expect(hydratePersistedUI.mock.calls[0][0].sidebarWidth).toBe(280) expect(hydratePersistedUI.mock.calls[0][0].groupBy).toBe('workspace-status') expect(hydratePersistedUI.mock.calls[0][0].sortBy).toBe('name') + expect(hydratePersistedUI.mock.calls[0][0].hideSleepingWorkspaces).toBe(false) + expect(hydratePersistedUI.mock.calls[0][0].showSleepingWorkspaces).toBe(true) }) it('does not mark UI hydrated after the startup effect has been cancelled', () => { diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index b84644b65..d761d3945 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -1,4 +1,6 @@ import { + DEFAULT_HIDE_SLEEPING_WORKSPACES, + DEFAULT_SHOW_SLEEPING_WORKSPACES, DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../shared/constants' @@ -37,7 +39,8 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState groupBy: 'workspace-status', sortBy: 'name', showActiveOnly: false, - showSleepingWorkspaces: false, + hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, + showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, filterRepoIds: [], collapsedGroups: [], diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 481191c83..edf39c6ac 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -54,6 +54,12 @@ function makePersistedUI(overrides: Partial = {}): PersistedUI } describe('createUISlice hydratePersistedUI', () => { + it('defaults to showing sleeping workspaces', () => { + const store = createUIStore() + + expect(store.getState().showSleepingWorkspaces).toBe(true) + }) + it('preserves the current right sidebar width when older persisted UI omits it', () => { const store = createUIStore() @@ -125,25 +131,37 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().showActiveOnly).toBe(false) }) - it('restores the show-sleeping filter from persisted UI state', () => { + it('restores the new hide-sleeping filter from persisted UI state', () => { const store = createUIStore() store.getState().hydratePersistedUI( makePersistedUI({ - showSleepingWorkspaces: true + hideSleepingWorkspaces: true + }) + ) + + expect(store.getState().showSleepingWorkspaces).toBe(false) + }) + + it('ignores legacy hidden-sleeping preference so existing users start with sleeping visible', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + showSleepingWorkspaces: false }) ) expect(store.getState().showSleepingWorkspaces).toBe(true) }) - it('restores the legacy show-inactive filter as show-sleeping', () => { + it('ignores the legacy show-inactive filter so existing users start with sleeping visible', () => { const store = createUIStore() store.getState().hydratePersistedUI( makePersistedUI({ showSleepingWorkspaces: undefined, - showInactiveWorkspaces: true + showInactiveWorkspaces: false }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ac01deb92..ac16e802a 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -34,6 +34,8 @@ import { resolveVisibleTaskProvider } from '../../../../shared/task-providers' import { + DEFAULT_HIDE_SLEEPING_WORKSPACES, + DEFAULT_SHOW_SLEEPING_WORKSPACES, DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES, normalizeWorktreeCardProperties @@ -885,7 +887,7 @@ export const createUISlice: StateCreator = (set, get) showActiveOnly: false, setShowActiveOnly: (v) => set({ showActiveOnly: v }), - showSleepingWorkspaces: false, + showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }), hideDefaultBranchWorkspace: false, @@ -1099,12 +1101,10 @@ export const createUISlice: StateCreator = (set, get) // Why: Active-only was retired. Force the old persisted flag off so an // old profile cannot invisibly keep narrowing the workspace list. showActiveOnly: false, - // Why: a short-lived build called this "inactive"; keep that key as a - // fallback so the renamed sleeping filter preserves user intent. - showSleepingWorkspaces: - ui.showSleepingWorkspaces ?? - (ui as PersistedUIState & { showInactiveWorkspaces?: boolean }).showInactiveWorkspaces ?? - false, + // Why: `hideSleepingWorkspaces` is the canonical negative-form filter. + // Older positive-form keys are intentionally ignored so old profiles + // start from the new default: sleeping workspaces visible. + showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES), hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), collapsedGroups: new Set(ui.collapsedGroups ?? []), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5268108fb..57bb94585 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -26,6 +26,8 @@ export { export const SCHEMA_VERSION = 1 export const DEFAULT_APP_FONT_FAMILY = 'Geist' +export const DEFAULT_SHOW_SLEEPING_WORKSPACES = true +export const DEFAULT_HIDE_SLEEPING_WORKSPACES = false // Why: the onboarding wizard's last step index. Centralized so backfill, // clamps, and UI step references all agree on the same upper bound. @@ -344,7 +346,8 @@ export function getDefaultUIState(): PersistedUIState { groupBy: 'workspace-status', sortBy: 'recent', showActiveOnly: false, - showSleepingWorkspaces: false, + hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, + showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index af0419f0e..39b0fb6f5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1996,9 +1996,11 @@ export type PersistedUIState = { sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' /** Deprecated; the Active only filter is retired and ignored on hydration. */ showActiveOnly: boolean - /** Off by default: sleeping/inactive workspaces stay hidden until shown. */ + /** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */ + hideSleepingWorkspaces?: boolean + /** Deprecated legacy positive-form setting. Ignored on hydration. */ showSleepingWorkspaces?: boolean - /** Legacy name for the same setting used by a short-lived build. */ + /** Deprecated legacy name used by a short-lived build. Ignored on hydration. */ showInactiveWorkspaces?: boolean /** Hide the repo's original checked-out branch from workspace navigation * (sidebar and Cmd+J jump palette). Folder-mode repos are unaffected —