mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user