2026 03 21 Git Operations Graph And Components
Documentation for Plans
Git Operations: Graph Visualization, Sub-Components & Multi-App Integration
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Metro + Living Intelligence git graph visualization, extract 7 reusable components into the git-operations library, and wire the library into gograb-dev (refactor), gograb-admin (new), and vitto-studio (dock panel).
Architecture: All UI components live in @libs-structural/git-operations as standalone Angular components with signal inputs. The git-graph is the star component using SVG rendering with a lane-assignment algorithm and living intelligence (glow/fade based on age). A template-filtering system hides core library files based on role. Backend enrichment adds parentShas to the commit history endpoint.
Tech Stack: Angular 20+ (standalone, signals, OnPush), SVG for graph rendering, Angular CDK virtual scroll, simple-git raw() for backend enrichment.
Spec: docs/superpowers/specs/2026-03-21-git-operations-graph-and-components-design.md
Constraint: NO git commits during this session — concurrent session on same branch. Build and type-check only.
Task 1: Update Models & Types
Files:
- Modify:
libs/libs-structural/git-operations/src/lib/models/git-operations.types.ts - Modify:
libs/libs-structural/git-operations/src/lib/models/git-operations-role.config.ts - Create:
libs/libs-structural/git-operations/src/lib/models/git-template-paths.config.ts
All subsequent tasks depend on these types compiling first.
- Step 1: Add parentShas to GitCommitInfo
In git-operations.types.ts, add parentShas to GitCommitInfo:
export interface GitCommitInfo {
sha: string;
shortSha: string;
message: string;
author: string;
date: string;
parentShas?: string[]; // optional for backward compat
}
- Step 2: Add GitCommitNode and GitGraphLane types
Append to git-operations.types.ts:
// ─── Graph Types ──────────────────────────────────────────────────────────
export type CommitAgeTier = 'recent' | 'active' | 'aging' | 'old';
export interface GitCommitNode {
sha: string;
shortSha: string;
message: string;
author: string;
date: string;
parentShas: string[];
laneIndex: number;
color: string;
isHead: boolean;
isMerge: boolean;
branchName?: string;
age: CommitAgeTier;
filesChanged?: number;
insertions?: number;
deletions?: number;
templateOnly?: boolean;
}
export interface GitGraphLane {
branchName: string;
color: string;
column: number;
active: boolean;
lastCommitDate: string;
}
- Step 3: Add canViewTemplateSources to role config + fix admin.showServerMetrics
In git-operations-role.config.ts, add canViewTemplateSources: boolean to the GitOpsFeatureFlags interface. Then replace the entire GIT_OPS_FEATURE_MAP with the complete updated version (all four tiers must have the new field or TypeScript will error):
// In GitOpsFeatureFlags interface, add after canDeleteRepos:
/** Can see template/core library source files (executive/developer only) */
canViewTemplateSources: boolean;
// Replace the entire GIT_OPS_FEATURE_MAP:
export const GIT_OPS_FEATURE_MAP: Record<GitOpsRoleTier, GitOpsFeatureFlags> = {
executive: {
showServerMetrics: true,
showAllSpaces: true,
canManageBranches: true,
canMergeBranches: true,
canViewFiles: true,
canCommitFiles: true,
showSandboxInfo: true,
showCommitHistory: true,
canDeleteRepos: true,
canViewTemplateSources: true,
},
developer: {
showServerMetrics: true,
showAllSpaces: true,
canManageBranches: true,
canMergeBranches: true,
canViewFiles: true,
canCommitFiles: true,
showSandboxInfo: true,
showCommitHistory: true,
canDeleteRepos: false,
canViewTemplateSources: true,
},
admin: {
showServerMetrics: false, // Changed from true — admins don't need infra visibility
showAllSpaces: true,
canManageBranches: true,
canMergeBranches: false,
canViewFiles: true,
canCommitFiles: false,
showSandboxInfo: true,
showCommitHistory: true,
canDeleteRepos: false,
canViewTemplateSources: false,
},
user: {
showServerMetrics: false,
showAllSpaces: false,
canManageBranches: false,
canMergeBranches: false,
canViewFiles: true,
canCommitFiles: false,
showSandboxInfo: false,
showCommitHistory: false,
canDeleteRepos: false,
canViewTemplateSources: false,
},
};
- Step 4: Create git-template-paths.config.ts
Create libs/libs-structural/git-operations/src/lib/models/git-template-paths.config.ts:
/**
* Template Core Paths — known paths from the platform-base-template.
*
* Used to filter out core library files from user-facing views.
* Role-gated: executive/developer can toggle visibility, others always filtered.
*
* Platform convention: promote to @libs-foundational when other libs consume this.
*/
export const TEMPLATE_CORE_PATHS = [
'libs/core/',
'libs/libs-foundational/',
'libs/libs-ui/',
'libs/libs-ui-core/',
'libs/libs-ux/',
'libs/libs-ai/',
'libs/libs-api/',
'libs/libs-dev-tools/',
'libs/libs-private/',
'libs/theme-system/',
] as const;
export function isTemplatePath(filePath: string): boolean {
return TEMPLATE_CORE_PATHS.some(p => filePath.startsWith(p));
}
export function isTemplateOnlyCommit(files: { path: string }[]): boolean {
return files.length > 0 && files.every(f => isTemplatePath(f.path));
}
export function filterTemplateFiles<T extends { path: string }>(files: T[]): T[] {
return files.filter(f => !isTemplatePath(f.path));
}
- Step 5: Verify types compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean compilation, 0 errors.
Task 2: Update API Service Return Type
Files:
Modify:
libs/libs-structural/git-operations/src/lib/services/git-operations-api.service.tsStep 1: Update getCommitHistory return type
In git-operations-api.service.ts, update the getCommitHistory method to include parentShas in its return type. The existing GitCommitInfo type now has the optional parentShas field, so the return type already picks it up via the type import. Verify the method signature at line 125:
getCommitHistory(org: string, repo: string, ref: string, limit = 30): Observable<GitCommitInfo[]> {
This already returns GitCommitInfo[] which now includes parentShas?: string[]. No code change needed — just verify the type flows through correctly.
- Step 2: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean, 0 errors.
Task 3: Backend Enrichment — Add parentShas
Files:
Modify:
APIs/sandbox-orchestrator/src/app/git/git.service.tsStep 1: Update getCommitHistory to return parentShas
In git.service.ts, replace the getCommitHistory method (lines 527-551) with:
async getCommitHistory(
orgSlug: string,
repoSlug: string,
branch: string,
limit = 30,
): Promise<Array<{
sha: string;
shortSha: string;
message: string;
author: string;
date: string;
parentShas: string[];
}>> {
const repoDir = this.safeRepoPath(orgSlug, repoSlug);
await this.assertRepoExists(repoDir);
const git = gitFor(repoDir);
const separator = '---GIT-LOG-SEP---';
const formatStr = `--format=%H${separator}%h${separator}%s${separator}%an${separator}%ai${separator}%P`;
const result = await git.raw([
'log',
branch,
`--max-count=${limit}`,
formatStr,
]);
if (!result || !result.trim()) return [];
return result
.trim()
.split('\n')
.filter(Boolean)
.map(line => {
const parts = line.split(separator);
return {
sha: parts[0],
shortSha: parts[1],
message: parts[2],
author: parts[3],
date: parts[4],
parentShas: parts[5] ? parts[5].split(' ').filter(Boolean) : [],
};
});
}
Note: Uses a separator string instead of JSON format to avoid issues with commit messages that contain quotes/braces.
- Step 2: Build sandbox-orchestrator
Run: npx nx build sandbox-orchestrator
Expected: Build succeeds.
Task 4: Lane Assignment Algorithm (Pure Utility)
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-graph/git-graph-lane.util.tsStep 1: Create the lane assignment utility
This is a pure function with no Angular dependencies. Create git-graph-lane.util.ts:
import type { GitCommitInfo, GitCommitNode, GitGraphLane, CommitAgeTier } from '../../models/git-operations.types';
import type { GitBranchInfo } from '../../models/git-operations.types';
const LANE_COLORS = [
'#7aa2f7', // blue (main)
'#9ece6a', // green
'#f38ba8', // red
'#f9e2af', // yellow
'#89dceb', // teal
'#cba6f7', // purple
'#fab387', // peach
'#74c7ec', // sapphire
'#a6e3a1', // mint
'#eba0ac', // maroon
];
const AGE_THRESHOLDS = {
recent: 4 * 60 * 60 * 1000, // 4 hours
active: 2 * 24 * 60 * 60 * 1000, // 2 days
aging: 7 * 24 * 60 * 60 * 1000, // 7 days
};
const STALE_THRESHOLD = 7 * 24 * 60 * 60 * 1000; // 7 days
export function computeCommitAge(dateStr: string): CommitAgeTier {
const diff = Date.now() - new Date(dateStr).getTime();
if (diff < AGE_THRESHOLDS.recent) return 'recent';
if (diff < AGE_THRESHOLDS.active) return 'active';
if (diff < AGE_THRESHOLDS.aging) return 'aging';
return 'old';
}
export interface LaneAssignmentResult {
nodes: GitCommitNode[];
lanes: GitGraphLane[];
}
/**
* Assign visual lanes to commits for graph rendering.
*
* Algorithm:
* 1. Walk commits top-to-bottom (newest first)
* 2. First commit gets lane 0
* 3. Forks assign next available column
* 4. Merges close the merging lane
* 5. Reuse closed columns to keep graph narrow
* 6. Lanes with last commit >7d are marked stale
*/
export function assignLanes(
commits: GitCommitInfo[],
branches: GitBranchInfo[],
): LaneAssignmentResult {
if (!commits.length) return { nodes: [], lanes: [] };
const branchTips = new Map<string, string>();
for (const b of branches) {
branchTips.set(b.sha, b.name);
}
// Track which lane each commit SHA is assigned to
const shaToLane = new Map<string, number>();
const lanes: GitGraphLane[] = [];
const availableColumns: number[] = [];
let nextColumn = 0;
function allocateColumn(): number {
if (availableColumns.length > 0) {
return availableColumns.sort((a, b) => a - b).shift()!;
}
return nextColumn++;
}
function releaseColumn(col: number): void {
if (!availableColumns.includes(col)) {
availableColumns.push(col);
}
}
const nodes: GitCommitNode[] = [];
for (let i = 0; i < commits.length; i++) {
const commit = commits[i];
const parentShas = commit.parentShas ?? [];
const isMerge = parentShas.length > 1;
const isHead = branchTips.has(commit.sha);
const branchName = branchTips.get(commit.sha);
const age = computeCommitAge(commit.date);
// Determine this commit's lane
let laneIndex: number;
if (shaToLane.has(commit.sha)) {
laneIndex = shaToLane.get(commit.sha)!;
} else if (i === 0) {
laneIndex = allocateColumn();
} else {
laneIndex = allocateColumn();
}
// Get or create lane
let lane = lanes.find(l => l.column === laneIndex);
if (!lane) {
const color = LANE_COLORS[lanes.length % LANE_COLORS.length];
lane = {
branchName: branchName ?? `lane-${laneIndex}`,
color,
column: laneIndex,
active: true,
lastCommitDate: commit.date,
};
lanes.push(lane);
}
lane.lastCommitDate = commit.date;
if (branchName) lane.branchName = branchName;
// Assign parents to lanes
for (let p = 0; p < parentShas.length; p++) {
const parentSha = parentShas[p];
if (!shaToLane.has(parentSha)) {
if (p === 0) {
// First parent continues in same lane
shaToLane.set(parentSha, laneIndex);
} else {
// Other parents get new lanes (they're merging in)
const col = allocateColumn();
shaToLane.set(parentSha, col);
}
}
}
// If this is a merge, close the non-primary parent lanes after rendering
if (isMerge) {
for (let p = 1; p < parentShas.length; p++) {
const mergingLane = shaToLane.get(parentShas[p]);
if (mergingLane !== undefined && mergingLane !== laneIndex) {
// Mark for visual merge path, release column later
// The column is released when the parent commit is processed
}
}
}
const node: GitCommitNode = {
sha: commit.sha,
shortSha: commit.shortSha,
message: commit.message,
author: commit.author,
date: commit.date,
parentShas,
laneIndex,
color: lane.color,
isHead,
isMerge,
branchName,
age,
};
nodes.push(node);
}
// Mark stale lanes
const now = Date.now();
for (const lane of lanes) {
const lastDate = new Date(lane.lastCommitDate).getTime();
if (now - lastDate > STALE_THRESHOLD) {
lane.active = false;
}
}
return { nodes, lanes };
}
- Step 2: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 5: SVG Renderer (Pure Utility)
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-graph/git-graph-renderer.util.tsStep 1: Create the SVG renderer utility
Pure functions that generate SVG element descriptors from GitCommitNode[] and GitGraphLane[]:
import type { GitCommitNode, GitGraphLane, CommitAgeTier } from '../../models/git-operations.types';
// ─── Constants ──────────────────────────────────────────────────────────────
const ROW_HEIGHT = 36;
const LANE_WIDTH = 28;
const LEFT_PADDING = 20;
const NODE_RADIUS = { recent: 7, active: 6, aging: 5, old: 4 };
const MERGE_RADIUS = 9;
const STROKE_WIDTH = 5;
const BACKGROUND_COLOR = '#1e1e2e';
// ─── Age-based visual config ────────────────────────────────────────────────
interface AgeVisualConfig {
opacity: number;
glowFilter: string | null;
strokeWidth: number;
}
const AGE_VISUALS: Record<CommitAgeTier, AgeVisualConfig> = {
recent: { opacity: 1.0, glowFilter: 'url(#glow-strong)', strokeWidth: 2.5 },
active: { opacity: 0.9, glowFilter: 'url(#glow-soft)', strokeWidth: 2.5 },
aging: { opacity: 0.7, glowFilter: null, strokeWidth: 2 },
old: { opacity: 0.35, glowFilter: null, strokeWidth: 1.5 },
};
// ─── Public API ─────────────────────────────────────────────────────────────
export function computeSvgWidth(laneCount: number): number {
return LEFT_PADDING + Math.max(1, laneCount) * LANE_WIDTH;
}
export function computeSvgHeight(commitCount: number): number {
return commitCount * ROW_HEIGHT + 20;
}
export function getRowY(index: number): number {
return index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
export function getLaneX(column: number): number {
return LEFT_PADDING + column * LANE_WIDTH;
}
export function getRowHeight(): number {
return ROW_HEIGHT;
}
/** Generate SVG filter definitions for glow effects. */
export function generateSvgDefs(): string {
return `
<defs>
<filter id="glow-strong">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="glow-soft">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
`;
}
/** Generate SVG path for a branch line segment between two commit rows. */
export function generateBranchLine(
fromRow: number,
toRow: number,
column: number,
color: string,
active: boolean,
): string {
const x = getLaneX(column);
const y1 = getRowY(fromRow);
const y2 = getRowY(toRow);
const dashAttr = active ? '' : 'stroke-dasharray="6 4"';
const opacityAttr = active ? '' : 'opacity="0.25"';
return `<line x1="${x}" y1="${y1}" x2="${x}" y2="${y2}" stroke="${color}" stroke-width="${STROKE_WIDTH}" stroke-linecap="round" ${dashAttr} ${opacityAttr}/>`;
}
/** Generate SVG path for a merge/fork curve between two lanes. */
export function generateMergePath(
fromRow: number,
fromColumn: number,
toRow: number,
toColumn: number,
color: string,
): string {
const x1 = getLaneX(fromColumn);
const y1 = getRowY(fromRow);
const x2 = getLaneX(toColumn);
const y2 = getRowY(toRow);
const midY = (y1 + y2) / 2;
return `<path d="M ${x1} ${y1} Q ${x1} ${midY}, ${x2} ${y2}" stroke="${color}" stroke-width="${STROKE_WIDTH - 1}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
}
/** Generate SVG circle for a commit node (station dot). */
export function generateCommitNode(node: GitCommitNode, rowIndex: number): string {
const x = getLaneX(node.laneIndex);
const y = getRowY(rowIndex);
const visuals = AGE_VISUALS[node.age];
const filterAttr = visuals.glowFilter ? `filter="${visuals.glowFilter}"` : '';
if (node.isMerge) {
// Merge node: filled ring (interchange station)
return `
<circle cx="${x}" cy="${y}" r="${MERGE_RADIUS}" fill="${node.color}" ${filterAttr} opacity="${visuals.opacity}"/>
<circle cx="${x}" cy="${y}" r="${MERGE_RADIUS - 3.5}" fill="${BACKGROUND_COLOR}"/>
`;
}
// Normal node: hollow station dot
const radius = NODE_RADIUS[node.age];
return `
<circle cx="${x}" cy="${y}" r="${radius}" fill="${BACKGROUND_COLOR}" stroke="${node.color}" stroke-width="${visuals.strokeWidth}" ${filterAttr} opacity="${visuals.opacity}"/>
`;
}
/** Generate SVG pill label for a branch name. */
export function generateBranchLabel(
branchName: string,
column: number,
rowIndex: number,
color: string,
): string {
const x = getLaneX(column);
const y = getRowY(rowIndex) - 14;
const textLen = branchName.length * 5.5 + 12;
const bgColor = hexToRgba(color, 0.2);
return `
<rect x="${x - textLen / 2}" y="${y - 7}" width="${textLen}" height="14" rx="7" fill="${bgColor}"/>
<text x="${x}" y="${y + 3}" fill="${color}" font-size="8" font-family="system-ui, sans-serif" text-anchor="middle" font-weight="700">${branchName.toUpperCase()}</text>
`;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
- Step 2: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 6: git-server-metrics Component
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-server-metrics/git-server-metrics.component.tsCreate:
libs/libs-structural/git-operations/src/lib/components/git-server-metrics/git-server-metrics.component.htmlCreate:
libs/libs-structural/git-operations/src/lib/components/git-server-metrics/git-server-metrics.component.scssStep 1: Create the component .ts
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import type { HetznerHostMetrics } from '../../models/git-operations.types';
import { formatGiB } from '../../services/git-operations-format.utils';
@Component({
selector: 'git-server-metrics',
standalone: true,
imports: [DecimalPipe],
templateUrl: './git-server-metrics.component.html',
styleUrl: './git-server-metrics.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitServerMetricsComponent {
readonly metrics = input.required<HetznerHostMetrics>();
readonly memUsagePct = computed(() => {
const m = this.metrics();
return Math.round((m.host.memUsedBytes / m.host.memTotalBytes) * 100);
});
readonly cpuStatus = computed(() => this.thresholdClass(this.metrics().host.load1m / this.metrics().host.cpuCount * 100));
readonly memStatus = computed(() => this.thresholdClass(this.memUsagePct()));
readonly diskStatus = computed(() => this.thresholdClass(this.metrics().host.diskRootUsedPct ?? 0));
readonly uptimeStr = computed(() => {
const sec = this.metrics().host.uptimeSec;
const days = Math.floor(sec / 86400);
const hours = Math.floor((sec % 86400) / 3600);
return days > 0 ? `${days}d ${hours}h` : `${hours}h`;
});
readonly memTotal = computed(() => formatGiB(this.metrics().host.memTotalBytes));
private thresholdClass(pct: number): string {
if (pct < 60) return 'healthy';
if (pct < 80) return 'warning';
return 'critical';
}
}
- Step 2: Create the template .html
<div class="metrics-strip">
<div class="metric" [class]="cpuStatus()">
<span class="metric-icon">⚡</span>
<span class="metric-label">CPU</span>
<span class="metric-value">{{ metrics().host.load1m | number:'1.1-1' }}</span>
</div>
<div class="metric" [class]="memStatus()">
<span class="metric-icon">🧠</span>
<span class="metric-label">Memory</span>
<span class="metric-value">{{ memUsagePct() }}%</span>
<span class="metric-sub">of {{ memTotal() }}</span>
</div>
@if (metrics().host.diskRootUsedPct !== null) {
<div class="metric" [class]="diskStatus()">
<span class="metric-icon">💾</span>
<span class="metric-label">Disk</span>
<span class="metric-value">{{ metrics().host.diskRootUsedPct | number:'1.0-0' }}%</span>
</div>
}
<div class="metric">
<span class="metric-icon">🖥️</span>
<span class="metric-label">VMs</span>
<span class="metric-value">{{ metrics().orchestrator.activeVMs }}/{{ metrics().orchestrator.capacity }}</span>
</div>
<div class="metric">
<span class="metric-icon">⏱️</span>
<span class="metric-label">Uptime</span>
<span class="metric-value">{{ uptimeStr() }}</span>
</div>
</div>
- Step 3: Create the styles .scss
:host {
display: block;
}
.metrics-strip {
display: flex;
gap: 16px;
padding: 12px 16px;
background: var(--git-surface, #1e1e2e);
border: 1px solid var(--git-border, #313244);
border-radius: 8px;
flex-wrap: wrap;
}
.metric {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
font-size: 13px;
&.healthy { background: rgba(158, 206, 106, 0.1); }
&.warning { background: rgba(249, 226, 175, 0.15); }
&.critical { background: rgba(243, 139, 168, 0.15); }
}
.metric-icon { font-size: 14px; }
.metric-label { color: var(--git-text-muted, #6c7086); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
.metric-value { color: var(--git-text, #cdd6f4); font-weight: 600; font-family: monospace; }
.metric-sub { color: var(--git-text-muted, #6c7086); font-size: 11px; }
- Step 4: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 7: git-repo-card Component
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-repo-card/git-repo-card.component.tsCreate:
libs/libs-structural/git-operations/src/lib/components/git-repo-card/git-repo-card.component.htmlCreate:
libs/libs-structural/git-operations/src/lib/components/git-repo-card/git-repo-card.component.scssStep 1: Create the component .ts
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import type { GitRepoInfo } from '../../models/git-operations.types';
import { shortenSha, timeAgo } from '../../services/git-operations-format.utils';
@Component({
selector: 'git-repo-card',
standalone: true,
templateUrl: './git-repo-card.component.html',
styleUrl: './git-repo-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitRepoCardComponent {
readonly repo = input.required<GitRepoInfo>();
readonly selected = output<GitRepoInfo>();
readonly shortenSha = shortenSha;
readonly timeAgo = timeAgo;
onSelect(): void {
this.selected.emit(this.repo());
}
}
- Step 2: Create the template .html
<div class="repo-card" (click)="onSelect()">
<div class="repo-header">
<span class="org-dot"></span>
<span class="org-name">{{ repo().orgSlug }}</span>
<span class="separator">/</span>
<span class="repo-name">{{ repo().repoSlug }}</span>
</div>
<div class="repo-stats">
<span class="stat">
<span class="stat-icon">⎇</span>
{{ repo().branchCount ?? 0 }}
</span>
<span class="stat">
<span class="stat-icon">📄</span>
{{ repo().fileCount ?? 0 }}
</span>
</div>
@if (repo().lastCommitMessage) {
<div class="repo-commit">
<span class="commit-sha">{{ shortenSha(repo().lastCommitSha) }}</span>
<span class="commit-msg">{{ repo().lastCommitMessage }}</span>
</div>
}
@if (repo().lastCommitDate) {
<div class="repo-time">{{ timeAgo(repo().lastCommitDate) }}</div>
}
</div>
- Step 3: Create the styles .scss
:host {
display: block;
}
.repo-card {
background: var(--git-surface, #1e1e2e);
border: 1px solid var(--git-border, #313244);
border-radius: 10px;
padding: 16px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border-color: var(--git-border-hover, #45475a);
}
}
.repo-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 10px;
font-family: monospace;
}
.org-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--git-accent, #89b4fa);
margin-right: 4px;
}
.org-name { color: var(--git-text-muted, #6c7086); font-size: 13px; }
.separator { color: var(--git-text-muted, #6c7086); }
.repo-name { color: var(--git-text, #cdd6f4); font-size: 14px; font-weight: 600; }
.repo-stats {
display: flex;
gap: 12px;
margin-bottom: 8px;
}
.stat {
display: flex;
align-items: center;
gap: 4px;
color: var(--git-text-muted, #6c7086);
font-size: 12px;
}
.stat-icon { font-size: 11px; }
.repo-commit {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
font-size: 12px;
overflow: hidden;
}
.commit-sha {
color: var(--git-accent, #89b4fa);
font-family: monospace;
flex-shrink: 0;
}
.commit-msg {
color: var(--git-text-muted, #a6adc8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.repo-time {
color: var(--git-text-dim, #585b70);
font-size: 11px;
}
- Step 4: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 8: git-branch-sidebar Component
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-branch-sidebar/git-branch-sidebar.component.tsCreate:
libs/libs-structural/git-operations/src/lib/components/git-branch-sidebar/git-branch-sidebar.component.htmlCreate:
libs/libs-structural/git-operations/src/lib/components/git-branch-sidebar/git-branch-sidebar.component.scssStep 1: Create the component .ts
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
import type { GitBranchInfo } from '../../models/git-operations.types';
import type { GitOpsFeatureFlags } from '../../models/git-operations-role.config';
import { shortenSha } from '../../services/git-operations-format.utils';
@Component({
selector: 'git-branch-sidebar',
standalone: true,
templateUrl: './git-branch-sidebar.component.html',
styleUrl: './git-branch-sidebar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitBranchSidebarComponent {
readonly branches = input.required<GitBranchInfo[]>();
readonly selectedBranch = input.required<string>();
readonly features = input.required<GitOpsFeatureFlags>();
readonly loading = input(false);
readonly error = input<string | null>(null);
readonly switched = output<string>();
readonly merged = output<string>();
readonly deleted = output<string>();
readonly created = output<string>();
readonly newBranchName = signal('');
readonly showCreateForm = signal(false);
readonly shortenSha = shortenSha;
protected readonly PROTECTED = ['main', 'develop', 'master'];
isProtected(name: string): boolean {
return this.PROTECTED.includes(name);
}
onSwitch(branch: string): void {
this.switched.emit(branch);
}
onMerge(branch: string): void {
this.merged.emit(branch);
}
onDelete(branch: string): void {
this.deleted.emit(branch);
}
onCreate(): void {
const name = this.newBranchName().trim();
if (name) {
this.created.emit(name);
this.newBranchName.set('');
this.showCreateForm.set(false);
}
}
}
- Step 2: Create the template .html
<div class="branch-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Branches</span>
@if (features().canManageBranches) {
<button class="btn-icon" (click)="showCreateForm.set(!showCreateForm())" title="New branch">+</button>
}
</div>
@if (showCreateForm()) {
<div class="create-form">
<input
class="branch-input"
placeholder="Branch name..."
[value]="newBranchName()"
(input)="newBranchName.set($any($event.target).value)"
(keydown.enter)="onCreate()"
/>
<button class="btn-sm btn-create" (click)="onCreate()">Create</button>
</div>
}
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
<div class="branch-list">
@for (branch of branches(); track branch.name) {
<div
class="branch-item"
[class.active]="branch.name === selectedBranch()"
(click)="onSwitch(branch.name)"
>
<div class="branch-info">
@if (isProtected(branch.name)) {
<span class="lock-icon">🔒</span>
}
<span class="branch-name" [class.current]="branch.current">{{ branch.name }}</span>
<span class="branch-sha">{{ shortenSha(branch.sha) }}</span>
</div>
@if (branch.name === selectedBranch() && !isProtected(branch.name)) {
<div class="branch-actions">
@if (features().canMergeBranches) {
<button class="btn-action" (click)="onMerge(branch.name); $event.stopPropagation()" title="Merge to develop">⊕</button>
}
@if (features().canManageBranches) {
<button class="btn-action btn-danger" (click)="onDelete(branch.name); $event.stopPropagation()" title="Delete branch">✕</button>
}
</div>
}
</div>
}
</div>
@if (loading()) {
<div class="loading-indicator">Loading...</div>
}
</div>
- Step 3: Create the styles .scss
:host {
display: block;
}
.branch-sidebar {
background: var(--git-surface, #1e1e2e);
border: 1px solid var(--git-border, #313244);
border-radius: 8px;
overflow: hidden;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--git-border, #313244);
}
.sidebar-title {
color: var(--git-text, #cdd6f4);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-icon {
background: none;
border: 1px solid var(--git-border, #45475a);
color: var(--git-text, #cdd6f4);
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
&:hover { background: var(--git-border, #45475a); }
}
.create-form {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--git-border, #313244);
}
.branch-input {
flex: 1;
background: var(--git-bg, #11111b);
border: 1px solid var(--git-border, #45475a);
border-radius: 4px;
padding: 4px 8px;
color: var(--git-text, #cdd6f4);
font-size: 12px;
outline: none;
&:focus { border-color: var(--git-accent, #89b4fa); }
}
.btn-sm {
padding: 4px 10px;
border-radius: 4px;
border: none;
font-size: 11px;
cursor: pointer;
}
.btn-create {
background: var(--git-accent, #89b4fa);
color: #11111b;
font-weight: 600;
}
.error-banner {
padding: 8px 12px;
background: rgba(243, 139, 168, 0.15);
color: #f38ba8;
font-size: 12px;
border-bottom: 1px solid var(--git-border, #313244);
}
.branch-list {
max-height: 400px;
overflow-y: auto;
}
.branch-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.1s;
&:hover { background: rgba(205, 214, 244, 0.05); }
&.active {
background: rgba(137, 180, 250, 0.1);
border-left-color: var(--git-accent, #89b4fa);
}
}
.branch-info {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.lock-icon { font-size: 10px; }
.branch-name {
color: var(--git-text, #cdd6f4);
font-size: 13px;
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.current { font-weight: 600; }
}
.branch-sha {
color: var(--git-text-dim, #585b70);
font-size: 11px;
font-family: monospace;
flex-shrink: 0;
}
.branch-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.btn-action {
background: none;
border: 1px solid var(--git-border, #45475a);
color: var(--git-text-muted, #a6adc8);
width: 22px;
height: 22px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
&:hover { background: var(--git-border, #45475a); }
&.btn-danger:hover { background: rgba(243, 139, 168, 0.2); color: #f38ba8; }
}
.loading-indicator {
padding: 12px;
text-align: center;
color: var(--git-text-dim, #585b70);
font-size: 12px;
}
- Step 4: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 9: git-file-browser Component
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-file-browser/git-file-browser.component.tsCreate:
libs/libs-structural/git-operations/src/lib/components/git-file-browser/git-file-browser.component.htmlCreate:
libs/libs-structural/git-operations/src/lib/components/git-file-browser/git-file-browser.component.scssStep 1: Create the component .ts
import { Component, ChangeDetectionStrategy, input, output, signal, computed } from '@angular/core';
import type { GitFileTreeItem, FileGroup } from '../../models/git-operations.types';
import type { GitOpsFeatureFlags } from '../../models/git-operations-role.config';
import { getFileTypeIcon, getFileTypeClass } from '../../services/git-operations-format.utils';
import { isTemplatePath, filterTemplateFiles } from '../../models/git-template-paths.config';
@Component({
selector: 'git-file-browser',
standalone: true,
templateUrl: './git-file-browser.component.html',
styleUrl: './git-file-browser.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitFileBrowserComponent {
readonly files = input.required<GitFileTreeItem[]>();
readonly features = input.required<GitOpsFeatureFlags>();
readonly searchQuery = input('');
readonly fileSelected = output<string>();
readonly showTemplateSources = signal(false);
readonly collapsedGroups = signal<Set<string>>(new Set());
readonly getFileTypeIcon = getFileTypeIcon;
readonly getFileTypeClass = getFileTypeClass;
readonly filteredFiles = computed(() => {
let files = this.files();
// Template filtering
if (!this.features().canViewTemplateSources || !this.showTemplateSources()) {
files = filterTemplateFiles(files);
}
// Search filtering
const q = this.searchQuery().toLowerCase();
if (q) {
files = files.filter(f => f.path.toLowerCase().includes(q));
}
return files;
});
readonly groupedFiles = computed<FileGroup[]>(() => {
const files = this.filteredFiles();
const groups = new Map<string, GitFileTreeItem[]>();
for (const f of files) {
const parts = f.path.split('/');
const group = parts.length > 1 ? parts[0] : '(root)';
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(f);
}
return Array.from(groups.entries())
.map(([name, items]) => ({ name, count: items.length, files: items }))
.sort((a, b) => b.count - a.count);
});
readonly allTemplateOnly = computed(() => {
const raw = this.files();
return raw.length > 0 && raw.every(f => isTemplatePath(f.path));
});
toggleGroup(name: string): void {
const set = new Set(this.collapsedGroups());
if (set.has(name)) {
set.delete(name);
} else {
set.add(name);
}
this.collapsedGroups.set(set);
}
isCollapsed(name: string): boolean {
return this.collapsedGroups().has(name);
}
onFileSelect(path: string): void {
this.fileSelected.emit(path);
}
}
- Step 2: Create the template .html
<div class="file-browser">
@if (features().canViewTemplateSources) {
<label class="template-toggle">
<input type="checkbox" [checked]="showTemplateSources()" (change)="showTemplateSources.set(!showTemplateSources())" />
<span>Show core libraries</span>
</label>
}
@if (allTemplateOnly() && !showTemplateSources()) {
<div class="empty-state">
<span class="empty-icon">📦</span>
<span>Only core library files in this repo</span>
@if (features().canViewTemplateSources) {
<button class="btn-link" (click)="showTemplateSources.set(true)">Show core libraries</button>
}
</div>
} @else if (filteredFiles().length === 0) {
<div class="empty-state">No files match your search</div>
} @else {
@for (group of groupedFiles(); track group.name) {
<div class="file-group">
<div class="group-header" (click)="toggleGroup(group.name)">
<span class="collapse-icon">{{ isCollapsed(group.name) ? '▸' : '▾' }}</span>
<span class="group-name">{{ group.name }}</span>
<span class="group-count">{{ group.count }}</span>
</div>
@if (!isCollapsed(group.name)) {
<div class="group-files">
@for (file of group.files; track file.path) {
<div class="file-item" (click)="onFileSelect(file.path)">
<span class="file-type-icon" [class]="getFileTypeClass(file.path)">{{ getFileTypeIcon(file.path) }}</span>
<span class="file-path">{{ file.path }}</span>
</div>
}
</div>
}
</div>
}
}
</div>
- Step 3: Create the styles .scss
:host {
display: block;
}
.file-browser {
background: var(--git-surface, #1e1e2e);
border: 1px solid var(--git-border, #313244);
border-radius: 8px;
overflow: hidden;
}
.template-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--git-border, #313244);
color: var(--git-text-muted, #a6adc8);
font-size: 12px;
cursor: pointer;
input[type="checkbox"] { accent-color: var(--git-accent, #89b4fa); }
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
color: var(--git-text-muted, #6c7086);
font-size: 13px;
}
.empty-icon { font-size: 24px; }
.btn-link {
background: none;
border: none;
color: var(--git-accent, #89b4fa);
cursor: pointer;
font-size: 12px;
text-decoration: underline;
}
.file-group {
border-bottom: 1px solid var(--git-border, #313244);
&:last-child { border-bottom: none; }
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
&:hover { background: rgba(205, 214, 244, 0.05); }
}
.collapse-icon { color: var(--git-text-dim, #585b70); font-size: 10px; width: 12px; }
.group-name { color: var(--git-text, #cdd6f4); font-weight: 500; font-family: monospace; }
.group-count {
color: var(--git-text-dim, #585b70);
font-size: 11px;
margin-left: auto;
background: var(--git-border, #313244);
padding: 1px 6px;
border-radius: 8px;
}
.group-files { padding-left: 8px; }
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
cursor: pointer;
font-size: 12px;
&:hover { background: rgba(205, 214, 244, 0.05); }
}
.file-type-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
font-family: monospace;
&.icon-ts { background: #1e3a5f; color: #89b4fa; }
&.icon-html { background: #3f2a1e; color: #fab387; }
&.icon-scss { background: #2e1f3f; color: #cba6f7; }
&.icon-json { background: #1e3f1e; color: #a6e3a1; }
&.icon-other { background: var(--git-border, #313244); color: var(--git-text-muted, #a6adc8); }
}
.file-path {
color: var(--git-text-muted, #a6adc8);
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
- Step 4: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 10: git-commit-list Component
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-commit-list/git-commit-list.component.tsCreate:
libs/libs-structural/git-operations/src/lib/components/git-commit-list/git-commit-list.component.htmlCreate:
libs/libs-structural/git-operations/src/lib/components/git-commit-list/git-commit-list.component.scssStep 1: Create the component .ts
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import type { GitCommitInfo, GitCommitDiff } from '../../models/git-operations.types';
import type { GitOpsFeatureFlags } from '../../models/git-operations-role.config';
import { shortenSha, timeAgo, getFileTypeIcon, getFileTypeClass } from '../../services/git-operations-format.utils';
@Component({
selector: 'git-commit-list',
standalone: true,
templateUrl: './git-commit-list.component.html',
styleUrl: './git-commit-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitCommitListComponent {
readonly commits = input.required<GitCommitInfo[]>();
readonly expandedSha = input<string | null>(null);
readonly diff = input<GitCommitDiff | null>(null);
readonly diffLoading = input(false);
readonly features = input.required<GitOpsFeatureFlags>();
readonly toggled = output<string>();
readonly shortenSha = shortenSha;
readonly timeAgo = timeAgo;
readonly getFileTypeIcon = getFileTypeIcon;
readonly getFileTypeClass = getFileTypeClass;
onToggle(sha: string): void {
this.toggled.emit(sha);
}
}
- Step 2: Create the template .html
<div class="commit-list">
@for (commit of commits(); track commit.sha) {
<div class="commit-row" [class.expanded]="expandedSha() === commit.sha" (click)="onToggle(commit.sha)">
<div class="commit-main">
<span class="commit-sha">{{ shortenSha(commit.sha) }}</span>
<span class="commit-msg">{{ commit.message }}</span>
<span class="commit-meta">
<span class="commit-author">{{ commit.author }}</span>
<span class="commit-time">{{ timeAgo(commit.date) }}</span>
</span>
</div>
@if (expandedSha() === commit.sha) {
<div class="commit-detail">
@if (diffLoading()) {
<div class="detail-loading">Loading diff...</div>
} @else if (diff()) {
<div class="detail-stats">
<span class="stat-files">{{ diff()!.stats.filesChanged }} files</span>
<span class="stat-add">+{{ diff()!.stats.insertions }}</span>
<span class="stat-del">-{{ diff()!.stats.deletions }}</span>
</div>
<div class="detail-files">
@for (file of diff()!.files; track file.path) {
<div class="diff-file">
<span class="file-icon" [class]="getFileTypeClass(file.path)">{{ getFileTypeIcon(file.path) }}</span>
<span class="file-path">{{ file.path }}</span>
<span class="file-stats">
@if (file.insertions > 0) { <span class="add">+{{ file.insertions }}</span> }
@if (file.deletions > 0) { <span class="del">-{{ file.deletions }}</span> }
</span>
</div>
}
</div>
}
</div>
}
</div>
}
@if (commits().length === 0) {
<div class="empty-state">No commits found</div>
}
</div>
- Step 3: Create the styles .scss
:host {
display: block;
}
.commit-list {
background: var(--git-surface, #1e1e2e);
border: 1px solid var(--git-border, #313244);
border-radius: 8px;
overflow: hidden;
}
.commit-row {
padding: 8px 12px;
border-bottom: 1px solid var(--git-border, #313244);
cursor: pointer;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: rgba(205, 214, 244, 0.03); }
&.expanded { background: rgba(137, 180, 250, 0.05); }
}
.commit-main {
display: flex;
align-items: center;
gap: 8px;
}
.commit-sha {
color: var(--git-accent, #89b4fa);
font-family: monospace;
font-size: 12px;
flex-shrink: 0;
}
.commit-msg {
color: var(--git-text, #cdd6f4);
font-size: 13px;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.commit-meta {
display: flex;
gap: 8px;
flex-shrink: 0;
font-size: 11px;
}
.commit-author { color: var(--git-text-muted, #a6adc8); }
.commit-time { color: var(--git-text-dim, #585b70); }
.commit-detail {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--git-border, #313244);
}
.detail-loading {
color: var(--git-text-dim, #585b70);
font-size: 12px;
padding: 8px 0;
}
.detail-stats {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
font-family: monospace;
}
.stat-files { color: var(--git-text-muted, #a6adc8); }
.stat-add { color: #9ece6a; }
.stat-del { color: #f38ba8; }
.detail-files {
display: flex;
flex-direction: column;
gap: 2px;
}
.diff-file {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 12px;
}
.file-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
font-size: 9px;
font-weight: 700;
font-family: monospace;
&.icon-ts { background: #1e3a5f; color: #89b4fa; }
&.icon-html { background: #3f2a1e; color: #fab387; }
&.icon-scss { background: #2e1f3f; color: #cba6f7; }
&.icon-json { background: #1e3f1e; color: #a6e3a1; }
&.icon-other { background: var(--git-border, #313244); color: var(--git-text-muted, #a6adc8); }
}
.file-path {
color: var(--git-text-muted, #a6adc8);
font-family: monospace;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-stats {
font-family: monospace;
flex-shrink: 0;
}
.add { color: #9ece6a; margin-right: 4px; }
.del { color: #f38ba8; }
.empty-state {
padding: 24px;
text-align: center;
color: var(--git-text-dim, #585b70);
font-size: 13px;
}
- Step 4: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 11: git-commit-detail-panel Component
Files:
Create:
libs/libs-structural/git-operations/src/lib/components/git-commit-detail-panel/git-commit-detail-panel.component.tsCreate:
libs/libs-structural/git-operations/src/lib/components/git-commit-detail-panel/git-commit-detail-panel.component.htmlCreate:
libs/libs-structural/git-operations/src/lib/components/git-commit-detail-panel/git-commit-detail-panel.component.scssStep 1: Create the component .ts
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import type { GitCommitDiff } from '../../models/git-operations.types';
import { shortenSha, timeAgo, getFileTypeIcon, getFileTypeClass } from '../../services/git-operations-format.utils';
@Component({
selector: 'git-commit-detail-panel',
standalone: true,
templateUrl: './git-commit-detail-panel.component.html',
styleUrl: './git-commit-detail-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitCommitDetailPanelComponent {
readonly diff = input.required<GitCommitDiff>();
readonly visible = input.required<boolean>();
readonly closed = output<void>();
readonly shortenSha = shortenSha;
readonly timeAgo = timeAgo;
readonly getFileTypeIcon = getFileTypeIcon;
readonly getFileTypeClass = getFileTypeClass;
onClose(): void {
this.closed.emit();
}
onBackdropClick(event: MouseEvent): void {
if ((event.target as HTMLElement).classList.contains('panel-backdrop')) {
this.onClose();
}
}
getInsertionBarWidth(file: { insertions: number; deletions: number }): number {
const total = file.insertions + file.deletions;
return total > 0 ? (file.insertions / total) * 100 : 0;
}
}
- Step 2: Create the template .html
@if (visible()) {
<div class="panel-backdrop" (click)="onBackdropClick($event)">
<div class="panel-slide">
<div class="panel-header">
<div class="panel-title">Commit Detail</div>
<button class="btn-close" (click)="onClose()">✕</button>
</div>
<div class="panel-body">
<div class="commit-info">
<div class="info-row">
<span class="info-label">SHA</span>
<span class="info-value sha">{{ diff().sha }}</span>
</div>
<div class="info-row">
<span class="info-label">Author</span>
<span class="info-value">{{ diff().author }}</span>
</div>
<div class="info-row">
<span class="info-label">Date</span>
<span class="info-value">{{ timeAgo(diff().date) }}</span>
</div>
<div class="info-row">
<span class="info-label">Message</span>
<span class="info-value msg">{{ diff().message }}</span>
</div>
</div>
<div class="stats-summary">
<span class="stat">{{ diff().stats.filesChanged }} files changed</span>
<span class="stat add">+{{ diff().stats.insertions }}</span>
<span class="stat del">-{{ diff().stats.deletions }}</span>
</div>
<div class="file-list">
@for (file of diff().files; track file.path) {
<div class="file-entry">
<div class="file-header">
<span class="file-icon" [class]="getFileTypeClass(file.path)">{{ getFileTypeIcon(file.path) }}</span>
<span class="file-path">{{ file.path }}</span>
</div>
<div class="file-bar">
<div class="bar-track">
<div class="bar-add" [style.width.%]="getInsertionBarWidth(file)"></div>
</div>
<span class="bar-stats">
<span class="add">+{{ file.insertions }}</span>
<span class="del">-{{ file.deletions }}</span>
</span>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
- Step 3: Create the styles .scss
.panel-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
justify-content: flex-end;
}
.panel-slide {
width: 420px;
max-width: 90vw;
background: var(--git-bg, #11111b);
border-left: 1px solid var(--git-border, #313244);
display: flex;
flex-direction: column;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--git-border, #313244);
}
.panel-title {
color: var(--git-text, #cdd6f4);
font-size: 14px;
font-weight: 600;
}
.btn-close {
background: none;
border: none;
color: var(--git-text-muted, #a6adc8);
font-size: 16px;
cursor: pointer;
padding: 4px;
&:hover { color: var(--git-text, #cdd6f4); }
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.commit-info {
margin-bottom: 16px;
}
.info-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 13px;
}
.info-label {
color: var(--git-text-dim, #585b70);
width: 60px;
flex-shrink: 0;
text-transform: uppercase;
font-size: 10px;
padding-top: 2px;
}
.info-value {
color: var(--git-text, #cdd6f4);
&.sha { font-family: monospace; color: var(--git-accent, #89b4fa); font-size: 12px; word-break: break-all; }
&.msg { line-height: 1.4; }
}
.stats-summary {
display: flex;
gap: 12px;
padding: 10px 12px;
background: var(--git-surface, #1e1e2e);
border-radius: 6px;
margin-bottom: 16px;
font-size: 12px;
font-family: monospace;
}
.stat { color: var(--git-text-muted, #a6adc8); }
.add { color: #9ece6a; }
.del { color: #f38ba8; }
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-entry {
padding: 8px;
background: var(--git-surface, #1e1e2e);
border-radius: 6px;
}
.file-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.file-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
font-family: monospace;
&.icon-ts { background: #1e3a5f; color: #89b4fa; }
&.icon-html { background: #3f2a1e; color: #fab387; }
&.icon-scss { background: #2e1f3f; color: #cba6f7; }
&.icon-json { background: #1e3f1e; color: #a6e3a1; }
&.icon-other { background: var(--git-border, #313244); color: var(--git-text-muted, #a6adc8); }
}
.file-path {
color: var(--git-text-muted, #a6adc8);
font-family: monospace;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-bar {
display: flex;
align-items: center;
gap: 8px;
}
.bar-track {
flex: 1;
height: 4px;
background: #f38ba8;
border-radius: 2px;
overflow: hidden;
}
.bar-add {
height: 100%;
background: #9ece6a;
border-radius: 2px;
}
.bar-stats {
font-family: monospace;
font-size: 11px;
flex-shrink: 0;
}
- Step 4: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 12: git-graph Star Component
Files:
- Create:
libs/libs-structural/git-operations/src/lib/components/git-graph/git-graph.component.ts - Create:
libs/libs-structural/git-operations/src/lib/components/git-graph/git-graph.component.html - Create:
libs/libs-structural/git-operations/src/lib/components/git-graph/git-graph.component.scss
This is the star component. It depends on Tasks 4, 5, and 11. It uses the lane algorithm and SVG renderer to produce the Metro + Living Intelligence graph.
- Step 1: Create the component .ts
import { Component, ChangeDetectionStrategy, input, output, computed, signal } from '@angular/core';
import { DomSanitizer, type SafeHtml } from '@angular/platform-browser';
import { inject } from '@angular/core';
import type { GitCommitInfo, GitBranchInfo, GitCommitDiff, GitCommitNode } from '../../models/git-operations.types';
import type { GitOpsFeatureFlags } from '../../models/git-operations-role.config';
import { assignLanes } from './git-graph-lane.util';
import {
computeSvgWidth,
computeSvgHeight,
getRowY,
getLaneX,
getRowHeight,
generateSvgDefs,
generateBranchLine,
generateMergePath,
generateCommitNode,
generateBranchLabel,
} from './git-graph-renderer.util';
import { shortenSha, timeAgo } from '../../services/git-operations-format.utils';
import { GitCommitDetailPanelComponent } from '../git-commit-detail-panel/git-commit-detail-panel.component';
@Component({
selector: 'git-graph',
standalone: true,
imports: [GitCommitDetailPanelComponent],
templateUrl: './git-graph.component.html',
styleUrl: './git-graph.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitGraphComponent {
private readonly sanitizer = inject(DomSanitizer);
readonly commits = input.required<GitCommitInfo[]>();
readonly branches = input.required<GitBranchInfo[]>();
readonly features = input.required<GitOpsFeatureFlags>();
readonly compact = input(false);
readonly expandedSha = input<string | null>(null);
readonly diff = input<GitCommitDiff | null>(null);
readonly diffLoading = input(false);
readonly commitClicked = output<string>();
readonly branchSwitched = output<string>();
readonly fullDetailRequested = output<string>();
readonly detailPanelVisible = signal(false);
readonly shortenSha = shortenSha;
readonly timeAgo = timeAgo;
readonly getRowHeight = getRowHeight;
readonly graphData = computed(() => {
return assignLanes(this.commits(), this.branches());
});
readonly svgWidth = computed(() => computeSvgWidth(this.graphData().lanes.length));
readonly svgHeight = computed(() => computeSvgHeight(this.graphData().nodes.length));
readonly svgContent = computed<SafeHtml>(() => {
const { nodes, lanes } = this.graphData();
if (!nodes.length) return '';
const parts: string[] = [];
parts.push(generateSvgDefs());
// Draw branch lines
for (const lane of lanes) {
const laneNodes = nodes.filter(n => n.laneIndex === lane.column);
if (laneNodes.length < 2) continue;
for (let i = 0; i < laneNodes.length - 1; i++) {
const fromIdx = nodes.indexOf(laneNodes[i]);
const toIdx = nodes.indexOf(laneNodes[i + 1]);
parts.push(generateBranchLine(fromIdx, toIdx, lane.column, lane.color, lane.active));
}
}
// Draw merge/fork paths
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.parentShas.length > 1) {
for (let p = 1; p < node.parentShas.length; p++) {
const parentNode = nodes.find(n => n.sha === node.parentShas[p]);
if (parentNode) {
const parentIdx = nodes.indexOf(parentNode);
parts.push(generateMergePath(i, node.laneIndex, parentIdx, parentNode.laneIndex, node.color));
}
}
}
}
// Draw commit nodes
for (let i = 0; i < nodes.length; i++) {
parts.push(generateCommitNode(nodes[i], i));
}
// Draw branch labels at tips
for (const node of nodes) {
if (node.isHead && node.branchName) {
const idx = nodes.indexOf(node);
parts.push(generateBranchLabel(node.branchName, node.laneIndex, idx, node.color));
}
}
return this.sanitizer.bypassSecurityTrustHtml(parts.join('\n'));
});
onCommitClick(sha: string): void {
this.commitClicked.emit(sha);
}
onBranchClick(branchName: string, event: MouseEvent): void {
event.stopPropagation();
this.branchSwitched.emit(branchName);
}
onFullDetail(sha: string): void {
this.detailPanelVisible.set(true);
this.fullDetailRequested.emit(sha);
}
closeDetailPanel(): void {
this.detailPanelVisible.set(false);
}
getRowY(index: number): number {
return getRowY(index);
}
getNodeOpacity(node: GitCommitNode): number {
const opacityMap = { recent: 1, active: 0.9, aging: 0.7, old: 0.35 };
return opacityMap[node.age];
}
}
- Step 2: Create the template .html
<div class="git-graph" [class.compact]="compact()">
@if (graphData().nodes.length === 0) {
<div class="empty-state">No commits yet</div>
} @else {
<div class="graph-container">
<!-- SVG Graph Column -->
<div class="graph-svg-col" [style.width.px]="svgWidth()">
<svg
[attr.width]="svgWidth()"
[attr.height]="svgHeight()"
[attr.viewBox]="'0 0 ' + svgWidth() + ' ' + svgHeight()"
class="graph-svg"
[innerHTML]="svgContent()"
></svg>
</div>
<!-- Commit List Column -->
<div class="graph-commits-col">
@for (node of graphData().nodes; track node.sha; let i = $index) {
<div
class="commit-row"
[class.expanded]="expandedSha() === node.sha"
[class.merge]="node.isMerge"
[style.height.px]="getRowHeight()"
[style.opacity]="getNodeOpacity(node)"
[style.border-left-color]="node.color"
(click)="onCommitClick(node.sha)"
>
<div class="commit-main">
<span class="commit-sha" [style.color]="node.color">{{ shortenSha(node.sha) }}</span>
@if (node.branchName) {
<span
class="branch-pill"
[style.color]="node.color"
[style.background]="node.color + '20'"
(click)="onBranchClick(node.branchName, $event)"
>{{ node.branchName }}</span>
}
@if (node.isMerge) {
<span class="merge-badge">merge</span>
}
<span class="commit-msg">{{ node.message }}</span>
@if (!compact()) {
<span class="commit-time">{{ timeAgo(node.date) }}</span>
}
</div>
@if (expandedSha() === node.sha) {
<div class="commit-expand" (click)="$event.stopPropagation()">
@if (diffLoading()) {
<span class="expand-loading">Loading...</span>
} @else if (diff()) {
<div class="expand-stats">
<span>{{ diff()!.stats.filesChanged }} files</span>
<span class="add">+{{ diff()!.stats.insertions }}</span>
<span class="del">-{{ diff()!.stats.deletions }}</span>
</div>
<div class="expand-files">
@for (file of diff()!.files; track file.path) {
<span class="expand-file">{{ file.path }}</span>
}
</div>
@if (!compact()) {
<button class="btn-full-detail" (click)="onFullDetail(node.sha)">Full detail →</button>
}
}
</div>
}
</div>
}
</div>
</div>
}
@if (!compact() && diff()) {
<git-commit-detail-panel
[diff]="diff()!"
[visible]="detailPanelVisible()"
(closed)="closeDetailPanel()"
/>
}
</div>
- Step 3: Create the styles .scss
:host {
display: block;
}
.git-graph {
background: var(--git-bg, #11111b);
border: 1px solid var(--git-border, #313244);
border-radius: 8px;
overflow: hidden;
}
.empty-state {
padding: 48px 24px;
text-align: center;
color: var(--git-text-dim, #585b70);
font-size: 14px;
}
.graph-container {
display: flex;
overflow-y: auto;
max-height: 600px;
}
.graph-svg-col {
flex-shrink: 0;
position: sticky;
left: 0;
z-index: 1;
background: var(--git-bg, #11111b);
}
.graph-svg {
display: block;
}
.graph-commits-col {
flex: 1;
min-width: 0;
}
.commit-row {
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 12px;
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.1s;
&:hover { background: rgba(205, 214, 244, 0.03); }
&.expanded { background: rgba(137, 180, 250, 0.05); }
&.merge { font-style: italic; }
}
.commit-main {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.commit-sha {
font-family: monospace;
font-size: 11px;
flex-shrink: 0;
}
.branch-pill {
font-size: 8px;
font-weight: 700;
padding: 1px 8px;
border-radius: 10px;
letter-spacing: 0.3px;
text-transform: uppercase;
cursor: pointer;
flex-shrink: 0;
&:hover { filter: brightness(1.2); }
}
.merge-badge {
font-size: 8px;
font-weight: 600;
padding: 1px 6px;
border-radius: 8px;
background: rgba(187, 154, 247, 0.2);
color: #cba6f7;
text-transform: uppercase;
flex-shrink: 0;
}
.commit-msg {
color: var(--git-text, #cdd6f4);
font-size: 12px;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.commit-time {
color: var(--git-text-dim, #585b70);
font-size: 10px;
flex-shrink: 0;
}
.commit-expand {
padding: 6px 0 4px;
font-size: 11px;
}
.expand-loading {
color: var(--git-text-dim, #585b70);
}
.expand-stats {
display: flex;
gap: 10px;
margin-bottom: 4px;
font-family: monospace;
color: var(--git-text-muted, #a6adc8);
.add { color: #9ece6a; }
.del { color: #f38ba8; }
}
.expand-files {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.expand-file {
font-family: monospace;
font-size: 10px;
color: var(--git-text-muted, #a6adc8);
background: var(--git-surface, #1e1e2e);
padding: 1px 6px;
border-radius: 4px;
}
.btn-full-detail {
background: none;
border: 1px solid var(--git-border, #45475a);
color: var(--git-accent, #89b4fa);
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
&:hover { background: rgba(137, 180, 250, 0.1); }
}
// Compact mode overrides
.compact {
.graph-container { max-height: 400px; }
.commit-row { padding: 0 8px; }
.commit-main { gap: 6px; }
.commit-msg { font-size: 11px; }
}
- Step 4: Verify compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 13: Update Library Exports
Files:
Modify:
libs/libs-structural/git-operations/src/index.tsStep 1: Add all new exports to index.ts
Add the following export blocks to src/index.ts:
// ─── Components ───────────────────────────────────────────────────────────────
export { GitGraphComponent } from './lib/components/git-graph/git-graph.component';
export { GitRepoCardComponent } from './lib/components/git-repo-card/git-repo-card.component';
export { GitBranchSidebarComponent } from './lib/components/git-branch-sidebar/git-branch-sidebar.component';
export { GitFileBrowserComponent } from './lib/components/git-file-browser/git-file-browser.component';
export { GitCommitListComponent } from './lib/components/git-commit-list/git-commit-list.component';
export { GitServerMetricsComponent } from './lib/components/git-server-metrics/git-server-metrics.component';
export { GitCommitDetailPanelComponent } from './lib/components/git-commit-detail-panel/git-commit-detail-panel.component';
// ─── Graph Utilities ──────────────────────────────────────────────────────────
export { assignLanes, computeCommitAge } from './lib/components/git-graph/git-graph-lane.util';
export type { LaneAssignmentResult } from './lib/components/git-graph/git-graph-lane.util';
// ─── Template Filtering ──────────────────────────────────────────────────────
export {
TEMPLATE_CORE_PATHS,
isTemplatePath,
isTemplateOnlyCommit,
filterTemplateFiles,
} from './lib/models/git-template-paths.config';
Also add the new types to the existing type exports block:
export type {
// ... existing types ...
GitCommitNode,
GitGraphLane,
CommitAgeTier,
} from './lib/models/git-operations.types';
- Step 2: Verify full library compile
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean.
Task 14: Refactor gograb-dev Dashboard
Files:
- Modify:
apps/gograb-dev/src/app/pages/infrastructure/git-operations/git-operations.component.ts - Modify:
apps/gograb-dev/src/app/pages/infrastructure/git-operations/git-operations.component.html - Modify:
apps/gograb-dev/src/app/pages/infrastructure/git-operations/git-operations.component.scss
The monolithic 304-line template + 1014-line SCSS becomes a thin shell composing the library components.
- Step 1: Refactor the component .ts
Replace the component to import and compose the new library components:
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, inject } from '@angular/core';
import { GitOperationsStateService } from '@libs-structural/git-operations';
import { GitGraphComponent } from '@libs-structural/git-operations';
import { GitRepoCardComponent } from '@libs-structural/git-operations';
import { GitBranchSidebarComponent } from '@libs-structural/git-operations';
import { GitFileBrowserComponent } from '@libs-structural/git-operations';
import { GitCommitListComponent } from '@libs-structural/git-operations';
import { GitServerMetricsComponent } from '@libs-structural/git-operations';
@Component({
selector: 'app-git-operations',
standalone: true,
imports: [
GitGraphComponent,
GitRepoCardComponent,
GitBranchSidebarComponent,
GitFileBrowserComponent,
GitCommitListComponent,
GitServerMetricsComponent,
],
templateUrl: './git-operations.component.html',
styleUrl: './git-operations.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GitOperationsComponent implements OnInit, OnDestroy {
readonly state = inject(GitOperationsStateService);
ngOnInit(): void {
this.state.setRole('developer');
this.state.startPolling(30_000);
}
ngOnDestroy(): void {
this.state.stopPolling();
}
}
- Step 2: Refactor the template .html
Replace the monolithic template with the composition of library components. The template uses the same two-view architecture (overview / detail) but delegates rendering to the sub-components:
<!-- Overview: no repo selected -->
@if (!state.selectedRepo()) {
<div class="dashboard-header">
<h1>Git Operations</h1>
<div class="header-actions">
<button class="btn-refresh" (click)="state.loadAll()">↻ Refresh</button>
@if (state.lastRefresh()) {
<span class="last-refresh">Updated {{ state.lastRefresh()!.toLocaleTimeString() }}</span>
}
</div>
</div>
@if (state.error()) {
<div class="error-banner">{{ state.error() }}</div>
}
@if (state.features().showServerMetrics && state.hostMetrics()) {
<git-server-metrics [metrics]="state.hostMetrics()!" />
}
<div class="stats-strip">
<span class="stat">{{ state.totalRepos() }} repos</span>
<span class="stat">{{ state.totalBranches() }} branches</span>
<span class="stat">{{ state.totalFiles() }} files</span>
</div>
<div class="search-bar">
<input
placeholder="Search repositories..."
[value]="state.searchQuery()"
(input)="state.searchQuery.set($any($event.target).value)"
/>
</div>
<div class="repo-grid">
@for (repo of state.searchedRepos(); track repo.repoPath) {
<git-repo-card [repo]="repo" (selected)="state.selectRepo($event)" />
}
</div>
} @else {
<!-- Detail: repo selected -->
<div class="detail-header">
<button class="btn-back" (click)="state.deselectRepo()">← Back</button>
<span class="detail-title">{{ state.selectedRepo()!.orgSlug }} / {{ state.selectedRepo()!.repoSlug }}</span>
</div>
<div class="detail-layout">
<div class="detail-sidebar">
<git-branch-sidebar
[branches]="state.repoBranches()"
[selectedBranch]="state.selectedBranch()"
[features]="state.features()"
[loading]="state.branchActionLoading()"
[error]="state.branchActionError()"
(switched)="state.switchBranch($event)"
(merged)="state.mergeBranchToDevelop($event)"
(deleted)="state.deleteBranch($event)"
(created)="state.createBranch($event)"
/>
</div>
<div class="detail-main">
<div class="tab-bar">
<button class="tab" [class.active]="state.detailTab() === 'graph'" (click)="state.setDetailTab('graph')">Graph</button>
<button class="tab" [class.active]="state.detailTab() === 'files'" (click)="state.setDetailTab('files')">Files</button>
<button class="tab" [class.active]="state.detailTab() === 'commits'" (click)="state.setDetailTab('commits')">Commits</button>
</div>
@switch (state.detailTab()) {
@case ('graph') {
<git-graph
[commits]="state.commitHistory()"
[branches]="state.repoBranches()"
[features]="state.features()"
[expandedSha]="state.expandedCommitSha()"
[diff]="state.commitDiff()"
[diffLoading]="state.commitDiffLoading()"
(commitClicked)="state.toggleCommitDetail($event)"
(branchSwitched)="state.switchBranch($event)"
/>
}
@case ('files') {
<git-file-browser
[files]="state.repoFiles()"
[features]="state.features()"
[searchQuery]="state.fileSearchQuery()"
(fileSelected)="state.openFile($event)"
/>
}
@case ('commits') {
<git-commit-list
[commits]="state.commitHistory()"
[expandedSha]="state.expandedCommitSha()"
[diff]="state.commitDiff()"
[diffLoading]="state.commitDiffLoading()"
[features]="state.features()"
(toggled)="state.toggleCommitDetail($event)"
/>
}
}
</div>
</div>
}
- Step 3: Update the state service detailTab to include 'graph'
In git-operations-state.service.ts, make three changes:
- Update the
detailTabsignal type and default:
readonly detailTab = signal<'graph' | 'files' | 'commits'>('graph');
- Update
setDetailTabmethod:
setDetailTab(tab: 'graph' | 'files' | 'commits'): void {
this.detailTab.set(tab);
}
- Update
selectRepo()to default to graph tab (currently hard-codes'files'on line 196):
// In selectRepo(), change:
this.detailTab.set('graph'); // was 'files'
- Step 4: Simplify the SCSS
Replace the entire 1014-line SCSS with a much smaller file that only styles the shell layout. Component-level styles now live in the library components:
:host {
display: block;
padding: 24px;
color: #cdd6f4;
font-family: system-ui, -apple-system, sans-serif;
--git-bg: #11111b;
--git-surface: #1e1e2e;
--git-border: #313244;
--git-border-hover: #45475a;
--git-text: #cdd6f4;
--git-text-muted: #a6adc8;
--git-text-dim: #585b70;
--git-accent: #89b4fa;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h1 {
font-size: 20px;
font-weight: 600;
color: var(--git-text);
margin: 0;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.btn-refresh {
background: var(--git-surface);
border: 1px solid var(--git-border);
color: var(--git-text);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
&:hover { border-color: var(--git-border-hover); }
}
.last-refresh {
color: var(--git-text-dim);
font-size: 12px;
}
.error-banner {
padding: 10px 14px;
background: rgba(243, 139, 168, 0.15);
border: 1px solid rgba(243, 139, 168, 0.3);
border-radius: 6px;
color: #f38ba8;
font-size: 13px;
margin-bottom: 16px;
}
.stats-strip {
display: flex;
gap: 16px;
margin: 16px 0;
font-size: 13px;
}
.stat {
color: var(--git-text-muted);
font-family: monospace;
}
.search-bar {
margin-bottom: 16px;
input {
width: 100%;
padding: 8px 12px;
background: var(--git-surface);
border: 1px solid var(--git-border);
border-radius: 6px;
color: var(--git-text);
font-size: 13px;
outline: none;
&:focus { border-color: var(--git-accent); }
}
}
.repo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.btn-back {
background: none;
border: 1px solid var(--git-border);
color: var(--git-text);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
&:hover { border-color: var(--git-border-hover); }
}
.detail-title {
font-family: monospace;
font-size: 16px;
font-weight: 600;
}
.detail-layout {
display: flex;
gap: 16px;
}
.detail-sidebar {
width: 240px;
flex-shrink: 0;
}
.detail-main {
flex: 1;
min-width: 0;
}
.tab-bar {
display: flex;
gap: 2px;
margin-bottom: 12px;
border-bottom: 1px solid var(--git-border);
}
.tab {
background: none;
border: none;
color: var(--git-text-muted);
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
&:hover { color: var(--git-text); }
&.active {
color: var(--git-accent);
border-bottom-color: var(--git-accent);
}
}
- Step 5: Verify gograb-dev compiles
Run: npx tsc --noEmit --project apps/gograb-dev/tsconfig.app.json
Expected: Clean.
Task 15: gograb-admin Integration
Files:
Create:
apps/gograb-admin/src/app/pages/platform/git-operations/admin-git-operations.component.tsCreate:
apps/gograb-admin/src/app/pages/platform/git-operations/admin-git-operations.component.htmlCreate:
apps/gograb-admin/src/app/pages/platform/git-operations/admin-git-operations.component.scssModify:
apps/gograb-admin/src/app/core/layout/admin-main-shell/admin-main-shell.component.tsModify:
apps/gograb-admin/src/app/app.routes.tsModify:
apps/gograb-admin/src/app/app.config.tsStep 1: Create admin page component (.ts, .html, .scss)
A thin page component similar to gograb-dev but with admin role and no server metrics. Create the 3 files in apps/gograb-admin/src/app/pages/platform/git-operations/. The component follows the same pattern as the refactored gograb-dev component but sets role to 'admin'.
- Step 2: Add nav group to admin-main-shell.component.ts
Add the platform nav group to adminNavGroups array (after the configuration group):
{
id: 'platform',
label: 'Platform',
items: [
{ id: 'git-operations', label: 'Git Operations', icon: 'commit', route: '/platform/git-operations' },
],
}
- Step 3: Add route to app.routes.ts
Add the lazy-loaded route for git operations (before the config routes):
{
path: 'platform',
children: [
{
path: 'git-operations',
loadComponent: () => import('./pages/platform/git-operations/admin-git-operations.component').then(m => m.AdminGitOperationsComponent),
},
],
}
- Step 4: Add provider to app.config.ts
Add provideGitOperations() to the providers array:
import { provideGitOperations } from '@libs-structural/git-operations';
// In providers array:
provideGitOperations({
baseUrl: '/sandbox-api',
apiKey: 'W8Wp0ltL8El_qRnXyoUoD0ffuSfZzmbn7Y7SVrx1k8s',
}),
- Step 5: Verify gograb-admin compiles
Run: npx tsc --noEmit --project apps/gograb-admin/tsconfig.app.json
Expected: Clean.
Task 16: vitto-studio Dock Panel
Files:
Create:
libs/libs-structural/vitto-studio/src/lib/panels/git-graph-panel.component.tsModify:
libs/libs-structural/vitto-studio/src/lib/config/studio-dock-panels.config.tsModify:
apps/gograb-ai/src/app/app.config.tsStep 1: Create the dock panel wrapper (3 files)
Create libs/libs-structural/vitto-studio/src/lib/panels/git-graph-panel.component.ts:
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { GitGraphComponent, GitOperationsStateService } from '@libs-structural/git-operations';
@Component({
selector: 'studio-git-graph-panel',
standalone: true,
imports: [GitGraphComponent],
templateUrl: './git-graph-panel.component.html',
styleUrl: './git-graph-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StudioGitGraphPanelComponent implements OnInit, OnDestroy {
readonly state = inject(GitOperationsStateService);
private readonly route = inject(ActivatedRoute);
ngOnInit(): void {
this.state.setRole('user');
// Get space slug from route params (walk up parent routes if needed)
let currentRoute = this.route;
while (currentRoute.parent) {
const spaceSlug = currentRoute.snapshot.paramMap.get('spaceSlug');
if (spaceSlug) {
this.state.setSpaceFilter(spaceSlug);
break;
}
currentRoute = currentRoute.parent;
}
this.state.startPolling(60_000);
}
ngOnDestroy(): void {
this.state.stopPolling();
}
}
Create libs/libs-structural/vitto-studio/src/lib/panels/git-graph-panel.component.html:
@if (state.commitHistory().length > 0) {
<git-graph
[commits]="state.commitHistory()"
[branches]="state.repoBranches()"
[features]="state.features()"
[compact]="true"
[expandedSha]="state.expandedCommitSha()"
[diff]="state.commitDiff()"
[diffLoading]="state.commitDiffLoading()"
(commitClicked)="state.toggleCommitDetail($event)"
(branchSwitched)="state.switchBranch($event)"
/>
} @else {
<div class="empty">No git data available</div>
}
Create libs/libs-structural/vitto-studio/src/lib/panels/git-graph-panel.component.scss:
:host {
display: block;
height: 100%;
overflow: auto;
}
.empty {
padding: 24px;
text-align: center;
color: #6c7086;
font-size: 13px;
}
- Step 2: Register in STUDIO_DOCK_PANELS
In studio-dock-panels.config.ts, add the import and panel registration:
import { StudioGitGraphPanelComponent } from '../panels/git-graph-panel.component';
// Under the // === GIT === section, add:
{ id: 'studio-git-graph', title: 'Graph', icon: 'commit', component: StudioGitGraphPanelComponent, closable: true, draggable: true, singleton: true, category: 'git' },
- Step 3: Add to STUDIO_CODE_LAYOUT
In the STUDIO_CODE_LAYOUT builder, add 'studio-git-graph' to the bottom tabs alongside the other git panels:
.tabs({ size: 0.3 })
.panel('studio-properties')
.panel('studio-api-explorer')
.panel('studio-file-review')
.panel('studio-git-graph') // ADD THIS
.panel('studio-branches')
.panel('studio-diff')
.panel('studio-history')
.panel('studio-generations')
.endTabs()
- Step 4: Add provideGitOperations to gograb-ai app.config.ts
Add the provider to the app config (gograb-ai hosts vitto-studio):
import { provideGitOperations } from '@libs-structural/git-operations';
// In providers array:
provideGitOperations({
baseUrl: '/sandbox-api',
apiKey: 'W8Wp0ltL8El_qRnXyoUoD0ffuSfZzmbn7Y7SVrx1k8s',
}),
- Step 5: Verify vitto-studio compiles
Run: npx tsc --noEmit --project libs/libs-structural/vitto-studio/tsconfig.lib.json
Expected: Clean.
Task 17: Final Verification
- Step 1: Verify git-operations library builds
Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json
Expected: Clean, 0 errors.
- Step 2: Verify gograb-dev app compiles
Run: npx tsc --noEmit --project apps/gograb-dev/tsconfig.app.json
Expected: Clean.
- Step 3: Verify gograb-admin app compiles
Run: npx tsc --noEmit --project apps/gograb-admin/tsconfig.app.json
Expected: Clean.
- Step 4: Verify vitto-studio library compiles
Run: npx tsc --noEmit --project libs/libs-structural/vitto-studio/tsconfig.lib.json
Expected: Clean.
- Step 5: Run existing git-provider tests (regression)
Run: npx nx test backend-api --testPathPattern="git-provider"
Expected: All 177 tests pass.
- Step 6: Visual smoke test
Start gograb-dev dev server and navigate to /platform/git-operations:
- Overview: repo cards render with branch/file counts
- Click a repo: detail view with graph tab as default
- Graph: SVG renders with metro-style thick lines, station dots, branch labels
- Click commit: inline expansion with diff stats
- File browser: template files filtered by default, toggle visible for developer role