mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Clarify upstream divergence stats for rebased branches (#16358)
* Clarify upstream divergence stats for rebased branches When a branch is rebased, it still tracks the pre-rebase upstream while comparing against the new base. Move upstream arrows to the head line to prevent them being confused with compare-base counts. * Show upstream divergence stats independent of compare base Measure HEAD against upstream regardless of compare-base state, so divergence indicators stay visible even when comparison is missing, loading, or failed. Also use cross-platform temp paths in tests. * Show commit counts against compare base, not upstream Upstream divergence (↑/↓ against tracking branch) was confusing for rebased branches — the counts appeared beside the base ref but measured against the upstream branch. Show only the compare base count instead, on the line that names it. * Report branch divergence in both directions Rebased branches are typically ahead AND behind their base; a single count hides this case. Use symmetric range with --left-right --count to capture both directions efficiently, then expose commitsBehind in the UI alongside commitsAhead. * Use semantic names for i18n keys and template variables Rename hash-based translation keys to descriptive identifiers and replace generic value0/value1 placeholders with semantic variable names like `count` and `ref`. Improves code maintainability and makes translation strings self-documenting.
This commit is contained in:
@@ -8,7 +8,7 @@ import type { GitRuntimeOptions } from '../git-runtime-options'
|
||||
import { resolveWorktreeBaseCommitOid } from '../worktree-base-ref-probe'
|
||||
import { loadBranchChanges } from './branch-change-entries'
|
||||
import {
|
||||
countAheadCommits,
|
||||
countCompareDivergence,
|
||||
resolveCompareRef,
|
||||
resolveMergeBase,
|
||||
resolveRefOid
|
||||
@@ -65,6 +65,7 @@ export async function getBranchCompare(
|
||||
// Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken.
|
||||
summary.changedFiles = 0
|
||||
summary.commitsAhead = 0
|
||||
summary.commitsBehind = 0
|
||||
summary.status = 'ready'
|
||||
return { summary, entries: [] }
|
||||
}
|
||||
@@ -94,12 +95,13 @@ export async function getBranchCompare(
|
||||
}
|
||||
|
||||
try {
|
||||
const [entries, commitsAhead] = await Promise.all([
|
||||
const [entries, divergence] = await Promise.all([
|
||||
loadBranchChanges(worktreePath, mergeBase, headOid, options),
|
||||
countAheadCommits(worktreePath, baseOid, headOid, options)
|
||||
countCompareDivergence(worktreePath, baseOid, headOid, options)
|
||||
])
|
||||
summary.changedFiles = entries.length
|
||||
summary.commitsAhead = commitsAhead
|
||||
summary.commitsAhead = divergence.ahead
|
||||
summary.commitsBehind = divergence.behind
|
||||
summary.status = 'ready'
|
||||
return { summary, entries }
|
||||
} catch (error) {
|
||||
|
||||
@@ -40,14 +40,21 @@ export async function resolveMergeBase(
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
export async function countAheadCommits(
|
||||
// Why: `--left-right --count` on the symmetric range answers both directions in one
|
||||
// rev-list walk; a rebased branch is usually ahead AND behind its base.
|
||||
export async function countCompareDivergence(
|
||||
worktreePath: string,
|
||||
baseOid: string,
|
||||
headOid: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<number> {
|
||||
const { stdout } = await gitExecFileAsync(['rev-list', '--count', `${baseOid}..${headOid}`], {
|
||||
...gitOptionsForWorktree(worktreePath, options)
|
||||
})
|
||||
return Number.parseInt(stdout.trim(), 10) || 0
|
||||
): Promise<{ ahead: number; behind: number }> {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-list', '--left-right', '--count', `${baseOid}...${headOid}`],
|
||||
{ ...gitOptionsForWorktree(worktreePath, options) }
|
||||
)
|
||||
const [behind = '', ahead = ''] = stdout.trim().split(/\s+/)
|
||||
return {
|
||||
ahead: Number.parseInt(ahead, 10) || 0,
|
||||
behind: Number.parseInt(behind, 10) || 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('getBranchCompare', () => {
|
||||
nameStatus: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n',
|
||||
numstat:
|
||||
'10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n',
|
||||
revList: '7\n'
|
||||
revList: '4\t7\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
@@ -137,6 +137,7 @@ describe('getBranchCompare', () => {
|
||||
mergeBase: 'merge-base-oid',
|
||||
changedFiles: 3,
|
||||
commitsAhead: 7,
|
||||
commitsBehind: 4,
|
||||
status: 'ready'
|
||||
})
|
||||
expect(result.entries).toEqual([
|
||||
@@ -146,6 +147,30 @@ describe('getBranchCompare', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Why: a rebased branch is ahead AND behind; the symmetric --left-right range is the
|
||||
// only form that reports both, and its columns are left=behind, right=ahead.
|
||||
it('counts both divergence directions from one symmetric rev-list', async () => {
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
|
||||
headOid: 'head-oid\n',
|
||||
baseOid: 'base-oid\n',
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: '',
|
||||
numstat: '',
|
||||
revList: '12\t33\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-list', '--left-right', '--count', 'base-oid...head-oid'],
|
||||
expect.objectContaining({ cwd: '/repo' })
|
||||
)
|
||||
expect(result.summary.commitsAhead).toBe(33)
|
||||
expect(result.summary.commitsBehind).toBe(12)
|
||||
})
|
||||
|
||||
it('returns invalid-base when the compare ref does not resolve', async () => {
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
@@ -198,6 +223,7 @@ describe('getBranchCompare', () => {
|
||||
mergeBase: null,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
})
|
||||
expect(result.entries).toEqual([])
|
||||
@@ -228,7 +254,7 @@ describe('getBranchCompare', () => {
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: 'M\tdocs/日本語/sample.md\n',
|
||||
numstat: '2\t1\tdocs/日本語/sample.md\n',
|
||||
revList: '1\n'
|
||||
revList: '0\t1\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
@@ -282,7 +308,7 @@ describe('getBranchCompare', () => {
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: '',
|
||||
numstat: '',
|
||||
revList: '0\n'
|
||||
revList: '0\t0\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
@@ -303,7 +329,7 @@ describe('getBranchCompare', () => {
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: '',
|
||||
numstat: '',
|
||||
revList: '0\n'
|
||||
revList: '0\t0\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'refs/remotes/origin/main')
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('relay branchCompare', () => {
|
||||
return Promise.resolve({ stdout: 'merge-base\n', stderr: '' })
|
||||
}
|
||||
if (args[0] === 'rev-list') {
|
||||
return Promise.resolve({ stdout: '1\n', stderr: '' })
|
||||
return Promise.resolve({ stdout: '2\t1\n', stderr: '' })
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
@@ -52,7 +52,13 @@ describe('relay branchCompare', () => {
|
||||
changes.resolve([{ path: 'file.ts' }])
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
summary: { compareRef: 'feature', changedFiles: 1, commitsAhead: 1, status: 'ready' }
|
||||
summary: {
|
||||
compareRef: 'feature',
|
||||
changedFiles: 1,
|
||||
commitsAhead: 1,
|
||||
commitsBehind: 2,
|
||||
status: 'ready'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -182,6 +182,7 @@ export async function branchCompare(
|
||||
// compare error makes the source-control panel look broken.
|
||||
summary.changedFiles = 0
|
||||
summary.commitsAhead = 0
|
||||
summary.commitsBehind = 0
|
||||
summary.status = 'ready'
|
||||
return { summary, entries: [] }
|
||||
}
|
||||
@@ -215,10 +216,12 @@ export async function branchCompare(
|
||||
try {
|
||||
const [entries, { stdout: countOut }] = await Promise.all([
|
||||
loadBranchChanges(mergeBase, headOid),
|
||||
git(['rev-list', '--count', `${baseOid}..${headOid}`], worktreePath)
|
||||
git(['rev-list', '--left-right', '--count', `${baseOid}...${headOid}`], worktreePath)
|
||||
])
|
||||
summary.changedFiles = entries.length
|
||||
summary.commitsAhead = Number.parseInt(countOut.trim(), 10) || 0
|
||||
const [behindOut = '', aheadOut = ''] = countOut.trim().split(/\s+/)
|
||||
summary.commitsAhead = Number.parseInt(aheadOut, 10) || 0
|
||||
summary.commitsBehind = Number.parseInt(behindOut, 10) || 0
|
||||
summary.status = 'ready'
|
||||
return { summary, entries }
|
||||
} catch (error) {
|
||||
|
||||
+63
-25
@@ -208,6 +208,44 @@ describe('SourceControlBranchContextRow', () => {
|
||||
expect(markup).toContain('Retry')
|
||||
})
|
||||
|
||||
// A branch rebased onto origin/main still tracks its pre-rebase remote branch,
|
||||
// so upstream ↑↓ answered a question nobody asked here. One count, against the
|
||||
// base ref, on the line that names it.
|
||||
it('counts commits against the compare base, on the base line', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SourceControlBranchContextRow
|
||||
summary={{ ...readySummary, baseRef: 'refs/remotes/origin/main', commitsAhead: 5 }}
|
||||
compareBaseRef={null}
|
||||
headDisplay={{ kind: 'branch', branchName: 'feature/rebased' }}
|
||||
onChangeBaseRef={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
// Anchor on the base-ref button, not on 'origin/main' — the group's
|
||||
// head→base aria-label repeats the base ref at the top of the markup.
|
||||
const baseIndex = markup.indexOf('aria-label="Change base ref:')
|
||||
expect(markup.indexOf('↑5')).toBeGreaterThan(baseIndex)
|
||||
expect(markup).toContain('aria-label="5 commits ahead of origin/main"')
|
||||
// Nothing claims the branch is behind — that count is not available.
|
||||
expect(markup).not.toContain('↓')
|
||||
})
|
||||
|
||||
it('shows the count on the base line when there is no head identity', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SourceControlBranchContextRow
|
||||
summary={{ ...readySummary, commitsAhead: 3 }}
|
||||
compareBaseRef={null}
|
||||
headDisplay={null}
|
||||
onChangeBaseRef={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('↑3')
|
||||
expect(markup).toContain('aria-label="3 commits ahead of origin/FRONT-192-ZisVoucherStrip"')
|
||||
})
|
||||
|
||||
it('renders a compact external review link when a manual URL is available', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SourceControlBranchContextRow
|
||||
@@ -319,35 +357,35 @@ describe('SourceControlBranchContextRow branch line total', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Lines measure the branch's work, commits measure the comparison, so each sits
|
||||
// on the line that names its subject. Adjacency is what made them read as one
|
||||
// number in the first place.
|
||||
it('puts the chip on the head line, ahead of the base line and its commit count', () => {
|
||||
const markup = renderWithLineTotal(
|
||||
{ added: 8259, removed: 670, mergeBase: 'base' },
|
||||
{
|
||||
...readySummary,
|
||||
commitsAhead: 2
|
||||
}
|
||||
)
|
||||
const headIndex = markup.indexOf('data-testid="source-control-head-identity"')
|
||||
const chipIndex = markup.indexOf('data-testid="source-control-branch-line-total"')
|
||||
const aheadIndex = markup.indexOf('↑2')
|
||||
const reviewIndex = markup.indexOf('aria-label="Open review page in browser"')
|
||||
|
||||
expect(headIndex).toBeGreaterThan(-1)
|
||||
expect(chipIndex).toBeGreaterThan(headIndex)
|
||||
expect(aheadIndex).toBeGreaterThan(chipIndex)
|
||||
expect(reviewIndex).toBeGreaterThan(aheadIndex)
|
||||
})
|
||||
|
||||
it('keeps the ahead count out of the line-total colors', () => {
|
||||
// Regression: with no upstream divergence this is the only count left, so
|
||||
// dropping it left a pushed, rebased branch showing no divergence at all.
|
||||
it('counts commits against the compare base on the base line', () => {
|
||||
const markup = renderWithLineTotal(
|
||||
{ added: 8259, removed: 670, mergeBase: 'base' },
|
||||
{ ...readySummary, commitsAhead: 2 }
|
||||
)
|
||||
// The `↑2` span must carry the muted class, not added-green — two adjacent
|
||||
// green numbers counting different units is the bug this guards.
|
||||
|
||||
expect(markup).toContain('↑2')
|
||||
expect(markup).toContain('2 commits ahead of origin/FRONT-192-ZisVoucherStrip')
|
||||
// It belongs to the base line, after the base-ref button.
|
||||
expect(markup.indexOf('↑2')).toBeGreaterThan(markup.indexOf('aria-label="Change base ref:'))
|
||||
// Nothing claims the branch is behind its base — that count does not exist.
|
||||
expect(markup).not.toContain('↓')
|
||||
})
|
||||
|
||||
it('keeps the commit count out of the line-total colors', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SourceControlBranchContextRow
|
||||
summary={{ ...readySummary, commitsAhead: 2 }}
|
||||
compareBaseRef={null}
|
||||
headDisplay={{ kind: 'branch', branchName: 'feature/line-total' }}
|
||||
branchLineTotal={{ added: 8259, removed: 670, mergeBase: 'base' }}
|
||||
onChangeBaseRef={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
// Two adjacent green numbers counting different units is the bug this guards.
|
||||
const aheadSpan = markup.slice(
|
||||
markup.lastIndexOf('<span', markup.indexOf('↑2')),
|
||||
markup.indexOf('↑2')
|
||||
|
||||
+64
-99
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSourceControlBranchContextStats,
|
||||
buildSourceControlCompareBaseStats,
|
||||
formatSourceControlRefLabel,
|
||||
resolveSourceControlDisplayedBaseRef,
|
||||
shouldShowSourceControlBranchContextChrome,
|
||||
@@ -62,112 +62,77 @@ describe('source-control branch context stats', () => {
|
||||
expect(shouldShowSourceControlBranchContextChrome(readySummary, null, null)).toBe(true)
|
||||
})
|
||||
|
||||
it('renders upstream ahead and behind counts against the tracking branch', () => {
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary: { ...readySummary, commitsAhead: 0 },
|
||||
baseRef: 'origin/main',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 2,
|
||||
behind: 1
|
||||
}
|
||||
})
|
||||
expect(stats.map((stat) => stat.label)).toEqual(['↑2', '↓1'])
|
||||
expect(stats[0]?.title).toBe('2 commits ahead of origin/feature')
|
||||
expect(stats[1]?.title).toBe('1 commit behind origin/feature')
|
||||
it('counts commits ahead of the compare base', () => {
|
||||
const stats = buildSourceControlCompareBaseStats(readySummary, 'refs/remotes/origin/main')
|
||||
expect(stats.map((stat) => stat.label)).toEqual(['\u21913'])
|
||||
expect(stats[0]?.title).toBe('3 commits ahead of origin/main')
|
||||
})
|
||||
|
||||
it('shows both upstream and compare ahead when counts match but targets differ', () => {
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary: readySummary,
|
||||
baseRef: 'origin/main',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 3,
|
||||
behind: 0
|
||||
}
|
||||
})
|
||||
expect(stats.map((stat) => ({ key: stat.key, label: stat.label, title: stat.title }))).toEqual([
|
||||
{
|
||||
key: 'upstream-ahead',
|
||||
label: '↑3',
|
||||
title: '3 commits ahead of origin/feature'
|
||||
},
|
||||
{
|
||||
key: 'compare-ahead',
|
||||
label: '↑3',
|
||||
title: '3 commits ahead of origin/main'
|
||||
}
|
||||
it('names the base ref, never the tracked branch', () => {
|
||||
// The rebase case: upstream still points at the pre-rebase branch. This count
|
||||
// is against the compare base and says so, so the two cannot be conflated.
|
||||
const stats = buildSourceControlCompareBaseStats(
|
||||
{ ...readySummary, commitsAhead: 36 },
|
||||
'origin/main'
|
||||
)
|
||||
expect(stats[0]?.label).toBe('\u219136')
|
||||
expect(stats[0]?.title).toBe('36 commits ahead of origin/main')
|
||||
})
|
||||
|
||||
it('singularizes a single commit', () => {
|
||||
const stats = buildSourceControlCompareBaseStats(
|
||||
{ ...readySummary, commitsAhead: 1, commitsBehind: 1 },
|
||||
'origin/main'
|
||||
)
|
||||
expect(stats.map((stat) => stat.title)).toEqual([
|
||||
'1 commit ahead of origin/main',
|
||||
'1 commit behind origin/main'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows branch-compare ahead when it differs from upstream ahead', () => {
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary: readySummary,
|
||||
baseRef: 'origin/main',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
})
|
||||
expect(stats.map((stat) => stat.label)).toEqual(['↑1', '↑3'])
|
||||
expect(stats[0]?.title).toBe('1 commit ahead of origin/feature')
|
||||
expect(stats[1]?.title).toBe('3 commits ahead of origin/main')
|
||||
// The case the row exists for: a rebased branch is ahead of its base and behind it.
|
||||
it('counts both directions against the compare base', () => {
|
||||
const stats = buildSourceControlCompareBaseStats(
|
||||
{ ...readySummary, commitsAhead: 33, commitsBehind: 12 },
|
||||
'refs/remotes/origin/main'
|
||||
)
|
||||
expect(stats.map((stat) => stat.label)).toEqual(['\u219133', '\u219312'])
|
||||
expect(stats[1]?.title).toBe('12 commits behind origin/main')
|
||||
})
|
||||
|
||||
it('dedupes branch-compare ahead only when target and count match upstream', () => {
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary: { ...readySummary, commitsAhead: 2 },
|
||||
baseRef: 'origin/main',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 2,
|
||||
behind: 0
|
||||
}
|
||||
})
|
||||
expect(stats.map((stat) => stat.label)).toEqual(['↑2'])
|
||||
expect(stats[0]?.key).toBe('upstream-ahead')
|
||||
expect(stats[0]?.title).toBe('2 commits ahead of origin/main')
|
||||
})
|
||||
|
||||
it('falls back to branch-compare ahead without upstream', () => {
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary: readySummary,
|
||||
baseRef: 'origin/main'
|
||||
})
|
||||
expect(stats.map((stat) => stat.label)).toEqual(['↑3'])
|
||||
expect(stats[0]?.title).toBe('3 commits ahead of origin/main')
|
||||
})
|
||||
|
||||
it('formats namespaced base refs in stat titles', () => {
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary: readySummary,
|
||||
baseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
expect(stats[0]?.title).toBe('3 commits ahead of origin/main')
|
||||
})
|
||||
|
||||
it('falls back to a generic upstream label when upstreamName is missing', () => {
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary: { ...readySummary, commitsAhead: 0 },
|
||||
baseRef: 'origin/main',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
|
||||
})
|
||||
expect(stats[0]?.title).toBe('2 commits ahead of upstream')
|
||||
})
|
||||
|
||||
it('returns no stats when branch is even with base', () => {
|
||||
it('drops each direction independently when it is zero or unreported', () => {
|
||||
expect(
|
||||
buildSourceControlBranchContextStats({
|
||||
summary: { ...readySummary, commitsAhead: 0 },
|
||||
baseRef: 'origin/main',
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
|
||||
})
|
||||
buildSourceControlCompareBaseStats(
|
||||
{ ...readySummary, commitsAhead: 0, commitsBehind: 4 },
|
||||
'origin/main'
|
||||
).map((stat) => stat.label)
|
||||
).toEqual(['\u21934'])
|
||||
// An older remote host omits commitsBehind entirely; ahead must still render.
|
||||
expect(
|
||||
buildSourceControlCompareBaseStats(
|
||||
{ ...readySummary, commitsBehind: undefined },
|
||||
'origin/main'
|
||||
).map((stat) => stat.label)
|
||||
).toEqual(['\u21913'])
|
||||
})
|
||||
|
||||
it('shows nothing without a ready summary or a positive count', () => {
|
||||
expect(buildSourceControlCompareBaseStats(null, 'origin/main')).toEqual([])
|
||||
expect(buildSourceControlCompareBaseStats(undefined, 'origin/main')).toEqual([])
|
||||
expect(
|
||||
buildSourceControlCompareBaseStats({ ...readySummary, status: 'loading' }, 'origin/main')
|
||||
).toEqual([])
|
||||
expect(
|
||||
buildSourceControlCompareBaseStats(
|
||||
{ ...readySummary, commitsAhead: 0, commitsBehind: 0 },
|
||||
'origin/main'
|
||||
)
|
||||
).toEqual([])
|
||||
expect(
|
||||
buildSourceControlCompareBaseStats(
|
||||
{ ...readySummary, commitsAhead: undefined, commitsBehind: undefined },
|
||||
'origin/main'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SourceControlBranchSection } from './source-control/listing/branch-section'
|
||||
import type { GitBranchCompareSummary } from '../../../../shared/git-diff-compare-types'
|
||||
|
||||
const readySummary: GitBranchCompareSummary = {
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
baseOid: 'base',
|
||||
compareRef: 'feature',
|
||||
headOid: 'head',
|
||||
mergeBase: 'base',
|
||||
changedFiles: 4,
|
||||
commitsAhead: 5,
|
||||
status: 'ready'
|
||||
}
|
||||
|
||||
function render(
|
||||
summary: GitBranchCompareSummary,
|
||||
fileCount = 4,
|
||||
totalBranchEntryCount = fileCount
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<SourceControlBranchSection
|
||||
branchSummary={summary}
|
||||
filteredBranchEntries={Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${index}.ts`,
|
||||
status: 'modified'
|
||||
}))}
|
||||
totalBranchEntryCount={totalBranchEntryCount}
|
||||
// Collapsed: the heading is the subject here, not the virtualized list.
|
||||
collapsedSections={new Set(['branch'])}
|
||||
toggleSection={vi.fn()}
|
||||
sourceControlViewMode="list"
|
||||
visibleBranchTreeRows={[]}
|
||||
fileListScrollElement={null}
|
||||
collapsedTreeDirs={new Set()}
|
||||
toggleTreeDir={vi.fn()}
|
||||
currentWorktreeId="wt-1"
|
||||
worktreePath={join(tmpdir(), 'wt-1')}
|
||||
revealInExplorer={vi.fn()}
|
||||
activeConnectionId={null}
|
||||
openCommittedDiff={vi.fn()}
|
||||
openBranchAllDiffs={vi.fn()}
|
||||
diffCommentCountByPath={new Map()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SourceControlBranchSection heading', () => {
|
||||
// The heading counts files that differ from the compare base, not every file
|
||||
// the branch ever touched — a rebased branch makes those read alike.
|
||||
it('names the compare base on the count, leaving the heading text alone', () => {
|
||||
const markup = render(readySummary)
|
||||
|
||||
expect(markup).toContain('Committed on Branch')
|
||||
expect(markup).toContain('title="4 files changed vs origin/main"')
|
||||
// No aria-label: inside the section toggle button it would rewrite the
|
||||
// button's accessible name ("Committed on Branch 4 files changed vs …").
|
||||
expect(markup).not.toContain('aria-label="4 files changed vs origin/main"')
|
||||
})
|
||||
|
||||
it('uses the singular count label for one file', () => {
|
||||
expect(render(readySummary, 1)).toContain('title="1 file changed vs origin/main"')
|
||||
})
|
||||
|
||||
// A narrowing filter changes what the number means: 2 matching files are not
|
||||
// "2 files changed vs origin/main", so the label must go silent.
|
||||
it('leaves the count unlabelled while a filter narrows the list', () => {
|
||||
expect(render(readySummary, 2, 4)).not.toContain('files changed vs')
|
||||
})
|
||||
|
||||
it('leaves the count unlabelled when the summary has no base ref', () => {
|
||||
const markup = render({ ...readySummary, baseRef: ' ' })
|
||||
|
||||
expect(markup).toContain('Committed on Branch')
|
||||
expect(markup).not.toContain('files changed vs')
|
||||
})
|
||||
})
|
||||
+26
-1
@@ -10,12 +10,14 @@ import type { SourceControlTreeNode } from '../../source-control-tree'
|
||||
import type { SourceControlRowOpenEvent } from './split-open'
|
||||
import { BranchEntryRow } from './branch-entry-row'
|
||||
import { SectionHeader } from './section-header'
|
||||
import { formatSourceControlRefLabel } from '../panel/branch-context-stats'
|
||||
import { SourceControlBranchTreeDirectoryRow } from './tree-directory-rows'
|
||||
import { SourceControlVirtualFileList } from './virtual-file-list'
|
||||
|
||||
export function SourceControlBranchSection({
|
||||
branchSummary,
|
||||
filteredBranchEntries,
|
||||
totalBranchEntryCount,
|
||||
collapsedSections,
|
||||
toggleSection,
|
||||
sourceControlViewMode,
|
||||
@@ -33,6 +35,7 @@ export function SourceControlBranchSection({
|
||||
}: {
|
||||
branchSummary: GitBranchCompareSummary
|
||||
filteredBranchEntries: GitBranchChangeEntry[]
|
||||
totalBranchEntryCount: number
|
||||
collapsedSections: Set<string>
|
||||
toggleSection: (section: string) => void
|
||||
sourceControlViewMode: SourceControlViewMode
|
||||
@@ -52,6 +55,27 @@ export function SourceControlBranchSection({
|
||||
) => void
|
||||
diffCommentCountByPath: Map<string, number>
|
||||
}): React.JSX.Element {
|
||||
const baseRef = branchSummary.baseRef?.trim()
|
||||
const fileCount = filteredBranchEntries.length
|
||||
// Why: the heading counts files that differ from the compare base, not every
|
||||
// file the branch ever touched — a rebased branch makes the two read alike.
|
||||
// A narrowing filter changes what the number means, so the label goes silent
|
||||
// rather than claim the filtered count is the branch total.
|
||||
const countTitle =
|
||||
baseRef && fileCount === totalBranchEntryCount
|
||||
? fileCount === 1
|
||||
? translate(
|
||||
'auto.components.right.sidebar.SourceControl.branchFilesChangedVsBaseOne',
|
||||
'1 file changed vs {{ref}}',
|
||||
{ ref: formatSourceControlRefLabel(baseRef) }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.SourceControl.branchFilesChangedVsBaseOther',
|
||||
'{{count}} files changed vs {{ref}}',
|
||||
{ count: fileCount, ref: formatSourceControlRefLabel(baseRef) }
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
@@ -59,7 +83,8 @@ export function SourceControlBranchSection({
|
||||
'auto.components.right.sidebar.SourceControl.d7ae61269b',
|
||||
'Committed on Branch'
|
||||
)}
|
||||
count={filteredBranchEntries.length}
|
||||
count={fileCount}
|
||||
countTitle={countTitle}
|
||||
isCollapsed={collapsedSections.has('branch')}
|
||||
onToggle={() => toggleSection('branch')}
|
||||
actions={
|
||||
|
||||
@@ -6,6 +6,7 @@ import { translate } from '@/i18n/i18n'
|
||||
export function SectionHeader({
|
||||
label,
|
||||
count,
|
||||
countTitle,
|
||||
conflictCount = 0,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
@@ -13,6 +14,8 @@ export function SectionHeader({
|
||||
}: {
|
||||
label: string
|
||||
count: number
|
||||
/** Spells out what the count measures — e.g. files changed against a compare base. */
|
||||
countTitle?: string
|
||||
conflictCount?: number
|
||||
isCollapsed: boolean
|
||||
onToggle: () => void
|
||||
@@ -31,7 +34,11 @@ export function SectionHeader({
|
||||
className={cn('size-3.5 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
<span className="text-[11px] font-medium tabular-nums">{count}</span>
|
||||
{/* Why: no aria-label here — inside the toggle button it would rewrite the
|
||||
button's accessible name; the explanation stays a hover-only title. */}
|
||||
<span className="text-[11px] font-medium tabular-nums" title={countTitle}>
|
||||
{count}
|
||||
</span>
|
||||
{conflictCount > 0 && (
|
||||
<span className="text-[11px] font-medium text-destructive/80">
|
||||
· {conflictCount}{' '}
|
||||
|
||||
+23
-41
@@ -1,10 +1,7 @@
|
||||
import React from 'react'
|
||||
import { ExternalLink, Loader2, RefreshCw } from 'lucide-react'
|
||||
import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type {
|
||||
GitBranchLineTotal,
|
||||
GitUpstreamStatus
|
||||
} from '../../../../../../shared/git-status-types'
|
||||
import type { GitBranchLineTotal } from '../../../../../../shared/git-status-types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -13,9 +10,10 @@ import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-dis
|
||||
import { SourceControlHeaderIconButton } from './header-icon-button'
|
||||
import { SourceControlBranchLineTotalChip } from './branch-line-total-chip'
|
||||
import {
|
||||
buildSourceControlBranchContextStats,
|
||||
buildSourceControlCompareBaseStats,
|
||||
formatSourceControlRefLabel,
|
||||
resolveSourceControlDisplayedBaseRef
|
||||
resolveSourceControlDisplayedBaseRef,
|
||||
type SourceControlBranchContextStat
|
||||
} from './branch-context-stats'
|
||||
|
||||
function BaseRefButton({
|
||||
@@ -47,24 +45,20 @@ function BaseRefButton({
|
||||
)
|
||||
}
|
||||
|
||||
function ContextStat({
|
||||
stat
|
||||
}: {
|
||||
stat: ReturnType<typeof buildSourceControlBranchContextStats>[number]
|
||||
}): React.JSX.Element {
|
||||
const className = cn(
|
||||
'shrink-0 tabular-nums text-muted-foreground',
|
||||
stat.tone === 'muted' && 'text-muted-foreground/70'
|
||||
)
|
||||
|
||||
if (!stat.title) {
|
||||
return <span className={className}>{stat.label}</span>
|
||||
}
|
||||
|
||||
function ContextStat({ stat }: { stat: SourceControlBranchContextStat }): React.JSX.Element {
|
||||
// Why: aria-label on an unfocusable span is never announced, so the ref this
|
||||
// count measures against would exist only for sighted hover users. tabIndex also
|
||||
// lets keyboard users open the tooltip, like HeadIdentity.
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={className}>{stat.label}</span>
|
||||
<span
|
||||
className="shrink-0 rounded-sm tabular-nums text-muted-foreground/70 outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
tabIndex={0}
|
||||
aria-label={stat.title}
|
||||
>
|
||||
{stat.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{stat.title}
|
||||
@@ -286,7 +280,6 @@ export function SourceControlBranchContextRow({
|
||||
summary,
|
||||
compareBaseRef,
|
||||
headDisplay = null,
|
||||
upstreamStatus,
|
||||
manualReviewUrl,
|
||||
branchLineTotal,
|
||||
onChangeBaseRef,
|
||||
@@ -295,7 +288,6 @@ export function SourceControlBranchContextRow({
|
||||
summary: GitBranchCompareSummary | null
|
||||
compareBaseRef: string | null
|
||||
headDisplay?: WorktreeGitIdentityDisplay | null
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
manualReviewUrl?: string | null
|
||||
branchLineTotal?: GitBranchLineTotal | null
|
||||
onChangeBaseRef: () => void
|
||||
@@ -392,23 +384,8 @@ export function SourceControlBranchContextRow({
|
||||
)
|
||||
}
|
||||
|
||||
const stats = buildSourceControlBranchContextStats({
|
||||
summary,
|
||||
baseRef: displayedBaseRef,
|
||||
upstreamStatus
|
||||
})
|
||||
|
||||
const trailing = (
|
||||
<>
|
||||
{stats.length > 0 ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5">
|
||||
{stats.map((stat) => (
|
||||
<ContextStat key={stat.key} stat={stat} />
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
<ManualReviewLinkButton url={manualReviewUrl} />
|
||||
</>
|
||||
const compareStatNodes = buildSourceControlCompareBaseStats(summary, displayedBaseRef).map(
|
||||
(stat) => <ContextStat key={stat.key} stat={stat} />
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -419,7 +396,12 @@ export function SourceControlBranchContextRow({
|
||||
baseLabel={baseLabel}
|
||||
onChangeBaseRef={onChangeBaseRef}
|
||||
changeBaseTitle={changeBaseTitle}
|
||||
trailing={trailing}
|
||||
trailing={
|
||||
<>
|
||||
{compareStatNodes}
|
||||
<ManualReviewLinkButton url={manualReviewUrl} />
|
||||
</>
|
||||
}
|
||||
headTrailing={<SourceControlBranchLineTotalChip branchLineTotal={branchLineTotal} />}
|
||||
/>
|
||||
</CompareFlowGroup>
|
||||
|
||||
+38
-80
@@ -1,45 +1,42 @@
|
||||
import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitUpstreamStatus } from '../../../../../../shared/git-status-types'
|
||||
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function formatAheadOfBaseTitle(count: number, baseRef: string): string {
|
||||
function formatAheadOfTitle(count: number, ref: string): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'auto.components.right.sidebar.SourceControl.f9b2441bb6',
|
||||
'1 commit ahead of {{value0}}',
|
||||
{ value0: baseRef }
|
||||
'auto.components.right.sidebar.SourceControl.compareBaseCommitsAheadOne',
|
||||
'1 commit ahead of {{ref}}',
|
||||
{ ref }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.SourceControl.b715ef615b',
|
||||
'{{value0}} commits ahead of {{value1}}',
|
||||
{ value0: count, value1: baseRef }
|
||||
'auto.components.right.sidebar.SourceControl.compareBaseCommitsAheadOther',
|
||||
'{{count}} commits ahead of {{ref}}',
|
||||
{ count, ref }
|
||||
)
|
||||
}
|
||||
|
||||
function formatBehindBaseTitle(count: number, baseRef: string): string {
|
||||
function formatBehindOfTitle(count: number, ref: string): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'auto.components.right.sidebar.SourceControl.c1a8f3e204',
|
||||
'1 commit behind {{value0}}',
|
||||
{ value0: baseRef }
|
||||
'auto.components.right.sidebar.SourceControl.compareBaseCommitsBehindOne',
|
||||
'1 commit behind {{ref}}',
|
||||
{ ref }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.SourceControl.d2b9g4f315',
|
||||
'{{value0}} commits behind {{value1}}',
|
||||
{ value0: count, value1: baseRef }
|
||||
'auto.components.right.sidebar.SourceControl.compareBaseCommitsBehindOther',
|
||||
'{{count}} commits behind {{ref}}',
|
||||
{ count, ref }
|
||||
)
|
||||
}
|
||||
|
||||
// Why: ahead/behind carry no color of their own. Green and red are reserved for
|
||||
// the line-total chip that sits beside them — an `↑1` in added-green next to
|
||||
// `+1,114` reads as one quantity when they count different things (commits vs
|
||||
// lines). The ↑/↓ glyph already carries direction.
|
||||
// Why: the counts carry no color of their own. Green and red are reserved for the
|
||||
// line-total chip beside them — an `↑1` in added-green next to `+1,114` reads as one
|
||||
// quantity when they count different things (commits vs lines).
|
||||
export type SourceControlBranchContextStat = {
|
||||
key: string
|
||||
label: string
|
||||
title?: string
|
||||
tone: 'default' | 'muted'
|
||||
title: string
|
||||
}
|
||||
|
||||
export function resolveSourceControlDisplayedBaseRef(
|
||||
@@ -83,71 +80,32 @@ export function shouldShowSourceControlBranchContextChrome(
|
||||
return shouldShowSourceControlBranchContextRow(summary, compareBaseRef) || headDisplay != null
|
||||
}
|
||||
|
||||
function resolveUpstreamDisplayLabel(upstreamStatus: GitUpstreamStatus): string {
|
||||
const named = upstreamStatus.upstreamName?.trim()
|
||||
if (named) {
|
||||
return formatSourceControlRefLabel(named)
|
||||
}
|
||||
return translate('auto.components.right.sidebar.SourceControl.f3a1b8c204', 'upstream')
|
||||
}
|
||||
|
||||
export function buildSourceControlBranchContextStats({
|
||||
summary,
|
||||
baseRef,
|
||||
upstreamStatus
|
||||
}: {
|
||||
summary: GitBranchCompareSummary
|
||||
// Why: both directions, on the line that names the ref they measure. Ahead alone hid
|
||||
// the case this row exists for — a rebased branch that has also fallen behind its base.
|
||||
export function buildSourceControlCompareBaseStats(
|
||||
summary: GitBranchCompareSummary | null | undefined,
|
||||
baseRef: string
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
}): SourceControlBranchContextStat[] {
|
||||
if (summary.status !== 'ready') {
|
||||
): SourceControlBranchContextStat[] {
|
||||
if (summary?.status !== 'ready') {
|
||||
return []
|
||||
}
|
||||
|
||||
const stats: SourceControlBranchContextStat[] = []
|
||||
const baseLabel = formatSourceControlRefLabel(baseRef)
|
||||
const hasUpstream = Boolean(upstreamStatus?.hasUpstream)
|
||||
const upstreamLabel =
|
||||
hasUpstream && upstreamStatus ? resolveUpstreamDisplayLabel(upstreamStatus) : null
|
||||
|
||||
if (hasUpstream && upstreamStatus && upstreamLabel != null) {
|
||||
if (upstreamStatus.ahead > 0) {
|
||||
stats.push({
|
||||
key: 'upstream-ahead',
|
||||
label: `↑${upstreamStatus.ahead}`,
|
||||
title: formatAheadOfBaseTitle(upstreamStatus.ahead, upstreamLabel),
|
||||
tone: 'muted'
|
||||
})
|
||||
}
|
||||
if (upstreamStatus.behind > 0) {
|
||||
stats.push({
|
||||
key: 'upstream-behind',
|
||||
label: `↓${upstreamStatus.behind}`,
|
||||
title: formatBehindBaseTitle(upstreamStatus.behind, upstreamLabel),
|
||||
tone: 'muted'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const stats: SourceControlBranchContextStat[] = []
|
||||
const commitsAhead = summary.commitsAhead
|
||||
if (typeof commitsAhead === 'number' && commitsAhead > 0) {
|
||||
// Why: only collapse compare-ahead into upstream-ahead when both describe the
|
||||
// same ref and count — equal numbers against different targets must both show.
|
||||
const sameTargetAsUpstream =
|
||||
hasUpstream &&
|
||||
upstreamStatus != null &&
|
||||
upstreamLabel != null &&
|
||||
upstreamLabel === baseLabel &&
|
||||
commitsAhead === upstreamStatus.ahead
|
||||
if (!sameTargetAsUpstream) {
|
||||
stats.push({
|
||||
key: 'compare-ahead',
|
||||
label: `↑${commitsAhead}`,
|
||||
title: formatAheadOfBaseTitle(commitsAhead, baseLabel),
|
||||
tone: 'muted'
|
||||
})
|
||||
}
|
||||
stats.push({
|
||||
key: 'compare-ahead',
|
||||
label: `↑${commitsAhead}`,
|
||||
title: formatAheadOfTitle(commitsAhead, baseLabel)
|
||||
})
|
||||
}
|
||||
const commitsBehind = summary.commitsBehind
|
||||
if (typeof commitsBehind === 'number' && commitsBehind > 0) {
|
||||
stats.push({
|
||||
key: 'compare-behind',
|
||||
label: `↓${commitsBehind}`,
|
||||
title: formatBehindOfTitle(commitsBehind, baseLabel)
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { GitPullRequestArrow, Loader2, Search, X } from 'lucide-react'
|
||||
import type { GitBranchCompareSummary } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type {
|
||||
GitBranchLineTotal,
|
||||
GitUpstreamStatus
|
||||
} from '../../../../../../shared/git-status-types'
|
||||
import type { GitBranchLineTotal } from '../../../../../../shared/git-status-types'
|
||||
import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-types'
|
||||
import type { HostedReviewInfo } from '../../../../../../shared/hosted-review'
|
||||
import type { PrimaryAction } from '../../source-control-primary-action'
|
||||
@@ -40,7 +37,6 @@ type SourceControlHeaderToolbarProps = {
|
||||
branchSummary: GitBranchCompareSummary | null
|
||||
compareBaseRef: string | null
|
||||
headDisplay?: WorktreeGitIdentityDisplay | null
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
manualReviewUrl?: string | null
|
||||
branchLineTotal?: GitBranchLineTotal | null
|
||||
}
|
||||
@@ -147,7 +143,6 @@ export function SourceControlHeaderToolbar({
|
||||
branchSummary,
|
||||
compareBaseRef,
|
||||
headDisplay = null,
|
||||
upstreamStatus,
|
||||
manualReviewUrl,
|
||||
branchLineTotal
|
||||
}: SourceControlHeaderToolbarProps): React.JSX.Element {
|
||||
@@ -294,7 +289,6 @@ export function SourceControlHeaderToolbar({
|
||||
summary={branchSummary}
|
||||
compareBaseRef={compareBaseRef}
|
||||
headDisplay={headDisplay}
|
||||
upstreamStatus={upstreamStatus}
|
||||
manualReviewUrl={manualReviewUrl}
|
||||
branchLineTotal={branchLineTotal}
|
||||
onChangeBaseRef={onChangeBaseRef}
|
||||
|
||||
@@ -189,6 +189,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) {
|
||||
<SourceControlBranchSection
|
||||
branchSummary={branchSummary}
|
||||
filteredBranchEntries={filteredBranchEntries}
|
||||
totalBranchEntryCount={branchEntries.length}
|
||||
collapsedSections={collapsedSections}
|
||||
toggleSection={toggleSection}
|
||||
sourceControlViewMode={sourceControlViewMode}
|
||||
|
||||
@@ -40,7 +40,6 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) {
|
||||
openHostedReviewInChecks,
|
||||
prGenerating,
|
||||
refreshBranchCompare,
|
||||
remoteStatus,
|
||||
selectedKeys,
|
||||
setBaseRefDialogOpen,
|
||||
setDiffCommentsExpanded,
|
||||
@@ -84,7 +83,6 @@ export function SourceControlPanelReady(props: SourceControlPanelReadyProps) {
|
||||
branchLineTotal={branchLineTotal}
|
||||
compareBaseRef={compareBaseRef}
|
||||
headDisplay={gitIdentityDisplay}
|
||||
upstreamStatus={remoteStatus}
|
||||
manualReviewUrl={manualReviewUrl}
|
||||
/>
|
||||
|
||||
|
||||
@@ -11862,6 +11862,8 @@
|
||||
"7a09d7f9d2": "base",
|
||||
"383cf92c73": "tree",
|
||||
"d7ae61269b": "Committed on Branch",
|
||||
"branchFilesChangedVsBaseOne": "1 file changed vs {{ref}}",
|
||||
"branchFilesChangedVsBaseOther": "{{count}} files changed vs {{ref}}",
|
||||
"48a003c1b1": "Staged Changes",
|
||||
"d4ef4bafc5": "Changes",
|
||||
"522f44dce5": "Untracked Files",
|
||||
@@ -11936,10 +11938,10 @@
|
||||
"b3c8f1a902": "Filter files by name",
|
||||
"d4f8c2a901": "Clear and close filter",
|
||||
"e8a1c4b203": "vs",
|
||||
"f9b2441bb6": "1 commit ahead of {{value0}}",
|
||||
"b715ef615b": "{{value0}} commits ahead of {{value1}}",
|
||||
"c1a8f3e204": "1 commit behind {{value0}}",
|
||||
"d2b9g4f315": "{{value0}} commits behind {{value1}}",
|
||||
"compareBaseCommitsAheadOne": "1 commit ahead of {{ref}}",
|
||||
"compareBaseCommitsAheadOther": "{{count}} commits ahead of {{ref}}",
|
||||
"compareBaseCommitsBehindOne": "1 commit behind {{ref}}",
|
||||
"compareBaseCommitsBehindOther": "{{count}} commits behind {{ref}}",
|
||||
"4b4a7de138": "Open review page in browser",
|
||||
"createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR.",
|
||||
"pushRecovery": {
|
||||
|
||||
@@ -10589,6 +10589,8 @@
|
||||
"7a09d7f9d2": "base",
|
||||
"383cf92c73": "árbol",
|
||||
"d7ae61269b": "Commits en la rama",
|
||||
"branchFilesChangedVsBaseOne": "1 archivo cambiado respecto a {{ref}}",
|
||||
"branchFilesChangedVsBaseOther": "{{count}} archivos cambiados respecto a {{ref}}",
|
||||
"48a003c1b1": "Cambios preparados",
|
||||
"d4ef4bafc5": "Cambios",
|
||||
"522f44dce5": "Archivos sin seguimiento",
|
||||
@@ -10663,10 +10665,10 @@
|
||||
"b3c8f1a902": "Filtrar archivos por nombre",
|
||||
"d4f8c2a901": "Limpiar y cerrar filtro",
|
||||
"e8a1c4b203": "vs",
|
||||
"f9b2441bb6": "1 commit por delante de {{value0}}",
|
||||
"b715ef615b": "{{value0}} commits por delante de {{value1}}",
|
||||
"c1a8f3e204": "1 commit por detrás de {{value0}}",
|
||||
"d2b9g4f315": "{{value0}} commits por detrás de {{value1}}",
|
||||
"compareBaseCommitsAheadOne": "1 commit por delante de {{ref}}",
|
||||
"compareBaseCommitsAheadOther": "{{count}} commits por delante de {{ref}}",
|
||||
"compareBaseCommitsBehindOne": "1 commit por detrás de {{ref}}",
|
||||
"compareBaseCommitsBehindOther": "{{count}} commits por detrás de {{ref}}",
|
||||
"4b4a7de138": "Abrir página de revisión en el navegador",
|
||||
"createPrIntentCommitBlockedSummary": "Commit bloqueado: {{value0}} Corrige el issue y reintenta crear PR.",
|
||||
"pushRecovery": {
|
||||
|
||||
@@ -10589,6 +10589,8 @@
|
||||
"7a09d7f9d2": "ベース",
|
||||
"383cf92c73": "木",
|
||||
"d7ae61269b": "コミット先のブランチ",
|
||||
"branchFilesChangedVsBaseOne": "{{ref}} と比べて 1 ファイルが変更されています",
|
||||
"branchFilesChangedVsBaseOther": "{{ref}} と比べて {{count}} ファイルが変更されています",
|
||||
"48a003c1b1": "ステージ済みの変更",
|
||||
"d4ef4bafc5": "変更点",
|
||||
"522f44dce5": "追跡されていないファイル",
|
||||
@@ -10663,10 +10665,10 @@
|
||||
"b3c8f1a902": "ファイル名でフィルター",
|
||||
"d4f8c2a901": "フィルターをクリアして閉じる",
|
||||
"e8a1c4b203": "vs",
|
||||
"f9b2441bb6": "{{value0}} より 1 コミット進んでいます",
|
||||
"b715ef615b": "{{value1}}より{{value0}}コミット進んでいます",
|
||||
"c1a8f3e204": "{{value0}} より 1 コミット遅れています",
|
||||
"d2b9g4f315": "{{value1}}より{{value0}}コミット遅れています",
|
||||
"compareBaseCommitsAheadOne": "{{ref}} より 1 コミット進んでいます",
|
||||
"compareBaseCommitsAheadOther": "{{ref}}より{{count}}コミット進んでいます",
|
||||
"compareBaseCommitsBehindOne": "{{ref}} より 1 コミット遅れています",
|
||||
"compareBaseCommitsBehindOther": "{{ref}}より{{count}}コミット遅れています",
|
||||
"4b4a7de138": "ブラウザでレビューページを開く",
|
||||
"createPrIntentCommitBlockedSummary": "コミットがブロックされました:{{value0}} Issue を修正して、PR 作成を再試行してください。",
|
||||
"pushRecovery": {
|
||||
|
||||
@@ -10610,6 +10610,8 @@
|
||||
"7a09d7f9d2": "베이스",
|
||||
"383cf92c73": "트리",
|
||||
"d7ae61269b": "브랜치에 Commit 됨",
|
||||
"branchFilesChangedVsBaseOne": "{{ref}} 대비 파일 1개 변경됨",
|
||||
"branchFilesChangedVsBaseOther": "{{ref}} 대비 파일 {{count}}개 변경됨",
|
||||
"48a003c1b1": "스테이징된 변경 사항",
|
||||
"d4ef4bafc5": "변경 사항",
|
||||
"522f44dce5": "추적되지 않은 파일",
|
||||
@@ -10684,10 +10686,10 @@
|
||||
"b3c8f1a902": "파일 이름으로 필터링",
|
||||
"d4f8c2a901": "필터 지우고 닫기",
|
||||
"e8a1c4b203": "vs",
|
||||
"f9b2441bb6": "{{value0}}보다 1 commit 앞서 있음",
|
||||
"b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음",
|
||||
"c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐",
|
||||
"d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐",
|
||||
"compareBaseCommitsAheadOne": "{{ref}}보다 1 commit 앞서 있음",
|
||||
"compareBaseCommitsAheadOther": "{{ref}}보다 {{count}} commits 앞서 있음",
|
||||
"compareBaseCommitsBehindOne": "{{ref}}보다 1 commit 뒤처짐",
|
||||
"compareBaseCommitsBehindOther": "{{ref}}보다 {{count}} commits 뒤처짐",
|
||||
"4b4a7de138": "브라우저에서 검토 페이지 열기",
|
||||
"createPrIntentCommitBlockedSummary": "커밋이 차단됨: {{value0}} 이슈를 수정한 후 PR 만들기를 다시 시도하세요.",
|
||||
"pushRecovery": {
|
||||
|
||||
@@ -10647,6 +10647,8 @@
|
||||
"7a09d7f9d2": "根据",
|
||||
"383cf92c73": "树",
|
||||
"d7ae61269b": "已提交的更改",
|
||||
"branchFilesChangedVsBaseOne": "与 {{ref}} 相比更改了 1 个文件",
|
||||
"branchFilesChangedVsBaseOther": "与 {{ref}} 相比更改了 {{count}} 个文件",
|
||||
"48a003c1b1": "已暂存的更改",
|
||||
"d4ef4bafc5": "更改",
|
||||
"522f44dce5": "未跟踪文件",
|
||||
@@ -10721,10 +10723,10 @@
|
||||
"b3c8f1a902": "按文件名筛选",
|
||||
"d4f8c2a901": "清除并关闭筛选器",
|
||||
"e8a1c4b203": "vs",
|
||||
"f9b2441bb6": "领先 {{value0}} 1 个提交",
|
||||
"b715ef615b": "领先 {{value1}} {{value0}} 个提交",
|
||||
"c1a8f3e204": "落后 {{value0}} 1 个提交",
|
||||
"d2b9g4f315": "落后 {{value1}} {{value0}} 个提交",
|
||||
"compareBaseCommitsAheadOne": "领先 {{ref}} 1 个提交",
|
||||
"compareBaseCommitsAheadOther": "领先 {{ref}} {{count}} 个提交",
|
||||
"compareBaseCommitsBehindOne": "落后 {{ref}} 1 个提交",
|
||||
"compareBaseCommitsBehindOther": "落后 {{ref}} {{count}} 个提交",
|
||||
"4b4a7de138": "在浏览器中打开审查页面",
|
||||
"createPrIntentCommitBlockedSummary": "提交被阻止:{{value0}} 修复问题后重试创建 PR。",
|
||||
"pushRecovery": {
|
||||
|
||||
@@ -20,6 +20,7 @@ export type GitBranchCompareSummary = {
|
||||
mergeBase: string | null
|
||||
changedFiles: number
|
||||
commitsAhead?: number
|
||||
commitsBehind?: number
|
||||
status: 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'loading' | 'error'
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TestInfo } from '@stablyai/playwright-test'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady, getActiveTabId } from './helpers/store'
|
||||
import {
|
||||
@@ -19,6 +19,25 @@ import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connecti
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
|
||||
|
||||
async function terminalTailContains(page: Page, marker: string): Promise<boolean> {
|
||||
return page.evaluate((expected) => {
|
||||
const tabId = window.__store?.getState().activeTabId
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : undefined
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const buffer = pane?.terminal?.buffer?.active
|
||||
if (!buffer) {
|
||||
return false
|
||||
}
|
||||
const firstRow = Math.max(0, buffer.length - 200)
|
||||
for (let row = buffer.length - 1; row >= firstRow; row -= 1) {
|
||||
if (buffer.getLine(row)?.translateToString(true).includes(expected) === true) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, marker)
|
||||
}
|
||||
|
||||
test.use({
|
||||
seedTestRepo: false,
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
|
||||
@@ -60,11 +79,11 @@ test.describe('SSH terminal hidden view parking', () => {
|
||||
`for i in $(seq 1 200); do echo "${marker}_$i:"; done\r`
|
||||
)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 20_000), {
|
||||
.poll(() => terminalTailContains(orcaPage, `${marker}_200:`), {
|
||||
timeout: 30_000,
|
||||
message: 'SSH marker output did not render before parking'
|
||||
})
|
||||
.toContain(`${marker}_200:`)
|
||||
.toBe(true)
|
||||
// Why the pad: ~3000 × ~60B ≈ 180KB pushes the early markers past the
|
||||
// relay's 100KiB rolling replay buffer while staying inside main's
|
||||
// ~5k-row headless model — so a revealed `${marker}_1:` can only have
|
||||
@@ -72,14 +91,14 @@ test.describe('SSH terminal hidden view parking', () => {
|
||||
await sendToTerminal(
|
||||
orcaPage,
|
||||
sshPtyId,
|
||||
`for i in $(seq 1 3000); do echo "PAD_$i:0123456789012345678901234567890123456789"; done; echo "${marker}_PAD_DONE:"\r`
|
||||
`for i in $(seq 1 3000); do echo "PAD_$i:0123456789012345678901234567890123456789"; done; printf '%s%s\\n' "${marker}" "_PAD_DONE:"\r`
|
||||
)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 20_000), {
|
||||
.poll(() => terminalTailContains(orcaPage, `${marker}_PAD_DONE:`), {
|
||||
timeout: 60_000,
|
||||
message: 'SSH pad output did not finish before parking'
|
||||
})
|
||||
.toContain(`${marker}_PAD_DONE:`)
|
||||
.toBe(true)
|
||||
|
||||
await parkHiddenTabBehindDecoy(orcaPage, remote.worktreeId, sshTabId, {
|
||||
parkDelayMs: PARKING_DELAY_MS
|
||||
@@ -94,11 +113,11 @@ test.describe('SSH terminal hidden view parking', () => {
|
||||
}, sshTabId)
|
||||
await waitForActiveTerminalManager(orcaPage, 60_000)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 20_000), {
|
||||
.poll(() => terminalTailContains(orcaPage, `${marker}_PAD_DONE:`), {
|
||||
timeout: 60_000,
|
||||
message: 'revealed SSH tab did not restore the final pad line'
|
||||
})
|
||||
.toContain(`${marker}_PAD_DONE:`)
|
||||
.toBe(true)
|
||||
// Depth proof: `${marker}_1:` predates >100KiB of later output, so its
|
||||
// presence after reveal proves the headless-model paint restored
|
||||
// scrollback the relay replay cannot hold.
|
||||
|
||||
Reference in New Issue
Block a user