mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Optimize and integrate mobile PR view within source control hub
- Lazy-load heavy PR comments and descriptions (Phase 2) only when the PR tab is active, using fast metadata (Phase 1) for the branch chip. - Unmount the PR body when inactive to avoid unnecessary comment tree re-renders and preserve WebView resources during commit text editing. - Implement soft-refresh on HEAD advancement to keep the ready UI visible while re-fetching checks post-commit. - Display the "Aborting..." label only when a merge or rebase abort is actively in flight. - Memoize the git history list and skip branch identity RPCs when gating the dock icon.
This commit is contained in:
@@ -3,8 +3,8 @@ import { firstParam } from '../../../../src/source-control/mobile-source-control
|
||||
|
||||
// The Pull Request view is now a segment of the Source Control hub. This route
|
||||
// stays as a thin redirect so existing deep links land on the hub with the Pull
|
||||
// Request segment selected. The wide-layout dock still renders MobilePrViewPanel
|
||||
// directly (it does not route through here).
|
||||
// Request segment selected. The wide-layout dock opens the same hub with
|
||||
// initialTab="pr" (SessionDockColumn), not this route.
|
||||
export default function PrRedirect() {
|
||||
const params = useLocalSearchParams<{
|
||||
hostId?: string | string[]
|
||||
|
||||
@@ -856,20 +856,16 @@ export default function SessionScreen() {
|
||||
setActivePanel(null)
|
||||
}
|
||||
}, [canDockPanel, activePanel])
|
||||
// Session-level PR context feeds the docked PR panel and gates the GitHub-only
|
||||
// PR entry so GitLab/other providers do not open a GitHub RPC surface.
|
||||
const {
|
||||
branch: prBranch,
|
||||
headSha: prHeadSha,
|
||||
status: prStatus,
|
||||
isGithubRepo: prIsGithubRepo,
|
||||
repoLoaded: prRepoContextLoaded,
|
||||
loaded: prContextLoaded
|
||||
} = useMobilePrBranchContext({
|
||||
client,
|
||||
connState,
|
||||
worktreeId
|
||||
})
|
||||
// Session-level GitHub remote probe gates the PR dock icon so non-GitHub
|
||||
// providers do not open the hosted-review surface. Branch/head/status for the
|
||||
// hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs.
|
||||
const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
|
||||
useMobilePrBranchContext({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
includeBranchIdentity: false
|
||||
})
|
||||
useEffect(() => {
|
||||
if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') {
|
||||
setActivePanel(null)
|
||||
@@ -4438,8 +4434,9 @@ export default function SessionScreen() {
|
||||
hostId,
|
||||
worktreeId,
|
||||
name: worktreeName || '',
|
||||
// Source control's post-diff-open dismissal keys off origin: 'session' (U2).
|
||||
...(action.panel === 'sourceControl' ? { origin: 'session' } : {}),
|
||||
// SC + PR both land on the source-control hub; post-diff-open dismissal
|
||||
// keys off origin: 'session' (U2). Files keeps its own route without origin.
|
||||
...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}),
|
||||
// The PR panel routes into the hub's Pull Request segment via descriptor params.
|
||||
...descriptor.params
|
||||
}
|
||||
@@ -5127,13 +5124,6 @@ export default function SessionScreen() {
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
name={worktreeName || ''}
|
||||
client={client}
|
||||
connState={connState}
|
||||
branch={prBranch}
|
||||
headSha={prHeadSha}
|
||||
gitStatus={prStatus}
|
||||
isGithubRepo={prIsGithubRepo}
|
||||
branchContextLoaded={prContextLoaded && prRepoContextLoaded}
|
||||
availableWidth={sessionContentRowWidth}
|
||||
onRequestClose={() => setActivePanel(null)}
|
||||
onFileOpenStart={handleFileOpenStart}
|
||||
|
||||
@@ -1258,21 +1258,54 @@
|
||||
</div>
|
||||
<div class="row-fg">
|
||||
<span class="row-check">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="status-badge s-mod">M</span>
|
||||
<span class="file-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="file-text">
|
||||
<span class="file-name">EditorFileTab.tsx</span>
|
||||
<span class="file-meta">
|
||||
<span class="file-dir">‎src/renderer/src/components/tab-bar/</span>
|
||||
<span class="diffstat"><span class="add">+12</span><span class="del">−4</span></span>
|
||||
<span class="diffstat"
|
||||
><span class="add">+12</span><span class="del">−4</span></span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="chev">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1293,17 +1326,53 @@
|
||||
<div class="row-action stage"><span>+</span><span>Stage</span></div>
|
||||
<div class="row-action discard"><span>🗑</span><span>Discard</span></div>
|
||||
<div class="row-fg">
|
||||
<span class="row-check"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="row-check"
|
||||
><svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" /></svg
|
||||
></span>
|
||||
<span class="status-badge s-mod">M</span>
|
||||
<span class="file-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
|
||||
<span class="file-icon"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" /></svg
|
||||
></span>
|
||||
<span class="file-text">
|
||||
<span class="file-name">CenterSplitDivider.tsx</span>
|
||||
<span class="file-meta">
|
||||
<span class="file-dir">‎src/renderer/src/components/split-view/</span>
|
||||
<span class="diffstat"><span class="add">+34</span><span class="del">−8</span></span>
|
||||
<span class="diffstat"
|
||||
><span class="add">+34</span><span class="del">−8</span></span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="chev"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||||
<span class="chev"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" /></svg
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1315,17 +1384,53 @@
|
||||
<div class="row-action stage"><span>+</span><span>Stage</span></div>
|
||||
<div class="row-action discard"><span>🗑</span><span>Discard</span></div>
|
||||
<div class="row-fg">
|
||||
<span class="row-check"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="row-check"
|
||||
><svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" /></svg
|
||||
></span>
|
||||
<span class="status-badge s-mod">M</span>
|
||||
<span class="file-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
|
||||
<span class="file-icon"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" /></svg
|
||||
></span>
|
||||
<span class="file-text">
|
||||
<span class="file-name">split-view-geometry.ts</span>
|
||||
<span class="file-meta">
|
||||
<span class="file-dir">‎src/renderer/src/lib/</span>
|
||||
<span class="diffstat"><span class="add">+6</span><span class="del">−2</span></span>
|
||||
<span class="diffstat"
|
||||
><span class="add">+6</span><span class="del">−2</span></span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="chev"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||||
<span class="chev"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" /></svg
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1337,17 +1442,55 @@
|
||||
<div class="row-action stage"><span>+</span><span>Stage</span></div>
|
||||
<div class="row-action discard"><span>🗑</span><span>Discard</span></div>
|
||||
<div class="row-fg">
|
||||
<span class="row-check"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="row-check"
|
||||
><svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" /></svg
|
||||
></span>
|
||||
<span class="status-badge s-add">A</span>
|
||||
<span class="file-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>
|
||||
<span class="file-icon"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" /></svg
|
||||
></span>
|
||||
<span class="file-text">
|
||||
<span class="file-name">SplitDividerHandle.test.tsx</span>
|
||||
<span class="file-meta">
|
||||
<span class="file-dir">‎src/renderer/src/components/split-view/__tests__/</span>
|
||||
<span class="diffstat"><span class="add">+48</span><span class="del">−0</span></span>
|
||||
<span class="file-dir"
|
||||
>‎src/renderer/src/components/split-view/__tests__/</span
|
||||
>
|
||||
<span class="diffstat"
|
||||
><span class="add">+48</span><span class="del">−0</span></span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="chev"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||||
<span class="chev"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" /></svg
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1356,22 +1499,78 @@
|
||||
<div class="commit-bar" id="commit-bar">
|
||||
<div class="commit-input">
|
||||
<input type="text" placeholder="Commit message" />
|
||||
<button class="sparkle-btn" title="Generate message" onclick="toast('Generating commit message ✦')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/><path d="M19 15l.7 1.8L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.7z"/></svg>
|
||||
<button
|
||||
class="sparkle-btn"
|
||||
title="Generate message"
|
||||
onclick="toast('Generating commit message ✦')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z" />
|
||||
<path d="M19 15l.7 1.8L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="split-btn">
|
||||
<button class="split-main" onclick="commitAction('Commit')">Commit</button>
|
||||
<button class="split-caret" onclick="toggleSplitMenu(event)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="split-menu" id="split-menu">
|
||||
<div class="menu-item" onclick="commitAction('Commit & Push')">
|
||||
<span class="mi-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></span>
|
||||
<span class="mi-icon"
|
||||
><svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" /></svg
|
||||
></span>
|
||||
Commit & Push
|
||||
</div>
|
||||
<div class="menu-item" onclick="commitAction('Commit & Sync')">
|
||||
<span class="mi-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span>
|
||||
<span class="mi-icon"
|
||||
><svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path
|
||||
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
||||
/></svg
|
||||
></span>
|
||||
Commit & Sync
|
||||
</div>
|
||||
</div>
|
||||
@@ -1383,7 +1582,13 @@
|
||||
<span class="bulk-count" id="bulk-count">0 selected</span>
|
||||
<button class="bulk-action" onclick="bulkAction('Stage')">Stage</button>
|
||||
<button class="bulk-action" onclick="bulkAction('Unstage')">Unstage</button>
|
||||
<button class="bulk-action destructive" onclick="bulkAction('Discard')" id="bulk-discard">Discard</button>
|
||||
<button
|
||||
class="bulk-action destructive"
|
||||
onclick="bulkAction('Discard')"
|
||||
id="bulk-discard"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1396,10 +1601,14 @@
|
||||
<div class="row">
|
||||
<span class="pill open">Open</span>
|
||||
<span class="pr-num">#7701</span>
|
||||
<span style="font-size:12px;color:var(--text-muted);">octocat wants to merge into main</span>
|
||||
<span style="font-size: 12px; color: var(--text-muted)"
|
||||
>octocat wants to merge into main</span
|
||||
>
|
||||
</div>
|
||||
<div class="pr-title">Make center split divider more visible</div>
|
||||
<div class="pr-subline">make-center-split-more-visible → main · updated 20m ago</div>
|
||||
<div class="pr-subline">
|
||||
make-center-split-more-visible → main · updated 20m ago
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- failing checks first -->
|
||||
@@ -1407,25 +1616,71 @@
|
||||
<span class="g-title warn">1 check failing</span>
|
||||
<span class="g-spacer"></span>
|
||||
<button class="ai-btn" onclick="toast('Launching agent to fix checks ✦')">
|
||||
<span class="sparkle"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/></svg></span>
|
||||
<span class="sparkle"
|
||||
><svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"
|
||||
/></svg
|
||||
></span>
|
||||
Fix failing checks
|
||||
</button>
|
||||
</div>
|
||||
<div class="check-row" onclick="toast('Opening check log tail')">
|
||||
<span class="check-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--status-red)" stroke-width="2.4" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></span>
|
||||
<span class="check-icon"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--status-red)"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
<line x1="18" y1="6" x2="6" y2="18" /></svg
|
||||
></span>
|
||||
<span class="check-name">
|
||||
<span class="n">e2e / playwright (ubuntu-latest)</span>
|
||||
<span class="d">Failing · 1 test · 2m 14s</span>
|
||||
</span>
|
||||
<button class="mini-btn" onclick="event.stopPropagation();toast('Rerunning check…')">Rerun</button>
|
||||
<button
|
||||
class="mini-btn"
|
||||
onclick="
|
||||
event.stopPropagation()
|
||||
toast('Rerunning check…')
|
||||
"
|
||||
>
|
||||
Rerun
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- merge box -->
|
||||
<div class="merge-box">
|
||||
<div class="merge-method" onclick="openMergeMethod()">
|
||||
<span class="lbl">Merge method</span>
|
||||
<span class="val">Squash and merge
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<span class="val"
|
||||
>Squash and merge
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
@@ -1433,10 +1688,25 @@
|
||||
<div class="t">Auto-merge when checks pass</div>
|
||||
<div class="s">Merges automatically once required checks are green</div>
|
||||
</div>
|
||||
<div class="switch" id="automerge" onclick="toggleSwitch('automerge')"><span class="knob"></span></div>
|
||||
<div class="switch" id="automerge" onclick="toggleSwitch('automerge')">
|
||||
<span class="knob"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="merge-btn" onclick="toast('Confirm merge…')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<path d="M6 21V9a9 9 0 0 0 9 9" />
|
||||
</svg>
|
||||
Squash and merge
|
||||
</button>
|
||||
<div class="merge-blocked">1 failing check must pass before merging</div>
|
||||
@@ -1448,20 +1718,37 @@
|
||||
<span class="g-sub">· 3</span>
|
||||
<span class="g-spacer"></span>
|
||||
<button class="ai-btn" onclick="toast('Resolving comments with AI ✦')">
|
||||
<span class="sparkle"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/></svg></span>
|
||||
<span class="sparkle"
|
||||
><svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"
|
||||
/></svg
|
||||
></span>
|
||||
Resolve with AI
|
||||
</button>
|
||||
</div>
|
||||
<div class="comment-row" onclick="toast('Opening comment thread')">
|
||||
<span class="avatar" style="background:#f59e0b;">R</span>
|
||||
<span class="avatar" style="background: #f59e0b">R</span>
|
||||
<span class="comment-body">
|
||||
<span class="comment-meta">rauchg · 18m ago</span>
|
||||
<span class="comment-text">Can we clamp the divider width so it doesn't overlap the gutter on narrow panes?</span>
|
||||
<span class="comment-text"
|
||||
>Can we clamp the divider width so it doesn't overlap the gutter on narrow
|
||||
panes?</span
|
||||
>
|
||||
<span class="comment-file">CenterSplitDivider.tsx:42</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="comment-row" onclick="toast('Opening comment thread')">
|
||||
<span class="avatar" style="background:#3b82f6;">M</span>
|
||||
<span class="avatar" style="background: #3b82f6">M</span>
|
||||
<span class="comment-body">
|
||||
<span class="comment-meta">maren · 25m ago</span>
|
||||
<span class="comment-text">Add a test for the min-width geometry branch.</span>
|
||||
@@ -1474,12 +1761,12 @@
|
||||
<span class="g-title">Reviewers</span>
|
||||
</div>
|
||||
<div class="reviewer-row">
|
||||
<span class="avatar" style="background:#22c55e;">O</span>
|
||||
<span class="avatar" style="background: #22c55e">O</span>
|
||||
<span class="r-name">octocat</span>
|
||||
<span class="r-state approved">Approved</span>
|
||||
</div>
|
||||
<div class="reviewer-row">
|
||||
<span class="avatar" style="background:#8b5cf6;">D</span>
|
||||
<span class="avatar" style="background: #8b5cf6">D</span>
|
||||
<span class="r-name">dstndev</span>
|
||||
<span class="r-state pending">Review requested</span>
|
||||
</div>
|
||||
@@ -1492,12 +1779,41 @@
|
||||
<button class="mini-btn" onclick="toast('Rerunning failed…')">Rerun failed</button>
|
||||
</div>
|
||||
<div class="check-row" onclick="toast('Opening check log tail')">
|
||||
<span class="check-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--status-green)" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="check-name"><span class="n">build / typecheck</span><span class="d">Passed · 1m 02s</span></span>
|
||||
<span class="check-icon"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--status-green)"
|
||||
stroke-width="2.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" /></svg
|
||||
></span>
|
||||
<span class="check-name"
|
||||
><span class="n">build / typecheck</span
|
||||
><span class="d">Passed · 1m 02s</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="check-row" onclick="toast('Opening check log tail')">
|
||||
<span class="check-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--status-green)" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<span class="check-name"><span class="n">lint / oxlint</span><span class="d">Passed · 38s</span></span>
|
||||
<span class="check-icon"
|
||||
><svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--status-green)"
|
||||
stroke-width="2.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" /></svg
|
||||
></span>
|
||||
<span class="check-name"
|
||||
><span class="n">lint / oxlint</span><span class="d">Passed · 38s</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1507,18 +1823,26 @@
|
||||
<div class="pane" id="pane-history">
|
||||
<div class="list-pad">
|
||||
<div class="history">
|
||||
<div class="commit" data-hash="a1b2c3d" data-msg="Make center split divider more visible">
|
||||
<div
|
||||
class="commit"
|
||||
data-hash="a1b2c3d"
|
||||
data-msg="Make center split divider more visible"
|
||||
>
|
||||
<div class="rail"><div class="dot head"></div></div>
|
||||
<div class="commit-body">
|
||||
<div class="commit-msg">Make center split divider more visible</div>
|
||||
<div class="commit-sub"><span class="hash">a1b2c3d</span> · Jinjing · 18m ago</div>
|
||||
<div class="commit-sub">
|
||||
<span class="hash">a1b2c3d</span> · Jinjing · 18m ago
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commit" data-hash="9f8e7d6" data-msg="Add split-view geometry helper">
|
||||
<div class="rail"><div class="dot"></div></div>
|
||||
<div class="commit-body">
|
||||
<div class="commit-msg">Add split-view geometry helper</div>
|
||||
<div class="commit-sub"><span class="hash">9f8e7d6</span> · Jinjing · 42m ago</div>
|
||||
<div class="commit-sub">
|
||||
<span class="hash">9f8e7d6</span> · Jinjing · 42m ago
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1527,11 +1851,17 @@
|
||||
<span class="line"></span>
|
||||
</div>
|
||||
|
||||
<div class="commit" data-hash="0f7658d" data-msg="Make center split divider more visible (#7701)">
|
||||
<div
|
||||
class="commit"
|
||||
data-hash="0f7658d"
|
||||
data-msg="Make center split divider more visible (#7701)"
|
||||
>
|
||||
<div class="rail"><div class="dot merge"></div></div>
|
||||
<div class="commit-body">
|
||||
<div class="commit-msg">Merge pull request #7699 from orca/main</div>
|
||||
<div class="commit-sub"><span class="hash">0f7658d</span> · web-flow · 3h ago</div>
|
||||
<div class="commit-sub">
|
||||
<span class="hash">0f7658d</span> · web-flow · 3h ago
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commit" data-hash="2dad1f0" data-msg="Update README downloads badge">
|
||||
@@ -1645,10 +1975,13 @@
|
||||
diff: '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/>',
|
||||
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
stage: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
discard: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
|
||||
discard:
|
||||
'<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
remote: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/>',
|
||||
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
|
||||
remote:
|
||||
'<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/>',
|
||||
merge:
|
||||
'<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
|
||||
hash: '<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>',
|
||||
message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
sparkle: '<path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/>'
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ExternalLink, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { ConnectionState } from '../../transport/types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
|
||||
import {
|
||||
useMobilePrSidebarController,
|
||||
type MobilePrSidebarController
|
||||
} from '../../session/use-mobile-pr-sidebar-controller'
|
||||
import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
|
||||
import { MobilePRSidebar } from '../MobilePRSidebar'
|
||||
import { openMobilePrUrl } from '../MobilePrComposeSheet'
|
||||
|
||||
type BodyProps = {
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
worktreeId: string
|
||||
@@ -24,32 +17,11 @@ type BodyProps = {
|
||||
isGithubRepo?: boolean
|
||||
branchContextLoaded?: boolean
|
||||
controller: MobilePrSidebarController
|
||||
// 'chromeless' drops the panel's own header + SafeAreaView so it can live inside
|
||||
// the source-control hub, which owns the header, segmented control, and chrome.
|
||||
chromeless?: boolean
|
||||
// When false, the parent (the hub) owns triggering the initial load — the body
|
||||
// must not also fire it, or a ?tab=pr deep link double-fetches. Default true so
|
||||
// the standalone route stays self-loading.
|
||||
autoLoad?: boolean
|
||||
embedded?: boolean
|
||||
onRequestClose?: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<BodyProps, 'controller' | 'autoLoad'>
|
||||
|
||||
// Standalone entry (the /pr route + wide-layout dock): owns its controller and
|
||||
// self-loads. The hub uses MobilePrViewPanelBody with a shared controller instead.
|
||||
export function MobilePrViewPanel(props: Props) {
|
||||
const controller = useMobilePrSidebarController({
|
||||
client: props.client,
|
||||
connState: props.connState,
|
||||
worktreeId: props.worktreeId,
|
||||
branch: props.branch,
|
||||
headSha: props.headSha
|
||||
})
|
||||
return <MobilePrViewPanelBody {...props} controller={controller} />
|
||||
}
|
||||
|
||||
// Chromeless PR sidebar body for the source-control hub's Pull Request segment.
|
||||
// The hub owns the header, segmented control, load triggers, and the shared
|
||||
// controller (one fetch feeds both the branch-card chip and this body).
|
||||
export function MobilePrViewPanelBody({
|
||||
client,
|
||||
connState,
|
||||
@@ -59,28 +31,10 @@ export function MobilePrViewPanelBody({
|
||||
gitStatus,
|
||||
isGithubRepo = true,
|
||||
branchContextLoaded = true,
|
||||
controller,
|
||||
chromeless = false,
|
||||
autoLoad = true,
|
||||
embedded = false,
|
||||
onRequestClose
|
||||
}: BodyProps) {
|
||||
const router = useRouter()
|
||||
controller
|
||||
}: Props) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
// A docked/full-screen PR panel is always visible — there is no drawer to open,
|
||||
// so trigger the load directly once context is ready rather than gating on the
|
||||
// showPRSidebar overlay flag (KTD4). Skipped when the hub owns the trigger.
|
||||
const prSidebarKind = controller.prSidebarState.kind
|
||||
const refetch = controller.refetchPRSidebar
|
||||
useEffect(() => {
|
||||
if (autoLoad && branch && isGithubRepo && prSidebarKind === 'hidden') {
|
||||
refetch()
|
||||
}
|
||||
}, [autoLoad, branch, isGithubRepo, prSidebarKind, refetch])
|
||||
|
||||
// Embedded: the dock column applies the bottom inset; full-screen relies on its own
|
||||
// SafeAreaView (edges top only), so content must clear the home indicator itself.
|
||||
const sidebarState = !branchContextLoaded
|
||||
? ({ kind: 'loading' } as const)
|
||||
: !isGithubRepo
|
||||
@@ -94,91 +48,22 @@ export function MobilePrViewPanelBody({
|
||||
message: 'Current branch unavailable.'
|
||||
} as const)
|
||||
: controller.prSidebarState
|
||||
// Why: open-on-host lives in the chrome so the PR URL is always flush-right of
|
||||
// the screen title, even when the body is scrolled past the PR header.
|
||||
const prUrl =
|
||||
sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null
|
||||
const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null
|
||||
const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined
|
||||
const openPrControl = openPr ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={openPr}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={
|
||||
prNumber != null
|
||||
? `Open pull request #${prNumber} on the web`
|
||||
: 'Open pull request on the web'
|
||||
}
|
||||
>
|
||||
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null
|
||||
const sidebar = (
|
||||
<MobilePRSidebar
|
||||
state={sidebarState}
|
||||
onRetry={controller.retryPRSidebar}
|
||||
refetch={controller.refetchPRSidebar}
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
gitBranch={branch}
|
||||
gitStatus={gitStatus}
|
||||
headSha={headSha}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
)
|
||||
|
||||
// Chromeless: the hub owns the header + chrome, so render just the scrollable body.
|
||||
if (chromeless) {
|
||||
return <View style={styles.container}>{sidebar}</View>
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={onRequestClose}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Close pull request panel"
|
||||
>
|
||||
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Pull Request
|
||||
</Text>
|
||||
{openPrControl}
|
||||
</View>
|
||||
</View>
|
||||
{sidebar}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Back to session"
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Pull Request
|
||||
</Text>
|
||||
{openPrControl}
|
||||
</View>
|
||||
</View>
|
||||
{sidebar}
|
||||
</SafeAreaView>
|
||||
<View style={styles.container}>
|
||||
<MobilePRSidebar
|
||||
state={sidebarState}
|
||||
onRetry={controller.retryPRSidebar}
|
||||
refetch={controller.refetchPRSidebar}
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
gitBranch={branch}
|
||||
gitStatus={gitStatus}
|
||||
headSha={headSha}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,34 +71,5 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
header: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
topBar: {
|
||||
minHeight: 58,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
iconButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { ArrowRight, Pencil } from 'lucide-react-native'
|
||||
import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native'
|
||||
import { colors } from '../../theme/mobile-theme'
|
||||
import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types'
|
||||
import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action'
|
||||
@@ -36,19 +36,32 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionBody}>
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
disabled={!openPr}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} on the web`}
|
||||
style={({ pressed }) => [
|
||||
styles.badge,
|
||||
{ borderColor: badgeColor },
|
||||
pressed && { opacity: 0.6 }
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.badgeRow}>
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
disabled={!openPr}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} on the web`}
|
||||
style={({ pressed }) => [
|
||||
styles.badge,
|
||||
{ borderColor: badgeColor },
|
||||
pressed && { opacity: 0.6 }
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
|
||||
</Pressable>
|
||||
{openPr ? (
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} on the web`}
|
||||
style={({ pressed }) => [styles.openWebButton, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<PRTitle
|
||||
title={title}
|
||||
number={pr.number}
|
||||
|
||||
@@ -56,6 +56,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
fontWeight: '600'
|
||||
},
|
||||
// Header section: state badge, title, author, base<-head branches.
|
||||
// Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it).
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm
|
||||
},
|
||||
badge: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.sm,
|
||||
@@ -68,6 +75,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '700'
|
||||
},
|
||||
openWebButton: {
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
prTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.titleSize,
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { memo } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileSourceControlPanel } from '../source-control/MobileSourceControlPanel'
|
||||
import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel'
|
||||
import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel'
|
||||
import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles'
|
||||
import { useMobileDockResize } from './use-mobile-dock-resize'
|
||||
import type { ActivePanel } from './session-panel-host'
|
||||
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
|
||||
|
||||
type Props = {
|
||||
activePanel: Exclude<ActivePanel, null>
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
name: string
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
branch: string | null
|
||||
headSha: string | null
|
||||
gitStatus: MobileGitStatusResult | null
|
||||
isGithubRepo: boolean
|
||||
branchContextLoaded: boolean
|
||||
availableWidth: number
|
||||
onRequestClose: () => void
|
||||
onFileOpenStart?: () => void
|
||||
@@ -40,13 +29,6 @@ export function SessionDockColumn({
|
||||
hostId,
|
||||
worktreeId,
|
||||
name,
|
||||
client,
|
||||
connState,
|
||||
branch,
|
||||
headSha,
|
||||
gitStatus,
|
||||
isGithubRepo,
|
||||
branchContextLoaded,
|
||||
availableWidth,
|
||||
onRequestClose,
|
||||
onFileOpenStart,
|
||||
@@ -63,13 +45,6 @@ export function SessionDockColumn({
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
name={name}
|
||||
client={client}
|
||||
connState={connState}
|
||||
branch={branch}
|
||||
headSha={headSha}
|
||||
gitStatus={gitStatus}
|
||||
isGithubRepo={isGithubRepo}
|
||||
branchContextLoaded={branchContextLoaded}
|
||||
onRequestClose={onRequestClose}
|
||||
onFileOpenStart={onFileOpenStart}
|
||||
onOpenedFileDiff={onOpenedFileDiff}
|
||||
@@ -85,18 +60,14 @@ const DockPanelContent = memo(function DockPanelContent({
|
||||
hostId,
|
||||
worktreeId,
|
||||
name,
|
||||
client,
|
||||
connState,
|
||||
branch,
|
||||
headSha,
|
||||
gitStatus,
|
||||
isGithubRepo,
|
||||
branchContextLoaded,
|
||||
onRequestClose,
|
||||
onFileOpenStart,
|
||||
onOpenedFileDiff
|
||||
}: DockPanelContentProps) {
|
||||
if (activePanel === 'sourceControl') {
|
||||
// Source Control and Pull Request share one hub instance so swapping dock icons
|
||||
// keeps commit draft / visited-tab / scroll state and lands on the right segment
|
||||
// (design: PR dock maps to hub + tab=pr).
|
||||
if (activePanel === 'sourceControl' || activePanel === 'pr') {
|
||||
return (
|
||||
<MobileSourceControlPanel
|
||||
hostId={hostId}
|
||||
@@ -104,33 +75,18 @@ const DockPanelContent = memo(function DockPanelContent({
|
||||
name={name}
|
||||
origin="session"
|
||||
embedded
|
||||
initialTab={activePanel === 'pr' ? 'pr' : 'changes'}
|
||||
onRequestClose={onRequestClose}
|
||||
onFileOpenStart={onFileOpenStart}
|
||||
onOpenedFileDiff={onOpenedFileDiff}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (activePanel === 'files') {
|
||||
return (
|
||||
<MobileFileExplorerPanel
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
name={name}
|
||||
embedded
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MobilePrViewPanel
|
||||
client={client}
|
||||
connState={connState}
|
||||
<MobileFileExplorerPanel
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
branch={branch}
|
||||
headSha={headSha}
|
||||
gitStatus={gitStatus}
|
||||
isGithubRepo={isGithubRepo}
|
||||
branchContextLoaded={branchContextLoaded}
|
||||
name={name}
|
||||
embedded
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
|
||||
@@ -140,6 +140,47 @@ export async function loadPrSidebarDetails(
|
||||
}
|
||||
}
|
||||
|
||||
// UI treats details===null as "still loading". After phase 2 finishes (success or
|
||||
// non-fatal failure), never leave null — use prior comments if any, else empty body.
|
||||
export function resolvePrSidebarDetailsAfterPhase2(args: {
|
||||
fetched: GitHubWorkItemDetails | null
|
||||
prior: GitHubWorkItemDetails | null
|
||||
pr: PRInfo
|
||||
}): GitHubWorkItemDetails {
|
||||
if (args.fetched != null) {
|
||||
return args.fetched
|
||||
}
|
||||
if (args.prior != null) {
|
||||
return args.prior
|
||||
}
|
||||
return emptyPrSidebarDetails(args.pr)
|
||||
}
|
||||
|
||||
// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
|
||||
export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
|
||||
return {
|
||||
item: {
|
||||
id: `pr-${pr.number}`,
|
||||
type: 'pr',
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.state,
|
||||
url: pr.url,
|
||||
labels: [],
|
||||
updatedAt: pr.updatedAt,
|
||||
author: null
|
||||
},
|
||||
body: '',
|
||||
comments: []
|
||||
}
|
||||
}
|
||||
|
||||
// Soft head refresh may restart an in-flight phase-1 load; only skip when there is
|
||||
// no visible/in-flight sidebar work (hidden or terminal error states).
|
||||
export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean {
|
||||
return kind === 'ready' || kind === 'none' || kind === 'loading'
|
||||
}
|
||||
|
||||
// Stale-response guard (KTD6): a load tagged with an older sequence must not
|
||||
// overwrite a newer one. The hook bumps a monotonic counter per load.
|
||||
export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean {
|
||||
|
||||
@@ -34,12 +34,16 @@ export function deriveMobilePrBranchContext(
|
||||
// Loads repo eligibility independently from branch/SHA. The header PR icon only
|
||||
// needs the cheap GitHub probe; the panel can keep loading branch context after
|
||||
// the entry point is already stable in the top bar.
|
||||
//
|
||||
// `includeBranchIdentity: false` skips git.status + branchCompare — use when the
|
||||
// caller only gates a PR entry on hosted-repo eligibility (session dock icon).
|
||||
export function useMobilePrBranchContext(input: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
worktreeId: string
|
||||
includeBranchIdentity?: boolean
|
||||
}): MobilePrBranchContext {
|
||||
const { client, connState, worktreeId } = input
|
||||
const { client, connState, worktreeId, includeBranchIdentity = true } = input
|
||||
const [context, setContext] = useState<MobilePrBranchContext>({
|
||||
branch: null,
|
||||
headSha: null,
|
||||
@@ -60,7 +64,7 @@ export function useMobilePrBranchContext(input: {
|
||||
status: null,
|
||||
isGithubRepo: false,
|
||||
repoLoaded: false,
|
||||
loaded: false
|
||||
loaded: !includeBranchIdentity
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -70,7 +74,8 @@ export function useMobilePrBranchContext(input: {
|
||||
status: null,
|
||||
isGithubRepo: false,
|
||||
repoLoaded: false,
|
||||
loaded: false
|
||||
// Repo-only mode is "loaded" for branch fields immediately (they stay null).
|
||||
loaded: !includeBranchIdentity
|
||||
})
|
||||
|
||||
void loadMobilePrRepoContext(client, worktreeId)
|
||||
@@ -95,6 +100,12 @@ export function useMobilePrBranchContext(input: {
|
||||
}
|
||||
})
|
||||
|
||||
if (!includeBranchIdentity) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
void loadMobilePrBranchIdentity(client, worktreeId)
|
||||
.then((next) => {
|
||||
if (!cancelled) {
|
||||
@@ -121,7 +132,7 @@ export function useMobilePrBranchContext(input: {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ready, client, worktreeId])
|
||||
}, [ready, client, worktreeId, includeBranchIdentity])
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
|
||||
import type { GitHubPrReadOutcome } from './github-pr-rpc'
|
||||
import {
|
||||
classifyPrSidebarFailure,
|
||||
emptyPrSidebarDetails,
|
||||
loadPrSidebarData,
|
||||
loadPrSidebarDetails,
|
||||
resolvePrSidebarDetailsAfterPhase2,
|
||||
shouldApplyResult,
|
||||
shouldSoftRefreshPrSidebarOnHeadChange,
|
||||
type PrSidebarLoadDeps
|
||||
} from './mobile-pr-sidebar-state'
|
||||
import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller'
|
||||
|
||||
function ok<T>(result: T): GitHubPrReadOutcome<T> {
|
||||
return { ok: true, result }
|
||||
@@ -200,3 +204,61 @@ describe('shouldApplyResult', () => {
|
||||
expect(shouldApplyResult(2, 3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSoftRefreshPrSidebarOnHeadChange', () => {
|
||||
it('restarts ready/none/loading so mid-flight head advances are not stuck', () => {
|
||||
expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true)
|
||||
expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true)
|
||||
expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => {
|
||||
expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false)
|
||||
expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false)
|
||||
expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePrSidebarDetailsAfterPhase2', () => {
|
||||
it('prefers a successful fetch over prior details', () => {
|
||||
const prior = emptyPrSidebarDetails(PR)
|
||||
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS)
|
||||
})
|
||||
|
||||
it('keeps prior details when the fetch is non-fatal null', () => {
|
||||
const prior = emptyPrSidebarDetails(PR)
|
||||
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior)
|
||||
})
|
||||
|
||||
it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => {
|
||||
const empty = resolvePrSidebarDetailsAfterPhase2({
|
||||
fetched: null,
|
||||
prior: null,
|
||||
pr: PR
|
||||
})
|
||||
expect(empty.body).toBe('')
|
||||
expect(empty.comments).toEqual([])
|
||||
expect(empty.item.number).toBe(7)
|
||||
expect(empty.item.type).toBe('pr')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMobilePrSidebarIdentity', () => {
|
||||
it('keys identity on worktree + branch, not head SHA', () => {
|
||||
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat')
|
||||
// Head advances on commit must not form a new identity — soft refresh keeps ready UI.
|
||||
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe(
|
||||
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })
|
||||
)
|
||||
})
|
||||
|
||||
it('is null without a branch and changes when branch or worktree changes', () => {
|
||||
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull()
|
||||
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
|
||||
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' })
|
||||
)
|
||||
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
|
||||
buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import {
|
||||
loadPrSidebarData,
|
||||
loadPrSidebarDetails,
|
||||
resolvePrSidebarDetailsAfterPhase2,
|
||||
shouldApplyResult,
|
||||
shouldSoftRefreshPrSidebarOnHeadChange,
|
||||
type PrSidebarLoadDeps,
|
||||
type PrSidebarState
|
||||
} from './mobile-pr-sidebar-state'
|
||||
@@ -27,12 +29,20 @@ type PrSidebarControllerInput = {
|
||||
headSha: string | null
|
||||
}
|
||||
|
||||
function buildPrSidebarIdentity(args: {
|
||||
// Load options for the shared PR controller. The Source Control hub chip only needs
|
||||
// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the
|
||||
// Pull Request segment is actually open.
|
||||
export type PrSidebarLoadOptions = {
|
||||
includeDetails?: boolean
|
||||
}
|
||||
|
||||
// Identity is worktree + branch only. Head SHA advances on every commit and must not
|
||||
// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead.
|
||||
export function buildMobilePrSidebarIdentity(args: {
|
||||
worktreeId: string
|
||||
branch: string | null
|
||||
headSha: string | null
|
||||
}): string | null {
|
||||
return args.branch ? `${args.worktreeId}\u0000${args.branch}\u0000${args.headSha ?? ''}` : null
|
||||
return args.branch ? `${args.worktreeId}\u0000${args.branch}` : null
|
||||
}
|
||||
|
||||
export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
@@ -41,13 +51,27 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
// independent of whether the branch has an open PR (a no-PR branch shows an
|
||||
// empty state rather than hiding the icon).
|
||||
const [isGithubRepo, setIsGithubRepo] = useState(false)
|
||||
// False until the probe resolves for this worktree. Consumers gate "unavailable
|
||||
// for this provider" copy on it — isGithubRepo=false is meaningless mid-probe.
|
||||
const [repoProbeLoaded, setRepoProbeLoaded] = useState(false)
|
||||
const [state, setState] = useState<PrSidebarState>({ kind: 'hidden' })
|
||||
const [showPRSidebar, setShowPRSidebar] = useState(false)
|
||||
const loadSeqRef = useRef(0)
|
||||
// Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent
|
||||
// phase-1 soft refresh (and vice versa) when chip bootstrap left details null.
|
||||
const detailsSeqRef = useRef(0)
|
||||
// The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's
|
||||
// fill-in effect fires as soon as phase 1 renders ready with null details —
|
||||
// exactly when load()'s own phase 2 just started. Without this claim, every
|
||||
// cold PR-segment open fetched the heavy details payload twice.
|
||||
const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null)
|
||||
const stateIdentityRef = useRef<string | null>(null)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const headShaRef = useRef(headSha)
|
||||
|
||||
const ready = client !== null && connState === 'connected' && !!branch
|
||||
const identity = buildPrSidebarIdentity({ worktreeId, branch, headSha })
|
||||
const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
|
||||
|
||||
const buildDeps = useCallback((): PrSidebarLoadDeps | null => {
|
||||
if (!client) {
|
||||
@@ -63,15 +87,22 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
}, [client])
|
||||
|
||||
// Probe whether this is a GitHub repo to decide icon availability (GitHub-only).
|
||||
// Worktree change must reset eligibility; a brief disconnect must not — otherwise
|
||||
// the hub shows "unavailable for this provider" and hides the chip mid-session.
|
||||
useEffect(() => {
|
||||
setIsGithubRepo(false)
|
||||
setRepoProbeLoaded(false)
|
||||
}, [worktreeId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (!ready || !client) {
|
||||
setIsGithubRepo(false)
|
||||
return
|
||||
}
|
||||
void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
|
||||
if (!cancelled) {
|
||||
setIsGithubRepo(outcome.ok && outcome.result !== null)
|
||||
setRepoProbeLoaded(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
@@ -82,47 +113,183 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
useEffect(() => {
|
||||
if (!identity) {
|
||||
loadSeqRef.current += 1
|
||||
detailsSeqRef.current += 1
|
||||
stateIdentityRef.current = null
|
||||
setState({ kind: 'hidden' })
|
||||
return
|
||||
}
|
||||
if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) {
|
||||
// Why: ready/loading data is scoped to branch+head. A branch switch must
|
||||
// not let the open panel keep rendering the previous PR as "fresh."
|
||||
// Why: ready/loading data is scoped to branch. A branch switch must not let
|
||||
// the open panel keep rendering the previous PR as "fresh."
|
||||
loadSeqRef.current += 1
|
||||
detailsSeqRef.current += 1
|
||||
stateIdentityRef.current = null
|
||||
setState({ kind: 'hidden' })
|
||||
}
|
||||
}, [identity])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(
|
||||
async (options?: PrSidebarLoadOptions) => {
|
||||
const includeDetails = options?.includeDetails ?? true
|
||||
const deps = buildDeps()
|
||||
const loadIdentity = identity
|
||||
if (!deps || !branch || !loadIdentity) {
|
||||
return
|
||||
}
|
||||
const seq = loadSeqRef.current + 1
|
||||
loadSeqRef.current = seq
|
||||
// In-flight phase-2 work is NOT invalidated here: this load only takes phase-2
|
||||
// ownership when its own phase 2 actually starts (the bump below), so a load
|
||||
// superseded at the phase-1 guard can never orphan a detailsSeq and silently
|
||||
// discard the only details fetch. A stale ensure applying mid-phase-1 is safe —
|
||||
// its identity/number/kind guards only let matching details through.
|
||||
const previousIdentity = stateIdentityRef.current
|
||||
stateIdentityRef.current = loadIdentity
|
||||
// Soft refresh: same branch already showing ready/none stays visible while
|
||||
// checks re-fetch (head advanced after commit). Hard loading only on first load
|
||||
// or after a real identity wipe.
|
||||
const keepVisible =
|
||||
previousIdentity === loadIdentity &&
|
||||
(stateRef.current.kind === 'ready' ||
|
||||
stateRef.current.kind === 'none' ||
|
||||
stateRef.current.kind === 'loading')
|
||||
if (!keepVisible) {
|
||||
setState({ kind: 'loading' })
|
||||
}
|
||||
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
|
||||
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
|
||||
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
|
||||
if (
|
||||
!shouldApplyResult(seq, loadSeqRef.current) ||
|
||||
stateIdentityRef.current !== loadIdentity
|
||||
) {
|
||||
return
|
||||
}
|
||||
stateIdentityRef.current = loadIdentity
|
||||
|
||||
// Keep prior comments/body visible across phase 1 when the same PR is still open.
|
||||
// loadPrSidebarData always returns details:null; without this, soft refresh and
|
||||
// PR-tab refresh blank the comment tree until phase 2 finishes.
|
||||
const priorDetails =
|
||||
next.kind === 'ready' &&
|
||||
stateRef.current.kind === 'ready' &&
|
||||
stateRef.current.data.details != null &&
|
||||
stateRef.current.data.pr.number === next.data.pr.number
|
||||
? stateRef.current.data.details
|
||||
: null
|
||||
|
||||
if (next.kind === 'ready' && priorDetails != null) {
|
||||
setState({ kind: 'ready', data: { ...next.data, details: priorDetails } })
|
||||
if (!includeDetails) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
setState(next)
|
||||
if (next.kind !== 'ready' || !includeDetails) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: refresh (or first-load) the heavy comments/body payload.
|
||||
const detailsSeq = detailsSeqRef.current + 1
|
||||
detailsSeqRef.current = detailsSeq
|
||||
detailsInFlightRef.current = { seq: detailsSeq, prNumber: next.data.pr.number }
|
||||
const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
|
||||
// Release the claim unless a newer phase-2 superseded it (never clear theirs).
|
||||
if (detailsInFlightRef.current?.seq === detailsSeq) {
|
||||
detailsInFlightRef.current = null
|
||||
}
|
||||
// Phase-2 ownership is encoded by detailsSeq + identity + PR number alone —
|
||||
// deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without
|
||||
// bumping detailsSeq, and must not discard the in-flight details it preserved
|
||||
// (ensure dedupes against this claim, so nothing would re-fetch them).
|
||||
if (
|
||||
detailsSeq !== detailsSeqRef.current ||
|
||||
stateIdentityRef.current !== loadIdentity ||
|
||||
stateRef.current.kind !== 'ready' ||
|
||||
stateRef.current.data.pr.number !== next.data.pr.number
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Non-fatal null must not leave details===null (UI treats that as forever-loading).
|
||||
const details = resolvePrSidebarDetailsAfterPhase2({
|
||||
fetched: fetchedDetails,
|
||||
prior: stateRef.current.data.details,
|
||||
pr: stateRef.current.data.pr
|
||||
})
|
||||
setState({ kind: 'ready', data: { ...stateRef.current.data, details } })
|
||||
},
|
||||
[buildDeps, branch, headSha, identity, worktreeId]
|
||||
)
|
||||
|
||||
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
|
||||
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
|
||||
const ensurePrSidebarDetails = useCallback(async () => {
|
||||
const current = stateRef.current
|
||||
if (current.kind !== 'ready' || current.data.details != null) {
|
||||
return
|
||||
}
|
||||
const deps = buildDeps()
|
||||
const loadIdentity = identity
|
||||
if (!deps || !branch || !loadIdentity) {
|
||||
if (!deps || !loadIdentity || stateIdentityRef.current !== loadIdentity) {
|
||||
return
|
||||
}
|
||||
const seq = loadSeqRef.current + 1
|
||||
loadSeqRef.current = seq
|
||||
stateIdentityRef.current = loadIdentity
|
||||
setState({ kind: 'loading' })
|
||||
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
|
||||
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
|
||||
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
|
||||
if (!shouldApplyResult(seq, loadSeqRef.current) || stateIdentityRef.current !== loadIdentity) {
|
||||
const prNumber = current.data.pr.number
|
||||
// A live phase-2 fetch for this PR is already in flight (its claim still owns
|
||||
// the latest details seq) — do not start a duplicate.
|
||||
const inFlight = detailsInFlightRef.current
|
||||
if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) {
|
||||
return
|
||||
}
|
||||
stateIdentityRef.current = loadIdentity
|
||||
setState(next)
|
||||
if (next.kind !== 'ready') {
|
||||
const detailsSeq = detailsSeqRef.current + 1
|
||||
detailsSeqRef.current = detailsSeq
|
||||
detailsInFlightRef.current = { seq: detailsSeq, prNumber }
|
||||
const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, prNumber)
|
||||
if (detailsInFlightRef.current?.seq === detailsSeq) {
|
||||
detailsInFlightRef.current = null
|
||||
}
|
||||
if (
|
||||
detailsSeq !== detailsSeqRef.current ||
|
||||
stateIdentityRef.current !== loadIdentity ||
|
||||
stateRef.current.kind !== 'ready' ||
|
||||
stateRef.current.data.pr.number !== prNumber
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Phase 2: lazy-load the heavy comments/body payload and merge it in, so it never
|
||||
// blocks the actionable PR UI. Re-check the seq so a newer load isn't clobbered.
|
||||
const details = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
|
||||
if (shouldApplyResult(seq, loadSeqRef.current) && stateIdentityRef.current === loadIdentity) {
|
||||
setState({ kind: 'ready', data: { ...next.data, details } })
|
||||
const details = resolvePrSidebarDetailsAfterPhase2({
|
||||
fetched: fetchedDetails,
|
||||
prior: stateRef.current.data.details,
|
||||
pr: stateRef.current.data.pr
|
||||
})
|
||||
setState({
|
||||
kind: 'ready',
|
||||
data: { ...stateRef.current.data, details }
|
||||
})
|
||||
}, [buildDeps, identity, worktreeId])
|
||||
|
||||
// Soft-refresh checks when HEAD advances on the same branch (post-commit).
|
||||
// Also restarts an in-flight phase-1 load so a mid-flight head advance is not
|
||||
// applied with a stale SHA (headShaRef would otherwise advance with no reload).
|
||||
useEffect(() => {
|
||||
if (headShaRef.current === headSha) {
|
||||
return
|
||||
}
|
||||
}, [buildDeps, branch, headSha, identity, worktreeId])
|
||||
headShaRef.current = headSha
|
||||
// Identity was just wiped (branch/worktree switch): stateRef still holds the
|
||||
// pre-wipe state in this effect flush, which would start a load flavored for
|
||||
// the OLD surface (e.g. heavy details on the Changes tab). Let the owning
|
||||
// surface's hidden-state effects drive the first load for the new identity.
|
||||
if (stateIdentityRef.current === null) {
|
||||
return
|
||||
}
|
||||
const current = stateRef.current
|
||||
if (!shouldSoftRefreshPrSidebarOnHeadChange(current.kind)) {
|
||||
return
|
||||
}
|
||||
void load({
|
||||
includeDetails: current.kind === 'ready' && current.data.details != null
|
||||
})
|
||||
}, [headSha, load])
|
||||
|
||||
const openPRSidebar = useCallback(() => {
|
||||
setShowPRSidebar(true)
|
||||
@@ -131,18 +298,26 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
|
||||
stateIdentityRef.current !== identity ||
|
||||
(state.kind !== 'ready' && state.kind !== 'loading')
|
||||
) {
|
||||
void load()
|
||||
void load({ includeDetails: true })
|
||||
} else if (state.kind === 'ready' && state.data.details == null) {
|
||||
void ensurePrSidebarDetails()
|
||||
}
|
||||
}, [identity, state.kind, load])
|
||||
}, [identity, state, load, ensurePrSidebarDetails])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
void load({ includeDetails: true })
|
||||
}, [load])
|
||||
|
||||
return {
|
||||
prSidebarState: state,
|
||||
prSidebarIsGithubRepo: isGithubRepo,
|
||||
prSidebarRepoProbeLoaded: repoProbeLoaded,
|
||||
showPRSidebar,
|
||||
setShowPRSidebar,
|
||||
openPRSidebar,
|
||||
retryPRSidebar: load,
|
||||
refetchPRSidebar: load
|
||||
retryPRSidebar: retry,
|
||||
refetchPRSidebar: load,
|
||||
ensurePrSidebarDetails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
@@ -26,7 +26,9 @@ type Props = {
|
||||
|
||||
// Headerless commit-history list. Extracted from the /history route so the hub's
|
||||
// History segment and the standalone route render the same body over one code path.
|
||||
export function MobileGitHistoryList({
|
||||
// Memoized: it stays mounted (hidden) while the Changes segment is active, and must
|
||||
// not re-reconcile its FlatList on every commit-message keystroke re-render.
|
||||
export const MobileGitHistoryList = memo(function MobileGitHistoryList({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
@@ -217,7 +219,7 @@ export function MobileGitHistoryList({
|
||||
contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-source-control-styles'
|
||||
import { MobileSourceControlPrChip } from './MobileSourceControlPrChip'
|
||||
import type { MobilePrChipSummary } from './mobile-pr-chip-summary'
|
||||
import { mobileConflictAbortLabel } from './mobile-source-control-conflict-abort'
|
||||
|
||||
type Props = {
|
||||
branchLabel: string
|
||||
@@ -12,7 +13,10 @@ type Props = {
|
||||
stagedCount: number
|
||||
branchCount: number
|
||||
conflictOperation: string | null
|
||||
// True while any serial git IO is in flight — disables Abort so ops don't race.
|
||||
conflictBusy: boolean
|
||||
// True only while abort-merge / abort-rebase itself is running (label accuracy).
|
||||
conflictAborting: boolean
|
||||
onAbortConflict: (operation: string) => void
|
||||
// The PR chip is shown only on repos with a hosted-review remote; null hides it.
|
||||
prChip: MobilePrChipSummary | null
|
||||
@@ -30,6 +34,7 @@ export function MobileSourceControlBranchCard({
|
||||
branchCount,
|
||||
conflictOperation,
|
||||
conflictBusy,
|
||||
conflictAborting,
|
||||
onAbortConflict,
|
||||
prChip,
|
||||
onOpenPr
|
||||
@@ -60,7 +65,7 @@ export function MobileSourceControlBranchCard({
|
||||
onPress={() => onAbortConflict(conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{conflictBusy ? 'Aborting…' : `Abort ${conflictOperation}`}
|
||||
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { ChevronLeft, RefreshCw, X } from 'lucide-react-native'
|
||||
import { ChevronLeft, ExternalLink, RefreshCw, X } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-source-control-styles'
|
||||
|
||||
@@ -9,6 +9,10 @@ type Props = {
|
||||
ioBusy: boolean
|
||||
onBack: () => void
|
||||
onRefresh: () => void
|
||||
// When set (PR segment ready with a host URL), show open-on-web flush-right of
|
||||
// the title so the control stays visible while the PR body scrolls.
|
||||
onOpenPrWeb?: () => void
|
||||
prNumber?: number | null
|
||||
}
|
||||
|
||||
export function MobileSourceControlHeader({
|
||||
@@ -16,7 +20,9 @@ export function MobileSourceControlHeader({
|
||||
worktreeLabel,
|
||||
ioBusy,
|
||||
onBack,
|
||||
onRefresh
|
||||
onRefresh,
|
||||
onOpenPrWeb,
|
||||
prNumber = null
|
||||
}: Props) {
|
||||
return (
|
||||
<View style={styles.topBar}>
|
||||
@@ -40,6 +46,21 @@ export function MobileSourceControlHeader({
|
||||
{worktreeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{onOpenPrWeb ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.refreshButton, pressed && styles.refreshButtonPressed]}
|
||||
onPress={onOpenPrWeb}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={
|
||||
prNumber != null
|
||||
? `Open pull request #${prNumber} on the web`
|
||||
: 'Open pull request on the web'
|
||||
}
|
||||
>
|
||||
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.refreshButton,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
@@ -14,8 +14,10 @@ import { styles } from './mobile-source-control-styles'
|
||||
import { hubStyles } from './mobile-source-control-hub-styles'
|
||||
import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
|
||||
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
|
||||
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
|
||||
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
|
||||
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
|
||||
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
|
||||
|
||||
export type MobileSourceControlPanelProps = {
|
||||
hostId: string
|
||||
@@ -43,8 +45,9 @@ export function MobileSourceControlPanel({
|
||||
onOpenedFileDiff
|
||||
}: MobileSourceControlPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
|
||||
// Track first visit so PR/History keep their fetch + scroll state across segment
|
||||
// switches without paying the mount cost until the user actually opens them.
|
||||
// Track first visit so Changes/History keep scroll state across segment switches
|
||||
// without paying the mount cost until the user actually opens them. PR unmounts
|
||||
// when inactive (WebViews / comment tree) but its controller state stays for the chip.
|
||||
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<SourceControlHubTab>>(
|
||||
() => new Set<SourceControlHubTab>([initialTab])
|
||||
)
|
||||
@@ -104,6 +107,7 @@ export function MobileSourceControlPanel({
|
||||
openingBranchPath,
|
||||
loadStatus,
|
||||
status,
|
||||
branchCompareResult,
|
||||
branchLabel,
|
||||
syncLabel,
|
||||
unstagedCount,
|
||||
@@ -118,8 +122,25 @@ export function MobileSourceControlPanel({
|
||||
// segment, so the chip's rollup can never disagree with the checks list it
|
||||
// links to. Branch + head come from the already-loaded git.status — no second
|
||||
// status read. The chip loads independently, so it never blocks the file list.
|
||||
const prBranch = status?.branch ?? null
|
||||
const prHeadSha = status?.head ?? null
|
||||
// Keep last-known identity across a transient status unload (disconnect /
|
||||
// failed refresh) so the controller does not wipe ready → hidden → cold start.
|
||||
// Head SHA matches the review path: status.head ?? branchCompare headOid.
|
||||
const lastPrBranchRef = useRef<string | null>(null)
|
||||
const lastPrHeadRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
lastPrBranchRef.current = null
|
||||
lastPrHeadRef.current = null
|
||||
}, [worktreeId])
|
||||
const statusBranch = status?.branch ?? null
|
||||
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
|
||||
if (statusBranch) {
|
||||
lastPrBranchRef.current = statusBranch
|
||||
}
|
||||
if (statusHead) {
|
||||
lastPrHeadRef.current = statusHead
|
||||
}
|
||||
const prBranch = statusBranch ?? lastPrBranchRef.current
|
||||
const prHeadSha = statusHead ?? lastPrHeadRef.current
|
||||
const prController = useMobilePrSidebarController({
|
||||
client,
|
||||
connState,
|
||||
@@ -130,11 +151,49 @@ export function MobileSourceControlPanel({
|
||||
const isHostedRepo = prController.prSidebarIsGithubRepo
|
||||
const prSidebarKind = prController.prSidebarState.kind
|
||||
const refetchPr = prController.refetchPRSidebar
|
||||
const ensurePrDetails = prController.ensurePrSidebarDetails
|
||||
// Refs so tab effects do not re-fire when headSha recreates load() (soft refresh).
|
||||
const refetchPrRef = useRef(refetchPr)
|
||||
refetchPrRef.current = refetchPr
|
||||
const ensurePrDetailsRef = useRef(ensurePrDetails)
|
||||
ensurePrDetailsRef.current = ensurePrDetails
|
||||
|
||||
// Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until
|
||||
// the Pull Request segment is open — opening SC to stage must not pull details.
|
||||
useEffect(() => {
|
||||
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
|
||||
refetchPr()
|
||||
if (activeTab === 'pr') {
|
||||
return
|
||||
}
|
||||
}, [prBranch, isHostedRepo, prSidebarKind, refetchPr])
|
||||
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
|
||||
void refetchPrRef.current({ includeDetails: false })
|
||||
}
|
||||
}, [activeTab, prBranch, isHostedRepo, prSidebarKind])
|
||||
|
||||
// Number of the ready PR whose phase-2 details are still missing (null when
|
||||
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
|
||||
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
|
||||
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
|
||||
const prDetailsMissingFor =
|
||||
prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null
|
||||
? prController.prSidebarState.data.pr.number
|
||||
: null
|
||||
|
||||
// PR segment: full load or phase-2 fill-in. Body only mounts while active.
|
||||
// Guarded (and keyed) on prBranch so a branch that arrives while the segment is
|
||||
// already open — e.g. mounted on a detached HEAD, then checkout — still loads;
|
||||
// kind stays 'hidden' through that transition, so kind alone can't re-fire this.
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
|
||||
return
|
||||
}
|
||||
if (prSidebarKind === 'hidden') {
|
||||
void refetchPrRef.current({ includeDetails: true })
|
||||
return
|
||||
}
|
||||
if (prDetailsMissingFor != null) {
|
||||
void ensurePrDetailsRef.current()
|
||||
}
|
||||
}, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
|
||||
|
||||
const prChip = useMemo(() => {
|
||||
if (!isHostedRepo) {
|
||||
@@ -147,21 +206,39 @@ export function MobileSourceControlPanel({
|
||||
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
|
||||
}, [isHostedRepo, prController.prSidebarState])
|
||||
|
||||
// Design: refresh the active segment's body work, plus git.status for the shared
|
||||
// branch card (counts/sync stay honest even while on History). Preserve ready
|
||||
// status on a failed refresh so PR chip identity is not wiped to hidden.
|
||||
const onRefresh = useCallback(() => {
|
||||
void loadStatus()
|
||||
// Chip + PR segment share the controller; always refresh when hosted so the
|
||||
// branch card stays in sync even while the Changes segment is active.
|
||||
if (isHostedRepo) {
|
||||
void refetchPr()
|
||||
}
|
||||
if (visitedTabs.has('history')) {
|
||||
void loadStatus({ preserveReadyOnFailure: true })
|
||||
if (activeTab === 'history') {
|
||||
setHistoryRefreshNonce((n) => n + 1)
|
||||
return
|
||||
}
|
||||
}, [isHostedRepo, loadStatus, refetchPr, visitedTabs])
|
||||
if (!isHostedRepo) {
|
||||
return
|
||||
}
|
||||
if (activeTab === 'pr') {
|
||||
void refetchPr({ includeDetails: true })
|
||||
return
|
||||
}
|
||||
// Changes: light chip refresh so the branch card stays current without comments.
|
||||
void refetchPr({ includeDetails: false })
|
||||
}, [activeTab, isHostedRepo, loadStatus, refetchPr])
|
||||
|
||||
// Embedded mode docks beside the terminal: close the dock instead of popping
|
||||
// a route, and skip the full-screen safe-area chrome (the dock column owns it).
|
||||
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
|
||||
// Chromeless PR body has no panel header — surface open-on-web on the hub chrome
|
||||
// while the Pull Request segment is active (same affordance as the old /pr route).
|
||||
const prWebUrl =
|
||||
activeTab === 'pr' &&
|
||||
prController.prSidebarState.kind === 'ready' &&
|
||||
prController.prSidebarState.data.pr.url
|
||||
? prController.prSidebarState.data.pr.url
|
||||
: null
|
||||
const prWebNumber =
|
||||
prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null
|
||||
const header = (
|
||||
<MobileSourceControlHeader
|
||||
embedded={embedded}
|
||||
@@ -169,6 +246,8 @@ export function MobileSourceControlPanel({
|
||||
ioBusy={ioBusy}
|
||||
onBack={onBack}
|
||||
onRefresh={onRefresh}
|
||||
onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined}
|
||||
prNumber={prWebNumber}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -206,8 +285,14 @@ export function MobileSourceControlPanel({
|
||||
|
||||
// History only needs the RPC client — do not block it behind git.status.
|
||||
// Changes/PR need status (branch, file list, head SHA), so they stay gated.
|
||||
const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
|
||||
const showHistory = activeTab === 'history' || visitedTabs.has('history')
|
||||
const showPr = ready && (activeTab === 'pr' || visitedTabs.has('pr'))
|
||||
// Why: only mount the PR body while its segment is active. Keep-mounting retained
|
||||
// Mermaid WebViews and re-rendered the comment tree on every commit keystroke.
|
||||
// Controller + chip state still live for instant re-open without a full cold start.
|
||||
const showPrBody = ready && activeTab === 'pr'
|
||||
const conflictOperation = status?.conflictOperation ?? null
|
||||
const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
|
||||
|
||||
return (
|
||||
<View ref={setRootRef} style={styles.container}>
|
||||
@@ -228,24 +313,25 @@ export function MobileSourceControlPanel({
|
||||
unstagedCount={unstagedCount}
|
||||
stagedCount={stagedCount}
|
||||
branchCount={branchEntries.length}
|
||||
conflictOperation={status?.conflictOperation ?? null}
|
||||
conflictOperation={conflictOperation}
|
||||
conflictBusy={busyAction !== null}
|
||||
conflictAborting={conflictAborting}
|
||||
onAbortConflict={(operation) => void abortConflictOperation(operation)}
|
||||
prChip={prChip}
|
||||
onOpenPr={openPrTab}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'changes' ? (
|
||||
ready ? (
|
||||
{showChanges ? (
|
||||
<View style={activeTab === 'changes' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
|
||||
<MobileSourceControlContent state={state} />
|
||||
) : (
|
||||
statusGate
|
||||
)
|
||||
</View>
|
||||
) : activeTab === 'changes' ? (
|
||||
statusGate
|
||||
) : null}
|
||||
|
||||
{showPr ? (
|
||||
<View style={activeTab === 'pr' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
|
||||
{showPrBody ? (
|
||||
<View style={hubStyles.tabBody}>
|
||||
<MobilePrViewPanelBody
|
||||
client={client}
|
||||
connState={connState}
|
||||
@@ -254,10 +340,10 @@ export function MobileSourceControlPanel({
|
||||
headSha={prHeadSha}
|
||||
gitStatus={status}
|
||||
isGithubRepo={isHostedRepo}
|
||||
branchContextLoaded={ready}
|
||||
// Gate on the probe too: isGithubRepo=false mid-probe must render as
|
||||
// loading, not flash "unavailable for this provider" (old /pr parity).
|
||||
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
|
||||
controller={prController}
|
||||
chromeless
|
||||
autoLoad={false}
|
||||
/>
|
||||
</View>
|
||||
) : activeTab === 'pr' ? (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isMobileConflictAborting,
|
||||
mobileConflictAbortLabel
|
||||
} from './mobile-source-control-conflict-abort'
|
||||
|
||||
describe('isMobileConflictAborting', () => {
|
||||
it('is true only for the matching abort action', () => {
|
||||
expect(isMobileConflictAborting('abort-merge', 'merge')).toBe(true)
|
||||
expect(isMobileConflictAborting('abort-rebase', 'rebase')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for other busy actions (stage/commit must not look like abort)', () => {
|
||||
expect(isMobileConflictAborting('stage-all', 'merge')).toBe(false)
|
||||
expect(isMobileConflictAborting('commit', 'rebase')).toBe(false)
|
||||
expect(isMobileConflictAborting(null, 'merge')).toBe(false)
|
||||
expect(isMobileConflictAborting('abort-merge', 'rebase')).toBe(false)
|
||||
expect(isMobileConflictAborting('abort-merge', 'unknown')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobileConflictAbortLabel', () => {
|
||||
it('shows Aborting only while abort is in flight', () => {
|
||||
expect(mobileConflictAbortLabel('merge', true)).toBe('Aborting…')
|
||||
expect(mobileConflictAbortLabel('merge', false)).toBe('Abort merge')
|
||||
expect(mobileConflictAbortLabel('rebase', false)).toBe('Abort rebase')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// Pure helpers for the branch-card conflict Abort control. Kept free of React so
|
||||
// the busy-label rule (abort-in-flight only) is unit-testable.
|
||||
|
||||
/** True while git.abortMerge / git.abortRebase is the active serial action. */
|
||||
export function isMobileConflictAborting(
|
||||
busyAction: string | null,
|
||||
conflictOperation: string | null
|
||||
): boolean {
|
||||
if (conflictOperation !== 'merge' && conflictOperation !== 'rebase') {
|
||||
return false
|
||||
}
|
||||
return busyAction === `abort-${conflictOperation}`
|
||||
}
|
||||
|
||||
/** Label for the Abort control — never says "Aborting…" for unrelated busy work. */
|
||||
export function mobileConflictAbortLabel(conflictOperation: string, aborting: boolean): string {
|
||||
return aborting ? 'Aborting…' : `Abort ${conflictOperation}`
|
||||
}
|
||||
Reference in New Issue
Block a user