Files
orca/mobile/src/source-control/MobileSourceControlHeader.tsx
T
Brennan Benson 4fc4d792be Show folder workspace Source Control on the phone (#39999)
* feat(mobile): show folder workspace Source Control on the phone

A folder workspace now has Source Control on the phone: one row per attached
worktree (repo, name, branch, ahead/behind, change count), read from the host
list's own lineage projection and each worktree's git.status. Tapping a row
opens that worktree's existing Source Control in place; Back returns to the list.

* fix(mobile): folder Source Control routing, back, hidden reads and failure states

- A desktop worktree under a server's folder is listed and opens with desktop routing and gates.
- A child opened in the dock gets Back to the folder list beside Close.
- Rows and the catalog stop reading while the screen is covered, as the single-worktree screen does.
- Offline, failed and missing-folder states; rows whose status can't be read say Unavailable.
- Server rows poll like the host list; branch shown without refs/heads/.
- Perf contract: git.status reads per signal at N and 10N rows.

* perf(mobile): folder Source Control reads summaries and rebuilds only on lineage changes

- Rows read a status summary without per-file line stats; relayed servers' rows open no desktop event stream.
- Membership resolves over a projection of lineage and label fields, so agent and terminal churn rebuilds nothing.
- A row whose re-read failed or whose host reported readError shows Unavailable.
- A connection still coming up shows loading, not offline.
- Perf contracts at N/10N rows and H/10H catalog rows.

* perf(mobile): folder membership keys on lineage only; child status reads update the folder list

Membership resolves again only when a row's lineage fields change, keying
only rows the catalog replaced; labels come from the current rows. A child
Source Control's newer status read is handed to the folder list's summary,
so Back shows what an action did without another read.

* fix(mobile): folder summary takes child reads as they land and ignores server health

Membership depends only on each relayed server's installation, so health
and relay flips no longer resolve the folder again. A child Source
Control hands each status read to the folder summary as it is accepted,
so a summary read answering just after can't hide it, and an older read
never replaces a newer summary failure.

* fix(mobile): folder summary skips restored and cached reads that could undo newer ones

Persisted status is marked restored before anyone hears of it, so a
child screen never hands an earlier launch's status to the folder list.
A cached read caught up on opening writes only an idle summary and never
cancels one in flight. Perf contracts count real lineage serializations,
committed row renders and summary publications.

* fix(mobile): clear the restored mark on any answer but the restore's own

A live answer timed before the stored record, as after a clock
correction, is still this launch's, so its reads and the folder summary
treat it as confirmed.

* fix(mobile): the restore's own write skips clearing its mark only once

A listener replacing restored data as it hears of it writes this
launch's answer, so the mark clears for it too.

* test(mobile): type folder Source Control test fixtures without assertions

* test(mobile): name mocked modules with type-only namespace imports

* feat(mobile): show folder worktrees as cards on the sheet surface

Each attached worktree is its own grouped card on the grey sheet
surface, the look the phone's option sheets use, so rows read apart
from the page.

* fix(mobile): keep folder Source Control's states on the card surface

Loading, empty, error and the list now share the sheet surface, so the
screen no longer changes background between states.
2026-11-08 20:01:22 -08:00

135 lines
4.2 KiB
TypeScript

import { useRef, useState, type ReactNode } from 'react'
import type { View } from 'react-native'
import { ChevronLeft, ExternalLink, MoreHorizontal, RefreshCw, X } from 'lucide-react-native'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import { NavBar } from '../components/nav-bar/NavBar'
import { NavBarButton } from '../components/nav-bar/NavBarButton'
import { PullDownMenu } from '../components/nav-bar/PullDownMenu'
import { useRefreshHint } from '../query/refresh-hint'
type Props = {
embedded: boolean
worktreeLabel: string
ioBusy: boolean
/** Shown rows are being re-read; the line waits out quick reads. */
refreshing: boolean
onBack: () => void
/** Back to the screen this hub was opened over in place, e.g. a folder's list; drawn beside dock Close. */
backInPlace?: { onPress: () => void; label: string }
// Dock dismissal does not pop a route; keep it separate from Back.
onClose: () => void
onRefresh: () => void
// Keep open-on-web in the header while the PR body scrolls.
onOpenPrWeb?: () => void
prNumber?: number | null
/** The Changes / Pull Request / Commits switch, under the title like every other screen's. */
accessory?: ReactNode
/** The active tab's own menu, e.g. the PR tab's; replaces the default refresh/open items. */
menuGroups?: ActionSheetAction[][] | null
}
export function MobileSourceControlHeader({
embedded,
worktreeLabel,
ioBusy,
refreshing,
onBack,
backInPlace,
onClose,
onRefresh,
onOpenPrWeb,
prNumber = null,
accessory,
menuGroups = null
}: Props) {
const t = useMobileTranslation()
// Here, not in the panel, so the hint's timers re-render only the bar.
const showRefreshLine = useRefreshHint(refreshing)
const moreRef = useRef<View>(null)
const [menuOpen, setMenuOpen] = useState(false)
const actions: ActionSheetAction[] = [
{
id: 'refresh',
label: t('sourceControl.header.refresh', 'Refresh source control'),
icon: RefreshCw,
disabled: ioBusy,
onPress: onRefresh
},
...(onOpenPrWeb
? [
{
id: 'openPrWeb',
label:
prNumber != null
? t(
'sourceControl.header.openNumberedPrWeb',
'Open pull request #{{number}} on the web',
{ number: prNumber }
)
: t('sourceControl.header.openPrWeb', 'Open pull request on the web'),
icon: ExternalLink,
closeBeforePress: true,
onPress: onOpenPrWeb
}
]
: [])
]
const closeButton = (
<NavBarButton
icon={X}
onPress={onClose}
accessibilityLabel={t('sourceControl.header.close', 'Close source control')}
/>
)
return (
<>
<NavBar
// Dock dismissal and route Back have distinct semantics.
leading={
backInPlace ? (
<NavBarButton
icon={ChevronLeft}
onPress={backInPlace.onPress}
accessibilityLabel={backInPlace.label}
/>
) : embedded ? (
closeButton
) : (
<NavBarButton
icon={ChevronLeft}
onPress={onBack}
accessibilityLabel={t('sourceControl.header.backToSession', 'Back to session')}
/>
)
}
title={t('sourceControl.header.title', 'Source Control')}
subtitle={worktreeLabel}
accessory={accessory}
refreshing={showRefreshLine}
trailing={
<>
{backInPlace && embedded ? closeButton : null}
<NavBarButton
ref={moreRef}
icon={MoreHorizontal}
onPress={() => setMenuOpen(true)}
active={menuOpen}
accessibilityLabel={t(
'sourceControl.header.moreActions',
'More source control actions'
)}
/>
</>
}
/>
<PullDownMenu
visible={menuOpen}
anchorRef={moreRef}
groups={menuGroups ?? [actions]}
onClose={() => setMenuOpen(false)}
/>
</>
)
}