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:

All subsequent tasks depend on these types compiling first.

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
}

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;
}

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,
  },
};

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));
}

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:

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.

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean, 0 errors.


Task 3: Backend Enrichment — Add parentShas

Files:

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.

Run: npx nx build sandbox-orchestrator Expected: Build succeeds.


Task 4: Lane Assignment Algorithm (Pure Utility)

Files:

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 };
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 5: SVG Renderer (Pure Utility)

Files:

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})`;
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 6: git-server-metrics Component

Files:

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';
  }
}
<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>
: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; }

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 7: git-repo-card Component

Files:

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());
  }
}
<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>
: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;
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 8: git-branch-sidebar Component

Files:

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);
    }
  }
}
<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>
: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;
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 9: git-file-browser Component

Files:

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);
  }
}
<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>
: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;
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 10: git-commit-list Component

Files:

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);
  }
}
<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>
: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;
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 11: git-commit-detail-panel Component

Files:

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;
  }
}
@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>
}
.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;
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 12: git-graph Star Component

Files:

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.

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];
  }
}
<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>
: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; }
}

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 13: Update Library Exports

Files:

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';

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean.


Task 14: Refactor gograb-dev Dashboard

Files:

The monolithic 304-line template + 1014-line SCSS becomes a thin shell composing the library components.

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();
  }
}

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>
}

In git-operations-state.service.ts, make three changes:

  1. Update the detailTab signal type and default:
readonly detailTab = signal<'graph' | 'files' | 'commits'>('graph');
  1. Update setDetailTab method:
setDetailTab(tab: 'graph' | 'files' | 'commits'): void {
  this.detailTab.set(tab);
}
  1. Update selectRepo() to default to graph tab (currently hard-codes 'files' on line 196):
// In selectRepo(), change:
this.detailTab.set('graph');  // was 'files'

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);
  }
}

Run: npx tsc --noEmit --project apps/gograb-dev/tsconfig.app.json Expected: Clean.


Task 15: gograb-admin Integration

Files:

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'.

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' },
  ],
}

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),
    },
  ],
}

Add provideGitOperations() to the providers array:

import { provideGitOperations } from '@libs-structural/git-operations';

// In providers array:
provideGitOperations({
  baseUrl: '/sandbox-api',
  apiKey: 'W8Wp0ltL8El_qRnXyoUoD0ffuSfZzmbn7Y7SVrx1k8s',
}),

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.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;
}

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' },

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()

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',
}),

Run: npx tsc --noEmit --project libs/libs-structural/vitto-studio/tsconfig.lib.json Expected: Clean.


Task 17: Final Verification

Run: npx tsc --noEmit --project libs/libs-structural/git-operations/tsconfig.lib.json Expected: Clean, 0 errors.

Run: npx tsc --noEmit --project apps/gograb-dev/tsconfig.app.json Expected: Clean.

Run: npx tsc --noEmit --project apps/gograb-admin/tsconfig.app.json Expected: Clean.

Run: npx tsc --noEmit --project libs/libs-structural/vitto-studio/tsconfig.lib.json Expected: Clean.

Run: npx nx test backend-api --testPathPattern="git-provider" Expected: All 177 tests pass.

Start gograb-dev dev server and navigate to /platform/git-operations:

  1. Overview: repo cards render with branch/file counts
  2. Click a repo: detail view with graph tab as default
  3. Graph: SVG renders with metro-style thick lines, station dots, branch labels
  4. Click commit: inline expansion with diff stats
  5. File browser: template files filtered by default, toggle visible for developer role