Sidebar: land the switch-on fixes from the flip branch on main, switch still off (#31466)

* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip)

The fixes found while the sidebar ran from main's feed, without turning
that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's
stop-gaps come along.

- Main stops counting a closed tab's terminal once it was asked to stop
  and no pane shows it; a failed or refused stop takes the request back,
  and a host out of contact stays unverifiable.
- Main fetches a paired server's rows with the transport's normal bound,
  so a server slower than 5 s still lists.
- A deferred unread raise flips the folder row it was decided for, and a
  read while the owner check is out cancels it.
- Phone: no worktree.ps flash before the feed; a host's last installed
  feed stands in while a fresh read is out; it highlights its own
  last-opened workspace and a fold never scrolls; projects file under
  their project group with the desktop's indent; the collapse spinner
  shows only after 500 ms; "Reopen to update" keeps the rows on screen.
- e2e helpers and journeys for the switch-on suite.

* fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises

- A stop-and-wait that throws takes its stop request back, like one that fails.
- A final stop the legacy relay still owes stays marked, since the relay retries it.
- Pending unread raises are tracked only while one waits, so the map no longer grows.
- The paired-server fallback poll test gives its third fetch a reply and asserts live.
This commit is contained in:
Jinwoo Hong
2026-10-21 12:24:27 -04:00
committed by GitHub
parent 50b07c0e8f
commit 92bbb380a6
53 changed files with 2404 additions and 299 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ import type { WorktreeCardDetails } from '../../../src/shared/worktree/card-prop
import { isRemoteExecutionHostId } from '../../../src/shared/execution-host'
import type { AgentActivityDisplayMode } from '../../../src/shared/ui-chrome-types'
import { areStructurallyEqual } from '../worktree/structural-equality'
import { projectGroupRowIndent } from '../worktree/project-group-indent'
import { getWorktreeMetaGlyphPresence } from '../worktree/worktree-meta-glyph-presence'
import { AgentSpinner } from './AgentSpinner'
import { HostKindIcon } from './HostKindIcon'
@@ -64,6 +65,7 @@ function WorktreeListRowComponent({
const folderMeta = item.comment?.trim() || item.path || 'Folder'
const metaText = isFolderWorkspace ? folderMeta : displayBranch(item.branch)
const lineageDepth = Math.max(0, item.lineageDepth ?? 0)
const indent = projectGroupRowIndent(item) + lineageDepth * 18
const lineageChildCount = item.lineageChildCount ?? 0
const shouldHideRepo = hideRepo && !item.showProjectName
const showRepoChip = cardDetails.repoInTitle && !shouldHideRepo && !isFolderWorkspace
@@ -79,7 +81,7 @@ function WorktreeListRowComponent({
<Pressable
style={({ pressed }) => [
styles.worktreeRow,
lineageDepth > 0 && { paddingLeft: spacing.lg + lineageDepth * 18 },
indent > 0 && { paddingLeft: spacing.lg + indent },
item.isActive && styles.worktreeRowActive,
pressed && styles.worktreeRowPressed
]}
@@ -0,0 +1,72 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { useActiveWorktreeScroll } from './use-active-worktree-scroll'
type Row = { worktreeId: string; isActive?: boolean }
type Sections = { key: string; data: Row[] }[]
/** Rows a, b, c with `active` highlighted; a folded section shows none of them. */
const listOf = (active: string | null, folded = false): Sections => [
{
key: 'all',
data: folded
? []
: ['a', 'b', 'c'].map((worktreeId) => ({ worktreeId, isActive: worktreeId === active }))
}
]
const NONE = new Set<string>()
const FOLDED = new Set(['all'])
function mount(sections: Sections, collapsedGroups: ReadonlySet<string> = NONE) {
const scrollToLocation = vi.fn()
function Probe(props: { sections: Sections; collapsedGroups: ReadonlySet<string> }): null {
const { sectionListRef } = useActiveWorktreeScroll(props.sections, props.collapsedGroups)
// The hook calls only scrollToLocation on its list.
Object.assign(sectionListRef, { current: { scrollToLocation } })
return null
}
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(createElement(Probe, { sections, collapsedGroups }))
})
const update = (next: Sections, nextCollapsed: ReadonlySet<string> = NONE) =>
act(() => {
renderer?.update(createElement(Probe, { sections: next, collapsedGroups: nextCollapsed }))
})
return { scrollToLocation, update }
}
describe('useActiveWorktreeScroll', () => {
it('scrolls to the highlighted row, and again when another workspace is highlighted', () => {
const list = mount(listOf('b'))
expect(list.scrollToLocation).toHaveBeenLastCalledWith(
expect.objectContaining({ sectionIndex: 0, itemIndex: 1 })
)
list.update(listOf('c'))
expect(list.scrollToLocation).toHaveBeenLastCalledWith(
expect.objectContaining({ itemIndex: 2 })
)
expect(list.scrollToLocation).toHaveBeenCalledTimes(2)
})
it('never scrolls when a fold or search hides the row and it comes back', () => {
const list = mount(listOf('b'))
list.update(listOf('b', true), FOLDED)
list.update(listOf('b'), NONE)
// A search hiding the row, then cleared.
list.update(listOf(null))
list.update(listOf('b'))
expect(list.scrollToLocation).toHaveBeenCalledTimes(1)
})
it('never scrolls to a highlight an unfold first reveals', () => {
const list = mount(listOf('b', true), FOLDED)
list.update(listOf('b'), NONE)
expect(list.scrollToLocation).not.toHaveBeenCalled()
// A later move to another workspace still scrolls.
list.update(listOf('c'), NONE)
expect(list.scrollToLocation).toHaveBeenCalledTimes(1)
})
})
+18 -10
View File
@@ -3,20 +3,25 @@ import type { SectionList, SectionListData } from 'react-native'
type WithId = { worktreeId: string; isActive?: boolean }
// Scrolls the desktop-focused worktree into view when the active selection
// changes, so the mobile list mirrors the desktop's current workspace. Fires
// only on a *change* of active id (not every re-render) so it never yanks the
// list while the user scrolls or searches. Returns the ref to attach to the
// SectionList and the onScrollToIndexFailed handler it needs for rows that
// aren't measured yet (variable heights from the inline agent list).
// Scrolls the highlighted (isActive) worktree into view when the highlight
// moves to a different workspace. A highlight a search or fold hides and then
// shows again is the same workspace, so it never yanks the list. Returns the
// ref to attach to the SectionList and the onScrollToIndexFailed handler it
// needs for rows that aren't measured yet (variable heights from the inline
// agent list).
export function useActiveWorktreeScroll<T extends WithId, S>(
sections: ReadonlyArray<SectionListData<T, S> & { data: readonly T[] }>
sections: readonly (SectionListData<T, S> & { data: readonly T[] })[],
/** The folded sections: a highlight an unfold first reveals is not scrolled to either. */
collapsedGroups?: ReadonlySet<string>
): {
sectionListRef: React.RefObject<SectionList<T, S> | null>
onScrollToIndexFailed: (info: { averageItemLength: number }) => void
} {
const sectionListRef = useRef<SectionList<T, S>>(null)
const lastScrolledActiveIdRef = useRef<string | null>(null)
// The last workspace highlighted, scrolled to or not: a hidden row coming back is no change.
const seenActiveIdRef = useRef<string | null>(null)
const collapsedGroupsRef = useRef(collapsedGroups)
const activeWorktreeId = useMemo(() => {
for (const section of sections) {
@@ -53,13 +58,16 @@ export function useActiveWorktreeScroll<T extends WithId, S>(
)
useEffect(() => {
if (!activeWorktreeId || activeWorktreeId === lastScrolledActiveIdRef.current) {
const folded = collapsedGroupsRef.current !== collapsedGroups
collapsedGroupsRef.current = collapsedGroups
if (!activeWorktreeId || activeWorktreeId === seenActiveIdRef.current) {
return
}
if (scrollToWorktree(activeWorktreeId)) {
seenActiveIdRef.current = activeWorktreeId
if (!folded && scrollToWorktree(activeWorktreeId)) {
lastScrolledActiveIdRef.current = activeWorktreeId
}
}, [activeWorktreeId, scrollToWorktree])
}, [collapsedGroups, activeWorktreeId, scrollToWorktree])
const onScrollToIndexFailed = useCallback(
(info: { averageItemLength: number }) => {
@@ -0,0 +1,153 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SIDEBAR_INPUTS_SEMANTICS_VERSION } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
import type { InstalledSidebarInputs } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-installer'
import type { RpcClient } from '../transport/rpc-client'
import type { Section } from '../worktree/workspace-list-types'
import type { HostSidebarFeedRead } from './host-sidebar-feed-reader'
import { FEED_ROWS, liveRow, phoneView } from './host-sidebar-feed.test-fixture'
const readers = vi.hoisted((): { onRead: ((read: HostSidebarFeedRead) => void)[] } => ({
onRead: []
}))
vi.mock('../../../src/shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
vi.mock('./host-sidebar-feed-reader', () => ({
HostSidebarFeedReader: class {
constructor(_client: unknown, onRead: (read: HostSidebarFeedRead) => void) {
readers.onRead.push(onRead)
}
refresh(): void {}
changed(): void {}
dispose(): void {}
}
}))
vi.mock('./use-host-repo-icon-data-urls', () => ({ useHostRepoIconDataUrls: () => new Map() }))
vi.mock('../worktree/use-last-visited-worktree-on-host', () => ({
useLastVisitedWorktreeOnHost: () => ({ kind: 'none' })
}))
import { useHostListSections } from './use-host-list-sections'
import type { HostScreenState } from './use-host-screen-state'
import { forgetRememberedSidebarFeeds, useHostSidebarFeed } from './use-host-sidebar-feed'
/** worktree.ps's rows: flat, unpinned, in its own order — not the shape the desktop draws. */
const LISTED = [
liveRow({ worktreeId: 'lab::/w/gpu', repoId: 'lab', repo: 'lab', hostId: 'ssh:gpu' }),
liveRow({ worktreeId: 'orca::/w/child' }),
liveRow({ worktreeId: 'orca::/w/parent' })
]
const INSTALLED: InstalledSidebarInputs = {
generation: 'g',
revision: 1,
schemaVersion: 1,
semanticsVersion: SIDEBAR_INPUTS_SEMANTICS_VERSION,
domains: new Map([
[
'workspaces',
new Map<string, unknown>([
['|orca::/w/parent', FEED_ROWS.parent],
['|orca::/w/child', FEED_ROWS.child]
])
]
])
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list builder reads only these fields.
const STATE = {
view: phoneView(),
search: '',
servedProjectSections: null,
desktopRepos: [],
hostProjectGroups: [],
repoHostIdByRepoId: new Map(),
pinnedIds: [],
showPinnedInGroups: false
} as unknown as HostScreenState
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked reader never sends.
const CLIENT = {} as RpcClient
let drawn: Section[] = []
let kind = ''
function List({ hostId }: { hostId: string }) {
const { feed } = useHostSidebarFeed(CLIENT, hostId)
kind = feed.kind
drawn = useHostListSections({
client: CLIENT,
hostId,
feed,
displayWorktrees: LISTED,
hostPlatform: 'darwin',
state: STATE
}).sections
return null
}
const renderers: ReactTestRenderer[] = []
async function open(hostId: string): Promise<void> {
await act(async () => {
renderers.push(create(createElement(List, { hostId })))
})
}
async function read(next: HostSidebarFeedRead): Promise<void> {
await act(async () => readers.onRead.at(-1)?.(next))
}
const rowIds = () => drawn.flatMap((section) => section.data.map((row) => row.worktreeId))
describe("the host list's first frame under the feed", () => {
beforeEach(() => {
readers.onRead.length = 0
forgetRememberedSidebarFeeds()
})
afterEach(() => {
act(() => renderers.splice(0).forEach((renderer) => renderer.unmount()))
})
it('draws no rows from worktree.ps before the feed installs, then the desktop’s list', async () => {
await open('host-1')
expect(kind).toBe('loading')
expect(drawn).toEqual([])
await read({ installed: INSTALLED, unavailable: false })
expect(drawn[0]?.icon).toBe('pin')
expect(rowIds()).toContain('orca::/w/parent')
})
it("opens the list again on the host's last installed feed, not on worktree.ps", async () => {
await open('host-1')
await read({ installed: INSTALLED, unavailable: false })
const desktopList = drawn
act(() => renderers.splice(0).forEach((renderer) => renderer.unmount()))
await open('host-1')
expect(kind).toBe('ready')
expect(drawn).toEqual(desktopList)
})
it("never opens one host's list on another's feed", async () => {
await open('host-1')
await read({ installed: INSTALLED, unavailable: false })
await open('host-2')
expect(kind).toBe('loading')
expect(drawn).toEqual([])
})
it('still lists an older desktop that has no feed, from worktree.ps', async () => {
await open('host-1')
await read({ installed: null, unavailable: true })
expect(kind).toBe('reopen')
expect(rowIds()).toEqual(LISTED.map((row) => row.worktreeId))
})
it('forgets a feed the host stopped serving', async () => {
await open('host-1')
await read({ installed: INSTALLED, unavailable: false })
await read({ installed: INSTALLED, unavailable: true })
act(() => renderers.splice(0).forEach((renderer) => renderer.unmount()))
await open('host-1')
expect(kind).toBe('loading')
})
})
@@ -34,6 +34,9 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
color: colors.textMuted,
marginLeft: spacing.xs
},
sectionPendingSpinner: {
marginLeft: spacing.sm
},
separator: {
height: 1,
backgroundColor: colors.borderSubtle,
@@ -13,10 +13,14 @@ import {
FEED_ROWS,
SERVED_SECTIONS,
feedDomains,
feedRow,
liveRow,
phoneView
} from './host-sidebar-feed.test-fixture'
import type { MobileViewState } from '../worktree/workspace-view-settings'
import type { ProjectGroup } from '../../../src/shared/project-group-types'
import { readServedProjectSections } from '../worktree/project-sections-operations'
import type { LastVisitedWorktreeOnHost } from '../worktree/host-list-highlight'
const catalog = buildSidebarFeedCatalog(SERVED_SECTIONS, [], [])
@@ -39,6 +43,7 @@ function sectionsFor(
showPinnedInGroups?: boolean
live?: ReturnType<typeof liveRow>[]
search?: string
lastVisited?: LastVisitedWorktreeOnHost | null
} = {}
) {
const built = buildSidebarFeedRows(
@@ -48,18 +53,23 @@ function sectionsFor(
)
return buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
lastVisited: options.lastVisited ?? null,
liveRows: options.live ?? [],
repoHostIdByRepoId: new Map([['lab', 'ssh:gpu']]),
search: options.search ?? ''
})
}
function sectionsFromDomains(domains: SidebarFeedDomains, view: MobileViewState, search = '') {
function sectionsFromDomains(
domains: SidebarFeedDomains,
view: MobileViewState,
search = '',
lastVisited: LastVisitedWorktreeOnHost | null = null
) {
const built = buildSidebarFeedRows(domains, catalog, phoneFeedView(view))
return buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
lastVisited,
liveRows: [],
repoHostIdByRepoId: new Map(),
search
@@ -77,18 +87,145 @@ function outline(sections: ReturnType<typeof sectionsFor>): string[] {
}
describe('the phone list from the sidebar feed', () => {
it('draws the shared builder’s headers, order, nesting and single highlight', () => {
it('draws the shared builder’s headers, order and nesting', () => {
const lines = outline(sectionsFor(phoneView()))
// The pinned parent carries its nested child.
expect(lines[0]).toMatch(/ "Pinned" 2 pin$/)
expect(lines.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1)
expect(lines.some((line) => line.includes('orca::/w/child depth=1'))).toBe(true)
expect(lines.some((line) => line.includes('"lab"'))).toBe(true)
})
it('highlights every occurrence when pinned rows also show in their groups', () => {
const lines = outline(sectionsFor(phoneView(), { showPinnedInGroups: true }))
expect(lines.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(2)
it('files a grouped project under its group header, nested and in the group’s order', () => {
const group = (id: string, name: string, extra: Partial<ProjectGroup> = {}): ProjectGroup => ({
id,
name,
parentPath: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 0,
updatedAt: 0,
...extra
})
// As the desktop serves it, read by the page's own reply parser.
const served = readServedProjectSections({
id: 'sections',
ok: true,
result: {
catalog: {
...SERVED_SECTIONS,
repos: [
// Listed against their group order, so the order must come from projectGroupOrder.
{ ...SERVED_SECTIONS.repos[0]!, projectGroupId: 'pg-inner', projectGroupOrder: 1 },
{ ...SERVED_SECTIONS.repos[1]!, projectGroupId: 'pg-inner', projectGroupOrder: 0 }
],
projectGroups: [
group('pg-school', 'school'),
group('pg-inner', 'inner', { parentGroupId: 'pg-school', createdFrom: 'folder-scan' })
]
}
}
})
if (served.kind !== 'answered' || !served.value) {
throw new Error('expected a served catalog')
}
const grouped = buildSidebarFeedCatalog(served.value, [], [])
const base = feedDomains()
// An unpinned orca workspace, so orca draws its own header beside lab.
const domains = feedDomains({
workspaces: new Map([
...base.workspaces,
['|orca::/w/solo', feedRow({ id: 'orca::/w/solo', manualOrder: 4 })]
])
})
const built = buildSidebarFeedRows(domains, grouped, phoneFeedView(phoneView()))
const sections = buildHostFeedSections({
rows: built.sectionRows,
lastVisited: null,
liveRows: [],
repoHostIdByRepoId: new Map([['lab', 'ssh:gpu']]),
search: ''
})
const headers = sections.map(
(section) =>
`${section.title}${section.isProjectGroup ? ' group' : ''} depth=${String(section.projectGroupDepth ?? 0)}`
)
expect(headers).toEqual([
'Pinned depth=0',
'school group depth=0',
'inner group depth=1',
'lab depth=2',
'orca depth=2'
])
// Rows carry their header's depth and the folder-scanned group, so they indent as the desktop's do.
expect(
sections
.slice(-2)
.map((section) =>
section.data.map((row) => [row.projectGroupDepth, row.inFolderScanGroup === true])
)
).toEqual([[[2, true]], [[2, true]]])
expect(sections.at(-2)?.data.map((row) => row.worktreeId)).toEqual(['lab::/w/gpu'])
})
describe('the highlight: only the workspace this phone last opened, where shown', () => {
const active = (sections: ReturnType<typeof sectionsFor>) =>
outline(sections).filter((line) => line.endsWith(' ACTIVE'))
it('highlights nothing while this phone has opened nothing on the host', () => {
// The fixture's desktop has the pinned parent selected; the phone never shows it.
expect(active(sectionsFor(phoneView(), { lastVisited: null }))).toEqual([])
})
it('highlights the listed row this phone opened, and only it', () => {
const lines = active(
sectionsFor(phoneView(), { lastVisited: { worktreeId: 'orca::/w/child' } })
)
expect(lines).toHaveLength(1)
expect(lines[0]).toContain('orca::/w/child')
})
it('highlights an SSH row by its id, as the session route names it', () => {
const lines = active(sectionsFor(phoneView(), { lastVisited: { worktreeId: 'lab::/w/gpu' } }))
expect(lines).toHaveLength(1)
expect(lines[0]).toContain('lab::/w/gpu')
})
it('never matches a desktop row for a visit on a server of the same id', () => {
const lastVisited = { worktreeId: 'orca::/w/child', executionHost: 'runtime:env-1' as const }
expect(active(sectionsFor(phoneView(), { lastVisited }))).toEqual([])
})
it('highlights nothing while the row is searched away or folded, and the row once it is back', () => {
const lastVisited = { worktreeId: 'lab::/w/gpu' }
expect(active(sectionsFor(phoneView(), { lastVisited, search: 'parent' }))).toEqual([])
const lab = sectionsFor(phoneView()).find((section) => section.title === 'lab')!
const folded = sectionsFor(phoneView({ collapsedGroups: [lab.key] }), { lastVisited })
expect(active(folded)).toEqual([])
expect(active(sectionsFor(phoneView(), { lastVisited }))).toHaveLength(1)
})
it('highlights nothing once the row is deleted', () => {
const workspaces = new Map(feedDomains().workspaces)
workspaces.delete('|orca::/w/child')
const domains = feedDomains({ workspaces })
const sections = sectionsFromDomains(domains, phoneView(), '', {
worktreeId: 'orca::/w/child'
})
expect(active(sections)).toEqual([])
})
it('highlights every occurrence when pinned rows also show in their groups', () => {
const lines = active(
sectionsFor(phoneView(), {
showPinnedInGroups: true,
lastVisited: { worktreeId: 'orca::/w/parent' }
})
)
expect(lines).toHaveLength(2)
})
})
// The phone sends its host list resolved with an "all" scope; the desktop keeps its legacy scope.
@@ -135,7 +272,7 @@ describe('the phone list from the sidebar feed', () => {
)
const sections = buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
lastVisited: null,
liveRows: [],
repoHostIdByRepoId: new Map(),
search: ''
@@ -206,7 +343,7 @@ describe('the phone list from the sidebar feed', () => {
})
const sections = buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
lastVisited: null,
liveRows: [stale],
repoHostIdByRepoId: new Map(),
search: 'new name'
@@ -299,11 +436,39 @@ describe('the phone list before the first feed read', () => {
liveRow({ worktreeId: 'orca::/w/a', displayName: 'alpha' }),
liveRow({ worktreeId: 'orca::/w/old', displayName: 'old', isArchived: true })
]
const preFeed = (search: string, rows = cached) =>
buildPreFeedSections({ rows, view: phoneView(), search, repoHostIdByRepoId: new Map() })
const preFeedVisiting = (
search: string,
rows: typeof cached,
lastVisited: LastVisitedWorktreeOnHost | null
) =>
buildPreFeedSections({
rows,
view: phoneView(),
search,
repoHostIdByRepoId: new Map(),
lastVisited
})
const preFeed = (search: string, rows = cached) => preFeedVisiting(search, rows, null)
it('shows the rows the page last listed, in their order under no header, filtered as the view is', () => {
expect(preFeed('')).toEqual([{ key: 'all', title: '', data: cached.slice(0, 2) }])
expect(preFeed('')).toEqual([
{
key: 'all',
title: '',
data: cached.slice(0, 2).map((row) => ({ ...row, isActive: false }))
}
])
})
it('highlights the row this phone opened, never the one the desktop marked active', () => {
const rows = [{ ...cached[0]!, isActive: true }, cached[1]!]
const highlighted = (lastVisited: LastVisitedWorktreeOnHost | null) =>
preFeedVisiting('', rows, lastVisited)[0]
?.data.filter((row) => row.isActive)
.map((row) => row.worktreeId)
expect(highlighted(null)).toEqual([])
expect(highlighted({ worktreeId: 'orca::/w/a' })).toEqual(['orca::/w/a'])
expect(highlighted({ worktreeId: 'orca::/w/gone' })).toEqual([])
})
it('narrows them by search, and draws nothing with none', () => {
@@ -11,6 +11,10 @@ import { PR_GROUP_LABELS } from '../../../src/shared/workspace-view/workspace-pr
import type { Worktree as HostWorktree } from '../../../src/shared/worktree/types'
import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity'
import type { Section, Worktree } from '../worktree/workspace-list-types'
import {
isLastVisitedWorktreeRow,
type LastVisitedWorktreeOnHost
} from '../worktree/host-list-highlight'
import { resolveWorktreeHostId } from '../worktree/worktree-host-context-labels'
import { filterWorktrees } from '../worktree/workspace-list-sections'
import { rowFiltersForView, type MobileViewState } from '../worktree/workspace-view-settings'
@@ -18,7 +22,8 @@ import { rowFiltersForView, type MobileViewState } from '../worktree/workspace-v
/** What the host screen draws from a feed-built list, besides its rows' own live content. */
export type HostFeedListSources = {
rows: readonly HostSectionRow[]
activeRowKeys: ReadonlySet<string>
/** The phone's own highlight; null while unread or never opened, when nothing is lit. */
lastVisited: LastVisitedWorktreeOnHost | null
/** The rows worktree.ps and the desktop's server lists served, for what each row draws. */
liveRows: readonly Worktree[]
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
@@ -97,7 +102,8 @@ function matchesSearch(row: Worktree, query: string): boolean {
/**
* The phone's sections from the list the shared builder made from the feed: the desktop's headers,
* order, nesting and highlight, each row drawn with the live content the phone already reads.
* order and nesting, each row drawn with the live content the phone already reads, and highlighted
* by `isLastVisitedWorktreeRow`.
* Search narrows the rows here, as the desktop's own search never reaches the feed.
*/
export function buildHostFeedSections(sources: HostFeedListSources): Section[] {
@@ -121,18 +127,29 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] {
return first
}
// Why: the desktop steps folder-scanned groups' rows by the compact header step; a group's header precedes its rows.
const folderScanGroupIds = new Set<string>()
for (const row of sources.rows) {
switch (row.type) {
case 'host-header':
push({ key: row.key, title: row.label, count: row.count, data: [] })
break
case 'header':
if (
row.projectGroup?.id &&
'createdFrom' in row.projectGroup &&
row.projectGroup.createdFrom === 'folder-scan'
) {
folderScanGroupIds.add(row.projectGroup.id)
}
push({
key: row.collapseKey ?? row.key,
title: headerTitle(row),
count: row.count,
...(row.icon.kind === 'pinned' ? { icon: 'pin' as const } : {}),
...(row.repo ? { repo: row.repo } : {}),
...(row.projectGroup && !row.repo ? { isProjectGroup: true } : {}),
...(row.projectGroupDepth ? { projectGroupDepth: row.projectGroupDepth } : {}),
data: []
})
break
@@ -142,9 +159,16 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] {
const drawn = rowOf(row.worktree, hostId, row.repo?.displayName, 'git')
current().data.push({
...drawn,
isActive: sources.activeRowKeys.has(row.rowKey),
isActive: isLastVisitedWorktreeRow(
{ worktreeId: row.worktree.id, hostId },
sources.lastVisited
),
sectionListKey: row.rowKey,
lineageDepth: row.depth,
...(row.groupDepth ? { projectGroupDepth: row.groupDepth } : {}),
...(row.repo?.projectGroupId && folderScanGroupIds.has(row.repo.projectGroupId)
? { inFolderScanGroup: true }
: {}),
lineageChildCount: row.lineageChildCount,
lineageCollapsed: row.lineageCollapsed === true,
isLastLineageChild: row.isLastLineageChild,
@@ -166,9 +190,14 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] {
)
current().data.push({
...rowOf(worktree, hostId, row.projectGroup.name, 'folder-workspace'),
isActive: sources.activeRowKeys.has(row.key),
isActive: isLastVisitedWorktreeRow(
{ worktreeId: worktree.id, hostId },
sources.lastVisited
),
sectionListKey: row.key,
lineageDepth: 0
lineageDepth: 0,
...(row.groupDepth ? { projectGroupDepth: row.groupDepth } : {}),
...(row.projectGroup.createdFrom === 'folder-scan' ? { inFolderScanGroup: true } : {})
})
break
}
@@ -195,22 +224,26 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] {
}
/**
* Before the first feed read: the rows this page last listed (worktree.ps, or the cache the screen
* opened with) under the view's filters, in their served order and no header, so an offline open
* is not blank.
* For a host whose feed this page cannot read: the rows worktree.ps listed under the view's
* filters, in their served order and no header, so an older desktop still gets a list.
*/
export function buildPreFeedSections(sources: {
rows: Worktree[]
view: MobileViewState
search: string
repoHostIdByRepoId: ReadonlyMap<string, ExecutionHostId>
lastVisited: LastVisitedWorktreeOnHost | null
}): Section[] {
const rows = filterWorktrees(
sources.rows,
rowFiltersForView(sources.view),
sources.search,
sources.repoHostIdByRepoId
)
).map((row) => ({
...row,
// Replaces worktree.ps's mark, which is the desktop's selection.
isActive: isLastVisitedWorktreeRow(row, sources.lastVisited)
}))
return rows.length > 0 ? [{ key: 'all', title: '', data: rows }] : []
}
@@ -0,0 +1,110 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
RefreshControl: 'RefreshControl',
SectionList: 'SectionList',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
Pin: 'Pin',
X: 'X'
}))
vi.mock('../components/AuthFailedBanner', () => ({ AuthFailedBanner: 'AuthFailedBanner' }))
vi.mock('../components/HostDiagnosticsLink', () => ({ HostDiagnosticsLink: 'HostDiagnosticsLink' }))
vi.mock('../components/MobileRepoIcon', () => ({ MobileRepoIcon: 'MobileRepoIcon' }))
vi.mock('../components/MobileSearchField', () => ({ MobileSearchField: 'MobileSearchField' }))
vi.mock('../components/NewWorkspaceFab', () => ({
NewWorkspaceFab: 'NewWorkspaceFab',
FAB_SIZE: 56
}))
vi.mock('../components/WorktreeListRow', () => ({ WorktreeListRow: 'WorktreeListRow' }))
import { HostWorkspaceList } from './host-workspace-list'
import { INITIAL_MOBILE_VIEW } from '../worktree/workspace-view-settings'
/** A host whose feed this page cannot read: the notice, above the rows the page already holds. */
function listWith(rows: number) {
const section = {
key: 'all',
title: '',
data: Array.from({ length: rows }, (_, i) => ({ id: `w${i}` }))
}
const sections = rows > 0 ? [section] : []
const controller = {
actions: { openWorktreeSession: () => {}, openNewWorktreeModal: () => {} },
activeWorktreeScroll: { sectionListRef: { current: null }, onScrollToIndexFailed: () => {} },
catalog: { refreshing: false, onRefresh: () => {} },
collapsedGroups: new Set(),
connState: 'connected',
contentMaxWidth: 600,
displayWorktrees: section.data,
embedded: true,
feed: { kind: 'reopen' },
forceReconnectHost: () => {},
hostId: 'host-1',
insets: { bottom: 0 },
isReadOnly: false,
isWideLayout: false,
noticeParam: undefined,
now: 0,
reconnectAttempts: 0,
relayRecovery: { pairingRejected: false },
routeNotice: '',
router: { push: () => {} },
sectionsResult: { rawSections: sections, sections, repoById: new Map() },
setDismissedNotice: () => {},
settings: { activeFilterCount: 0, pendingViewChanges: { pending: [], stale: false } },
state: {
actionError: '',
setActionError: () => {},
catalogError: null,
view: { ...INITIAL_MOBILE_VIEW, groupBy: 'none' },
search: '',
showSearch: false,
worktreesLoaded: true
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every row and header below the notice is mocked to a host string here, so the list reads only the fields named above.
return { controller } as unknown as Parameters<typeof HostWorkspaceList>[0]
}
function render(rows: number): ReactTestRenderer {
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
act(() => {
rendered.tree = create(createElement(HostWorkspaceList, listWith(rows)))
})
if (rendered.tree === null) {
throw new Error('the list did not render')
}
return rendered.tree
}
function texts(tree: ReactTestRenderer): unknown[] {
return tree.root
.findAll((node) => String(node.type) === 'Text')
.map((node) => node.props.children)
}
describe('a host whose sidebar feed this page cannot read', () => {
it('keeps the rows the page holds on screen under "Reopen to update"', () => {
const tree = render(2)
expect(texts(tree)).toContain('Reopen to update')
const [list] = tree.root.findAll((node) => String(node.type) === 'SectionList')
expect(list?.props.sections[0].data).toHaveLength(2)
})
it('says only "Reopen to update" when the page holds no rows yet', () => {
const tree = render(0)
expect(texts(tree)).toContain('Reopen to update')
expect(tree.root.findAll((node) => String(node.type) === 'SectionList')).toEqual([])
})
})
+24 -10
View File
@@ -8,7 +8,7 @@ import {
Text,
View
} from 'react-native'
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
import { ChevronDown, ChevronRight, FolderTree, Pin } from 'lucide-react-native'
import { AuthFailedBanner } from '../components/AuthFailedBanner'
import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner'
@@ -28,6 +28,8 @@ import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-proper
import { hostScreenStyles as styles } from './host-screen-styles'
import { buildRepoLookById } from './host-repo-looks'
import { HostPendingCreationRow } from './host-pending-creation-row'
import { useSlowPendingKeys } from './use-slow-pending-keys'
import { projectGroupHeaderIndent } from '../worktree/project-group-indent'
import type { HostScreenController } from './use-host-screen-controller'
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
@@ -57,10 +59,12 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
} = controller
const { rawSections, sections, repoById } = sectionsResult
const { pending } = settings.pendingViewChanges
// A collapse the desktop has not answered spins on its header.
const pendingCollapseKeys = useMemo(
() => new Set(pending.flatMap((change) => (change.kind === 'collapse' ? [change.key] : []))),
[pending]
// A collapse the desktop has not answered within SLOW_PENDING_MS spins on its header.
const pendingCollapseKeys = useSlowPendingKeys(
useMemo(
() => new Set(pending.flatMap((change) => (change.kind === 'collapse' ? [change.key] : []))),
[pending]
)
)
const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById])
const { worktreeCardProperties, compactWorktreeCards, newCardStyle } = state.view
@@ -133,7 +137,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
{/* A feed this page cannot read: the host is older or newer than the page. */}
{controller.feed?.kind === 'reopen' ? (
<HostWorkspaceListReopen />
<HostWorkspaceListReopen aboveRows={sections.length > 0} />
) : (
<HostWorkspaceListStates
connState={connState}
@@ -150,7 +154,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
/>
)}
{sections.length > 0 && controller.feed?.kind !== 'reopen' && (
{sections.length > 0 && (
<SectionList
ref={activeWorktreeScroll.sectionListRef}
sections={sections}
@@ -189,7 +193,15 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
return (
<>
<Pressable
style={styles.sectionHeader}
style={[
styles.sectionHeader,
section.projectGroupDepth
? {
paddingLeft:
spacing.lg + projectGroupHeaderIndent(section.projectGroupDepth)
}
: null
]}
onPress={() => settings.toggleCollapsed(section.key)}
>
{isCollapsed ? (
@@ -200,7 +212,9 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
{section.icon === 'pin' && (
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{state.view.groupBy === 'repo' ? (
{state.view.groupBy === 'repo' && section.isProjectGroup ? (
<FolderTree size={12} color={colors.textPrimary} style={styles.sectionIcon} />
) : state.view.groupBy === 'repo' ? (
<View style={styles.sectionRepoIcon}>
<MobileRepoIcon
repoIcon={repoSectionIcon}
@@ -215,7 +229,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
<ActivityIndicator
size="small"
color={colors.textMuted}
style={styles.sectionIcon}
style={styles.sectionPendingSpinner}
/>
)}
</Pressable>
@@ -8,6 +8,7 @@ import { buildProjectSectionCatalog } from '../worktree/project-section-catalog'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { buildFilterRepos, type WorkspaceSectionRepo } from '../worktree/workspace-filter-repos'
import type { Section, SectionRepo, Worktree } from '../worktree/workspace-list-types'
import { useLastVisitedWorktreeOnHost } from '../worktree/use-last-visited-worktree-on-host'
import { rowFiltersForView } from '../worktree/workspace-view-settings'
import { buildSidebarFeedCatalog } from '../../../src/shared/sidebar-list/sidebar-feed-catalog'
import { buildHostFeedView } from './host-sidebar-feed-inputs'
@@ -18,6 +19,8 @@ import type { HostScreenState } from './use-host-screen-state'
export type HostListSectionsInputs = {
client: RpcClient | null
/** The paired host whose list this is. */
hostId: string | undefined
/** Null in a build that lists without the feed. */
feed: HostSidebarFeed | null
/** The rows worktree.ps and the desktop's server lists served, with display state applied. */
@@ -97,6 +100,7 @@ const NO_SECTIONS: Section[] = []
/** The desktop sidebar's own list, built by the shared builder from the host's feed. */
function useFeedHostListSections({
client,
hostId,
feed,
displayWorktrees,
hostPlatform,
@@ -126,26 +130,38 @@ function useFeedHostListSections({
() => (domains ? buildSidebarFeedRows(domains, catalog, view) : null),
[domains, catalog, view]
)
const loading = feed?.kind === 'loading'
const lastVisited = useLastVisitedWorktreeOnHost(hostId)
// Only under a feed this page cannot read (an older desktop): before the first read the list
// waits for the feed, or the host's last installed one, so it never draws a shape the desktop doesn't.
const preFeed = feed?.kind === 'reopen'
const sections = useMemo(
() =>
built
? buildHostFeedSections({
rows: built.sectionRows,
activeRowKeys: built.activeRowKeys,
lastVisited,
liveRows: displayWorktrees,
repoHostIdByRepoId: state.repoHostIdByRepoId,
search: state.search
})
: loading
: preFeed
? buildPreFeedSections({
rows: displayWorktrees,
view: state.view,
search: state.search,
repoHostIdByRepoId: state.repoHostIdByRepoId
repoHostIdByRepoId: state.repoHostIdByRepoId,
lastVisited
})
: NO_SECTIONS,
[built, loading, displayWorktrees, state.view, state.repoHostIdByRepoId, state.search]
[
built,
preFeed,
displayWorktrees,
state.view,
state.repoHostIdByRepoId,
state.search,
lastVisited
]
)
const iconIds = useMemo(() => [...new Set(domains?.repoIcons.values() ?? [])], [domains])
@@ -80,7 +80,7 @@ export function useHostScreenController({
useHostScreenIdentity({ client, hostId, state })
const fetchRepoMetadataOnly = useHostRepoMetadata({ client, connState, hostId, state })
const sidebarFeed = useSwitchedHostSidebarFeed(client)
const sidebarFeed = useSwitchedHostSidebarFeed(client, hostId)
// Read through a ref so the metadata refresh keeps its identity across installs.
const feedRef = useRef(sidebarFeed?.feed)
useEffect(() => {
@@ -140,6 +140,7 @@ export function useHostScreenController({
const feed = sidebarFeed?.feed ?? null
const sectionsResult = useHostListSections({
client,
hostId,
feed,
displayWorktrees,
hostPlatform,
@@ -156,7 +157,7 @@ export function useHostScreenController({
[state.view.filters, uniqueRepos]
)
const existingWorktreePaths = useMemo(() => state.worktrees.map((w) => w.path), [state.worktrees])
const activeWorktreeScroll = useActiveWorktreeScroll(sectionsResult.sections)
const activeWorktreeScroll = useActiveWorktreeScroll(sectionsResult.sections, collapsedGroups)
return {
actions,
@@ -4,6 +4,7 @@ import {
SIDEBAR_INPUTS_SEMANTICS_VERSION,
type SidebarInputsChangedEvent
} from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract'
import type { InstalledSidebarInputs } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-installer'
import {
readSidebarFeedDomains,
type SidebarFeedDomains
@@ -32,6 +33,38 @@ export type HostSidebarFeedControls = {
const NOTHING_READ: HostSidebarFeedRead = { installed: null, unavailable: false }
const MAX_REMEMBERED_HOSTS = 20
/** Each paired host's last installed feed, for this document's next list of it. */
const rememberedFeeds = new Map<string, InstalledSidebarInputs>()
function rememberFeed(hostId: string, installed: InstalledSidebarInputs | null): void {
rememberedFeeds.delete(hostId)
if (!installed) {
return
}
rememberedFeeds.set(hostId, installed)
if (rememberedFeeds.size > MAX_REMEMBERED_HOSTS) {
const oldest = rememberedFeeds.keys().next().value
if (oldest !== undefined) {
rememberedFeeds.delete(oldest)
}
}
}
/** The host's last installed feed while a fresh read is out: the desktop's list, if not its newest. */
function rememberedRead(hostId: string | undefined): HostSidebarFeedRead {
const installed = hostId ? rememberedFeeds.get(hostId) : undefined
// One this page cannot read would flash "Reopen" for a host whose fresh read may be fine.
return installed && SUPPORTED_SIDEBAR_SEMANTICS_VERSIONS.includes(installed.semanticsVersion)
? { installed, unavailable: false }
: NOTHING_READ
}
/** Test seam: a fresh document remembers nothing. */
export function forgetRememberedSidebarFeeds(): void {
rememberedFeeds.clear()
}
export function readHostSidebarFeed({
installed,
unavailable
@@ -48,20 +81,29 @@ export function readHostSidebarFeed({
return { kind: 'ready', domains: readSidebarFeedDomains(installed) }
}
/** The connected host's installed sidebar feed; a new client is a new host, read from scratch. */
export function useHostSidebarFeed(client: RpcClient | null): HostSidebarFeedControls {
/**
* The connected host's installed sidebar feed; a new client is a new host, read from scratch.
* Until that read installs, the host's last installed feed stands in for it.
*/
export function useHostSidebarFeed(
client: RpcClient | null,
hostId: string | undefined
): HostSidebarFeedControls {
const readerRef = useRef<HostSidebarFeedReader | null>(null)
const installListenersRef = useRef(new Set<() => void>())
const [read, setRead] = useState(NOTHING_READ)
const [read, setRead] = useState(() => rememberedRead(hostId))
useEffect(() => {
setRead(NOTHING_READ)
setRead(rememberedRead(hostId))
if (!client) {
return
}
let lastInstalled: HostSidebarFeedRead['installed'] = null
const reader = new HostSidebarFeedReader(client, (next) => {
setRead(next)
if (hostId) {
rememberFeed(hostId, next.unavailable ? null : next.installed)
}
if (next.installed && next.installed !== lastInstalled) {
installListenersRef.current.forEach((listener) => listener())
}
@@ -74,7 +116,7 @@ export function useHostSidebarFeed(client: RpcClient | null): HostSidebarFeedCon
readerRef.current = null
}
}
}, [client])
}, [client, hostId])
const triggers = useMemo(
(): HostSidebarFeedTriggers => ({
@@ -94,5 +136,6 @@ export function useHostSidebarFeed(client: RpcClient | null): HostSidebarFeedCon
/** Switch point: a page built without the feed reads none. */
export const useSwitchedHostSidebarFeed: (
client: RpcClient | null
client: RpcClient | null,
hostId: string | undefined
) => HostSidebarFeedControls | null = ORCA_SIDEBAR_INPUTS_IS_MAIN ? useHostSidebarFeed : () => null
@@ -20,6 +20,7 @@ import {
isSameWorktreeRow,
removeWorktreeRow
} from '../worktree/worktree-host-row-identity'
import { noteLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host'
import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections'
import {
worktreeActivate,
@@ -257,9 +258,12 @@ export function useHostWorktreeActions(args: {
if (routeHost === null) {
return
}
// Switch point: a feed-built list highlights only what the desktop has open.
// Switch point: a feed-built list highlights this phone's last visit, noted here because the
// session may open as another page whose stored write this list never sees.
if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) {
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
} else if (hostId) {
noteLastVisitedWorktree({ hostId, worktreeId: item.worktreeId, executionHost: routeHost })
}
const rowClient = clientForRow(item)
if (rowClient && connState === 'connected') {
@@ -0,0 +1,87 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSlowPendingKeys } from './use-slow-pending-keys'
let latest: ReadonlySet<string> = new Set()
function Probe({ pending }: { pending: ReadonlySet<string> }) {
latest = useSlowPendingKeys(pending)
return null
}
function mount(pending: ReadonlySet<string>): ReactTestRenderer {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(createElement(Probe, { pending }))
})
if (!renderer) {
throw new Error('not mounted')
}
return renderer
}
function update(renderer: ReactTestRenderer, pending: ReadonlySet<string>): void {
act(() => renderer.update(createElement(Probe, { pending })))
}
function advance(ms: number): void {
act(() => {
vi.advanceTimersByTime(ms)
})
}
describe('useSlowPendingKeys', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows a key only once it has been pending for 500 ms', () => {
mount(new Set(['a']))
expect([...latest]).toEqual([])
advance(499)
expect([...latest]).toEqual([])
advance(1)
expect([...latest]).toEqual(['a'])
})
it('never shows a key the desktop answers within 500 ms', () => {
const renderer = mount(new Set(['a']))
advance(300)
update(renderer, new Set())
advance(1_000)
expect([...latest]).toEqual([])
})
it('hides a slow key on the render it settles', () => {
const renderer = mount(new Set(['a']))
advance(500)
expect([...latest]).toEqual(['a'])
update(renderer, new Set())
expect([...latest]).toEqual([])
})
it('times each key from when it first went pending, across re-renders', () => {
const renderer = mount(new Set(['a']))
advance(300)
update(renderer, new Set(['a', 'b']))
advance(200)
expect([...latest]).toEqual(['a'])
advance(300)
expect([...latest].sort()).toEqual(['a', 'b'])
})
it('waits out the delay afresh for a key that settles and goes pending again', () => {
const renderer = mount(new Set(['a']))
advance(600)
update(renderer, new Set())
update(renderer, new Set(['a']))
expect([...latest]).toEqual([])
advance(499)
expect([...latest]).toEqual([])
advance(1)
expect([...latest]).toEqual(['a'])
})
})
@@ -0,0 +1,48 @@
import { useEffect, useMemo, useRef, useState } from 'react'
/** How long the desktop has to answer before a pending change shows a spinner. */
export const SLOW_PENDING_MS = 500
const NONE: ReadonlySet<string> = new Set()
/** The pending keys the desktop has not answered within `delayMs` of each first going pending. */
export function useSlowPendingKeys(
pendingKeys: ReadonlySet<string>,
delayMs: number = SLOW_PENDING_MS
): ReadonlySet<string> {
const pendingSinceRef = useRef(new Map<string, number>())
const [slowKeys, setSlowKeys] = useState<ReadonlySet<string>>(NONE)
useEffect(() => {
const pendingSince = pendingSinceRef.current
const now = Date.now()
for (const key of pendingSince.keys()) {
if (!pendingKeys.has(key)) {
pendingSince.delete(key)
}
}
// Why prune: a settled key that goes pending again must wait out the delay afresh.
setSlowKeys((prev) => {
const kept = [...prev].filter((key) => pendingKeys.has(key))
return kept.length === prev.size ? prev : new Set(kept)
})
const timers: ReturnType<typeof setTimeout>[] = []
for (const key of pendingKeys) {
const since = pendingSince.get(key) ?? now
pendingSince.set(key, since)
timers.push(
setTimeout(
() => setSlowKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key))),
Math.max(0, since + delayMs - now)
)
)
}
return () => timers.forEach(clearTimeout)
}, [pendingKeys, delayMs])
// Why intersect: a key that settled hides on this render, not after the effect's prune.
return useMemo(() => {
const visible = [...slowKeys].filter((key) => pendingKeys.has(key))
return visible.length === slowKeys.size ? slowKeys : new Set(visible)
}, [slowKeys, pendingKeys])
}
@@ -64,7 +64,7 @@ vi.mock('../transport/host-store', () => ({
}))
import { savePinnedIds } from '../storage/preferences'
import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-repo'
import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host'
import { PAGE_STORAGE_MAX_VALUE_CHARS } from './page-storage-keys'
import { usePageHostSnapshot, type PageHostSnapshotView } from './use-page-host-snapshot'
@@ -138,10 +138,14 @@ describe('what the shell puts on every init', () => {
// The device repro: the session screen writes while the page is open, the document reloads,
// and the `init` answering its ready is built from this map with no read in between. A mirror
// only the store read refreshed would hand the drawer the repo the user left, an `init` late.
writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' })
await writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' })
await savePinnedIds('host-1', new Set(['one']))
expect(mounted.view().readStorage().storage).toEqual({
[LAST_VISITED]: JSON.stringify({ hostId: 'host-1', worktreeId: 'host-1/repo/wt' }),
[LAST_VISITED]: JSON.stringify({
hostId: 'host-1',
worktreeId: 'host-1/repo/wt',
byHost: { 'host-1': { worktreeId: 'host-1/repo/wt' } }
}),
[PINS]: '["one"]'
})
})
@@ -34,7 +34,9 @@ vi.mock('../terminal/terminal-viewport-refit', () => ({
})
}))
vi.mock('../terminal/terminal-custom-keys', () => ({ removeCustomKey: harness.removeCustomKey }))
vi.mock('../worktree/last-visited-worktree-repo', () => ({ writeLastVisitedWorktree: () => {} }))
vi.mock('../worktree/last-visited-worktree-by-host', () => ({
writeLastVisitedWorktree: async () => {}
}))
import { useMobileSessionKeyboardState } from './use-mobile-session-keyboard-state'
@@ -2,7 +2,8 @@ import { useEffect, useCallback } from 'react'
import { useSoftKeyboard } from '../platform/keyboard-occlusion'
import { useTerminalViewportRefit } from '../terminal/terminal-viewport-refit'
import { removeCustomKey, type CustomKey } from '../terminal/terminal-custom-keys'
import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-repo'
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
import { writeLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host'
import { resolveTabStripScrollOffset } from './tab-strip-scroll'
import type { MobileSessionLifecycleModel } from './use-mobile-session-lifecycle'
@@ -127,11 +128,12 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope)
return () => cancelAnimationFrame(id)
}, [activeSessionTabId, scrollActiveTabIntoView])
const executionHost = useWorkspaceExecutionHost()
useEffect(() => {
if (hostId && worktreeId) {
writeLastVisitedWorktree({ hostId, worktreeId })
void writeLastVisitedWorktree({ hostId, worktreeId, executionHost })
}
}, [hostId, worktreeId])
}, [hostId, worktreeId, executionHost])
const handleDeleteCustomKey = useCallback(async (key: CustomKey) => {
try {
@@ -62,9 +62,25 @@ export async function readMirroredKeyOrStore(key: string): Promise<string | null
return held === undefined ? AsyncStorage.getItem(key) : held
}
type MirroredKeyListener = (value: string | null) => void
const keyListeners = new Map<string, Set<MirroredKeyListener>>()
/** Each noted write of `key`, including a page's write the app takes on its behalf. */
export function subscribeMirroredKey(key: string, listener: MirroredKeyListener): () => void {
const forKey = keyListeners.get(key) ?? new Set<MirroredKeyListener>()
keyListeners.set(key, forKey)
forKey.add(listener)
return () => {
forKey.delete(listener)
}
}
function note(key: string, value: string | null): void {
writeCount += 1
mirror.set(key, value)
for (const listener of keyListeners.get(key) ?? []) {
listener(value)
}
}
/**
@@ -0,0 +1,34 @@
import { parseExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import type { Worktree } from './workspace-list-types'
// Kept free of storage and React Native: the e2e phone-list helper builds sections through this.
/** The workspace this phone last opened on one paired host: the session route's own params. */
export type LastVisitedWorktreeOnHost = {
worktreeId: string
/** A server workspace's `runtime:` host; absent for the desktop's own (local and SSH) rows. */
executionHost?: ExecutionHostId
}
/** The route's server for a row, as the host list routes it: only a `runtime:` host is named. */
export function routeExecutionHostOf(
hostId: string | null | undefined
): ExecutionHostId | undefined {
const parsed = parseExecutionHostId(hostId)
return parsed?.kind === 'runtime' ? parsed.id : undefined
}
/**
* Whether `row` is the workspace this phone last opened on its host: the host list's only highlight.
* The desktop's selection never stands in, so a hidden or never-opened row leaves nothing lit.
*/
export function isLastVisitedWorktreeRow(
row: Pick<Worktree, 'worktreeId' | 'hostId'>,
visited: LastVisitedWorktreeOnHost | null
): boolean {
return (
visited !== null &&
row.worktreeId === visited.worktreeId &&
routeExecutionHostOf(row.hostId) === visited.executionHost
)
}
@@ -45,10 +45,10 @@ export function HostWorkspaceListStates(
return null
}
/** The host serves a list this page cannot build: a page from the host's own build can. */
export function HostWorkspaceListReopen() {
/** A list this page cannot build; shown above the rows it already holds, so it never blanks. */
export function HostWorkspaceListReopen({ aboveRows }: { aboveRows: boolean }) {
return (
<View style={styles.centered}>
<View style={aboveRows ? styles.aboveRows : styles.centered}>
<Text style={styles.emptyText}>Reopen to update</Text>
<Text style={styles.catalogErrorDetail}>
This host lists workspaces in a way this page does not know yet.
@@ -63,6 +63,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center'
},
aboveRows: {
alignItems: 'center',
paddingVertical: spacing.md
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
type StorageDouble = { rejection: unknown; values: Map<string, string> }
const storage = vi.hoisted((): StorageDouble => ({ rejection: null, values: new Map() }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: async (key: string) => storage.values.get(key) ?? null,
setItem: async (key: string, value: string) => {
if (storage.rejection !== null) {
throw storage.rejection
}
storage.values.set(key, value)
},
removeItem: async (key: string) => {
storage.values.delete(key)
}
}
}))
import { isLastVisitedWorktreeRow } from './host-list-highlight'
import {
readLastVisitedWorktreeOnHost,
subscribeLastVisitedWorktree,
withLastVisitedWorktree,
writeLastVisitedWorktree
} from './last-visited-worktree-by-host'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord
} from './last-visited-worktree-repo'
const stored = () => storage.values.get(LAST_VISITED_WORKTREE_STORAGE_KEY) ?? null
describe('readLastVisitedWorktreeOnHost', () => {
it('reads nothing for a host this phone never opened a workspace on', () => {
expect(readLastVisitedWorktreeOnHost(null, 'host-1')).toBeNull()
const raw = withLastVisitedWorktree(null, { hostId: 'host-2', worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toBeNull()
})
it('remembers each host’s own visit, whichever host was visited last', () => {
let raw = withLastVisitedWorktree(null, { hostId: 'host-1', worktreeId: 'repo::/w/a' })
raw = withLastVisitedWorktree(raw, {
hostId: 'host-2',
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(raw, 'host-2')).toEqual({
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
// Home's Resume card still reads the latest visit off the same record.
expect(readLastVisitedWorktreeRecord(raw)).toEqual({
hostId: 'host-2',
worktreeId: 'repo::/w/b'
})
})
it('reads a record stored before per-host visits for its own host, and keeps it on the next write', () => {
const legacy = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(legacy, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(legacy, 'host-2')).toBeNull()
const raw = withLastVisitedWorktree(legacy, { hostId: 'host-2', worktreeId: 'repo::/w/b' })
expect(readLastVisitedWorktreeOnHost(raw, 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
})
it('drops the oldest hosts past the cap rather than the store refusing the value', () => {
let raw: string | null = null
for (let index = 0; index < 20; index++) {
raw = withLastVisitedWorktree(raw, { hostId: `host-${index}`, worktreeId: 'repo::/w/a' })
}
expect(readLastVisitedWorktreeOnHost(raw, 'host-0')).toBeNull()
expect(readLastVisitedWorktreeOnHost(raw, 'host-19')).toEqual({ worktreeId: 'repo::/w/a' })
})
})
describe('isLastVisitedWorktreeRow', () => {
it('matches a desktop row (local or SSH) by id, and a server row by its server too', () => {
const visit = { worktreeId: 'repo::/w/a' }
expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'local' }, visit)).toBe(
true
)
expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'ssh:gpu' }, visit)).toBe(
true
)
expect(
isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'runtime:env-1' }, visit)
).toBe(false)
const serverVisit = { worktreeId: 'repo::/w/a', executionHost: 'runtime:env-1' as const }
expect(
isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'runtime:env-1' }, serverVisit)
).toBe(true)
expect(isLastVisitedWorktreeRow({ worktreeId: 'repo::/w/a', hostId: 'local' }, null)).toBe(
false
)
})
})
describe('writeLastVisitedWorktree', () => {
it('tells listeners at once and stores the visit beside other hosts’ visits', async () => {
storage.values.clear()
const told: unknown[] = []
const unsubscribe = subscribeLastVisitedWorktree((hostId, visit) => told.push([hostId, visit]))
void writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo::/w/a' })
expect(told).toEqual([['host-1', { worktreeId: 'repo::/w/a' }]])
await writeLastVisitedWorktree({
hostId: 'host-2',
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
unsubscribe()
expect(readLastVisitedWorktreeOnHost(stored(), 'host-1')).toEqual({ worktreeId: 'repo::/w/a' })
expect(readLastVisitedWorktreeOnHost(stored(), 'host-2')).toEqual({
worktreeId: 'repo::/w/b',
executionHost: 'runtime:env-1'
})
})
it('handles a store that refuses the write instead of leaving the rejection loose', async () => {
storage.rejection = new Error('quota exceeded')
const seen: unknown[] = []
const listener = (reason: unknown) => seen.push(reason)
process.on('unhandledRejection', listener)
try {
await writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo::/w/c' })
await new Promise<void>((resolve) => setImmediate(resolve))
} finally {
process.off('unhandledRejection', listener)
storage.rejection = null
}
expect(seen).toEqual([])
})
})
@@ -0,0 +1,148 @@
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import { PAGE_STORAGE_MAX_VALUE_CHARS } from '../mobile-web-shell/page-storage-keys'
import { persistMirrored, readMirroredKeyOrStore } from '../storage/mirrored-storage-keys'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord,
type LastVisitedWorktreeRecord
} from './last-visited-worktree-repo'
import { routeExecutionHostOf, type LastVisitedWorktreeOnHost } from './host-list-highlight'
// Why a cap: the page's store refuses a value past its frame cap, and a forgotten host never writes again.
const MAX_REMEMBERED_HOSTS = 16
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function visitOf(worktreeId: string, executionHost: string | undefined): LastVisitedWorktreeOnHost {
const server = routeExecutionHostOf(executionHost)
return server ? { worktreeId, executionHost: server } : { worktreeId }
}
function readEntry(value: unknown): LastVisitedWorktreeOnHost | null {
if (!isRecord(value) || typeof value.worktreeId !== 'string' || value.worktreeId === '') {
return null
}
return visitOf(
value.worktreeId,
typeof value.executionHost === 'string' ? value.executionHost : undefined
)
}
function readByHost(raw: string | null): Map<string, LastVisitedWorktreeOnHost> {
const byHost = new Map<string, LastVisitedWorktreeOnHost>()
if (!raw) {
return byHost
}
try {
const parsed: unknown = JSON.parse(raw)
if (isRecord(parsed) && isRecord(parsed.byHost)) {
for (const [hostId, value] of Object.entries(parsed.byHost)) {
const entry = readEntry(value)
if (entry) {
byHost.set(hostId, entry)
}
}
}
} catch {
// Unreadable history is no history.
}
return byHost
}
/** This phone's last-opened workspace on `hostId`, or null when it has opened none there. */
export function readLastVisitedWorktreeOnHost(
raw: string | null,
hostId: string
): LastVisitedWorktreeOnHost | null {
const entry = readByHost(raw).get(hostId)
if (entry) {
return entry
}
// A record written before `byHost` names only the last host, and only the desktop's own rows.
const record = readLastVisitedWorktreeRecord(raw)
return record?.hostId === hostId ? { worktreeId: record.worktreeId } : null
}
/** The stored value with `record` as the latest visit and its host's entry. */
export function withLastVisitedWorktree(
raw: string | null,
record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId }
): string {
const byHost = readByHost(raw)
const previous = readLastVisitedWorktreeRecord(raw)
if (previous && !byHost.has(previous.hostId)) {
byHost.set(previous.hostId, { worktreeId: previous.worktreeId })
}
// Re-inserted so the map's order is oldest visit first, which is what the cap drops.
byHost.delete(record.hostId)
byHost.set(record.hostId, visitOf(record.worktreeId, record.executionHost))
const serialize = () =>
JSON.stringify({
hostId: record.hostId,
worktreeId: record.worktreeId,
byHost: Object.fromEntries(byHost)
})
let value = serialize()
while (
byHost.size > 1 &&
(byHost.size > MAX_REMEMBERED_HOSTS || value.length > PAGE_STORAGE_MAX_VALUE_CHARS)
) {
const oldest = byHost.keys().next().value
if (oldest === undefined) {
break
}
byHost.delete(oldest)
value = serialize()
}
return value
}
type Listener = (hostId: string, visited: LastVisitedWorktreeOnHost) => void
const listeners = new Set<Listener>()
/** Visits noted in this JS context, told at once so an open list moves without a re-read. */
export function subscribeLastVisitedWorktree(listener: Listener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/**
* Tells this context's open lists about a visit without storing it. The list's tap uses this: the
* shell's session page is another page that stores the visit itself, and a long-lived list page's
* load-time copy of the key could overwrite newer visits to other hosts.
*/
export function noteLastVisitedWorktree(
record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId }
): void {
const visited = visitOf(record.worktreeId, record.executionHost)
for (const listener of listeners) {
listener(record.hostId, visited)
}
}
// Serialized: each write merges into what the last one stored.
let writeChain: Promise<void> = Promise.resolve()
/**
* The one writer of this key (the session route), so the hybrid shell's mirror sees it as it is
* written. The page is handed this key on every `init`, built from that mirror; a write that went
* straight to the store would reach the page one `init` later.
*/
export function writeLastVisitedWorktree(
record: LastVisitedWorktreeRecord & { executionHost?: ExecutionHostId }
): Promise<void> {
noteLastVisitedWorktree(record)
// Through the path that notes on an accepted write (ruling 35); the page's adapter may refuse,
// and nothing is owed a caller that cannot act on one, so a refusal is the adapter's log.
writeChain = writeChain
.then(() => readMirroredKeyOrStore(LAST_VISITED_WORKTREE_STORAGE_KEY).catch(() => null))
.then((raw) =>
persistMirrored(LAST_VISITED_WORKTREE_STORAGE_KEY, withLastVisitedWorktree(raw, record))
)
.catch(() => {})
return writeChain
}
@@ -1,65 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
type StorageDouble = { rejection: unknown; written: string[] }
const storage = vi.hoisted((): StorageDouble => ({ rejection: null, written: [] }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
setItem: async (_key: string, value: string) => {
if (storage.rejection !== null) {
throw storage.rejection
}
storage.written.push(value)
},
removeItem: async () => undefined
}
}))
import { describe, expect, it } from 'vitest'
import {
readLastVisitedWorktreeRecord,
readLastVisitedWorktreeRepoId,
writeLastVisitedWorktree
readLastVisitedWorktreeRepoId
} from './last-visited-worktree-repo'
/** Node reports an unhandled rejection at the end of a microtask checkpoint, so one macrotask is
* long enough to see it, and a listener is the only way to observe one from inside a test. */
async function unhandledRejectionsWhile(run: () => void): Promise<unknown[]> {
const seen: unknown[] = []
const listener = (reason: unknown) => seen.push(reason)
process.on('unhandledRejection', listener)
try {
run()
await new Promise<void>((resolve) => setImmediate(resolve))
await new Promise<void>((resolve) => setImmediate(resolve))
} finally {
process.off('unhandledRejection', listener)
}
return seen
}
// Why: the mirror reports this key as written the moment it is noted, so a store write that
// rejects must be handled where it is made. Nothing above it is holding a catch.
describe('writeLastVisitedWorktree', () => {
it('handles a store that refuses the write instead of leaving the rejection loose', async () => {
storage.rejection = new Error('quota exceeded')
const loose = await unhandledRejectionsWhile(() => {
writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
})
storage.rejection = null
expect(loose).toEqual([])
})
it('still persists the record when the store takes it', async () => {
storage.written.length = 0
writeLastVisitedWorktree({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
await new Promise<void>((resolve) => setImmediate(resolve))
expect(storage.written).toEqual([
JSON.stringify({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
])
})
})
describe('last visited worktree repo', () => {
it('extracts the repo id for the current host', () => {
const raw = JSON.stringify({ hostId: 'host-1', worktreeId: 'repo-2::/tmp/worktree' })
@@ -1,4 +1,3 @@
import { persistMirrored } from '../storage/mirrored-storage-keys'
import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-helpers'
export const LAST_VISITED_WORKTREE_STORAGE_KEY = 'orca:last-visited-worktree'
@@ -47,18 +46,3 @@ export function readLastVisitedWorktreeRepoId(raw: string | null, hostId: string
const repoId = getRepoIdFromMobileWorktreeId(record.worktreeId).trim()
return repoId || null
}
/**
* The one writer of this key, so the hybrid shell's mirror sees it as it is written.
*
* The page is handed this key on every `init`, built synchronously from that mirror; a write that
* went straight to the store would reach the page one `init` later, and the New Workspace drawer
* would open on the repo the user left rather than the one they just came from.
*/
export function writeLastVisitedWorktree(record: LastVisitedWorktreeRecord): void {
// Through the path that notes on an accepted write (ruling 35): this module is in the page's
// own closure, so the store behind it may be the bridge's adapter, which refuses a key this
// route was never given. Nothing is owed a caller that cannot act on one, so the refusal is the
// adapter's log rather than a rejection here.
void persistMirrored(LAST_VISITED_WORKTREE_STORAGE_KEY, JSON.stringify(record)).catch(() => {})
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { projectGroupHeaderIndent, projectGroupRowIndent } from './project-group-indent'
const DEPTHS = [0, 1, 2, 6, 9, undefined]
describe('project-group indent', () => {
it('steps headers 10 px a level, up to six levels', () => {
expect(DEPTHS.map(projectGroupHeaderIndent)).toEqual([0, 10, 20, 60, 60, 0])
})
it('steps a hand-made group’s rows, folder workspaces too, a full tree step a level', () => {
for (const workspaceKind of ['git', 'folder-workspace', undefined] as const) {
expect(
DEPTHS.map((projectGroupDepth) =>
projectGroupRowIndent({ workspaceKind, projectGroupDepth })
)
).toEqual([0, 18, 36, 108, 108, 0])
}
})
it('steps a folder-scanned group’s rows by the header step, its folder workspaces one level less', () => {
const indent = (workspaceKind: 'git' | 'folder-workspace', projectGroupDepth: number) =>
projectGroupRowIndent({ workspaceKind, projectGroupDepth, inFolderScanGroup: true })
expect([1, 2, 7].map((depth) => indent('git', depth))).toEqual([10, 20, 60])
expect([1, 2, 7, 9].map((depth) => indent('folder-workspace', depth))).toEqual([0, 10, 60, 60])
})
})
@@ -0,0 +1,32 @@
import type { Worktree } from './workspace-list-types'
// The desktop's project-group tree steps (indentation.ts): headers 10 px a level, workspace cards a
// full 18 px tree step; capped at six levels, as the headers are.
const PROJECT_GROUP_HEADER_INDENT = 10
const TREE_INDENT = 18
const MAX_PROJECT_GROUP_DEPTH = 6
function cappedDepth(depth: number | undefined): number {
return Math.min(Math.max(0, Math.floor(depth ?? 0)), MAX_PROJECT_GROUP_DEPTH)
}
/** A header's indent past the phone's base padding, for its depth in the group tree. */
export function projectGroupHeaderIndent(depth: number | undefined): number {
return cappedDepth(depth) * PROJECT_GROUP_HEADER_INDENT
}
/**
* A row's indent past the phone's base padding: what the desktop adds over an ungrouped card. A
* folder-scanned group's rows take the header step, its folder workspaces one level less.
*/
export function projectGroupRowIndent(
row: Pick<Worktree, 'workspaceKind' | 'projectGroupDepth' | 'inFolderScanGroup'>
): number {
const depth = row.projectGroupDepth ?? 0
if (!row.inFolderScanGroup) {
return cappedDepth(depth) * TREE_INDENT
}
return row.workspaceKind === 'folder-workspace'
? cappedDepth(depth - 1) * PROJECT_GROUP_HEADER_INDENT
: cappedDepth(depth) * PROJECT_GROUP_HEADER_INDENT
}
@@ -0,0 +1,136 @@
import type * as PageStorage from '../mobile-web-shell/bridge/page-async-storage'
import type * as Visits from './last-visited-worktree-by-host'
import { describe, expect, it, vi } from 'vitest'
const KEY = 'orca:last-visited-worktree'
const HOST = 'host-1'
/** The app's own store, which every page's writes reach and every page load copies. */
const appStore = new Map<string, string>()
type PageModules = {
storage: typeof PageStorage
visits: typeof Visits
}
/** One embedded page: its own module graph and its own copy of the store, taken at load. */
async function loadPage(pathname: string): Promise<PageModules> {
vi.resetModules()
// Each page's store is its own page adapter, as in the shell: a load-time copy of the app's.
// doMock, not mock: the mock registry outlives resetModules, which would share one adapter.
vi.doMock('@react-native-async-storage/async-storage', async () => ({
default: (await import('../mobile-web-shell/bridge/page-async-storage')).default
}))
const storage = await import('../mobile-web-shell/bridge/page-async-storage')
const visits = await import('./last-visited-worktree-by-host')
storage.publishPageStorage(
Object.fromEntries(appStore),
(key, value) => {
if (value === null) {
appStore.delete(key)
} else {
appStore.set(key, value)
}
return true
},
HOST,
pathname
)
return { storage, visits }
}
/** The list page: a fresh graph rendering the hook, with its React and renderer from that graph. */
async function mountListPage(): Promise<{
page: PageModules
value: () => unknown
settle: () => Promise<void>
unmount: () => void
}> {
const page = await loadPage(`/h/${HOST}`)
const { createElement } = await import('react')
const { act, create } = await import('react-test-renderer')
const { useLastVisitedWorktreeOnHost } = await import('./use-last-visited-worktree-on-host')
const held: { value: unknown } = { value: undefined }
function ListProbe(): null {
held.value = useLastVisitedWorktreeOnHost(HOST)
return null
}
let unmount = () => {}
await act(async () => {
const renderer = create(createElement(ListProbe))
unmount = () => act(() => renderer.unmount())
})
// Lets this graph's renderer take state set from outside an act scope.
const settle = () => act(async () => {})
return { page, value: () => held.value, settle, unmount }
}
describe('the list page highlight across the shell’s separate pages', () => {
it('highlights the tapped row after the session page opens and Back returns to the list', async () => {
appStore.clear()
const list = await mountListPage()
expect(list.value()).toBeNull()
// Another host's visit lands after the list page loaded, so the list's copy is stale.
appStore.set(
KEY,
JSON.stringify({
hostId: 'host-2',
worktreeId: 'repo::/w/other',
byHost: { 'host-2': { worktreeId: 'repo::/w/other' } }
})
)
// Tap w2: the list notes the visit in its own page.
list.page.visits.noteLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/2' })
await list.settle()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/2' })
// The shell pushes the session page: a fresh graph and a fresh copy, which stores the visit.
const session = await loadPage(`/h/${HOST}/session/${encodeURIComponent('repo::/w/2')}`)
await session.visits.writeLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/2' })
// Back: the list page was never reloaded and still highlights the tapped row.
expect(list.value()).toEqual({ worktreeId: 'repo::/w/2' })
// Neither page's write overwrote the newer visit to the other host.
const stored = appStore.get(KEY) ?? null
expect(session.visits.readLastVisitedWorktreeOnHost(stored, 'host-2')).toEqual({
worktreeId: 'repo::/w/other'
})
expect(session.visits.readLastVisitedWorktreeOnHost(stored, HOST)).toEqual({
worktreeId: 'repo::/w/2'
})
list.unmount()
})
it('reads the stored visit when the list page loads again', async () => {
appStore.clear()
const session = await loadPage(`/h/${HOST}/session/w`)
await session.visits.writeLastVisitedWorktree({ hostId: HOST, worktreeId: 'repo::/w/3' })
const list = await mountListPage()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/3' })
list.unmount()
})
it('follows a session page’s write when the list is the app’s own screen beside it', async () => {
appStore.clear()
const list = await mountListPage()
const mirror = await import('../storage/mirrored-storage-keys')
// The app taking a session page's write, which is how a page's visit reaches an app screen.
mirror.writeMirroredStorage(
KEY,
JSON.stringify({
hostId: HOST,
worktreeId: 'repo::/w/4',
byHost: { [HOST]: { worktreeId: 'repo::/w/4' } }
})
)
await list.settle()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/4' })
// An older page's write names another host only; this host's visit stands.
mirror.writeMirroredStorage(KEY, JSON.stringify({ hostId: 'host-2', worktreeId: 'repo::/w/x' }))
await list.settle()
expect(list.value()).toEqual({ worktreeId: 'repo::/w/4' })
list.unmount()
})
})
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { readMirroredKeyOrStore, subscribeMirroredKey } from '../storage/mirrored-storage-keys'
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from './last-visited-worktree-repo'
import type { LastVisitedWorktreeOnHost } from './host-list-highlight'
import {
readLastVisitedWorktreeOnHost,
subscribeLastVisitedWorktree
} from './last-visited-worktree-by-host'
/**
* The workspace this phone last opened on `hostId`: stored, then each visit noted in this context
* (a list tap) or written through this context's store (in the app, a session page's write).
* A web page's store is its load-time copy, so a visit another page makes reaches it on reload.
* Null until the stored visit is read, and when this phone has opened nothing there.
*/
export function useLastVisitedWorktreeOnHost(
hostId: string | undefined
): LastVisitedWorktreeOnHost | null {
const [visited, setVisited] = useState<{
hostId: string
value: LastVisitedWorktreeOnHost | null
} | null>(null)
useEffect(() => {
if (!hostId) {
return
}
let stale = false
let noted = false
const take = (value: LastVisitedWorktreeOnHost) => {
noted = true
setVisited({ hostId, value })
}
const unsubscribeNotes = subscribeLastVisitedWorktree((visitedHostId, value) => {
if (visitedHostId === hostId) {
take(value)
}
})
const unsubscribeStore = subscribeMirroredKey(LAST_VISITED_WORKTREE_STORAGE_KEY, (raw) => {
// A write naming no visit here (another host's, in an older shape) leaves this one standing.
const value = readLastVisitedWorktreeOnHost(raw, hostId)
if (value) {
take(value)
}
})
void readMirroredKeyOrStore(LAST_VISITED_WORKTREE_STORAGE_KEY)
.then((raw) => {
// A visit noted while the read was open is newer than anything it returns.
if (!stale && !noted) {
setVisited({ hostId, value: readLastVisitedWorktreeOnHost(raw, hostId) })
}
})
.catch(() => {})
return () => {
stale = true
unsubscribeNotes()
unsubscribeStore()
}
}, [hostId])
// Keyed by host so a switch to another host never shows the last one's visit.
return visited && visited.hostId === hostId ? visited.value : null
}
@@ -55,6 +55,10 @@ export type Worktree = {
parentWorktreeId?: string | null
childWorktreeIds?: string[]
lineageDepth?: number
/** How deep the row sits in the desktop's project-group tree; indents it the same way. */
projectGroupDepth?: number
/** Its project group came from a folder scan, whose rows the desktop steps more compactly. */
inFolderScanGroup?: boolean
lineageChildCount?: number
lineageCollapsed?: boolean
/** The collapse key the shared builder gave this row's children; set only on a feed-built list. */
@@ -100,6 +104,10 @@ export type Section = {
icon?: 'pin'
/** Under Project grouping, the repo whose colour and icon the header shows, as on the desktop. */
repo?: SectionRepo
/** A project group's own header (no repo), drawn with the group icon, as on the desktop. */
isProjectGroup?: boolean
/** How deep the header sits in the desktop's project-group tree; indents it the same way. */
projectGroupDepth?: number
/** The header's own count from a feed-built list, which a collapsed section still shows. */
count?: number
/** Drawn under the header, above its rows, as the desktop places them. */