fix(sidebar-inputs): republish workspace rows on visibility settings saves; fence pre-gap paired-server answers (#31106)

A settings save that changes what the visibility filter reads (external/agent
defaults, workspace folder and its history, nesting) now moves the workspace
rows revision, whichever path wrote it. A paired-server answer to a fetch that
started before a contact gap keeps its rows but no longer marks them live.

Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
Jinwoo Hong
2026-10-20 07:19:14 -04:00
committed by GitHub
co-authored by E2E Test
parent 41b219d541
commit 085cc38e75
5 changed files with 122 additions and 9 deletions
@@ -108,6 +108,7 @@ export type RuntimeStore = {
nestedWorkerMaxDepth?: GlobalSettings['nestedWorkerMaxDepth']
workspaceDir: string
nestWorkspaces: boolean
workspaceDirHistory?: GlobalSettings['workspaceDirHistory']
// Read by worktree placement: decides whether this project's worktrees
// mirror into a WSL distro instead of the Windows drive.
localWindowsRuntimeDefault?: GlobalSettings['localWindowsRuntimeDefault']
@@ -458,6 +458,40 @@ describe('PairedServerRowsProducer', () => {
expect(rowsOf(producer, 'runtime:box')?.freshness).toBe('live')
})
it('never certifies rows live from a fetch that started before a contact gap', async () => {
const { hosts, call, watches, state, publishStatus } = fakeHosts([{ id: 'box', live: true }])
let answerBefore: (response: RuntimeRpcResponse<unknown>) => void = () => {}
let answerAfter: (response: RuntimeRpcResponse<unknown>) => void = () => {}
call.mockReturnValueOnce(new Promise((resolve) => (answerBefore = resolve)))
call.mockReturnValueOnce(new Promise((resolve) => (answerAfter = resolve)))
const producer = new PairedServerRowsProducer({
hasDemand: () => true,
hostLabelOverrides: () => new Map()
})
producer.attach(hosts)
watches.get('box')?.onChange()
state.servers = [{ id: 'box', live: false }]
publishStatus()
state.servers = [{ id: 'box', live: true }]
publishStatus()
watches.get('box')?.onChange()
answerBefore(psReply([row('deleted-during-gap')]))
await vi.advanceTimersByTimeAsync(0)
expect(call).toHaveBeenCalledTimes(2)
expect(rowsOf(producer, 'runtime:box')).toMatchObject({
freshness: 'unverifiable',
rows: [{ worktreeId: 'deleted-during-gap' }]
})
answerAfter(psReply([row('current')]))
await vi.advanceTimersByTimeAsync(0)
expect(rowsOf(producer, 'runtime:box')).toMatchObject({
freshness: 'live',
rows: [{ worktreeId: 'current' }]
})
})
it('opens a new watch once the old one ends with its transport, even without a close notice', () => {
const { hosts, watches, publishStatus } = fakeHosts([{ id: 'box', live: true }])
const producer = new PairedServerRowsProducer({
@@ -31,6 +31,8 @@ type ServerEntry = {
fence: string
identity: Pick<MobileDesktopRelayHostListing, 'pairingRevision' | 'runtimeId'>
inContact: boolean
/** Moves on every contact change, so an answer can tell it predates a gap. */
contactGeneration: number
/** Null until a fetch under this pairing answered. */
rows: MobileRelayServerWorktreeRow[] | null
/** The rows are the server's answer since it last came into contact; else unverifiable. */
@@ -44,7 +46,7 @@ type ServerEntry = {
* The desktop's one live set of paired-server workspace rows, needing no window. A server is
* refetched when it comes into contact, on each of its change events, when a reader asks, and
* every 30 s while the list is showing. A server's last rows read unverifiable once it loses
* contact, until a fetch answers again. Only removing or re-pairing a server drops them; a fetch
* contact, until a fetch started since it came back answers. Only removing or re-pairing a server drops them; a fetch
* that answers replaces them, deletions included.
*/
export class PairedServerRowsProducer implements ServerWorktreeRows {
@@ -112,6 +114,7 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
fence: relayHostFence(host.identity),
identity: host.identity,
inContact: false,
contactGeneration: 0,
rows: null,
confirmed: false,
watch: null,
@@ -125,6 +128,7 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
const inContact = shown.get(entry.environmentId)?.health === 'available'
if (inContact !== entry.inContact) {
entry.inContact = inContact
entry.contactGeneration += 1
entry.confirmed = false
this.revision.bump()
}
@@ -222,6 +226,7 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
}
private async fetch(hosts: MobileDesktopRelayHosts, entry: ServerEntry): Promise<void> {
const { contactGeneration } = entry
let response: Awaited<ReturnType<MobileDesktopRelayHosts['call']>> | null = null
try {
response = await hosts.call(
@@ -244,7 +249,8 @@ export class PairedServerRowsProducer implements ServerWorktreeRows {
return
}
entry.rows = stampServerWorktreeRows(entry.environmentId, reply.data.worktrees)
entry.confirmed = entry.inContact
// An answer from before a contact gap may keep its rows, but cannot vouch for them now.
entry.confirmed = entry.inContact && entry.contactGeneration === contactGeneration
// Every answer moves the revision: rows carry activity that changes without a catalog event.
this.revision.bump()
}
@@ -54,7 +54,8 @@ function resolved(id: string, repo: Repo, hostId: ResolvedWorktree['hostId']): R
}
}
function setup() {
function setup(repos: Repo[] = [localRepo, sshRepo]) {
let settings: Partial<ReturnType<RuntimeStore['getSettings']>> = {}
let onWrite: (domains: readonly string[] | undefined) => void = () => {}
const store: Pick<
RuntimeStore,
@@ -69,8 +70,8 @@ function setup() {
| 'getSettings'
| 'onStateWritten'
> = {
getRepos: () => [localRepo, sshRepo],
getRepo: (id) => [localRepo, sshRepo].find((repo) => repo.id === id),
getRepos: () => repos,
getRepo: (id) => repos.find((repo) => repo.id === id),
getWorktreeMeta: () => undefined,
getAllWorktreeMeta: () => ({}),
getFolderWorkspaces: () => [],
@@ -78,7 +79,7 @@ function setup() {
getSshTargets: () => [],
getAllWorktreeLineage: () => ({}),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the visibility filter reads only absent fields as defaults.
getSettings: () => ({}) as ReturnType<RuntimeStore['getSettings']>,
getSettings: () => settings as ReturnType<RuntimeStore['getSettings']>,
onStateWritten: (listener) => {
onWrite = listener
return () => {}
@@ -112,7 +113,12 @@ function setup() {
runtime,
rows: requireSwitchedOn(runtime.workspaceRows),
listing,
write: (domains?: readonly string[]) => onWrite(domains)
write: (domains?: readonly string[]) => onWrite(domains),
// As the store does: replace the settings in memory, then report a settings-tagged write.
writeSettings: (updates: Partial<ReturnType<RuntimeStore['getSettings']>>) => {
settings = { ...settings, ...updates }
onWrite(['settings'])
}
}
}
@@ -135,7 +141,7 @@ describe('WorkspaceRowsProducer', () => {
expect(snapshot['ssh:box|repo-2::/remote/b']).toMatchObject({ hostId: 'ssh:box', branch: '' })
})
it('moves on catalog changes and metadata writes, never on session or settings writes', () => {
it('moves on catalog changes and metadata writes, never on session or unrelated settings writes', () => {
const { runtime, rows, write } = setup()
const at = (): number => rows.revision.value
const start = at()
@@ -151,6 +157,48 @@ describe('WorkspaceRowsProducer', () => {
expect(at()).toBe(start + 4)
})
it('moves when a settings write changes what the visibility filter reads, and only then', () => {
const { rows, writeSettings } = setup()
const at = (): number => rows.revision.value
const start = at()
writeSettings({ compactWorktreeCards: true })
expect(at()).toBe(start)
writeSettings({ worktreeVisibilityDefaults: { external: 'show' } })
expect(at()).toBe(start + 1)
writeSettings({ worktreeVisibilityDefaults: { external: 'show' } })
expect(at()).toBe(start + 1)
writeSettings({ workspaceDir: '/w' })
writeSettings({ nestWorkspaces: false })
writeSettings({ workspaceDirHistory: [{ path: '/old', nestWorkspaces: true }] })
expect(at()).toBe(start + 4)
})
it('republishes rows to the feed when a visibility setting is saved', async () => {
// Added after the rollout, so the global default decides whether an external checkout shows.
const recentRepo: Repo = { ...localRepo, id: 'repo-3', externalWorktreeVisibilityLegacy: false }
const { runtime, listing, writeSettings } = setup([localRepo, sshRepo, recentRepo])
listing.worktrees.push(resolved('repo-3::/external', recentRepo, 'local'))
writeSettings({ worktreeVisibilityDefaults: { external: 'hide' } })
const feed = requireSwitchedOn(runtime.sidebarInputsFeed)
const events: RuntimeClientEvent[] = []
runtime.onClientEvent((event) => events.push(event))
const phone = new TestPhone(feed, 'conn-1')
phone.refresh()
await phone.settle()
expect(Object.keys(phone.view().workspaces)).not.toContain('local|repo-3::/external')
writeSettings({ worktreeVisibilityDefaults: { external: 'show' } })
await vi.advanceTimersByTimeAsync(60)
const notice = events.findLast((event) => event.type === 'sidebarInputsChanged')
if (notice?.type !== 'sidebarInputsChanged') {
throw new Error('no change notice')
}
phone.notice(notice)
await phone.settle()
expect(Object.keys(phone.view().workspaces)).toContain('local|repo-3::/external')
phone.disconnect()
})
it('reads past the shared listing cache and leaves it alone', async () => {
const { runtime, rows, listing } = setup()
const key = 'local|repo-1::/r/a'
@@ -9,6 +9,19 @@ import { runtimeVisibleFolderWorkspaces } from '../runtime-visible-catalog'
import { WORKTREE_METADATA_DOMAINS } from '../../persistence/loading-store/worktree-identity-metadata'
import { ProducerRevision } from './producer-revision'
// The settings the visibility filter reads: the defaults, and the layouts that classify a checkout.
const VISIBILITY_SETTINGS = [
'worktreeVisibilityDefaults',
'workspaceDir',
'nestWorkspaces',
'workspaceDirHistory'
] as const
function visibilitySettingsKey(store: RuntimeStore): string {
const settings = store.getSettings()
return JSON.stringify(VISIBILITY_SETTINGS.map((key) => settings[key] ?? null))
}
export type WorkspaceRowsSources = {
store: RuntimeStore | null
/** A freshly merged listing, never the shared cache, through worktree.ps's visibility filters. */
@@ -25,6 +38,8 @@ export type WorkspaceRowsSources = {
*/
export class WorkspaceRowsProducer {
readonly revision = new ProducerRevision()
// What the last snapshot's visibility filter read; null until a snapshot or settings save.
private visibilityKey: string | null = null
constructor(private readonly sources: WorkspaceRowsSources) {
sources.onClientEvent(
@@ -39,19 +54,28 @@ export class WorkspaceRowsProducer {
sources.store?.onStateWritten?.((domains) => {
if (
domains === undefined ||
WORKTREE_METADATA_DOMAINS.some((domain) => domains.includes(domain))
WORKTREE_METADATA_DOMAINS.some((domain) => domains.includes(domain)) ||
(domains.includes('settings') && this.visibilitySettingsChanged())
) {
this.revision.bump()
}
})
}
// Every settings save reports above, whichever path wrote it and whether or not it notified.
private visibilitySettingsChanged(): boolean {
const previous = this.visibilityKey
this.visibilityKey = this.sources.store ? visibilitySettingsKey(this.sources.store) : null
return previous !== null && previous !== this.visibilityKey
}
async snapshot(): Promise<WorkspaceRowsSnapshot> {
const { store } = this.sources
const revision = this.revision.value
if (!store) {
return { revision, rows: {} }
}
this.visibilityKey = visibilitySettingsKey(store)
const { worktrees, platformByRepoId } = await this.sources.listVisibleWorktrees()
const rows = buildWorkspaceRows({
worktrees,