mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +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:
@@ -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