Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/renderer/src/components/sidebar/WorktreeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ type VirtualizedWorktreeViewportProps = {
collapsedGroups: Set<string>
handleCreateForRepo: (projectId: string) => void
handleOpenRepoSettings: (projectId: string, sectionId?: string) => void
handleOpenWorktreeVisibility: (projectId: string) => void
handleOpenWorktreeVisibility: (projectId: string, hostId?: ExecutionHostId) => void
handleShowImportedWorktrees: (projectId: string) => void
handleKeepImportedWorktreesHidden: (projectId: string) => void
importedWorktreeCardActionState: ReadonlyMap<string, ImportedWorktreeCardActionState>
Expand Down Expand Up @@ -4584,7 +4584,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<DropdownMenuItem
onSelect={() => {
if (row.repo) {
handleOpenWorktreeVisibility(row.repo.id)
handleOpenWorktreeVisibility(
row.repo.id,
getRepoExecutionHostId(row.repo)
)
}
}}
>
Expand Down Expand Up @@ -5978,8 +5981,10 @@ const WorktreeList = React.memo(function WorktreeList({
)

const handleOpenWorktreeVisibility = useCallback(
(projectId: string) => {
openModal('worktree-visibility', { repoId: projectId })
(projectId: string, hostId?: ExecutionHostId) => {
// Why: the id can exist on several hosts, so carry the row's own host when the caller
// knows it — otherwise the dialog reads and writes whichever row resolves first.
openModal('worktree-visibility', { repoId: projectId, ...(hostId ? { hostId } : {}) })
},
[openModal]
)
Expand Down
38 changes: 28 additions & 10 deletions src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'

export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
Expand All @@ -28,9 +30,21 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const updateRepo = useAppStore((s) => s.updateRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const settings = useAppStore((s) => s.settings)

const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
const repo = repos.find((candidate) => candidate.id === repoId) ?? null
// Why: a repo id can exist on several hosts, so read and write the row the caller meant
// rather than whichever one matches the id first.
const hostId =
normalizeExecutionHostId(typeof modalData.hostId === 'string' ? modalData.hostId : null) ??
undefined
// Why: findRepoForHost deliberately returns null when a bare id is ambiguous across hosts.
// Fall back to the previous first-match so a host-less caller still opens the dialog rather
// than silently rendering nothing.
const repo =
findRepoForHost(repos, repoId, { hostId, settings }) ??
repos.find((candidate) => candidate.id === repoId) ??
null
const detected = repoId ? detectedWorktreesByRepo[repoId] : undefined
const showOther = repo
? effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) ===
Expand All @@ -45,17 +59,21 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
if (!repoId) {
return
}
await updateRepo(repoId, {
externalWorktreeVisibility: showOther ? 'hide' : 'show',
// Why: showing hidden externals again should re-enable the inbox if the
// user previously opted out of discovery prompts for this repo.
// Why: null is the transport sentinel for clearing on remote runtime paths
// where `undefined` is stripped before persistence.
...(!showOther ? { externalWorktreeDiscoverySuppressedAt: null } : {})
})
await updateRepo(
repoId,
{
externalWorktreeVisibility: showOther ? 'hide' : 'show',
// Why: showing hidden externals again should re-enable the inbox if the
// user previously opted out of discovery prompts for this repo.
// Why: null is the transport sentinel for clearing on remote runtime paths
// where `undefined` is stripped before persistence.
...(!showOther ? { externalWorktreeDiscoverySuppressedAt: null } : {})
},
hostId ? { hostId } : undefined
)
await fetchWorktrees(repoId)
closeModal()
}, [closeModal, fetchWorktrees, repoId, showOther, updateRepo])
}, [closeModal, fetchWorktrees, hostId, repoId, showOther, updateRepo])

if (activeModal !== 'worktree-visibility' || !repo || !isGitRepoKind(repo)) {
return null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @vitest-environment happy-dom

import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'

const { storeState } = vi.hoisted(() => ({
storeState: {
activeModal: 'worktree-visibility' as string,
modalData: {} as Record<string, unknown>,
closeModal: vi.fn(),
repos: [] as Repo[],
updateRepo: vi.fn().mockResolvedValue(true),
fetchWorktrees: vi.fn().mockResolvedValue(true),
detectedWorktreesByRepo: {} as Record<string, unknown>,
settings: {} as Record<string, unknown>
}
}))

vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: typeof storeState) => unknown) => selector(storeState),
{ getState: () => storeState }
)
}))

vi.mock('@/i18n/i18n', () => ({
i18n: { language: 'en' },
translate: (_key: string, fallback: string) => fallback
}))

import WorktreeVisibilityDialog from './WorktreeVisibilityDialog'

/** Same repo id on two hosts; only host-a currently shows its external worktrees. */
function reposOnTwoHosts(): Repo[] {
const base = { id: 'repo-1', badgeColor: '#000000', addedAt: 0, kind: 'git' as const }
return [
{
...base,
path: '/repos/b',
displayName: 'on host B',
connectionId: 'host-b',
externalWorktreeVisibility: 'hide'
},
{
...base,
path: '/repos/a',
displayName: 'on host A',
connectionId: 'host-a',
externalWorktreeVisibility: 'show'
}
] as unknown as Repo[]
}

afterEach(cleanup)

describe('WorktreeVisibilityDialog host identity', () => {
// Why: one repo id can exist on several hosts. Resolved by bare id, the dialog reads whichever
// row matches first — so it can show host B's state while the user opened it for host A, then
// write the toggle to the wrong row.
it('writes the toggle to the host the dialog was opened for', async () => {
storeState.repos = reposOnTwoHosts()
storeState.modalData = { repoId: 'repo-1', hostId: 'ssh:host-a' }
storeState.updateRepo = vi.fn().mockResolvedValue(true)

render(<WorktreeVisibilityDialog />)
fireEvent.click(await screen.findByRole('button', { name: /import|hide/i }))

expect(storeState.updateRepo).toHaveBeenCalledWith(
'repo-1',
expect.objectContaining({ externalWorktreeVisibility: 'hide' }),
{ hostId: 'ssh:host-a' }
)
})

it('omits the host option when the caller could not supply one', async () => {
storeState.repos = reposOnTwoHosts()
storeState.modalData = { repoId: 'repo-1' }
storeState.updateRepo = vi.fn().mockResolvedValue(true)

render(<WorktreeVisibilityDialog />)
fireEvent.click(await screen.findByRole('button', { name: /import|hide/i }))

expect(storeState.updateRepo).toHaveBeenCalledWith(
'repo-1',
expect.objectContaining({}),
undefined
)
})
})
Loading