diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 3e4ae28369e..801dadf81f8 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -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'] diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts index 0da92e7d763..1aa17e2cbc5 100644 --- a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts @@ -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) => void = () => {} + let answerAfter: (response: RuntimeRpcResponse) => 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({ diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts index 567e080c26b..d5439035307 100644 --- a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.ts @@ -31,6 +31,8 @@ type ServerEntry = { fence: string identity: Pick 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 { + const { contactGeneration } = entry let response: Awaited> | 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() } diff --git a/src/main/runtime/sidebar-inputs/workspace-rows-producer.test.ts b/src/main/runtime/sidebar-inputs/workspace-rows-producer.test.ts index fd3709d9fcc..fe050dda046 100644 --- a/src/main/runtime/sidebar-inputs/workspace-rows-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/workspace-rows-producer.test.ts @@ -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> = {} 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, + getSettings: () => settings as ReturnType, 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>) => { + 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' diff --git a/src/main/runtime/sidebar-inputs/workspace-rows-producer.ts b/src/main/runtime/sidebar-inputs/workspace-rows-producer.ts index 17701e1af5e..62400dc8f2c 100644 --- a/src/main/runtime/sidebar-inputs/workspace-rows-producer.ts +++ b/src/main/runtime/sidebar-inputs/workspace-rows-producer.ts @@ -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 { 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,