feat(diff): add branch scope to context panel diff view

Show every change on the current branch relative to its base in the
Changed/Staged/Last turn dropdown. The base comes from the branch's
reflog record or an explicit per-branch user choice (persisted), never
a main/master guess; when git has no record the user picks a base once
from a searchable branch list.

- server: GET /api/git/branch-base (reflog-derived base),
  GET /api/git/range-files (name-status -z with rename/copy
  destination paths and -C copy detection)
- shared UI: optional getBranchBase/getGitRangeFiles runtime APIs
  with boundary parsing; persisted per-branch overrides keyed by
  runtime+directory+branch
- DiffView: branch scope with confirmed-unavailability coercion of
  persisted tabs (detached HEAD, default-branch checkout, metadata
  settled without a default), range-invalidated diff cache guarded
  against stale completions, bounded branch-metadata retry, read-only
  diff actions in branch scope; hidden in VS Code
- helper module branchDiffScope.ts with tests for coercion,
  availability, race conditions, and retry exhaustion
This commit is contained in:
Bohdan Triapitsyn
2026-08-22 01:10:19 +03:00
parent 9f7d839fc6
commit 0b01f5ae2d
24 changed files with 1582 additions and 38 deletions
+18
View File
@@ -157,6 +157,22 @@ export interface GetGitRangeDiffOptions {
contextLines?: number;
}
export interface GetGitRangeFilesOptions {
base: string;
head: string;
}
/** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */
export interface GitRangeFileEntry {
path: string;
status: string;
}
export interface GitBranchBaseResponse {
/** Null when git has no authoritative record of where the branch started. */
base: string | null;
}
export interface GitFileDiffResponse {
original: string;
modified: string;
@@ -466,6 +482,8 @@ export interface GitAPI {
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
getGitRangeFiles?(directory: string, options: GetGitRangeFilesOptions): Promise<GitRangeFileEntry[]>;
getBranchBase?(directory: string, branch: string): Promise<GitBranchBaseResponse>;
revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void>;
stageGitFile(directory: string, filePath: string): Promise<void>;
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
+18
View File
@@ -119,6 +119,24 @@ export async function getGitRangeDiff(
return gitHttp.getGitRangeDiff(directory, options);
}
export async function getGitRangeFiles(
directory: string,
options: import('./api/types').GetGitRangeFilesOptions
): Promise<import('./api/types').GitRangeFileEntry[]> {
const runtime = getRuntimeGit();
if (runtime?.getGitRangeFiles) return runtime.getGitRangeFiles(directory, options);
return gitHttp.getGitRangeFiles(directory, options);
}
export async function getBranchBase(
directory: string,
branch: string
): Promise<import('./api/types').GitBranchBaseResponse> {
const runtime = getRuntimeGit();
if (runtime?.getBranchBase) return runtime.getBranchBase(directory, branch);
return gitHttp.getBranchBase(directory, branch);
}
export async function revertGitFile(
directory: string,
filePath: string,
+46
View File
@@ -3,6 +3,7 @@ import type {
GitDiffResponse,
GetGitDiffOptions,
GetGitRangeDiffOptions,
GetGitRangeFilesOptions,
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
@@ -248,6 +249,51 @@ export async function getGitRangeDiff(
return response.json();
}
export async function getGitRangeFiles(
directory: string,
options: GetGitRangeFilesOptions
): Promise<import('./api/types').GitRangeFileEntry[]> {
const { base, head } = options;
if (!base || !head) {
throw new Error('base and head are required to fetch git range files');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/range-files`, directory, { base, head })
);
if (!response.ok) {
throw new Error(`Failed to get git range files: ${response.statusText}`);
}
const payload = (await response.json()) as { files?: unknown };
if (!Array.isArray(payload.files)) return [];
return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => {
if (!entry || typeof entry !== 'object') return false;
const candidate = entry as { path?: unknown; status?: unknown };
return typeof candidate.path === 'string' && typeof candidate.status === 'string';
});
}
export async function getBranchBase(
directory: string,
branch: string
): Promise<import('./api/types').GitBranchBaseResponse> {
if (!branch) {
throw new Error('branch is required to get branch base');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/branch-base`, directory, { branch })
);
if (!response.ok) {
throw new Error(`Failed to get branch base: ${response.statusText}`);
}
return response.json();
}
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
const { path, staged } = options;
if (!path) {
+7
View File
@@ -1360,6 +1360,13 @@ export const dict = {
'diffView.scope.changed': 'Geändert',
'diffView.scope.staged': 'Staged',
'diffView.scope.lastTurn': 'Letzter Zug',
'diffView.scope.branch': 'Branch',
'diffView.branch.resolvingBase': 'Basis-Branch wird ermittelt...',
'diffView.branch.noBaseTitle': 'Kein Basis-Branch',
'diffView.branch.noBaseDescription': 'Git enthält keinen Eintrag, wo dieser Branch entstanden ist. Wähle einen Basis-Branch für den Vergleich.',
'diffView.branch.loadError': 'Branch-Änderungen konnten nicht geladen werden',
'diffView.branch.loadingFiles': 'Branch-Änderungen werden geladen...',
'diffView.branch.empty': 'Keine Änderungen in diesem Branch gegenüber {base}',
'diffView.scope.selectorAria': 'Änderungsmodus auswählen',
'diffView.actions.retry': 'Erneut versuchen',
'diffView.actions.renderAnyway': 'Trotzdem rendern',
+7
View File
@@ -1517,6 +1517,13 @@ export const dict = {
'diffView.scope.changed': 'Changed',
'diffView.scope.staged': 'Staged',
'diffView.scope.lastTurn': 'Last turn',
'diffView.scope.branch': 'Branch',
'diffView.branch.resolvingBase': 'Detecting base branch...',
'diffView.branch.noBaseTitle': 'No base branch',
'diffView.branch.noBaseDescription': 'Git has no record of where this branch started. Choose a base branch to compare against.',
'diffView.branch.loadError': 'Failed to load branch changes',
'diffView.branch.loadingFiles': 'Loading branch changes...',
'diffView.branch.empty': 'No changes on this branch relative to {base}',
'diffView.scope.selectorAria': 'Select change mode',
'diffView.actions.retry': 'Retry',
'diffView.actions.renderAnyway': 'Render anyway',
+7
View File
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Cambiados",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Último turno",
"diffView.scope.branch": "Rama",
"diffView.branch.resolvingBase": "Detectando rama base...",
"diffView.branch.noBaseTitle": "Sin rama base",
"diffView.branch.noBaseDescription": "Git no tiene registro de dónde surgió esta rama. Elige una rama base para comparar.",
"diffView.branch.loadError": "No se pudieron cargar los cambios de la rama",
"diffView.branch.loadingFiles": "Cargando cambios de la rama...",
"diffView.branch.empty": "No hay cambios en esta rama respecto a {base}",
"diffView.scope.selectorAria": "Seleccionar modo de cambios",
"diffView.actions.retry": "Volver a intentar",
"diffView.actions.renderAnyway": "Renderizar de todos modos",
+7
View File
@@ -1282,6 +1282,13 @@ export const dict = {
"diffView.scope.changed": "Modifiés",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Dernier tour",
"diffView.scope.branch": "Branche",
"diffView.branch.resolvingBase": "Détection de la branche de base...",
"diffView.branch.noBaseTitle": "Aucune branche de base",
"diffView.branch.noBaseDescription": "Git ne conserve aucune trace de la branche dorigine de cette branche. Choisissez une branche de base pour la comparaison.",
"diffView.branch.loadError": "Échec du chargement des modifications de la branche",
"diffView.branch.loadingFiles": "Chargement des modifications de la branche...",
"diffView.branch.empty": "Aucune modification sur cette branche par rapport à {base}",
"diffView.scope.selectorAria": "Sélectionner le mode de changements",
'diffView.actions.retry': 'Réessayer',
'diffView.actions.renderAnyway': 'Afficher quand même',
+7
View File
@@ -1513,6 +1513,13 @@ export const dict: Record<I18nKey, string> = {
'diffView.scope.changed': '変更済み',
'diffView.scope.staged': 'ステージ済み',
'diffView.scope.lastTurn': '最後のターン',
'diffView.scope.branch': 'ブランチ',
'diffView.branch.resolvingBase': 'ベースブランチを検出中...',
'diffView.branch.noBaseTitle': 'ベースブランチがありません',
'diffView.branch.noBaseDescription': 'このブランチがどこから作られたかの記録がGitにありません。比較するベースブランチを選択してください。',
'diffView.branch.loadError': 'ブランチの変更を読み込めませんでした',
'diffView.branch.loadingFiles': 'ブランチの変更を読み込み中...',
'diffView.branch.empty': 'このブランチには{base}に対する変更はありません',
'diffView.scope.selectorAria': '変更モードを選択',
'diffView.actions.retry': '再試行',
'diffView.actions.renderAnyway': 'とにかくレンダリング',
+7
View File
@@ -1519,6 +1519,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Changed",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "마지막 턴",
"diffView.scope.branch": "브랜치",
"diffView.branch.resolvingBase": "베이스 브랜치 감지 중...",
"diffView.branch.noBaseTitle": "베이스 브랜치 없음",
"diffView.branch.noBaseDescription": "이 브랜치가 어디서 시작되었는지 Git에 기록이 없습니다. 비교할 베이스 브랜치를 선택하세요.",
"diffView.branch.loadError": "브랜치 변경 사항을 불러오지 못했습니다",
"diffView.branch.loadingFiles": "브랜치 변경 사항 불러오는 중...",
"diffView.branch.empty": "이 브랜치에는 {base}에 대한 변경 사항이 없습니다",
"diffView.scope.selectorAria": "변경 모드 선택",
'diffView.actions.retry': '다시 시도',
'diffView.actions.renderAnyway': '그래도 렌더링',
+7
View File
@@ -1795,6 +1795,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Zmienione",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Ostatnia tura",
"diffView.scope.branch": "Gałąź",
"diffView.branch.resolvingBase": "Wykrywanie gałęzi bazowej...",
"diffView.branch.noBaseTitle": "Brak gałęzi bazowej",
"diffView.branch.noBaseDescription": "Git nie zapisuje, od której gałęzi ta gałąź powstała. Wybierz gałąź bazową do porównania.",
"diffView.branch.loadError": "Nie udało się wczytać zmian gałęzi",
"diffView.branch.loadingFiles": "Wczytywanie zmian gałęzi...",
"diffView.branch.empty": "Brak zmian w tej gałęzi względem {base}",
"diffView.scope.selectorAria": "Wybierz tryb zmian",
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Alteradas",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Último turno",
"diffView.scope.branch": "Branch",
"diffView.branch.resolvingBase": "Detectando branch base...",
"diffView.branch.noBaseTitle": "Sem branch base",
"diffView.branch.noBaseDescription": "O Git não tem registro de onde este branch começou. Escolha um branch base para comparar.",
"diffView.branch.loadError": "Falha ao carregar as alterações do branch",
"diffView.branch.loadingFiles": "Carregando alterações do branch...",
"diffView.branch.empty": "Nenhuma alteração neste branch em relação a {base}",
"diffView.scope.selectorAria": "Selecionar modo de alterações",
"diffView.actions.retry": "Tentar novamente",
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
+7
View File
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "Змінені",
"diffView.scope.staged": "Індексовані",
"diffView.scope.lastTurn": "Останній хід",
"diffView.scope.branch": "Гілка",
"diffView.branch.resolvingBase": "Визначаємо базову гілку...",
"diffView.branch.noBaseTitle": "Немає базової гілки",
"diffView.branch.noBaseDescription": "Git не зберігає, від якої гілки почалася ця гілка. Виберіть базову гілку для порівняння.",
"diffView.branch.loadError": "Не вдалося завантажити зміни гілки",
"diffView.branch.loadingFiles": "Завантаження змін гілки...",
"diffView.branch.empty": "Немає змін у цій гілці відносно {base}",
"diffView.scope.selectorAria": "Вибрати режим змін",
"diffView.actions.retry": "Повторити спробу",
"diffView.actions.renderAnyway": "Все одно відрендерити",
@@ -1483,6 +1483,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "已更改",
"diffView.scope.staged": "已暂存",
"diffView.scope.lastTurn": "上一轮",
"diffView.scope.branch": "分支",
"diffView.branch.resolvingBase": "正在检测基础分支...",
"diffView.branch.noBaseTitle": "没有基础分支",
"diffView.branch.noBaseDescription": "Git 中没有记录此分支的起点。请选择一个基础分支进行比较。",
"diffView.branch.loadError": "加载分支更改失败",
"diffView.branch.loadingFiles": "正在加载分支更改...",
"diffView.branch.empty": "此分支相对于 {base} 没有更改",
"diffView.scope.selectorAria": "选择更改模式",
'diffView.actions.retry': '重试',
'diffView.actions.renderAnyway': '仍然渲染',
@@ -1493,6 +1493,13 @@ export const dict: Record<I18nKey, string> = {
"diffView.scope.changed": "已變更",
"diffView.scope.staged": "已暫存",
"diffView.scope.lastTurn": "上一輪",
"diffView.scope.branch": "分支",
"diffView.branch.resolvingBase": "正在偵測基礎分支...",
"diffView.branch.noBaseTitle": "沒有基礎分支",
"diffView.branch.noBaseDescription": "Git 中沒有記錄此分支的起點。請選擇基礎分支進行比較。",
"diffView.branch.loadError": "載入分支變更失敗",
"diffView.branch.loadingFiles": "正在載入分支變更...",
"diffView.branch.empty": "此分支相對於 {base} 沒有變更",
"diffView.scope.selectorAria": "選擇變更模式",
'diffView.actions.retry': '重試',
'diffView.actions.renderAnyway': '仍然渲染',