Implement file heatmap visualization that tracks which files are modified most frequently and by which workers. Helps identify hotspots and potential collision areas. Features: - Track file modifications across all workers - Heat levels (cold/warm/hot/critical) based on modification frequency - Worker contribution percentages per file - Collision risk detection for files with multiple workers - Sortable by modifications, recent activity, workers, or collisions - Filter by directory or collision-only files - Statistics overview with heat distribution Integration: - Press 'H' in TUI to toggle heatmap view - Press 's' to cycle sort modes - Press 'c' to toggle collision-only filter - Press 'Esc' to return to default view Also fixed pre-existing DependencyDag component build issues: - Created missing dagUtils.ts utility module - Fixed import paths and type annotations Tests: 20 new tests for file heatmap, all 154 tests passing Co-Authored-By: Claude Worker <noreply@anthropic.com>
339 lines
8.9 KiB
TypeScript
339 lines
8.9 KiB
TypeScript
/**
|
|
* FileHeatmap Component
|
|
*
|
|
* Displays a heatmap of files showing modification frequency and collision risks.
|
|
* Helps identify hotspots and potential collision areas between workers.
|
|
*/
|
|
|
|
import * as blessed from 'blessed';
|
|
import { FileHeatmapEntry, FileHeatmapStats, HeatmapOptions, HeatLevel } from '../../types.js';
|
|
import { colors, getHeatColor, getHeatIcon } from '../utils/colors.js';
|
|
|
|
export interface FileHeatmapOptions {
|
|
/** 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;
|
|
|
|
/** Position from bottom */
|
|
bottom: number | string;
|
|
}
|
|
|
|
export type HeatmapSortMode = 'modifications' | 'recent' | 'workers' | 'collisions';
|
|
|
|
/**
|
|
* FileHeatmap displays file modification frequency as a visual heatmap
|
|
*/
|
|
export class FileHeatmap {
|
|
private box: blessed.Widgets.BoxElement;
|
|
private entries: FileHeatmapEntry[] = [];
|
|
private stats: FileHeatmapStats | null = null;
|
|
private selectedIndex = 0;
|
|
private sortMode: HeatmapSortMode = 'modifications';
|
|
private filter: string = '';
|
|
private showCollisionOnly = false;
|
|
|
|
constructor(options: FileHeatmapOptions) {
|
|
this.box = blessed.box({
|
|
parent: options.parent,
|
|
top: options.top,
|
|
left: options.left,
|
|
width: options.width,
|
|
bottom: options.bottom,
|
|
label: ' File Heatmap ',
|
|
border: { type: 'line' },
|
|
style: {
|
|
border: { fg: colors.border },
|
|
label: { fg: colors.header },
|
|
selected: { fg: colors.focus },
|
|
},
|
|
scrollable: true,
|
|
alwaysScroll: true,
|
|
keys: true,
|
|
vi: true,
|
|
mouse: true,
|
|
});
|
|
|
|
this.bindKeys();
|
|
}
|
|
|
|
/**
|
|
* Bind component-specific keys
|
|
*/
|
|
private bindKeys(): void {
|
|
this.box.key(['up', 'k'], () => {
|
|
this.selectPrevious();
|
|
});
|
|
|
|
this.box.key(['down', 'j'], () => {
|
|
this.selectNext();
|
|
});
|
|
|
|
this.box.key(['g'], () => {
|
|
this.selectedIndex = 0;
|
|
this.render();
|
|
});
|
|
|
|
this.box.key(['G'], () => {
|
|
this.selectedIndex = Math.max(0, this.entries.length - 1);
|
|
this.render();
|
|
});
|
|
|
|
// Sort mode cycling
|
|
this.box.key(['s'], () => {
|
|
this.cycleSortMode();
|
|
});
|
|
|
|
// Toggle collision filter
|
|
this.box.key(['c'], () => {
|
|
this.showCollisionOnly = !this.showCollisionOnly;
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cycle through sort modes
|
|
*/
|
|
private cycleSortMode(): void {
|
|
const modes: HeatmapSortMode[] = ['modifications', 'recent', 'workers', 'collisions'];
|
|
const currentIndex = modes.indexOf(this.sortMode);
|
|
this.sortMode = modes[(currentIndex + 1) % modes.length];
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Get heat bar visualization
|
|
*/
|
|
private getHeatBar(level: HeatLevel, modifications: number): string {
|
|
const maxBars = 10;
|
|
let bars: number;
|
|
|
|
switch (level) {
|
|
case 'cold': bars = Math.min(2, modifications); break;
|
|
case 'warm': bars = Math.min(4, Math.floor(modifications / 2) + 2); break;
|
|
case 'hot': bars = Math.min(7, Math.floor(modifications / 2) + 4); break;
|
|
case 'critical': bars = Math.min(10, Math.floor(modifications / 2) + 6); break;
|
|
}
|
|
|
|
const filled = '█'.repeat(bars);
|
|
const empty = '░'.repeat(maxBars - bars);
|
|
const color = getHeatColor(level);
|
|
|
|
return `{${color}-fg}${filled}{/}${empty}`;
|
|
}
|
|
|
|
/**
|
|
* Format path for display (truncate if too long)
|
|
*/
|
|
private formatPath(path: string, maxLength: number = 40): string {
|
|
if (path.length <= maxLength) return path;
|
|
|
|
// Try to keep the filename visible
|
|
const fileName = path.substring(path.lastIndexOf('/') + 1);
|
|
const dir = path.substring(0, path.lastIndexOf('/'));
|
|
|
|
if (fileName.length >= maxLength - 3) {
|
|
return '...' + fileName.substring(0, maxLength - 3);
|
|
}
|
|
|
|
const available = maxLength - fileName.length - 4; // 4 for ".../"
|
|
if (available > 0 && dir.length > available) {
|
|
return dir.substring(0, available) + '.../' + fileName;
|
|
}
|
|
|
|
return '...' + path.substring(path.length - maxLength + 3);
|
|
}
|
|
|
|
/**
|
|
* Format worker list for display
|
|
*/
|
|
private formatWorkers(workers: FileHeatmapEntry['workers']): string {
|
|
if (workers.length === 0) return '-';
|
|
if (workers.length === 1) return `{cyan-fg}${workers[0].workerId.slice(0, 8)}{/}`;
|
|
|
|
// Show top 2 workers with count
|
|
const top = workers.slice(0, 2).map(w => w.workerId.slice(0, 6)).join(', ');
|
|
const extra = workers.length > 2 ? ` +${workers.length - 2}` : '';
|
|
return `{cyan-fg}${top}{/}${extra}`;
|
|
}
|
|
|
|
/**
|
|
* Format a single heatmap entry
|
|
*/
|
|
private formatEntry(entry: FileHeatmapEntry, isSelected: boolean): string {
|
|
const icon = getHeatIcon(entry.heatLevel);
|
|
const color = getHeatColor(entry.heatLevel);
|
|
const heatBar = this.getHeatBar(entry.heatLevel, entry.modifications);
|
|
const path = this.formatPath(entry.path);
|
|
const workers = this.formatWorkers(entry.workers);
|
|
|
|
// Collision indicator
|
|
const collisionIndicator = entry.hasCollision
|
|
? '{red-fg}⚠{/}'
|
|
: entry.activeWorkers > 1
|
|
? '{yellow-fg}⚡{/}'
|
|
: ' ';
|
|
|
|
// Modification count
|
|
const modCount = `{bold}${entry.modifications.toString().padStart(3)}{/}`;
|
|
|
|
const selectedMarker = isSelected ? '>' : ' ';
|
|
|
|
// Format: [icon] [heat bar] [count] [path] [workers] [collision]
|
|
return `${selectedMarker} {${color}-fg}${icon}{/} ${heatBar} ${modCount} ${path} ${workers} ${collisionIndicator}`;
|
|
}
|
|
|
|
/**
|
|
* Format statistics header
|
|
*/
|
|
private formatStats(stats: FileHeatmapStats): string {
|
|
const heatDist = stats.heatDistribution;
|
|
const sortLabel = `Sort: ${this.sortMode}`;
|
|
const filterLabel = this.showCollisionOnly ? ' | Collisions Only' : '';
|
|
|
|
return `{bold}Files: ${stats.totalFiles}{/} | ` +
|
|
`Mods: ${stats.totalModifications} | ` +
|
|
`Active: ${stats.activeFiles} | ` +
|
|
`{red-fg}⚠ ${stats.collisionFiles}{/} | ` +
|
|
`[s] ${sortLabel}${filterLabel}\n` +
|
|
`{blue-fg}○${heatDist.cold}{/} ` +
|
|
`{yellow-fg}◐${heatDist.warm}{/} ` +
|
|
`{magenta-fg}●${heatDist.hot}{/} ` +
|
|
`{red-fg}🔥${heatDist.critical}{/}`;
|
|
}
|
|
|
|
/**
|
|
* Update heatmap data
|
|
*/
|
|
updateData(getHeatmap: (options: HeatmapOptions) => FileHeatmapEntry[], getStats: () => FileHeatmapStats): void {
|
|
this.entries = getHeatmap({
|
|
sortBy: this.sortMode,
|
|
maxEntries: 100,
|
|
collisionsOnly: this.showCollisionOnly,
|
|
directoryFilter: this.filter || undefined,
|
|
});
|
|
this.stats = getStats();
|
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.entries.length - 1));
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Set directory filter
|
|
*/
|
|
setFilter(filter: string): void {
|
|
this.filter = filter;
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Clear filter
|
|
*/
|
|
clearFilter(): void {
|
|
this.filter = '';
|
|
this.showCollisionOnly = false;
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Select next entry
|
|
*/
|
|
selectNext(): void {
|
|
if (this.entries.length === 0) return;
|
|
this.selectedIndex = (this.selectedIndex + 1) % this.entries.length;
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Select previous entry
|
|
*/
|
|
selectPrevious(): void {
|
|
if (this.entries.length === 0) return;
|
|
this.selectedIndex = this.selectedIndex === 0
|
|
? this.entries.length - 1
|
|
: this.selectedIndex - 1;
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Get currently selected entry
|
|
*/
|
|
getSelected(): FileHeatmapEntry | undefined {
|
|
return this.entries[this.selectedIndex];
|
|
}
|
|
|
|
/**
|
|
* Get current sort mode
|
|
*/
|
|
getSortMode(): HeatmapSortMode {
|
|
return this.sortMode;
|
|
}
|
|
|
|
/**
|
|
* Get collision filter state
|
|
*/
|
|
getCollisionFilter(): boolean {
|
|
return this.showCollisionOnly;
|
|
}
|
|
|
|
/**
|
|
* Render the component
|
|
*/
|
|
render(): void {
|
|
const lines: string[] = [];
|
|
|
|
// Stats header
|
|
if (this.stats) {
|
|
lines.push(this.formatStats(this.stats));
|
|
lines.push(''); // Empty line separator
|
|
}
|
|
|
|
if (this.entries.length === 0) {
|
|
lines.push('{gray-fg}No file modifications detected{/}');
|
|
if (this.showCollisionOnly) {
|
|
lines.push('{gray-fg}Press [c] to show all files{/}');
|
|
}
|
|
} else {
|
|
for (let i = 0; i < this.entries.length; i++) {
|
|
const entry = this.entries[i];
|
|
const isSelected = i === this.selectedIndex;
|
|
lines.push(this.formatEntry(entry, isSelected));
|
|
}
|
|
|
|
// Footer help
|
|
lines.push('');
|
|
lines.push('{gray-fg}[s] Sort [c] Collisions only [j/k] Scroll{/}');
|
|
}
|
|
|
|
// Update label with current mode
|
|
const label = this.showCollisionOnly
|
|
? ' File Heatmap [COLLISIONS] '
|
|
: ' File Heatmap ';
|
|
this.box.setLabel(label);
|
|
|
|
this.box.setContent(lines.join('\n'));
|
|
this.box.screen.render();
|
|
}
|
|
|
|
/**
|
|
* Focus this component
|
|
*/
|
|
focus(): void {
|
|
this.box.focus();
|
|
}
|
|
|
|
/**
|
|
* Get the underlying box element
|
|
*/
|
|
getElement(): blessed.Widgets.BoxElement {
|
|
return this.box;
|
|
}
|
|
}
|
|
|
|
export default FileHeatmap;
|