feat: Redesign review tab with phase selection, commit navigation, and consolidated toolbar

- Add BranchManager.listCommits() and diffCommit() for commit-level navigation
- Add getPhaseReviewCommits and getCommitDiff tRPC procedures
- New ReviewHeader: consolidated toolbar with phase selector pills, branch info,
  stats, integrated preview controls, and approve/reject actions
- New CommitNav: horizontal commit strip with "All changes" + individual commits,
  each showing hash, message, and change stats
- Slim down ReviewSidebar: file list only with dimming for out-of-scope files
  when viewing a single commit
- ReviewTab orchestrates all pieces in a single bordered card layout
This commit is contained in:
Lukas May
2026-03-05 10:20:43 +01:00
parent f91ed5ab2d
commit c58e0ea77e
12 changed files with 739 additions and 225 deletions

View File

@@ -6,7 +6,7 @@
* a worktree to be checked out.
*/
import type { MergeResult } from './types.js';
import type { MergeResult, BranchCommit } from './types.js';
export interface BranchManager {
/**
@@ -45,4 +45,15 @@ export interface BranchManager {
* since local branches may not include all remote branches.
*/
remoteBranchExists(repoPath: string, branch: string): Promise<boolean>;
/**
* List commits that headBranch has but baseBranch doesn't.
* Used for commit-level navigation in code review.
*/
listCommits(repoPath: string, baseBranch: string, headBranch: string): Promise<BranchCommit[]>;
/**
* Get the raw unified diff for a single commit.
*/
diffCommit(repoPath: string, commitHash: string): Promise<string>;
}

View File

@@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js';
import type { MergeResult } from './types.js';
import type { MergeResult, BranchCommit } from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('branch-manager');
@@ -116,4 +116,28 @@ export class SimpleGitBranchManager implements BranchManager {
return false;
}
}
async listCommits(repoPath: string, baseBranch: string, headBranch: string): Promise<BranchCommit[]> {
const git = simpleGit(repoPath);
const logResult = await git.log({ from: baseBranch, to: headBranch, '--stat': null });
return logResult.all.map((entry) => {
const diffStat = entry.diff;
return {
hash: entry.hash,
shortHash: entry.hash.slice(0, 7),
message: entry.message,
author: entry.author_name,
date: entry.date,
filesChanged: diffStat?.files?.length ?? 0,
insertions: diffStat?.insertions ?? 0,
deletions: diffStat?.deletions ?? 0,
};
});
}
async diffCommit(repoPath: string, commitHash: string): Promise<string> {
const git = simpleGit(repoPath);
return git.diff([`${commitHash}~1`, commitHash]);
}
}

View File

@@ -58,6 +58,33 @@ export interface MergeResult {
message: string;
}
// =============================================================================
// Branch Commit Info
// =============================================================================
/**
* Represents a single commit in a branch's history.
* Used for commit-level navigation in code review.
*/
export interface BranchCommit {
/** Full commit hash */
hash: string;
/** Short commit hash (7 chars) */
shortHash: string;
/** First line of commit message */
message: string;
/** Author name */
author: string;
/** ISO date string */
date: string;
/** Number of files changed */
filesChanged: number;
/** Number of insertions */
insertions: number;
/** Number of deletions */
deletions: number;
}
// =============================================================================
// WorktreeManager Port Interface
// =============================================================================

View File

@@ -234,5 +234,69 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
await orchestrator.approveAndMergePhase(input.phaseId);
return { success: true };
}),
getPhaseReviewCommits: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const phaseRepo = requirePhaseRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
}
if (phase.status !== 'pending_review') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` });
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative?.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = [];
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const commits = await branchManager.listCommits(clonePath, initBranch, phBranch);
allCommits.push(...commits);
}
return { commits: allCommits, sourceBranch: phBranch, targetBranch: initBranch };
}),
getCommitDiff: publicProcedure
.input(z.object({ phaseId: z.string().min(1), commitHash: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const phaseRepo = requirePhaseRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
}
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
let rawDiff = '';
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
try {
const diff = await branchManager.diffCommit(clonePath, input.commitHash);
if (diff) rawDiff += diff + '\n';
} catch {
// commit not in this project clone
}
}
return { rawDiff };
}),
};
}