From 70efa0bf0f66d211fd6c9dfdf2e26e09ccb249e6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:02:01 -0700 Subject: [PATCH] Use activeWorkspaceKey to reveal active workspace in sidebar Older folder-based workspaces are tracked by `activeWorkspaceKey` rather than the legacy `activeWorktreeId`. Deriving the active sidebar workspace ID from the workspace key enables the "Reveal active workspace" action to work correctly for both types of workspaces. --- .../src/components/sidebar/WorktreeList.tsx | 34 ++- tests/e2e/worktree-scroll-to-current.spec.ts | 264 +++++++++++++++++- 2 files changed, 282 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 2320889a3e3..dbf77ecf0f9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -268,7 +268,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -405,6 +405,22 @@ function getWorktreeOptionId(rowKey: string): string { return `worktree-list-option-${encodeURIComponent(rowKey)}` } +// Why: folder workspaces are tracked by the scoped active key, while older +// worktree-only paths still read activeWorktreeId. +function getActiveSidebarWorkspaceId( + activeWorkspaceKey: string | null, + activeWorktreeId: string | null +): string | null { + const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null + if (scope?.type === 'folder') { + return folderWorkspaceKey(scope.folderWorkspaceId) + } + if (scope?.type === 'worktree') { + return scope.worktreeId + } + return activeWorktreeId +} + function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { const scope = root ?? document const result: HTMLElement[] = [] @@ -5151,7 +5167,11 @@ const WorktreeList = React.memo(function WorktreeList({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const currentSidebarWorktreeId = activeWorktreeId + const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) + const currentSidebarWorktreeId = useMemo( + () => getActiveSidebarWorkspaceId(activeWorkspaceKey, activeWorktreeId), + [activeWorkspaceKey, activeWorktreeId] + ) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) @@ -6642,31 +6662,31 @@ const WorktreeList = React.memo(function WorktreeList({ }) return } - if (!activeWorktreeId) { + if (!currentSidebarWorktreeId) { return } const activeWorktree = getKnownSidebarWorktreeById( - activeWorktreeId, + currentSidebarWorktreeId, worktreeMap, folderWorkspaces ) if (!activeWorktree || activeWorktree.isArchived) { return } - if (!renderedWorktreeIds.includes(activeWorktreeId)) { + if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { // Why: the toolbar action promises to reveal the current workspace; when // sidebar filters hide it, relax those filters before queuing the reveal. clearFilters() } - revealWorktreeInSidebar(activeWorktreeId, { + revealWorktreeInSidebar(currentSidebarWorktreeId, { behavior: 'smooth', highlight: true, beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) }, [ - activeWorktreeId, clearFilters, + currentSidebarWorktreeId, folderWorkspaces, revealSidebarRow, renderedWorktreeIds, diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 2cbd0df4a08..91ae5e88142 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,11 +1,6 @@ import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { worktreeRow } from './worktree-row-locators' - -function worktreeOption(page: Page, worktreeId: string) { - return worktreeRow(page, worktreeId) -} async function prepareSidebarForScrollTest(page: Page): Promise { await page.evaluate(() => { @@ -117,7 +112,9 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) await renderedOptions.last().click() @@ -175,10 +172,31 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRows = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const targetRow = targetRows.first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) - await renderedOptions.last().click() + await orcaPage.evaluate((targetId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const target = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === targetId) + if (!target) { + throw new Error(`Target workspace ${targetId} not found`) + } + store.setState({ + activeRepoId: target.repoId, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null + }) + }, targetId) await expect(targetRow).toHaveAttribute('aria-current', 'page') await orcaPage.evaluate(() => { @@ -190,7 +208,7 @@ test.describe('Reveal active workspace button', () => { }) await expect(renderedOptions).toHaveCount(0) - await expect(orcaPage.getByText('No workspaces found')).toBeVisible() + await expect(targetRows).toHaveCount(0) await revealButton.click() @@ -213,4 +231,232 @@ test.describe('Reveal active workspace button', () => { ) .toEqual([]) }) + + test('reveals the current workspace when it starts outside the virtualized window', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const targetId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + + const now = Date.now() + const seededWorktrees = state.worktreesByRepo[repo.id] ?? [] + const syntheticWorktrees = Array.from({ length: 60 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `${repo.id}::/virtual-reveal-${suffix}`, + instanceId: `virtual-reveal-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-reveal-${suffix}`, + displayName: `Virtual reveal ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-reveal-${suffix}`, + isBare: false, + isMainWorktree: false + } + }) + const target = syntheticWorktrees.at(-1) + if (!target) { + throw new Error('Expected a synthetic target worktree') + } + + store.setState({ + activeRepoId: repo.id, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null, + sortBy: 'manual', + worktreesByRepo: { + ...state.worktreesByRepo, + [repo.id]: [...seededWorktrees, ...syntheticWorktrees] + } + }) + return target.id + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await expect + .poll( + () => + orcaPage.evaluate(() => { + const scroller = document.querySelector('[data-worktree-sidebar]') + return scroller?.scrollTop ?? null + }), + { timeout: 10_000, message: 'sidebar scroller did not mount' } + ) + .not.toBeNull() + + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, targetId), + { timeout: 10_000, message: 'target workspace should start outside the sidebar viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + + await revealButton.click() + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() + await expect(targetRow).toBeVisible() + await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') + }) + + test('uses the active workspace key when the legacy active worktree id is not set', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const folderWorktreeId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const now = Date.now() + const folderWorkspaces = Array.from({ length: 36 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `reveal-folder-workspace-${suffix}`, + projectGroupId: 'reveal-folder-group', + name: `Reveal folder workspace ${suffix}`, + folderPath: `/tmp/reveal-folder-workspace-${suffix}`, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1_000 - index, + lastActivityAt: now - index, + createdAt: now, + updatedAt: now + } + }) + const targetFolder = folderWorkspaces.at(-1) + if (!targetFolder) { + throw new Error('Expected a target folder workspace') + } + const folderWorktreeId = `folder:${targetFolder.id}` + store.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceKey: folderWorktreeId, + projectGroups: [ + { + id: 'reveal-folder-group', + name: 'Reveal folder group', + parentPath: '/tmp/reveal-folder-group', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: now, + updatedAt: now + } + ], + folderWorkspaces + }) + store.getState().setGroupBy('repo') + return folderWorktreeId + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { timeout: 10_000, message: 'target folder workspace should start outside the viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + await revealButton.click() + + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { + timeout: 10_000, + message: 'Reveal button should scroll to the active folder workspace' + } + ) + .toBe(true) + }) })