Show sleeping workspaces by default (#2514)
- Add a narrow SSH relay RPC for refreshing remote-tracking refs without reopening generic fetch execution - Resolve SSH connection context from composite worktree IDs during startup before worktree discovery completes - Make the sleeping workspace filter negative-form and reset to the new visible default - Tolerate transient xterm scroll restoration failures during layout - Restore macOS Electron framework symlinks after copying the dev app
This commit is contained in:
parent
39badc9d5a
commit
ac10c4075f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await this.mux.request('git.fetchRemoteTrackingRef', {
|
||||
worktreePath,
|
||||
remote,
|
||||
branch,
|
||||
ref
|
||||
})
|
||||
}
|
||||
|
||||
async getBranchDiff(
|
||||
worktreePath: string,
|
||||
baseRef: string,
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
const worktreePath = params.worktreePath as string
|
||||
// Why: mirror src/main/git/remote.ts. Push to a configured upstream when
|
||||
|
|
|
|||
|
|
@ -878,6 +878,7 @@ function App(): React.JSX.Element {
|
|||
groupBy,
|
||||
sortBy,
|
||||
showActiveOnly: false,
|
||||
hideSleepingWorkspaces: !showSleepingWorkspaces,
|
||||
showSleepingWorkspaces,
|
||||
hideDefaultBranchWorkspace,
|
||||
filterRepoIds,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
>
|
||||
<FilterToggleRow
|
||||
icon={<Moon className="size-3.5" />}
|
||||
label="Show sleeping"
|
||||
checked={showSleepingWorkspaces}
|
||||
onChange={setShowSleepingWorkspaces}
|
||||
label="Hide sleeping"
|
||||
checked={!showSleepingWorkspaces}
|
||||
onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)}
|
||||
/>
|
||||
<FilterToggleRow
|
||||
icon={<GitBranch className="size-3.5" />}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
|
|||
</div>
|
||||
<FilterToggleRow
|
||||
icon={<Moon className="size-3.5" />}
|
||||
label="Show sleeping"
|
||||
checked={showSleepingWorkspaces}
|
||||
onChange={setShowSleepingWorkspaces}
|
||||
label="Hide sleeping"
|
||||
checked={!showSleepingWorkspaces}
|
||||
onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)}
|
||||
/>
|
||||
<FilterToggleRow
|
||||
icon={<GitBranch className="size-3.5" />}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ type FilterState = Parameters<typeof sidebarHasActiveFilters>[0]
|
|||
|
||||
function filterState(overrides: Partial<FilterState> = {}): 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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Repo> & { 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ function makePersistedUI(overrides: Partial<PersistedUIState> = {}): 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
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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 ?? []),
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
Loading…
Reference in New Issue