FABRIC/src/tui/components/GitIntegration.ts
jeda 61cd3e321a feat(bd-3o4): Git PR Preview
Add PR preview functionality to the Git Integration panel:

- Generate PR title from commit messages
- Auto-generate PR description with file changes summary
- Generate commit message preview from activity
- List files changed with +/lines count
- Detect potential conflicts with upstream
- Rebase recommendation when conflicts detected
- New keyboard shortcuts: [p] Preview PR, [d] Diff, [s] Status
- PRPreview type with conflict detection and file stats

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-07 05:19:27 +00:00

618 lines
16 KiB
TypeScript

/**
* GitIntegration Component
*
* Displays live git status per workspace including current branch,
* staged/unstaged files, recent commits, PR preview, and conflict detection.
*/
import blessed from 'blessed';
import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange, PRPreview, PRFileChange } from '../../types.js';
import { colors } from '../utils/colors.js';
import { generatePRPreview, formatPRPreview } from '../utils/prPreview.js';
export interface GitIntegrationOptions {
/** Parent screen */
parent: blessed.Widgets.Screen;
/** Position from top */
top: number | string;
/** Position from left */
left: number | string;
/** Width of the panel */
width: number | string;
/** Height of the panel */
height?: number | string;
/** Position from bottom */
bottom?: number | string;
/** Maximum commits to display */
maxCommits?: number;
/** Maximum files to display */
maxFiles?: number;
}
/**
* View mode for the git panel
*/
type GitViewMode = 'status' | 'pr-preview' | 'diff';
/**
* GitIntegration displays live git status and activity with PR preview
*/
export class GitIntegration {
private box: blessed.Widgets.BoxElement;
private contentBox: blessed.Widgets.BoxElement;
private buttonBox: blessed.Widgets.BoxElement;
private maxCommits: number;
private maxFiles: number;
// State tracking
private gitEvents: GitEvent[] = [];
private currentStatus?: GitStatusEvent;
private recentCommits: GitCommitEvent[] = [];
private conflictDetected = false;
private prPreview?: PRPreview;
private viewMode: GitViewMode = 'status';
// Workspace tracking (worker -> workspace path)
private workspaces: Map<string, string> = new Map();
constructor(options: GitIntegrationOptions) {
this.maxCommits = options.maxCommits || 5;
this.maxFiles = options.maxFiles || 10;
this.box = blessed.box({
parent: options.parent,
top: options.top,
left: options.left,
width: options.width,
...(options.bottom !== undefined ? { bottom: options.bottom } : { height: options.height }),
label: ' Git Integration ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.header },
},
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
tags: true,
});
// Create content box for scrollable content
this.contentBox = blessed.box({
parent: this.box,
top: 0,
left: 0,
right: 0,
bottom: 1,
tags: true,
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
});
// Create button bar at bottom
this.buttonBox = blessed.box({
parent: this.box,
bottom: 0,
left: 0,
right: 0,
height: 1,
tags: true,
});
this.bindKeys();
this.render();
}
/**
* Bind component-specific keys
*/
private bindKeys(): void {
this.box.key(['r'], () => {
this.refresh();
});
this.box.key(['c'], () => {
this.clearHistory();
});
this.box.key(['p'], () => {
this.togglePRPreview();
});
this.box.key(['d'], () => {
this.toggleDiffView();
});
this.box.key(['s'], () => {
this.toggleStatusView();
});
this.box.key(['escape'], () => {
if (this.viewMode !== 'status') {
this.viewMode = 'status';
this.render();
} else {
this.hide();
}
});
}
private get screen(): blessed.Widgets.Screen {
return this.box.screen;
}
/**
* Toggle PR preview view
*/
private togglePRPreview(): void {
this.viewMode = this.viewMode === 'pr-preview' ? 'status' : 'pr-preview';
this.render();
}
/**
* Toggle diff view
*/
private toggleDiffView(): void {
this.viewMode = this.viewMode === 'diff' ? 'status' : 'diff';
this.render();
}
/**
* Toggle status view
*/
private toggleStatusView(): void {
this.viewMode = 'status';
this.render();
}
/**
* Get status icon for file change
*/
private getFileStatusIcon(status: string): { icon: string; color: string } {
switch (status) {
case 'added':
return { icon: '+', color: 'green' };
case 'modified':
return { icon: 'M', color: 'yellow' };
case 'deleted':
return { icon: '-', color: 'red' };
case 'renamed':
return { icon: 'R', color: 'cyan' };
case 'copied':
return { icon: 'C', color: 'cyan' };
case 'untracked':
return { icon: '?', color: 'gray' };
case 'unmerged':
return { icon: 'U', color: 'red' };
default:
return { icon: '•', color: 'white' };
}
}
/**
* Format file change for display with line counts
*/
private formatFileChange(file: PRFileChange | GitFileChange, maxLength: number = 50): string {
const statusInfo = this.getFileStatusIcon(file.status);
const path = file.path.length > maxLength
? '...' + file.path.slice(-maxLength + 3)
: file.path;
let line = `{${statusInfo.color}-fg}${statusInfo.icon}{/} ${path}`;
// Add line counts if available (PRFileChange)
const prFile = file as PRFileChange;
if (prFile.linesAdded !== undefined || prFile.linesDeleted !== undefined) {
const added = prFile.linesAdded || 0;
const deleted = prFile.linesDeleted || 0;
if (added > 0 || deleted > 0) {
line += ` {green-fg}+${added}{/}/{red-fg}-${deleted}{/}`;
}
}
if (file.originalPath) {
line += ` {gray-fg}(from ${file.originalPath}){/}`;
}
return line;
}
/**
* Format commit for display
*/
private formatCommit(commit: GitCommitEvent): string {
const hash = commit.hash.slice(0, 7);
const time = new Date(commit.ts).toLocaleTimeString();
const message = commit.message.split('\n')[0].slice(0, 60);
const author = commit.author ? ` - ${commit.author.split(' ')[0]}` : '';
return `{yellow-fg}${hash}{/} {gray-fg}${time}{/} ${message}${author}`;
}
/**
* Format relative timestamp
*/
private formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
}
/**
* Update git events
*/
updateGitEvents(events: GitEvent[]): void {
this.gitEvents = events;
// Extract latest status
const statusEvents = events.filter((e): e is GitStatusEvent => e.type === 'status');
if (statusEvents.length > 0) {
this.currentStatus = statusEvents[statusEvents.length - 1];
// Check for conflicts
this.conflictDetected = this.currentStatus.staged.some(f => f.status === 'unmerged') ||
this.currentStatus.unstaged.some(f => f.status === 'unmerged');
}
// Extract recent commits
const commitEvents = events.filter((e): e is GitCommitEvent => e.type === 'commit');
this.recentCommits = commitEvents.slice(-this.maxCommits);
// Generate PR preview
this.prPreview = generatePRPreview(events);
this.render();
}
/**
* Set workspace for a worker
*/
setWorkspace(workerId: string, workspacePath: string): void {
this.workspaces.set(workerId, workspacePath);
this.render();
}
/**
* Refresh the display
*/
refresh(): void {
this.render();
}
/**
* Clear git history
*/
clearHistory(): void {
this.gitEvents = [];
this.currentStatus = undefined;
this.recentCommits = [];
this.conflictDetected = false;
this.prPreview = undefined;
this.render();
}
/**
* Show the panel
*/
show(): void {
this.box.show();
this.box.focus();
this.render();
}
/**
* Hide the panel
*/
hide(): void {
this.box.hide();
this.screen.render();
}
/**
* Check if visible
*/
isVisible(): boolean {
return this.box.visible;
}
/**
* Render status section
*/
private renderStatus(): string {
if (!this.currentStatus) {
return '{gray-fg}No git status available{/}\n';
}
const lines: string[] = [];
// Branch info
const branchColor = this.conflictDetected ? 'red' : 'cyan';
const conflictWarning = this.conflictDetected ? ' {red-fg}⚠ CONFLICTS{/}' : '';
lines.push(`{bold}Branch:{/} {${branchColor}-fg}${this.currentStatus.branch}{/}${conflictWarning}`);
// Tracking info
if (this.currentStatus.tracking) {
const ahead = this.currentStatus.ahead || 0;
const behind = this.currentStatus.behind || 0;
let trackingInfo = `{gray-fg}tracking ${this.currentStatus.tracking}{/}`;
if (ahead > 0) {
trackingInfo += ` {green-fg}↑${ahead}{/}`;
}
if (behind > 0) {
trackingInfo += ` {red-fg}↓${behind}{/}`;
}
lines.push(` ${trackingInfo}`);
}
// Commit hash
if (this.currentStatus.commit) {
lines.push(`{bold}Commit:{/} {yellow-fg}${this.currentStatus.commit.slice(0, 7)}{/}`);
}
// Last updated
const lastUpdated = this.formatRelativeTime(this.currentStatus.ts);
lines.push(`{gray-fg}Updated ${lastUpdated}{/}`);
return lines.join('\n') + '\n';
}
/**
* Render files section with line counts
*/
private renderFiles(): string {
if (!this.currentStatus) {
return '';
}
const lines: string[] = [];
const staged = this.currentStatus.staged.slice(0, this.maxFiles);
const unstaged = this.currentStatus.unstaged.slice(0, this.maxFiles);
const untracked = this.currentStatus.untracked.slice(0, this.maxFiles);
// Calculate totals
const stagedAdded = staged.length;
const unstagedCount = unstaged.length;
const untrackedCount = untracked.length;
// Summary line
if (stagedAdded > 0 || unstagedCount > 0 || untrackedCount > 0) {
lines.push(`\n{bold}Changes:{/} {green-fg}${stagedAdded} staged{/}, {yellow-fg}${unstagedCount} unstaged{/}, {gray-fg}${untrackedCount} untracked{/}`);
}
// Staged files
if (staged.length > 0) {
lines.push(`\n{bold}{green-fg}Staged:{/}`);
for (const file of staged) {
lines.push(` ${this.formatFileChange(file)}`);
}
if (this.currentStatus.staged.length > this.maxFiles) {
lines.push(` {gray-fg}... and ${this.currentStatus.staged.length - this.maxFiles} more{/}`);
}
}
// Unstaged files
if (unstaged.length > 0) {
lines.push(`\n{bold}{yellow-fg}Unstaged:{/}`);
for (const file of unstaged) {
lines.push(` ${this.formatFileChange(file)}`);
}
if (this.currentStatus.unstaged.length > this.maxFiles) {
lines.push(` {gray-fg}... and ${this.currentStatus.unstaged.length - this.maxFiles} more{/}`);
}
}
// Untracked files
if (untracked.length > 0) {
lines.push(`\n{bold}{gray-fg}Untracked:{/}`);
for (const file of untracked.slice(0, this.maxFiles)) {
lines.push(` {gray-fg}? ${file}{/}`);
}
if (this.currentStatus.untracked.length > this.maxFiles) {
lines.push(` {gray-fg}... and ${this.currentStatus.untracked.length - this.maxFiles} more{/}`);
}
}
// Show clean state if no files
if (staged.length === 0 && unstaged.length === 0 && untracked.length === 0) {
lines.push('\n{green-fg}Working tree clean{/}');
}
return lines.join('\n') + '\n';
}
/**
* Render commits section
*/
private renderCommits(): string {
if (this.recentCommits.length === 0) {
return '{gray-fg}No recent commits{/}';
}
const lines: string[] = [];
lines.push(`\n{bold}Recent Commits (${this.recentCommits.length}):{/}`);
for (const commit of this.recentCommits.slice().reverse()) {
lines.push(` ${this.formatCommit(commit)}`);
}
return lines.join('\n');
}
/**
* Render PR preview section
*/
private renderPRPreview(): string {
if (!this.prPreview) {
return '{gray-fg}No PR preview available{/}';
}
return formatPRPreview(this.prPreview);
}
/**
* Render potential conflicts section
*/
private renderConflicts(): string {
if (!this.prPreview || !this.prPreview.conflicts) {
return '';
}
const conflicts = this.prPreview.conflicts;
const lines: string[] = [];
if (conflicts.hasUpstreamCommits || conflicts.rebaseRecommended) {
lines.push('\n{bold}{yellow-fg}⚠ Potential Conflicts{/}');
lines.push('');
if (conflicts.upstreamCommitCount > 0) {
lines.push(` {yellow-fg}main has ${conflicts.upstreamCommitCount} new commit${conflicts.upstreamCommitCount !== 1 ? 's' : ''} since branch creation{/}`);
}
if (conflicts.conflictingFiles.length > 0) {
lines.push(' {gray-fg}Files that may conflict:{/}');
for (const file of conflicts.conflictingFiles.slice(0, 3)) {
lines.push(` {red-fg}• ${file}{/}`);
}
if (conflicts.conflictingFiles.length > 3) {
lines.push(` {gray-fg}... and ${conflicts.conflictingFiles.length - 3} more{/}`);
}
}
if (conflicts.rebaseRecommended) {
lines.push('');
lines.push(` {cyan-fg}Recommendation: rebase before merging{/}`);
if (conflicts.rebaseReason) {
lines.push(` {gray-fg}${conflicts.rebaseReason}{/}`);
}
}
}
return lines.join('\n');
}
/**
* Render the button bar
*/
private renderButtons(): string {
const modeIndicator = this.viewMode === 'pr-preview' ? '{green-fg}[PR Preview]{/} '
: this.viewMode === 'diff' ? '{cyan-fg}[Diff View]{/} '
: '';
return `${modeIndicator}{gray-fg}[p] Preview PR [d] Diff [s] Status [r] Refresh [c] Clear [Esc] Back{/}`;
}
/**
* Render the component
*/
render(): void {
let content: string;
switch (this.viewMode) {
case 'pr-preview':
content = this.renderPRPreview() + '\n' + this.renderConflicts();
break;
case 'diff':
content = this.renderFiles() + '\n' + this.renderCommits();
break;
default:
content = this.renderStatus() + this.renderFiles() + this.renderCommits();
}
this.contentBox.setContent(content);
this.buttonBox.setContent(this.renderButtons());
// Update label
const modeLabel = this.viewMode === 'pr-preview' ? ' PR Preview '
: this.viewMode === 'diff' ? ' Git Diff '
: ' Git Integration ';
const conflictLabel = this.conflictDetected ? ' {red-fg}⚠ CONFLICTS{/} ' : '';
this.box.setLabel(` ${modeLabel}${conflictLabel}`);
this.screen.render();
}
/**
* Focus this component
*/
focus(): void {
this.box.focus();
}
/**
* Get the underlying box element
*/
getElement(): blessed.Widgets.BoxElement {
return this.box;
}
/**
* Get conflict status
*/
hasConflicts(): boolean {
return this.conflictDetected;
}
/**
* Get current branch name
*/
getCurrentBranch(): string | undefined {
return this.currentStatus?.branch;
}
/**
* Get file change counts
*/
getFileCounts(): { staged: number; unstaged: number; untracked: number } {
if (!this.currentStatus) {
return { staged: 0, unstaged: 0, untracked: 0 };
}
return {
staged: this.currentStatus.staged.length,
unstaged: this.currentStatus.unstaged.length,
untracked: this.currentStatus.untracked.length,
};
}
/**
* Get recent commits count
*/
getCommitsCount(): number {
return this.recentCommits.length;
}
/**
* Get current PR preview
*/
getPRPreview(): PRPreview | undefined {
return this.prPreview;
}
/**
* Get current view mode
*/
getViewMode(): GitViewMode {
return this.viewMode;
}
}
export default GitIntegration;