diff --git a/.claude/skills/commitflow-canvas-dag/SKILL.md b/.claude/skills/commitflow-canvas-dag/SKILL.md new file mode 100644 index 0000000..4aedc4b --- /dev/null +++ b/.claude/skills/commitflow-canvas-dag/SKILL.md @@ -0,0 +1,19 @@ +--- +name: commitflow-canvas-dag +description: Guía técnica para diseñar y optimizar el motor topológico del DAG, renderizado de splines Bézier, virtual scrolling (< 150 nodos DOM), minimapa de ramas, drag-and-drop y Web Workers / OffscreenCanvas en CommitFlow. +--- + +# CommitFlow DAG & Virtual Scroll Skill + +Cuando trabajes en `src/graph/graphLayout.ts`, `src/graph/dagWorkerClient.ts` o el renderizado del grafo en `src/webview/main.ts`: +1. **Presupuesto de DOM y Rendimiento:** + - El scroll virtual (`updateVirtualWindow`) DEBE mantener `< 150` filas DOM montadas en el viewport en todo momento, reciclando elementos del pool cuando sea posible incluso con `100,000+` commits. + - Usa cálculos incrementales con `requestAnimationFrame` y evita reflows síncronos (`offsetHeight`/`getBoundingClientRect`) dentro de bucles por fila. +2. **Motor Topológico y Offload (`GraphLayout` & `DagWorkerEngine`):** + - Mantén el cálculo puro en funciones deterministas sin dependencias de `vscode` para poder ejecutarlas tanto en Node.js como dentro de un Web Worker. + - Al usar `OffscreenCanvas` (para el minimapa o carriles), provee siempre fallback limpio para entornos sin `OffscreenCanvas`. + - Soporta hashes SHA-1 (40 chars) y SHA-256 (64 chars), así como merges múltiples (octopus) y commits raíz huérfanos. +3. **Interactividad & A11y:** + - Todo nodo SVG/Canvas decorativo debe llevar `aria-hidden="true"`. + - Cada fila de commit debe mantener su `role="row"`, `aria-label` descriptivo con ramas/tags/padres y sincronización exacta con la navegación por teclado (`ArrowUp`, `ArrowDown`, `PageUp`, `PageDown`, `Home`, `End`). + - Las operaciones Drag-and-Drop sobre ramas/commits (reset, checkout, cherry-pick, rebase) DEBEN pedir confirmación explícita en modal seguro antes de mutar Git. diff --git a/.claude/skills/commitflow-issue-workflow/SKILL.md b/.claude/skills/commitflow-issue-workflow/SKILL.md new file mode 100644 index 0000000..11d31f0 --- /dev/null +++ b/.claude/skills/commitflow-issue-workflow/SKILL.md @@ -0,0 +1,27 @@ +--- +name: commitflow-issue-workflow +description: Flujo de trabajo GitFlow estricto de CommitFlow: main <- staging <- ramas por issue (feat|fix|design|test|perf/-), enlace con gh issue develop, Conventional Commits y PRs hacia staging. +--- + +# CommitFlow Issue & Branch Workflow Skill + +Sigue siempre este ciclo de vida GitFlow adaptado al repositorio `Ignaciobrenas/CommitFlow`: +1. **Jerarquía de Ramas:** + - `main`: Producción estable y releases del Marketplace. Solo recibe PRs desde `staging` (o `hotfix/*`). + - `staging`: Rama de integración continua. **Todas** las ramas de trabajo nacen de `staging` y regresan mediante PR hacia `staging`. + - Nomenclatura de ramas (compatible con `IssueBranchLinker`): + - `feat/-` (nuevas funcionalidades) + - `fix/-` (corrección de bugs) + - `design/-` (UI/UX, `DESIGN.md`, `SKILL.md`, accesibilidad) + - `test/-` (suites de tests, fuzzing, E2E, CI) + - `perf/-` (rendimiento, Web Workers, `OffscreenCanvas`, watcher) +2. **Crear rama enlazada al Issue:** + ```bash + git checkout staging && git pull origin staging + gh issue develop --base staging --name /- --checkout + ``` +3. **Commits Convencionales:** + - Usa `type(scope): description (#ID)`, por ejemplo: `feat(engine): add binary packfile reader in GitFsReader (#10)`. +4. **Pull Request hacia `staging`:** + - Incluye siempre `Refs #` en el cuerpo del PR hacia `staging` (y `Closes #` en el PR de release `staging` -> `main` para cierre automático). + - Antes de abrir o mergear el PR, ejecuta `npm run compile`, `npm test` y `npm run build`. diff --git a/.claude/skills/commitflow-qa-testing/SKILL.md b/.claude/skills/commitflow-qa-testing/SKILL.md new file mode 100644 index 0000000..6a2a48c --- /dev/null +++ b/.claude/skills/commitflow-qa-testing/SKILL.md @@ -0,0 +1,20 @@ +--- +name: commitflow-qa-testing +description: Guía de la pirámide completa de QA para CommitFlow: tests unitarios, property-based fuzzing, pruebas E2E del servidor MCP (stdio + HTTP/SSE), auditoría responsiva y WCAG 2.1 AA en Chromium headless, benchmarks de 50k commits y smoke tests E2E de extensión VS Code. +--- + +# CommitFlow QA & Testing Pyramid Skill + +Antes de dar por finalizada cualquier feature, bugfix o refactorización, valida las capas aplicables de la pirámide de calidad: +1. **Typecheck y Compilación (`npm run compile` & `npm run build`):** + - Cero errores de TypeScript estricto y bundle limpio en `dist/`. +2. **Tests Unitarios y de Integración (`npm test` -> `test/runTests.ts`):** + - Cubre lógica pura (`GraphLayout`, `GitFsReader`, `GitParser`, `SshConfigParser`, `IssueBranchLinker`, `ConventionalCommitsBuilder`, `IpcSchemaValidator`, `AdaptiveDebouncer`). +3. **Property-Based Fuzzing (`test/fuzzTests.ts`):** + - Ejecuta 1,000+ iteraciones deterministas inyectando `packed-refs` corruptos, reflogs truncados, buffers binarios aleatorios, alias SSH circulares, rutas Unicode y grafos complejos sin lanzar excepciones no controladas. +4. **E2E del Servidor MCP (`test/mcpE2eTests.ts`):** + - Prueba real contra repositorio temporal en disco: `stdio` (`dist/mcp.js`) y `CommitFlowHttpServer` (`/health`, `/sse`, `/mcp`), JSON-RPC 2.0 `initialize`, todas las tools y resources, scoping de repo, rechazo 401/403, rate-limiting 429 y log de auditoría. +5. **Webview Multi-Viewport, Temas y WCAG 2.1 AA (`scripts/verify-responsive.js`):** + - Verifica en Chromium/Edge headless los 4 breakpoints (`240px` Nano, `360px` Sidebar, `520px` Compact, `1100px` Wide Split) y temas (`dark`, `light`, `high-contrast`, `high-contrast-light`) con `0` overflows, `0` colisiones y `0` violaciones A11y. +6. **Benchmarks y Empaquetado (`scripts/benchmark-50k.js` & `npx vsce package`):** + - Verifica layout de 50,000 commits dentro del presupuesto de latencia/memoria y que el `.vsix` se empaquete sin errores. diff --git a/.claude/skills/commitflow-ui-design/SKILL.md b/.claude/skills/commitflow-ui-design/SKILL.md new file mode 100644 index 0000000..d10fa9c --- /dev/null +++ b/.claude/skills/commitflow-ui-design/SKILL.md @@ -0,0 +1,19 @@ +--- +name: commitflow-ui-design +description: Diseña e implementa interfaces Webview para CommitFlow siguiendo DESIGN.md, variables CSS de VS Code, Codicons, accesibilidad WCAG 2.1 AA y los 4 breakpoints responsivos multi-dock. Úsala al crear o modificar componentes UI, drawers, modales o temas. +--- + +# CommitFlow UI & Webview Design Skill + +Antes de escribir código UI en `src/graph/graphWebviewProvider.ts`, `src/webview/main.ts`, `src/webview/primitives.ts` o `src/webview/style.css`: +1. Lee `DESIGN.md` en la raíz del proyecto. +2. Verifica que **NO** se importe ni se referencie `@vscode/webview-ui-toolkit` (deprecado en enero de 2025). Utiliza tokens `--vscode-*` y los primitivos nativos en `src/webview/primitives.ts` (`CfButton`, `CfBadge`, `CfDrawer`, `CfTabs`, `CfToast`, `CfSkeleton`, `clampPopoverToViewport`). +3. **Checklist obligatorio por componente:** + - [ ] Funciona en temas VS Code: Dark+, Light+, High Contrast Dark (`.vscode-high-contrast`) y High Contrast Light (`.vscode-high-contrast-light`). + - [ ] Verificado en los 4 viewports multi-dock: Nano (`240px`), Sidebar Compact (`360px`), Compact (`520px`), Wide Split (`1100px`) y Bottom Panel (`1000x250px`). + - [ ] Usa `tabular-nums` (`font-variant-numeric: tabular-nums`) para hashes SHA, timestamps y contadores `+N/-N`. + - [ ] Incluye roles ARIA semánticos (`treegrid`, `row`, `gridcell` en el grafo; `role="dialog"` y `trapModalFocus` en modales) y foco visible (`outline: 1px solid var(--vscode-focusBorder)`). + - [ ] Cero `innerHTML` con datos de commits/issues/usuarios sin escapar (`escapeHtml`); CSP estricto con `nonce`. + - [ ] Popovers/drawers usan `clampPopoverToViewport` y se cierran al hacer clic fuera o pulsar `Escape`. + - [ ] Mensajes de estado vacío claros con CTA accionable y `CfSkeleton` loaders no bloqueantes. + - [ ] Respeta `@media (prefers-reduced-motion: reduce)`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3eb164..1f8937b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI & Quality Gate on: push: - branches: [ main ] + branches: [ main, staging ] pull_request: - branches: [ main ] + branches: [ main, staging ] jobs: build-and-test: @@ -38,5 +38,11 @@ jobs: - name: Compile and Bundle Production Assets run: npm run build + - name: Run Headless E2E Smoke Suite + run: npm run test:e2e + + - name: Run 50k DAG Performance Benchmark + run: npm run benchmark + - name: Verify VSIX Package Creation run: npx vsce package --no-git-tag-version diff --git a/.github/workflows/staging-pr-check.yml b/.github/workflows/staging-pr-check.yml new file mode 100644 index 0000000..af69174 --- /dev/null +++ b/.github/workflows/staging-pr-check.yml @@ -0,0 +1,23 @@ +name: Staging PR & Issue Link Gate + +on: + pull_request: + branches: [ staging, main ] + +jobs: + verify-issue-link: + name: Verify Issue Reference in PR + runs-on: ubuntu-latest + steps: + - name: Check PR references an Issue + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const text = `${pr.title}\n${pr.body || ''}`; + const hasIssueRef = /#\d+/.test(text); + if (!hasIssueRef) { + core.setFailed('El PR debe referenciar al menos un Issue (ej: "Refs #6" o "Closes #6").'); + } else { + core.info(`PR #${pr.number} references issue(s) cleanly.`); + } diff --git a/CHANGELOG.md b/CHANGELOG.md index ecda1e2..92f4601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to the "commitflow" extension will be documented in this file. +## [1.0.0] - 2026-11-14 + +### Added +- **Design System & Agent Skills**: Created root `DESIGN.md` visual system and `.claude/skills/` suites (`commitflow-ui-design`, `commitflow-canvas-dag`, `commitflow-qa-testing`, `commitflow-issue-workflow`) plus custom `--vscode-*` UI primitives (`CfButton`, `CfBadge`, `CfDrawer`, `CfTabs`, `CfToast`, `CfSkeleton`) with zero deprecated `@vscode/webview-ui-toolkit` dependencies. +- **DAG Minimap, Filters, Drag-and-Drop & Onboarding**: Interactive canvas minimap, combined `--first-parent`/date/path/remote filters, quick `Jump to HEAD`/`Jump to Bisect Probe`, drag-and-drop branch reset & commit cherry-pick/rebase workflows, illustrated empty states, and initial skeleton loaders. +- **Offline Binary `.git` Engine & Submodules**: Completed binary loose object decompression, packfile v2 (`0xff744f63`) + `OFS_DELTA`/`REF_DELTA` decoding, `.git/index` (`DIRC` v2/v3/v4) staging inspection, `.gitmodules` nested submodule discovery, `gitdir:` worktree linking, SHA-256 detection, and `reftable` detection. +- **Web Worker / OffscreenCanvas & Virtualization**: Off-thread topological DAG computation via `DagWorkerEngine`, DOM row recycling pool, adaptive `FileSystemWatcher` debouncer, and batched webview IPC dispatch. +- **Conflict Resolution Studio, Conventional Commits & Remote Parity**: Interactive Conflict Resolution Studio integrated with VS Code 3-way Merge Editor (`git.openMergeEditor`), Conventional Commits Builder with `.gitmessage` loading and live `#issue` autocomplete, plus GitHub/GitLab/Forgejo TTL-cached PR/MR inline comments, approvals, CI status checks, and triage. +- **Security Hardening & MCP Audit Trail**: Strict CSP nonces, `IpcSchemaValidator` runtime command allowlist + path-traversal/prototype-pollution guard, sliding-window MCP HTTP rate limiter (`429 Retry-After`), and circular `McpAuditRingBuffer` exposed via `commitflow://mcp/audit-log`. +- **Enterprise QA Pyramid (50 Suites)**: Added 1,050 deterministic seeded fuzzing iterations (`test/fuzzTests.ts`), real-repo MCP stdio/HTTP/SSE end-to-end suite (`test/mcpE2eTests.ts`), 50,000-commit synthetic DAG benchmark (`scripts/benchmark-50k.js`), headless extension lifecycle smoke runner (`scripts/run-e2e-smoke.js`), and expanded Edge CDP audit matrix (`64/64` across 4 themes × 4 views × 4 breakpoints with WCAG 2.1 AA treegrid/ARIA validation). + +### Fixed +- Eliminated an $O(N^2)$ converging-lane memory leak in `GraphLayout.compute` by clearing duplicate converging commit pointers across active lanes (50,000 commits now lay out in ~28 ms sync / ~15 ms worker with ~38 MB heap delta). +- Fixed `IssueBranchLinker.createSlug` leading-hyphen sanitization edge case and extended `IpcSchemaValidator` payload scanning across entire IPC message envelopes. + ## [0.4.8] - 2026-11-04 ### Added diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..9aa1359 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,61 @@ +# CommitFlow Design System (DESIGN.md) + +## 1. Visual Theme & Atmosphere +- **Philosophy:** **"Precision Developer Telemetry"**. High information density without visual noise; native VS Code integration that respects 100% of the user's active IDE theme (Light, Dark, High Contrast Dark, High Contrast Light) while delivering vivid semantic identity across the commit DAG via curated chromatic lane palettes. +- **Surface Hierarchy:** Strictly layered via native VS Code tokens with sensible fallbacks: + - Primary Canvas / Editor Surface: `var(--vscode-editor-background)` (`--bg-primary`) + - Secondary Chrome / Sidebar / Header: `var(--vscode-sideBar-background)` (`--bg-secondary`) + - Elevated / Tabs / Popovers / Modals: `var(--vscode-editorGroupHeader-tabsBackground)` / `var(--vscode-panel-background)` (`--bg-tertiary`) + - Interactive States: `var(--vscode-list-hoverBackground)` (`--bg-hover`), `var(--vscode-list-activeSelectionBackground)` (`--bg-active`) + - Borders & Focus: `var(--vscode-widget-border)` / `var(--vscode-panel-border)` (`--border-subtle`), `var(--vscode-focusBorder)` (`--focus-ring`) + +## 2. Color Palette & Tokens +- **Base IDE Integration:** + - Foreground: `var(--vscode-editor-foreground)` / `var(--vscode-foreground)` + - Secondary Muted Text: `var(--vscode-descriptionForeground)` + - Focus Ring: `var(--vscode-focusBorder)` (must always remain visible on keyboard focus) + - High Contrast Borders: `var(--vscode-contrastBorder)`, `var(--vscode-contrastActiveBorder)` +- **Semantic Status Tokens:** + - `Success / Bisect Good / Valid Sig / Ready PR`: `#10b981` (`--accent-emerald` in Dark) / `#1a7f37` (Light) + - `Danger / Bisect Bad / Conflict / Drop`: `#ef4444` / `#f43f5e` (`--accent-rose` in Dark) / `#cf222e` (Light) + - `Warning / Untrusted / Dirty / Pending`: `#f59e0b` (`--accent-amber` in Dark) / `#9a6700` (Light) + - `Probe / Active Bisect / Primary Accent`: `#38bdf8` / `#06b6d4` (`--accent-cyan` in Dark) / `#0969da` (Light) + - `MCP / AI Studio / Rebase Accent`: `#8b5cf6` (`--accent-violet` in Dark) / `#8250df` (Light) +- **DAG Chromatic Lane Palettes (`GraphLayout.THEME_LANE_COLORS`):** + - Supported presets: `auto`, `dark`, `light`, `cyberpunk`, `dracula`, `github`, `neon-pink`, `neon-green`, `neon-blue`. + - Adjacent lanes must maintain clear perceptual color separation. In High Contrast themes (`vscode-high-contrast`, `vscode-high-contrast-light`), borders and focus outlines override subtle alpha fills. + +## 3. Typography & Rhythm +- **UI Font:** `var(--vscode-font-family)` with adaptive sizing (`--font-size-base: 12px`, `--font-size-sm: 11px`, `--font-size-xs: 10px`). +- **Monospace Telemetry Font:** `var(--vscode-editor-font-family)` with mandatory `font-variant-numeric: tabular-nums` (`class="tabular-nums"`) for: + - Commit SHAs (`shortHash`, full 40/64-char SHA) + - Diff line deltas (`+N / -M`) + - Relative/absolute timestamps, branch pill counters, and stat bar counters + - File paths and bisect step estimators +- **Spacing & Radii:** `--radius-xs: 4px`, `--radius-sm: 6px`, `--radius-md: 8px`, `--radius-lg: 10px`, `--radius-xl: 12px`, `--radius-full: 9999px`. +- **Density Tiers (`commitflow.graph.density`):** `compact` (`22px` row height), `comfortable` (`26px`), `spacious` (`32px`). + +## 4. Adaptive Multi-Dock Breakpoints +CommitFlow operates seamlessly across left/right Activity Bar sidebars, bottom terminal panel, and full Editor panels using container-aware layout classes: +- **Nano (`<= 260px` / `.layout-nano`):** + - Navigation tabs collapse to icon-only buttons with native `title` + `aria-label` tooltips. + - Secondary metadata (author email, timestamps, extra stats dividers) are hidden cleanly. + - Minimum touch/click target size `28x28px`. +- **Sidebar Compact (`261px - 440px` / `.layout-sidebar-compact`):** + - Single-column stacked layout for DAG rows and cards. + - Inspector drawer renders as a full-width overlay drawer clamped cleanly inside the viewport. +- **Compact (`441px - 600px` / `.layout-width-compact`):** + - Compact branch pills, abbreviated badges, wrapped flex toolbars with zero horizontal overflow. +- **Wide / Bottom Panel (`> 600px`):** + - Dual-pane split view (DAG + Minimap on the left, Commit/PR Inspector Drawer on the right) or horizontal bottom-dock layout (`>= 540px` width and `<= 380px` height -> `.auto-detected-bottom` / `.layout-height-short`). + +## 5. Component Rules (Zero Deprecated Toolkits) +1. **ZERO `@vscode/webview-ui-toolkit`:** Officially deprecated and archived in January 2025. Never import or depend on it. +2. **Native UI Primitives (`src/webview/primitives.ts`):** All new UI elements use CommitFlow's lightweight zero-dependency primitives (`CfButton`, `CfBadge`, `CfDrawer`, `CfTabs`, `CfToast`, `CfSkeleton`) built exclusively with `--vscode-*` CSS variables and `@vscode/codicons`. +3. **Icons:** Use `@vscode/codicons` (``) or accessible inline SVGs marked with `aria-hidden="true"`. +4. **Viewport Clamping & Popover Dismissal:** All popovers, tooltips, context menus, and drawers must clamp against viewport edges (`clampPopoverToViewport`) and dismiss on outside click or `Escape`. +5. **Motion & Reduced Motion:** Use `--ease-spring` / `--duration-fast` for crisp transitions, and strictly disable animations, spinners, and pulse keyframes inside `@media (prefers-reduced-motion: reduce)`. +6. **Security & CSP:** Enforce `default-src 'none'` with cryptographic `nonce` per load; never inject untrusted user or commit strings via raw `innerHTML` without HTML escaping. +7. **Accessibility (WCAG 2.1 AA):** + - Graph container uses `role="treegrid"` with `role="row"` and `role="gridcell"`, descriptive `aria-label` per commit, and roving `tabindex` (`ArrowUp`, `ArrowDown`, `PageUp`, `PageDown`, `Home`, `End`). + - All modals trap `Tab`/`Shift+Tab` focus (`trapModalFocus`) and restore focus on close. diff --git a/README.md b/README.md index e080db2..7e50ce2 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@

- VS Code Marketplace + VS Code Marketplace CI Status PR Approval Gate - Tests Status + Tests Status License TypeScript Privacy @@ -368,7 +368,7 @@ CommitFlow includes a built-in Model Context Protocol server (`CommitFlowMcpServ To install a pre-built `.vsix` release: ```bash -code --install-extension commitflow-0.4.8.vsix +code --install-extension commitflow-1.0.0.vsix ``` --- @@ -520,7 +520,7 @@ To connect external tools such as Claude Desktop, add CommitFlow to your client "commitflow": { "command": "node", "args": [ - "C:/Users//.vscode/extensions/ignaciobrenas.commitflow-0.4.8/dist/mcp.js", + "C:/Users//.vscode/extensions/ignaciobrenas.commitflow-1.0.0/dist/mcp.js", "--repo", "C:/Path/To/Your/Repository" ] diff --git a/package.json b/package.json index d53b0ab..6eb6bd6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "commitflow", "displayName": "CommitFlow - Git Graph, Visual History & Developer Hub", "description": "High-performance interactive Git commit & branch graph visualizer, real-time live blame with author avatars, visual file history, PR launchpad, worktrees & multi-WIP, cloud patches, and adaptive layout modes for VS Code.", - "version": "0.4.8", + "version": "1.0.0", "publisher": "Ignaciobrenas", "icon": "resources/commitflow-logo-color.png", "engines": { @@ -403,6 +403,8 @@ "dev": "node esbuild.js --watch", "test": "node -e \"require('esbuild').buildSync({entryPoints:['test/runTests.ts'],outfile:'dist/test.js',bundle:true,platform:'node',alias:{'vscode':'./test/vscodeMock.ts'}}); require('./dist/test.js')\"", "test:live": "node -e \"require('esbuild').buildSync({entryPoints:['test/verifyLiveIntegration.ts'],outfile:'dist/verifyLive.js',bundle:true,platform:'node',alias:{'vscode':'./test/vscodeMock.ts'}}); require('./dist/verifyLive.js')\"", + "test:e2e": "node scripts/run-e2e-smoke.js", + "benchmark": "node scripts/benchmark-50k.js", "mcp": "node dist/mcp.js", "package": "vsce package" }, diff --git a/scripts/benchmark-50k.js b/scripts/benchmark-50k.js new file mode 100644 index 0000000..34e14af --- /dev/null +++ b/scripts/benchmark-50k.js @@ -0,0 +1,132 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { performance } = require('perf_hooks'); +const esbuild = require('esbuild'); + +function loadDagModules() { + const repoRoot = path.resolve(__dirname, '..'); + const tmpOut = path.join(os.tmpdir(), `commitflow-bench-${process.pid}-${Date.now()}.js`); + esbuild.buildSync({ + stdin: { + contents: ` + export { GraphLayout } from './src/graph/graphLayout'; + export { DagWorkerEngine } from './src/graph/dagWorkerClient'; + `, + resolveDir: repoRoot, + loader: 'ts', + }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile: tmpOut, + }); + + try { + return require(tmpOut); + } finally { + try { + fs.rmSync(tmpOut, { force: true }); + } catch { + // ignore cleanup errors on temp file + } + } +} + +function generateSyntheticCommits(count) { + const commits = new Array(count); + const baseMs = Date.UTC(2026, 0, 1, 0, 0, 0); + const hexHashes = new Array(count); + + for (let i = 0; i < count; i++) { + hexHashes[i] = i.toString(16).padStart(8, '0').padEnd(40, 'a'); + } + + for (let i = 0; i < count; i++) { + const parentHashes = []; + if (i + 1 < count) { + parentHashes.push(hexHashes[i + 1]); + } + // Every 20th commit forms a merge/branch topology edge + if (i % 20 === 0 && i + 7 < count) { + parentHashes.push(hexHashes[i + 7]); + } + + const author = { + name: i % 4 === 0 ? 'Elena' : 'Ignacio', + email: 'dev@commitflow.local', + date: new Date(baseMs - i * 60000), + relativeTime: `${i}m ago`, + avatarUrl: '', + }; + + commits[i] = { + hash: hexHashes[i], + shortHash: hexHashes[i].slice(0, 7), + parentHashes, + author, + committer: author, + subject: i % 20 === 0 ? `merge: integrate topic-${i} into main` : `feat(dag): commit #${i}`, + body: '', + refs: + i === 0 + ? [{ type: 'head', name: 'main', isHead: true }] + : i % 2500 === 0 + ? [{ type: 'tag', name: `v0.${Math.floor(i / 2500)}.0`, isHead: false }] + : [], + }; + } + + return commits; +} + +async function runBenchmark() { + const COMMIT_COUNT = 50000; + const BUDGET_MS = 2500; + + console.log(`\n======================================================`); + console.log(`CommitFlow 50k DAG Layout Performance Benchmark`); + console.log(`======================================================`); + + const { GraphLayout, DagWorkerEngine } = loadDagModules(); + const commits = generateSyntheticCommits(COMMIT_COUNT); + + const memBeforeSync = process.memoryUsage().heapUsed; + const t0 = performance.now(); + const syncNodes = GraphLayout.compute(commits, 'cyberpunk'); + const syncElapsedMs = performance.now() - t0; + const memAfterSync = process.memoryUsage().heapUsed; + + assert.strictEqual(syncNodes.length, COMMIT_COUNT, `Expected ${COMMIT_COUNT} nodes from GraphLayout.compute`); + assert.ok( + syncElapsedMs < BUDGET_MS, + `GraphLayout.compute(50k) exceeded ${BUDGET_MS}ms budget: ${syncElapsedMs.toFixed(2)}ms` + ); + + const engine = DagWorkerEngine.getInstance(); + const t1 = performance.now(); + const asyncNodes = await engine.computeLayoutAsync(commits, 'cyberpunk'); + const asyncElapsedMs = performance.now() - t1; + engine.dispose(); + + assert.strictEqual(asyncNodes.length, COMMIT_COUNT, `Expected ${COMMIT_COUNT} nodes from DagWorkerEngine`); + assert.ok( + asyncElapsedMs < BUDGET_MS, + `DagWorkerEngine.computeLayoutAsync(50k) exceeded ${BUDGET_MS}ms budget: ${asyncElapsedMs.toFixed(2)}ms` + ); + + const deltaMb = ((memAfterSync - memBeforeSync) / (1024 * 1024)).toFixed(2); + const throughput = Math.round(COMMIT_COUNT / (syncElapsedMs / 1000)); + + console.log(` Commits rendered : ${COMMIT_COUNT.toLocaleString()}`); + console.log(` GraphLayout sync : ${syncElapsedMs.toFixed(2)} ms (${throughput.toLocaleString()} commits/sec)`); + console.log(` DagWorkerEngine : ${asyncElapsedMs.toFixed(2)} ms`); + console.log(` Heap delta (sync) : ${deltaMb} MB`); + console.log(` SLA Budget : < ${BUDGET_MS} ms [PASS]\n`); +} + +runBenchmark().catch((err) => { + console.error('[CommitFlow] 50k Benchmark failed:', err); + process.exit(1); +}); diff --git a/scripts/bootstrap-issues.sh b/scripts/bootstrap-issues.sh new file mode 100644 index 0000000..db868c1 --- /dev/null +++ b/scripts/bootstrap-issues.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 1. Asegurar main actualizada y crear staging (si no existe) +git checkout main +git pull origin main +if ! git show-ref --verify --quiet refs/heads/staging; then + git checkout -b staging + git push -u origin staging +else + git checkout staging + git pull origin staging +fi + +# 2. Crear etiquetas organizadas en el repositorio +gh label create "epic:design" --color "ec4899" --description "Design system, SKILLS, UI/UX y responsive" --force +gh label create "epic:engine" --color "0284c7" --description "GitFsReader offline, performance y DAG" --force +gh label create "epic:features" --color "10b981" --description "Funcionalidades Git, Providers y Conflict Studio" --force +gh label create "epic:security" --color "ef4444" --description "Seguridad MCP, CSP y validacion IPC" --force +gh label create "epic:testing" --color "8b5cf6" --description "Playwright, Fuzzing, A11y, MCP E2E y CI" --force + +create_issue() { + local title="$1" + local label="$2" + local body="$3" + gh issue create --title "$title" --label "$label" --body "$body" +} + +echo "Usa create_issue \"\" \"\" \"\" para crear nuevos issues enlazables a staging." diff --git a/scripts/capture-screenshots.js b/scripts/capture-screenshots.js index 171c8ca..a1af7a7 100644 --- a/scripts/capture-screenshots.js +++ b/scripts/capture-screenshots.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); -const { execFileSync } = require('child_process'); +const { spawn } = require('child_process'); +const os = require('os'); const edgePath = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; const outDir = path.resolve(__dirname, '..', 'docs', 'screenshots'); @@ -72,6 +73,7 @@ function buildGraphHtml(themeName) { + + + +

+ +`; +} + +function buildCommitDrawerHtml(themeName) { + return ` + + + + + + + +
+
+
+ Detalles del Commit + +
+
+
+ +
+ Ignacio Breñas + ignaciobrenas@gmail.com + Hace 5 minutos (cf4821a) +
+
+ +
+ + + + + + + +
+ +
+
Mensaje del commit
+
feat(responsive): complete responsive design and virtual DAG windowing engine
+
+ +
+
+ Archivos Modificados + 3 archivos +
+
+
+
+ M + src/webview/style.css +
+ +42 +
+
+
+ M + src/webview/main.ts +
+ +18 +
+
+
+
+
+
+ +`; +} + +function buildIssuesHubHtml(themeName) { + return ` + + + + + + + +
+
+
+ + + +
+
+ + +
+
+ +
+
+
+
+ #104 + open + Vinculado +
+
+ + +
+
+
Responsive UI overhaul for sidebars and auxiliary windows
+
+ frontend + v0.4.8 +
+ +
+
+
+ +`; +} + +function buildBisectViewHtml(themeName) { + return ` + + + + + + + +
+
+
+
+ + Git Bisect Activo +
+
+ Probando commit e4a19b8 (~2 pasos restantes) +
+
+ + + + +
+
+
+
+ +`; +} + const themes = ['dark', 'light', 'high-contrast']; const viewports = [ { name: 'narrow-280px', width: 280, height: 750 }, + { name: 'sidebar-360px', width: 360, height: 750 }, { name: 'medium-450px', width: 450, height: 800 }, + { name: 'split-750px', width: 750, height: 800 }, { name: 'full-1200px', width: 1200, height: 800 }, ]; -for (const theme of themes) { - const graphHtml = buildGraphHtml(theme); - const graphFile = path.join(outDir, `temp_graph_${theme}.html`); - fs.writeFileSync(graphFile, graphHtml); - - const aiHtml = buildAiStudioHtml(theme); - const aiFile = path.join(outDir, `temp_ai_${theme}.html`); - fs.writeFileSync(aiFile, aiHtml); - - for (const vp of viewports) { - const graphImg = path.join(outDir, `graph_${theme}_${vp.name}.png`); - execFileSync(edgePath, [ - '--headless', - '--disable-gpu', - `--screenshot=${graphImg}`, - `--window-size=${vp.width},${vp.height}`, - `file://${graphFile}`, - ]); - - const aiImg = path.join(outDir, `ai_studio_${theme}_${vp.name}.png`); - execFileSync(edgePath, [ - '--headless', - '--disable-gpu', - `--screenshot=${aiImg}`, - `--window-size=${vp.width},${vp.height}`, - `file://${aiFile}`, - ]); - - console.log(`Captured: graph & ai_studio for theme=${theme}, viewport=${vp.name}`); +const views = [ + { prefix: 'graph', builder: buildGraphHtml }, + { prefix: 'ai_studio', builder: buildAiStudioHtml }, + { prefix: 'rebase_studio', builder: buildRebaseStudioHtml }, + { prefix: 'commit_drawer', builder: buildCommitDrawerHtml }, + { prefix: 'issues_hub', builder: buildIssuesHubHtml }, + { prefix: 'bisect_view', builder: buildBisectViewHtml }, +]; + +async function captureAll() { + const profileDir = path.join(os.tmpdir(), 'commitflow_edge_cdp_' + Date.now()); + const edgeProc = spawn(edgePath, [ + '--headless=new', + '--remote-debugging-port=9222', + '--disable-gpu', + '--user-data-dir=' + profileDir, + 'about:blank' + ]); + + let wsUrl = ''; + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 200)); + try { + const res = await fetch('http://127.0.0.1:9222/json/list'); + const data = await res.json(); + const page = data.find(p => p.type === 'page'); + if (page && page.webSocketDebuggerUrl) { + wsUrl = page.webSocketDebuggerUrl; + break; + } + } catch(e) {} } - fs.unlinkSync(graphFile); - fs.unlinkSync(aiFile); + if (!wsUrl) { + edgeProc.kill(); + throw new Error('Could not connect to Edge DevTools Protocol'); + } + + const ws = new WebSocket(wsUrl); + let id = 1; + const callbacks = new Map(); + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.id && callbacks.has(msg.id)) { + callbacks.get(msg.id)(msg.result); + callbacks.delete(msg.id); + } + }; + + await new Promise(r => ws.onopen = r); + + function send(method, params = {}) { + return new Promise((resolve) => { + const reqId = id++; + callbacks.set(reqId, resolve); + ws.send(JSON.stringify({ id: reqId, method, params })); + }); + } + + await send('Page.enable'); + const tree = await send('Page.getFrameTree'); + const frameId = tree.frameTree.frame.id; + + for (const theme of themes) { + for (const view of views) { + const html = view.builder(theme); + await send('Page.setDocumentContent', { frameId, html }); + + for (const vp of viewports) { + await send('Emulation.setDeviceMetricsOverride', { + width: vp.width, + height: vp.height, + deviceScaleFactor: 1, + mobile: false + }); + + // Small pause for layout calculation and fonts + await new Promise(r => setTimeout(r, 120)); + + const shot = await send('Page.captureScreenshot', { format: 'png' }); + const outImg = path.join(outDir, `${view.prefix}_${theme}_${vp.name}.png`); + fs.writeFileSync(outImg, Buffer.from(shot.data, 'base64')); + console.log(`Captured (CDP): ${view.prefix} for theme=${theme}, viewport=${vp.name} (${vp.width}x${vp.height})`); + } + } + } + + ws.close(); + edgeProc.kill(); + + setTimeout(() => { + try { fs.rmSync(profileDir, { recursive: true, force: true }); } catch(e) {} + }, 1000); + + console.log('All comprehensive screenshots captured successfully in docs/screenshots/'); } -console.log('All screenshots captured successfully in docs/screenshots/'); +captureAll().catch((err) => { + console.error('Capture error:', err); + process.exit(1); +}); diff --git a/scripts/run-e2e-smoke.js b/scripts/run-e2e-smoke.js new file mode 100644 index 0000000..d6bfdf3 --- /dev/null +++ b/scripts/run-e2e-smoke.js @@ -0,0 +1,193 @@ +const assert = require('assert'); +const fs = require('fs'); +const Module = require('module'); +const os = require('os'); +const path = require('path'); +const esbuild = require('esbuild'); + +const ROOT = path.resolve(__dirname, '..'); + +function verifyDistArtifacts() { + const requiredBundles = [ + 'dist/extension.js', + 'dist/webview.js', + 'dist/webview.css', + 'dist/codicon.css', + 'dist/mcp.js', + ]; + + const stats = {}; + for (const rel of requiredBundles) { + const abs = path.join(ROOT, rel); + assert.ok(fs.existsSync(abs), `Missing compiled bundle artifact: ${rel}`); + const st = fs.statSync(abs); + assert.ok(st.size > 0, `Empty bundle artifact: ${rel}`); + stats[rel] = st.size; + } + return stats; +} + +function verifyManifest() { + const pkgPath = path.join(ROOT, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + + assert.ok(pkg.main && pkg.main.includes('dist/extension.js'), `Unexpected manifest main: ${pkg.main}`); + assert.ok(Array.isArray(pkg.contributes?.commands) && pkg.contributes.commands.length >= 15, 'Missing contributed commands'); + assert.ok(pkg.contributes?.views && Object.keys(pkg.contributes.views).length > 0, 'Missing contributed views'); + assert.ok(pkg.contributes?.configuration?.properties, 'Missing contributed configuration properties'); + + return pkg; +} + +async function verifyHeadlessExtensionLifecycle(pkg) { + const tmpMockFile = path.join(os.tmpdir(), `cf-vscodemock-${process.pid}-${Date.now()}.js`); + esbuild.buildSync({ + entryPoints: [path.join(ROOT, 'test', 'vscodeMock.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: tmpMockFile, + }); + + const originalLoad = Module._load; + const registeredCommands = new Set(); + const registeredProviders = new Set(); + + try { + const rawMock = require(tmpMockFile); + const instrumentedVscode = { + ...rawMock, + env: { + ...rawMock.env, + language: 'en', + }, + commands: { + ...rawMock.commands, + registerCommand: (name, handler) => { + registeredCommands.add(name); + return rawMock.commands.registerCommand(name, handler); + }, + }, + window: { + ...rawMock.window, + registerWebviewViewProvider: (viewId, provider, options) => { + registeredProviders.add(viewId); + return rawMock.window.registerWebviewViewProvider(viewId, provider, options); + }, + }, + }; + + Module._load = function (request, parent, isMain) { + if (request === 'vscode') { + return instrumentedVscode; + } + return originalLoad.apply(this, arguments); + }; + + const extBundlePath = path.join(ROOT, 'dist', 'extension.js'); + delete require.cache[require.resolve(extBundlePath)]; + const ext = require(extBundlePath); + + assert.strictEqual(typeof ext.activate, 'function', 'dist/extension.js must export activate()'); + assert.strictEqual(typeof ext.deactivate, 'function', 'dist/extension.js must export deactivate()'); + + const secretMap = new Map(); + const stateMap = new Map(); + const mockContext = { + subscriptions: [], + extensionUri: rawMock.Uri.file(ROOT), + extensionPath: ROOT, + globalState: { + get: (k, d) => (stateMap.has(k) ? stateMap.get(k) : d), + update: (k, v) => { + stateMap.set(k, v); + return Promise.resolve(); + }, + keys: () => Array.from(stateMap.keys()), + }, + workspaceState: { + get: (k, d) => (stateMap.has(k) ? stateMap.get(k) : d), + update: (k, v) => { + stateMap.set(k, v); + return Promise.resolve(); + }, + keys: () => Array.from(stateMap.keys()), + }, + secrets: { + get: (k) => Promise.resolve(secretMap.get(k)), + store: (k, v) => { + secretMap.set(k, v); + return Promise.resolve(); + }, + delete: (k) => { + secretMap.delete(k); + return Promise.resolve(); + }, + }, + }; + + await Promise.resolve(ext.activate(mockContext)); + // Allow background initialization (e.g., async server startup) to settle before deactivate() + await new Promise((r) => setTimeout(r, 150)); + + assert.ok( + mockContext.subscriptions.length >= 10, + `Expected >= 10 subscriptions registered during activate(), got ${mockContext.subscriptions.length}` + ); + assert.ok(registeredCommands.has('commitflow.openGraph'), 'Missing commitflow.openGraph command registration'); + assert.ok(registeredCommands.has('commitflow.connectAi'), 'Missing commitflow.connectAi command registration'); + assert.ok( + registeredCommands.size >= pkg.contributes.commands.length - 2, + `Too few commands registered (${registeredCommands.size} vs ${pkg.contributes.commands.length} in manifest)` + ); + assert.ok(registeredProviders.size >= 1, 'Expected at least 1 registered WebviewViewProvider'); + + ext.deactivate(); + for (const sub of mockContext.subscriptions) { + try { + if (sub && typeof sub.dispose === 'function') sub.dispose(); + } catch { + // ignore mock dispose errors + } + } + await new Promise((r) => setTimeout(r, 80)); + + return { + subscriptions: mockContext.subscriptions.length, + registeredCommands: registeredCommands.size, + registeredProviders: registeredProviders.size, + }; + } finally { + Module._load = originalLoad; + try { + fs.rmSync(tmpMockFile, { force: true }); + } catch { + // ignore temp cleanup failure + } + } +} + +async function main() { + console.log(`\n======================================================`); + console.log(`CommitFlow Headless E2E Smoke Suite`); + console.log(`======================================================`); + + const bundleStats = verifyDistArtifacts(); + console.log(` ✔ Bundles verified (${Object.keys(bundleStats).length} artifacts non-empty)`); + + const pkg = verifyManifest(); + console.log(` ✔ Manifest verified (${pkg.contributes.commands.length} contributed commands)`); + + const lifecycle = await verifyHeadlessExtensionLifecycle(pkg); + console.log( + ` ✔ Extension activate/deactivate OK (${lifecycle.registeredCommands} commands, ${lifecycle.registeredProviders} webview views, ${lifecycle.subscriptions} disposables)` + ); + console.log(`======================================================\n`); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('[CommitFlow] E2E smoke suite failed:', err); + process.exit(1); + }); diff --git a/scripts/verify-responsive.js b/scripts/verify-responsive.js new file mode 100644 index 0000000..7777f97 --- /dev/null +++ b/scripts/verify-responsive.js @@ -0,0 +1,767 @@ +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const os = require('os'); + +const edgePath = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'; + +const cssWebview = fs.readFileSync(path.resolve(__dirname, '..', 'dist', 'webview.css'), 'utf8'); +const cssCodicon = fs.readFileSync(path.resolve(__dirname, '..', 'dist', 'codicon.css'), 'utf8'); + +const themeVariables = { + dark: ` + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #d4d4d4; + --vscode-sideBar-background: #252526; + --vscode-sideBar-foreground: #cccccc; + --vscode-input-background: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-border: #3c3c3c; + --vscode-button-background: #007acc; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #0062a3; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-secondaryForeground: #ffffff; + --vscode-errorForeground: #f48771; + --vscode-testing-iconPassed: #89d185; + --vscode-editorWarning-foreground: #cca700; + `, + light: ` + --vscode-editor-background: #ffffff; + --vscode-editor-foreground: #333333; + --vscode-sideBar-background: #f3f3f3; + --vscode-sideBar-foreground: #616161; + --vscode-input-background: #ffffff; + --vscode-input-foreground: #333333; + --vscode-input-border: #cecece; + --vscode-button-background: #007acc; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #0062a3; + --vscode-button-secondaryBackground: #e5e5e5; + --vscode-button-secondaryForeground: #333333; + --vscode-errorForeground: #e51400; + --vscode-testing-iconPassed: #388a34; + --vscode-editorWarning-foreground: #bf8803; + `, + 'high-contrast': ` + --vscode-editor-background: #000000; + --vscode-editor-foreground: #ffffff; + --vscode-sideBar-background: #000000; + --vscode-sideBar-foreground: #ffffff; + --vscode-input-background: #000000; + --vscode-input-foreground: #ffffff; + --vscode-input-border: #6fc3df; + --vscode-contrastActiveBorder: #f38518; + --vscode-button-background: #000000; + --vscode-button-foreground: #ffffff; + --vscode-button-border: #6fc3df; + --vscode-button-hoverBackground: #1a1a1a; + --vscode-button-secondaryBackground: #000000; + --vscode-button-secondaryForeground: #ffffff; + --vscode-errorForeground: #f48771; + --vscode-testing-iconPassed: #89d185; + --vscode-editorWarning-foreground: #cca700; + `, + 'high-contrast-light': ` + --vscode-editor-background: #ffffff; + --vscode-editor-foreground: #292929; + --vscode-sideBar-background: #ffffff; + --vscode-sideBar-foreground: #292929; + --vscode-input-background: #ffffff; + --vscode-input-foreground: #292929; + --vscode-input-border: #0f4a85; + --vscode-contrastBorder: #0f4a85; + --vscode-contrastActiveBorder: #006bbd; + --vscode-button-background: #0f4a85; + --vscode-button-foreground: #ffffff; + --vscode-button-border: #0f4a85; + --vscode-button-hoverBackground: #0c3b6b; + --vscode-button-secondaryBackground: #ffffff; + --vscode-button-secondaryForeground: #292929; + --vscode-errorForeground: #b5200d; + --vscode-testing-iconPassed: #374e06; + --vscode-editorWarning-foreground: #895503; + `, +}; + +function buildGraphHtml(themeName) { + return ` + + + + + + + +
+
+
+
+ CommitFlow +
+
+
+ main +
+
+ + + + + + + + + + + + +
+
+ +
+
+ + +
+ + +
+
+ +
+
Commits: 128
+
Autores: 4
+
Ramas: 6
+
Stashes: 1
+
+ +
+
+
+ + cf4821a + main feat(mcp): streamable HTTP and stdio authenticated server +
+
+ + ab8291f + dev feat(ai): Connect with your AI studio and key generator +
+
+
+
+ +`; +} + +function buildRebaseStudioHtml(themeName) { + return ` + + + + + + + + + +`; +} + +function buildCommitDrawerHtml(themeName) { + return ` + + + + + + + +
+
+
+ Detalles del Commit + +
+
+
+ +
+ Ignacio Breñas + ignaciobrenas@gmail.com + Hace 5 minutos (cf4821a) +
+
+ +
+ + + + + + + + +
+ +
+
Mensaje del commit
+
feat(responsive): complete responsive design and virtual DAG windowing engine
+
+ +
+
+ Archivos Modificados + 3 archivos +
+
+
+
+ M + src/webview/style.css +
+ +42 +
+
+
+ M + src/webview/main.ts +
+ +18 +
+
+
+
+
+
+ +`; +} + +function buildAiStudioHtml(themeName) { + return ` + + + + + + + +
+
+
+
+ +

Conectar con tu IA

+ + + Activo en 127.0.0.1:47291 + +
+

Genera claves de acceso seguras y conecta Claude Code, Antigravity o cualquier IA mediante MCP.

+
+
+ +
+
+ + Generar Nueva Clave API +
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ +
+
+ +
+
+ + Copia tu clave ahora. Por motivos de seguridad, no volverá a mostrarse. +
+
+ + +
+
+ +
+
+ + Comandos de Conexión Rápida +
+ +
+ + + + +
+ +
+
claude mcp add --transport sse commitflow http://127.0.0.1:47291/sse?token=cf_live_9b4e7a82c31045f918e9d023b7498c1a
+ +
+
+ +
+
+ + Claves API Generadas +
+
+ + + + + + + + + + + + + + + + + + + + + +
NombrePrefijoPermisosCreaciónEstadoAcciones
Antigravity Agentcf_live_9b4e...readNov 1, 2026Activa
+
+
+
+ +`; +} + +const themes = ['dark', 'light', 'high-contrast', 'high-contrast-light']; + +const viewports = [ + { name: 'nano-240px', width: 240, height: 700 }, + { name: 'sidebar-360px', width: 360, height: 750 }, + { name: 'compact-520px', width: 520, height: 800 }, + { name: 'wide-1100px', width: 1100, height: 800 }, +]; + +const views = [ + { name: 'Graph View', builder: buildGraphHtml, checkA11yGraph: true }, + { name: 'AI Studio', builder: buildAiStudioHtml, checkA11yGraph: false }, + { name: 'Rebase Studio Modal', builder: buildRebaseStudioHtml, checkA11yGraph: false }, + { name: 'Commit Drawer', builder: buildCommitDrawerHtml, checkA11yGraph: false }, +]; + +async function verifyResponsiveAndButtons() { + console.log('=== CommitFlow Responsive & Button Interaction Verification ===\n'); + + const profileDir = path.join(os.tmpdir(), 'commitflow_verify_cdp_' + Date.now()); + const edgeProc = spawn(edgePath, [ + '--headless=new', + '--remote-debugging-port=9223', + '--disable-gpu', + '--user-data-dir=' + profileDir, + 'about:blank' + ]); + + let wsUrl = ''; + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 200)); + try { + const res = await fetch('http://127.0.0.1:9223/json/list'); + const data = await res.json(); + const page = data.find(p => p.type === 'page'); + if (page && page.webSocketDebuggerUrl) { + wsUrl = page.webSocketDebuggerUrl; + break; + } + } catch(e) {} + } + + if (!wsUrl) { + edgeProc.kill(); + throw new Error('Could not connect to Edge DevTools Protocol on port 9223'); + } + + const ws = new WebSocket(wsUrl); + let id = 1; + const callbacks = new Map(); + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.id && callbacks.has(msg.id)) { + callbacks.get(msg.id)(msg.result); + callbacks.delete(msg.id); + } + }; + + await new Promise(r => ws.onopen = r); + + function send(method, params = {}) { + return new Promise((resolve) => { + const reqId = id++; + callbacks.set(reqId, resolve); + ws.send(JSON.stringify({ id: reqId, method, params })); + }); + } + + await send('Page.enable'); + await send('Runtime.enable'); + const tree = await send('Page.getFrameTree'); + const frameId = tree.frameTree.frame.id; + + let totalAudits = 0; + let passedAudits = 0; + let totalButtonsVerified = 0; + + for (const themeName of themes) { + console.log(`\n==================== THEME: ${themeName} ====================`); + for (const view of views) { + console.log(`\n▶ Auditing View: ${view.name} [${themeName}]`); + const html = view.builder(themeName); + await send('Page.setDocumentContent', { frameId, html }); + + for (const vp of viewports) { + await send('Emulation.setDeviceMetricsOverride', { + width: vp.width, + height: vp.height, + deviceScaleFactor: 1, + mobile: false + }); + + await new Promise(r => setTimeout(r, 60)); + + const evalRes = await send('Runtime.evaluate', { + expression: ` + (function() { + const results = { + horizontalOverflows: [], + overlappingElements: [], + buttonsInfo: [], + a11yErrors: [], + themeBg: window.getComputedStyle(document.body).backgroundColor + }; + + // 1. Check for unintentional horizontal overflows + const allElements = Array.from(document.querySelectorAll('*')); + for (const el of allElements) { + const cs = window.getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden') continue; + if (cs.overflowX === 'auto' || cs.overflowX === 'scroll') continue; + const tag = el.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea') continue; + if (cs.textOverflow === 'ellipsis') continue; + if (cs.overflowX === 'hidden' && (cs.textOverflow === 'clip' || cs.textOverflow === 'ellipsis')) continue; + if (el.scrollWidth > el.clientWidth + 3 && el.clientWidth > 0) { + const cls = (el.className && typeof el.className === 'string') ? el.className.slice(0, 30) : ''; + const id = el.id ? '#' + el.id : ''; + results.horizontalOverflows.push({ + selector: tag + id + (cls ? '.' + cls.split(' ').join('.') : ''), + clientWidth: el.clientWidth, + scrollWidth: el.scrollWidth + }); + } + } + + // 2. Check all interactive buttons & controls + WCAG accessible name + const buttons = Array.from(document.querySelectorAll('button, select, input[type="text"]')); + for (const btn of buttons) { + const rect = btn.getBoundingClientRect(); + const cs = window.getComputedStyle(btn); + if (cs.display === 'none' || cs.visibility === 'hidden') continue; + + const isVisible = rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight; + let isObscured = false; + if (isVisible) { + const centerX = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1); + const centerY = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1); + const topEl = document.elementFromPoint(centerX, centerY); + if (topEl && !btn.contains(topEl) && !topEl.contains(btn)) { + isObscured = true; + } + } + + const accessibleName = ( + btn.getAttribute('aria-label') || + btn.getAttribute('title') || + btn.textContent || + btn.value || + '' + ).trim(); + if (btn.tagName.toLowerCase() === 'button' && !accessibleName) { + results.a11yErrors.push('Button missing accessible name: #' + (btn.id || btn.className)); + } + + results.buttonsInfo.push({ + tag: btn.tagName.toLowerCase(), + id: btn.id || '', + text: accessibleName.slice(0, 25), + width: Math.round(rect.width), + height: Math.round(rect.height), + isVisible, + isObscured + }); + } + + // 3. Collision/Overlap detection between direct siblings in flex/grid + const containers = Array.from(document.querySelectorAll('.drawer-action-buttons, .bisect-actions, .issues-filter-pills, .snippet-subtabs, .modal-footer, .header-row-primary')); + for (const container of containers) { + const children = Array.from(container.children).filter(c => { + const s = window.getComputedStyle(c); + return s.display !== 'none' && s.visibility !== 'hidden'; + }); + for (let i = 0; i < children.length; i++) { + for (let j = i + 1; j < children.length; j++) { + const r1 = children[i].getBoundingClientRect(); + const r2 = children[j].getBoundingClientRect(); + if (r1.width === 0 || r2.width === 0) continue; + const overlapX = Math.max(0, Math.min(r1.right, r2.right) - Math.max(r1.left, r2.left)); + const overlapY = Math.max(0, Math.min(r1.bottom, r2.bottom) - Math.max(r1.top, r2.top)); + if (overlapX > 3 && overlapY > 3) { + results.overlappingElements.push({ + container: container.className, + el1: children[i].tagName + (children[i].id ? '#' + children[i].id : ''), + el2: children[j].tagName + (children[j].id ? '#' + children[j].id : ''), + overlapX, + overlapY + }); + } + } + } + } + + // 4. WCAG 2.1 AA Treegrid, Row ARIA labels & tabular-nums checks on Graph View + if (${view.checkA11yGraph ? 'true' : 'false'}) { + const treegrid = document.querySelector('[role="treegrid"]'); + if (!treegrid || !(treegrid.getAttribute('aria-label') || '').trim()) { + results.a11yErrors.push('Missing role="treegrid" with aria-label on graph container'); + } + const rows = Array.from(document.querySelectorAll('.commit-row[role="row"]')); + if (rows.length === 0) { + results.a11yErrors.push('Expected at least one .commit-row[role="row"]'); + } else { + for (const r of rows) { + if (!(r.getAttribute('aria-label') || '').trim()) { + results.a11yErrors.push('Commit row missing descriptive aria-label'); + } + if (r.getAttribute('tabindex') === null) { + results.a11yErrors.push('Commit row missing focusable tabindex'); + } + } + } + const monoNums = Array.from(document.querySelectorAll('.mono-num, .tabular-nums')); + if (monoNums.length === 0) { + results.a11yErrors.push('Missing .mono-num / .tabular-nums numeric elements'); + } else { + for (const m of monoNums) { + const fvn = window.getComputedStyle(m).fontVariantNumeric || ''; + if (!fvn.includes('tabular-nums')) { + results.a11yErrors.push('Numeric element missing tabular-nums font-variant-numeric: ' + fvn); + } + } + } + } + + return results; + })() + `, + returnByValue: true + }); + + const res = evalRes.result.value; + totalAudits++; + + const hasOverflows = res.horizontalOverflows.length > 0; + const hasOverlaps = res.overlappingElements.length > 0; + const hasA11yErrors = res.a11yErrors.length > 0; + const obscuredButtons = res.buttonsInfo.filter(b => b.isVisible && b.isObscured); + + if (!hasOverflows && !hasOverlaps && !hasA11yErrors && obscuredButtons.length === 0) { + passedAudits++; + console.log(` ✔ [PASS] ${themeName.padEnd(19)} | ${vp.name.padEnd(14)} (${vp.width}x${vp.height}) - ${res.buttonsInfo.length} controls, a11y OK, 0 overlaps, 0 overflows`); + } else { + console.log(` ✖ [WARN] ${themeName} | ${vp.name} (${vp.width}x${vp.height}):`); + if (hasOverflows) { + console.log(` Overflows:`, res.horizontalOverflows); + } + if (hasOverlaps) { + console.log(` Overlaps:`, res.overlappingElements); + } + if (hasA11yErrors) { + console.log(` A11y errors:`, res.a11yErrors); + } + if (obscuredButtons.length > 0) { + console.log(` Obscured buttons:`, obscuredButtons); + } + } + + totalButtonsVerified += res.buttonsInfo.length; + } + } + } + + ws.close(); + edgeProc.kill(); + + setTimeout(() => { + try { fs.rmSync(profileDir, { recursive: true, force: true }); } catch(e) {} + }, 1000); + + console.log(`\n======================================================`); + console.log(`AUDIT RESULTS: ${passedAudits}/${totalAudits} audits passed with zero collisions.`); + console.log(`TOTAL BUTTONS & CONTROLS VERIFIED: ${totalButtonsVerified}`); + console.log(`======================================================\n`); + + if (passedAudits !== totalAudits) { + throw new Error(`Some viewports have responsive or button layout issues`); + } +} + +verifyResponsiveAndButtons().catch((err) => { + console.error('Audit failed:', err); + process.exit(1); +}); diff --git a/src/git/branchWatcher.ts b/src/git/branchWatcher.ts index fdd8294..53b8d0c 100644 --- a/src/git/branchWatcher.ts +++ b/src/git/branchWatcher.ts @@ -2,14 +2,93 @@ import * as vscode from 'vscode'; import { GitClient } from './gitClient'; import { t, Language } from '../i18n/locales'; +export interface AdaptiveDebouncerOptions { + minDelayMs?: number; + maxDelayMs?: number; + burstWindowMs?: number; + burstThreshold?: number; +} + +/** + * Adaptive event debouncer that scales delay from minDelayMs (150ms) up to + * maxDelayMs (1000ms) when rapid filesystem bursts (e.g. git rebase/gc) occur. + */ +export class AdaptiveDebouncer { + private readonly minDelayMs: number; + private readonly maxDelayMs: number; + private readonly burstWindowMs: number; + private readonly burstThreshold: number; + private timer: NodeJS.Timeout | null = null; + private timestamps: number[] = []; + private currentDelayMs: number; + + constructor( + private readonly callback: () => void | Promise, + options: AdaptiveDebouncerOptions = {} + ) { + this.minDelayMs = options.minDelayMs ?? 150; + this.maxDelayMs = options.maxDelayMs ?? 1000; + this.burstWindowMs = options.burstWindowMs ?? 600; + this.burstThreshold = options.burstThreshold ?? 3; + this.currentDelayMs = this.minDelayMs; + } + + public getCurrentDelayMs(): number { + return this.currentDelayMs; + } + + public trigger(now = Date.now()): void { + this.timestamps = this.timestamps.filter((ts) => now - ts <= this.burstWindowMs); + this.timestamps.push(now); + + if (this.timestamps.length >= this.burstThreshold) { + const exponent = this.timestamps.length - this.burstThreshold + 1; + this.currentDelayMs = Math.min(this.maxDelayMs, this.minDelayMs * Math.pow(2, exponent)); + } else { + this.currentDelayMs = this.minDelayMs; + } + + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = null; + this.currentDelayMs = this.minDelayMs; + void this.callback(); + }, this.currentDelayMs); + } + + public flush(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.currentDelayMs = this.minDelayMs; + void this.callback(); + } + + public dispose(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.timestamps = []; + } +} + export class BranchWatcherService implements vscode.Disposable { private readonly gitClient = GitClient.getInstance(); private readonly disposables: vscode.Disposable[] = []; private fetchTimer: NodeJS.Timeout | null = null; private lastAlertedState: string | null = null; private isChecking = false; + private readonly refDebouncer: AdaptiveDebouncer; constructor(private readonly onRefreshRequired: () => void) { + this.refDebouncer = new AdaptiveDebouncer(() => this.onGitRefChanged(), { + minDelayMs: 150, + maxDelayMs: 1000, + }); this.setupFileWatcher(); this.setupAutoFetchTimer(); @@ -31,8 +110,9 @@ export class BranchWatcherService implements vscode.Disposable { '**/.git/{refs/heads/**,refs/remotes/**,FETCH_HEAD,HEAD}' ); - watcher.onDidChange(() => this.onGitRefChanged(), this, this.disposables); - watcher.onDidCreate(() => this.onGitRefChanged(), this, this.disposables); + watcher.onDidChange(() => this.refDebouncer.trigger(), this, this.disposables); + watcher.onDidCreate(() => this.refDebouncer.trigger(), this, this.disposables); + watcher.onDidDelete(() => this.refDebouncer.trigger(), this, this.disposables); this.disposables.push(watcher); } @@ -241,6 +321,7 @@ export class BranchWatcherService implements vscode.Disposable { } public dispose(): void { + this.refDebouncer.dispose(); if (this.fetchTimer) { clearInterval(this.fetchTimer); this.fetchTimer = null; diff --git a/src/git/conventionalCommits.ts b/src/git/conventionalCommits.ts new file mode 100644 index 0000000..749fb5d --- /dev/null +++ b/src/git/conventionalCommits.ts @@ -0,0 +1,158 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export const CONVENTIONAL_COMMIT_TYPES = [ + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'chore', + 'ci', +] as const; + +export type ConventionalCommitType = (typeof CONVENTIONAL_COMMIT_TYPES)[number]; + +export interface ConventionalCommitParts { + type: string; + scope?: string; + breaking?: boolean; + subject: string; + body?: string; + footer?: string; +} + +export interface IssueSuggestionItem { + number: number; + title: string; + state?: string; +} + +/** + * Formats structured Conventional Commit fields into a standard Git commit message. + */ +export function formatConventionalCommit(parts: ConventionalCommitParts): string { + const cleanType = (parts.type || 'feat').trim().toLowerCase() || 'feat'; + const cleanScope = parts.scope ? parts.scope.trim().replace(/[()\r\n]/g, '') : ''; + const cleanSubject = (parts.subject || '').trim().replace(/\r?\n/g, ' '); + const bang = parts.breaking ? '!' : ''; + const scopeSegment = cleanScope ? `(${cleanScope})` : ''; + + const header = `${cleanType}${scopeSegment}${bang}: ${cleanSubject}`; + const sections: string[] = [header]; + + const cleanBody = parts.body ? parts.body.trim() : ''; + if (cleanBody) { + sections.push(cleanBody); + } + + let cleanFooter = parts.footer ? parts.footer.trim() : ''; + if (parts.breaking && !/BREAKING CHANGE:/i.test(cleanFooter)) { + const breakingNote = `BREAKING CHANGE: ${cleanSubject || 'introduces breaking changes'}`; + cleanFooter = cleanFooter ? `${cleanFooter}\n${breakingNote}` : breakingNote; + } + if (cleanFooter) { + sections.push(cleanFooter); + } + + return sections.join('\n\n'); +} + +/** + * Parses a Conventional Commit string back into its structured parts if it matches the spec. + */ +export function parseConventionalCommit(message: string): ConventionalCommitParts | null { + const normalized = (message || '').replace(/\r\n/g, '\n').trim(); + if (!normalized) return null; + + const firstNewline = normalized.indexOf('\n'); + const header = firstNewline === -1 ? normalized : normalized.slice(0, firstNewline); + const rest = firstNewline === -1 ? '' : normalized.slice(firstNewline + 1).trim(); + + const match = header.match(/^([a-zA-Z0-9_-]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/); + if (!match) return null; + + const hasBreakingFooter = /BREAKING CHANGE:/i.test(rest); + return { + type: match[1].toLowerCase(), + scope: match[2]?.trim() || undefined, + breaking: Boolean(match[3]) || hasBreakingFooter, + subject: match[4].trim(), + body: rest || undefined, + }; +} + +/** + * Strips `#` comment lines from a `.gitmessage` template while collapsing extra blank lines. + */ +export function stripGitTemplateComments(rawTemplate: string): string { + const lines = rawTemplate + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')); + + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +/** + * Reads a repository's commit template from `.gitmessage`, `.git/.gitmessage`, or `.github/COMMIT_TEMPLATE`. + */ +export function readGitMessageTemplate(repoRoot: string): string | null { + if (!repoRoot) return null; + + const candidatePaths = [ + path.join(repoRoot, '.gitmessage'), + path.join(repoRoot, '.gitmessage.txt'), + path.join(repoRoot, '.git', '.gitmessage'), + path.join(repoRoot, '.github', 'COMMIT_TEMPLATE'), + ]; + + for (const candidate of candidatePaths) { + try { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + const raw = fs.readFileSync(candidate, 'utf8'); + const cleaned = stripGitTemplateComments(raw); + if (cleaned.length > 0) { + return cleaned; + } + } + } catch { + // Ignore unreadable template files and continue fallback chain + } + } + + return null; +} + +/** + * Filters repository issues for `#issue` inline autocomplete in the commit builder. + */ +export function filterIssueSuggestions( + issues: IssueSuggestionItem[], + query: string, + limit = 8 +): IssueSuggestionItem[] { + const normalizedQuery = (query || '').replace(/^#/, '').trim().toLowerCase(); + const safeLimit = Math.max(1, Math.min(25, limit)); + + const sorted = [...issues].sort((a, b) => { + const aClosed = a.state === 'closed' ? 1 : 0; + const bClosed = b.state === 'closed' ? 1 : 0; + if (aClosed !== bClosed) return aClosed - bClosed; + return b.number - a.number; + }); + + if (!normalizedQuery) { + return sorted.slice(0, safeLimit); + } + + return sorted + .filter( + (issue) => + String(issue.number).includes(normalizedQuery) || + (issue.title || '').toLowerCase().includes(normalizedQuery) + ) + .slice(0, safeLimit); +} diff --git a/src/git/gitClient.ts b/src/git/gitClient.ts index 1d345a0..c340e80 100644 --- a/src/git/gitClient.ts +++ b/src/git/gitClient.ts @@ -9,6 +9,7 @@ import { CloudPatch, CommitComparisonResult, ContributorStat, + DagQueryFilters, FileCommitRevision, GitAuthor, GitBisectStatus, @@ -25,6 +26,7 @@ import { RebaseSimulationResult, RemoteRepoInfo, RepoHealthMetrics, + ConflictedFileInfo, RepositoryOption, RepoStats, UserPersonalStats, @@ -202,20 +204,35 @@ export class GitClient { public async getCommits( cwd: string, limit = 300, - skip = 0 + skip = 0, + filters?: DagQueryFilters ): Promise { const format = '%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%D%x1f%s%x1f%b%x1f%G?%x1f%GS%x1f%GK%x1e'; - const args = [ - 'log', - '--all', + const args = ['log', filters?.hideRemotes ? '--branches' : '--all']; + if (filters?.hideRemotes) { + args.push('--tags'); + } + if (filters?.firstParent) { + args.push('--first-parent'); + } + args.push( '--topo-order', '--parents', `--format=${format}`, - `-n`, + '-n', limit.toString(), - `--skip=${skip}`, - ]; + `--skip=${skip}` + ); + if (filters?.since) { + args.push(`--since=${filters.since}`); + } + if (filters?.until) { + args.push(`--until=${filters.until}`); + } + if (filters?.filePath && !filters.filePath.includes('..') && !filters.filePath.startsWith('-')) { + args.push('--', filters.filePath); + } try { const raw = await this.execute(args, cwd); @@ -1155,6 +1172,19 @@ export class GitClient { const userIdentity = await this.getUserIdentity(cwd); const pullRequests: PullRequestInfo[] = []; + // 0. Prefer native RemoteProvider REST integration (GitHub Enterprise / GitLab / Forgejo) when available + if (remoteInfo) { + try { + const provider = RemoteManager.getInstance().getProvider(remoteInfo.provider); + if (provider?.getPullRequests) { + const providerPrs = await provider.getPullRequests(remoteInfo, 'all'); + if (providerPrs.length > 0) { + return providerPrs; + } + } + } catch {} + } + // 1. Try GitHub CLI (`gh pr list`) if remote is GitHub if (remoteInfo?.provider === 'github') { try { @@ -2096,6 +2126,24 @@ export class GitClient { }; } + /** + * Moves or resets a branch pointer to a target commit hash (supports soft/mixed/hard for active branch). + */ + public async resetBranchToCommit( + cwd: string, + branchName: string, + targetHash: string, + mode: 'soft' | 'mixed' | 'hard' = 'mixed' + ): Promise { + const cleanBranch = branchName.replace(/^HEAD\s*->\s*/, '').trim(); + const current = await this.getCurrentBranch(cwd).catch(() => ''); + if (current && current === cleanBranch) { + await this.execute(['reset', `--${mode}`, targetHash], cwd); + } else { + await this.execute(['branch', '-f', cleanBranch, targetHash], cwd); + } + } + /** * Discovers all submodules in the repository completely offline via GitFsReader */ @@ -2343,4 +2391,130 @@ export class GitClient { public async skipRebase(cwd: string): Promise { return await this.execute(['rebase', '--skip'], cwd); } + + /** + * Aborts an in-flight merge operation + */ + public async abortMerge(cwd: string): Promise { + return await this.execute(['merge', '--abort'], cwd); + } + + /** + * Returns unmerged conflicted files with conflict hunk counts and header preview lines. + */ + public async getConflictedFiles(cwd: string): Promise { + let raw = ''; + try { + raw = await this.execute(['diff', '--name-only', '--diff-filter=U'], cwd); + } catch { + return []; + } + + const relPaths = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const rootResolved = path.resolve(cwd); + const results: ConflictedFileInfo[] = []; + + for (const relPath of relPaths) { + const absPath = path.resolve(cwd, relPath); + if (absPath !== rootResolved && !absPath.startsWith(rootResolved + path.sep)) { + continue; + } + + let hunksCount = 0; + let oursLabel: string | undefined; + let theirsLabel: string | undefined; + const previewLines: string[] = []; + + try { + if (fs.existsSync(absPath) && fs.statSync(absPath).isFile()) { + const content = fs.readFileSync(absPath, 'utf8'); + const lines = content.split(/\r?\n/); + for (const line of lines) { + if (line.startsWith('<<<<<<<')) { + hunksCount++; + if (!oursLabel) oursLabel = line.slice(7).trim() || 'HEAD'; + } else if (line.startsWith('>>>>>>>') && !theirsLabel) { + theirsLabel = line.slice(7).trim() || 'incoming'; + } + if (previewLines.length < 12) { + previewLines.push(line); + } + } + } + } catch { + // Binary or unreadable conflicted file; keep metadata entry + } + + results.push({ + filePath: relPath.replace(/\\/g, '/'), + hunksCount, + oursLabel, + theirsLabel, + previewLines, + }); + } + + return results; + } + + /** + * Resolves a conflicted file using 'ours', 'theirs', or staging manually edited content. + */ + public async resolveConflictFile( + cwd: string, + filePath: string, + strategy: 'ours' | 'theirs' | 'mark-resolved' + ): Promise { + const cleanPath = (filePath || '').trim(); + if (!cleanPath) { + throw new Error('File path is required to resolve conflict.'); + } + + const rootResolved = path.resolve(cwd); + const absPath = path.resolve(cwd, cleanPath); + if (absPath !== rootResolved && !absPath.startsWith(rootResolved + path.sep)) { + throw new Error('Invalid conflict path outside repository root.'); + } + + if (strategy === 'ours') { + await this.execute(['checkout', '--ours', '--', cleanPath], cwd); + } else if (strategy === 'theirs') { + await this.execute(['checkout', '--theirs', '--', cleanPath], cwd); + } + + await this.execute(['add', '--', cleanPath], cwd); + } + + /** + * Commits staged changes (optionally staging all tracked/untracked changes when index is empty). + */ + public async commitStagedChanges( + cwd: string, + message: string, + stageAllIfEmpty = false + ): Promise<{ hash: string; subject: string }> { + const cleanMessage = (message || '').trim(); + if (!cleanMessage) { + throw new Error('Commit message cannot be empty.'); + } + + let staged = (await this.execute(['diff', '--cached', '--name-only'], cwd)).trim(); + if (!staged && stageAllIfEmpty) { + await this.execute(['add', '-A'], cwd); + staged = (await this.execute(['diff', '--cached', '--name-only'], cwd)).trim(); + } + + if (!staged) { + throw new Error('No staged changes found. Stage files or enable Stage All Changes.'); + } + + await this.execute(['commit', '-m', cleanMessage], cwd); + const hash = (await this.execute(['rev-parse', 'HEAD'], cwd)).trim(); + const subject = cleanMessage.split(/\r?\n/)[0].trim(); + return { hash, subject }; + } } diff --git a/src/git/gitFsReader.ts b/src/git/gitFsReader.ts index c9767ef..bdc694a 100644 --- a/src/git/gitFsReader.ts +++ b/src/git/gitFsReader.ts @@ -1,5 +1,49 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as zlib from 'zlib'; + +export type GitHashAlgorithm = 'sha1' | 'sha256'; +export type GitRefStorageFormat = 'files' | 'reftable'; + +export interface OfflineGitObject { + type: 'commit' | 'tree' | 'blob' | 'tag'; + size: number; + data: Buffer; +} + +export interface OfflineCommitObject { + hash: string; + tree: string; + parents: string[]; + author: string; + committer: string; + message: string; +} + +export interface OfflineTreeEntry { + mode: string; + type: 'blob' | 'tree' | 'commit'; + name: string; + hash: string; +} + +export interface OfflineGitIndexEntry { + path: string; + sha1: string; + size: number; + mtimeMs: number; + mode: number; + flags: number; + assumeValid: boolean; + stage: number; +} + +export interface OfflineWorkingTreeStatus { + stagedCount: number; + unstagedModified: string[]; + untracked: string[]; + totalIndexEntries: number; +} export interface OfflineGitRef { name: string; @@ -88,6 +132,8 @@ export interface OfflineRepoState { recentCommitsFromReflog: OfflineGitCommitEntry[]; userName?: string; userEmail?: string; + objectFormat?: GitHashAlgorithm; + refStorage?: GitRefStorageFormat; } /** @@ -183,6 +229,8 @@ export class GitFsReader { recentCommitsFromReflog, userName: configInfo.userName, userEmail: configInfo.userEmail, + objectFormat: configInfo.objectFormat || this.detectHashAlgorithm(commonDir), + refStorage: configInfo.refStorage || (this.detectReftable(commonDir).isReftable ? 'reftable' : 'files'), }; } @@ -276,18 +324,22 @@ export class GitFsReader { } /** - * Reads .git/config to extract remotes, upstream tracking, and user info. + * Reads .git/config to extract remotes, upstream tracking, user info, and repository extensions. */ public readConfig(commonDir: string): { remotes: OfflineGitRemote[]; branchUpstreams: Map; userName?: string; userEmail?: string; + objectFormat?: GitHashAlgorithm; + refStorage?: GitRefStorageFormat; } { const remotesMap = new Map(); const branchUpstreams = new Map(); let userName: string | undefined; let userEmail: string | undefined; + let objectFormat: GitHashAlgorithm | undefined; + let refStorage: GitRefStorageFormat | undefined; try { const configFile = path.join(commonDir, 'config'); @@ -347,6 +399,12 @@ export class GitFsReader { } else if (currentSection.type === 'user') { if (key === 'name') userName = value; else if (key === 'email') userEmail = value; + } else if (currentSection.type === 'extensions') { + if (key === 'objectformat' && (value === 'sha1' || value === 'sha256')) { + objectFormat = value; + } else if (key === 'refstorage' && (value === 'files' || value === 'reftable')) { + refStorage = value; + } } } } catch {} @@ -356,6 +414,61 @@ export class GitFsReader { branchUpstreams, userName, userEmail, + objectFormat, + refStorage, + }; + } + + /** + * Validates 40-char SHA-1 or 64-char SHA-256 Git object identifiers. + */ + public isValidObjectId(oid: string): boolean { + return /^[0-9a-f]{40}$|^[0-9a-f]{64}$/i.test(oid.trim()); + } + + /** + * Detects repository hash algorithm ('sha1' or 'sha256') from config or loose refs. + */ + public detectHashAlgorithm(commonDir: string): GitHashAlgorithm { + const cfg = this.readConfig(commonDir); + if (cfg.objectFormat) return cfg.objectFormat; + try { + const refsHeads = path.join(commonDir, 'refs', 'heads'); + if (fs.existsSync(refsHeads)) { + const refs = this.readLooseRefsRecursively(refsHeads, refsHeads); + if (refs.some((r) => r.commitHash.length === 64)) return 'sha256'; + } + const headFile = path.join(commonDir, 'HEAD'); + if (fs.existsSync(headFile)) { + const headContent = fs.readFileSync(headFile, 'utf8').trim(); + if (/^[0-9a-f]{64}$/i.test(headContent)) return 'sha256'; + } + } catch {} + return 'sha1'; + } + + /** + * Detects whether a repository uses Git 2.45+ reftable storage instead of loose refs. + */ + public detectReftable(commonDir: string): { + isReftable: boolean; + tablesListPath?: string; + tablesCount: number; + } { + const cfg = this.readConfig(commonDir); + const tablesListPath = path.join(commonDir, 'reftable', 'tables.list'); + const hasTablesList = fs.existsSync(tablesListPath); + let tablesCount = 0; + if (hasTablesList) { + try { + tablesCount = fs.readFileSync(tablesListPath, 'utf8').trim().split(/\r?\n/).filter(Boolean).length; + } catch {} + } + const isReftable = cfg.refStorage === 'reftable' || hasTablesList; + return { + isReftable, + tablesListPath: hasTablesList ? tablesListPath : undefined, + tablesCount, }; } @@ -375,7 +488,7 @@ export class GitFsReader { } else if (entry.isFile() && !entry.isSymbolicLink()) { try { const sha = fs.readFileSync(fullPath, 'utf8').trim(); - if (/^[0-9a-f]{40}$/i.test(sha)) { + if (this.isValidObjectId(sha)) { const relName = path.relative(baseDir, fullPath).replace(/\\/g, '/'); results.push({ name: relName, commitHash: sha }); } @@ -547,9 +660,20 @@ export class GitFsReader { * Reads .gitmodules and detects active/nested git submodules directly from the filesystem. * Runs 100% offline with zero external processes. */ - public readSubmodules(repoPath: string, commonDir: string): OfflineGitSubmodule[] { + public readSubmodules( + repoPath: string, + commonDir: string, + depth = 0, + maxDepth = 4, + visited: Set = new Set() + ): OfflineGitSubmodule[] { const submodules: OfflineGitSubmodule[] = []; + if (depth > maxDepth) return submodules; try { + const normalizedRoot = path.resolve(repoPath); + if (visited.has(normalizedRoot)) return submodules; + visited.add(normalizedRoot); + const gitmodulesPath = path.join(repoPath, '.gitmodules'); if (!fs.existsSync(gitmodulesPath)) { // Also check if .git/modules exists even without .gitmodules @@ -564,12 +688,28 @@ export class GitFsReader { const lines = content.split(/\r?\n/); let currentSub: { name: string; path?: string; url?: string; branch?: string } | null = null; + const pushAndRecurse = (sub: { name: string; path: string; url: string; branch?: string }) => { + const enriched = this.enrichSubmoduleInfo(repoPath, commonDir, sub); + submodules.push(enriched); + if (enriched.isInitialized && fs.existsSync(enriched.fullPath)) { + const nestedResolved = this.resolveGitDir(enriched.fullPath); + if (nestedResolved) { + const nested = this.readSubmodules(enriched.fullPath, nestedResolved.commonDir, depth + 1, maxDepth, visited); + for (const child of nested) { + child.name = `${enriched.name}/${child.name}`; + child.path = path.posix.join(enriched.path.replace(/\\/g, '/'), child.path.replace(/\\/g, '/')); + submodules.push(child); + } + } + } + }; + for (const line of lines) { const trimmed = line.trim(); const sectionMatch = trimmed.match(/^\[submodule\s+"(.+)"\]$/i); if (sectionMatch) { if (currentSub && currentSub.path && currentSub.url) { - submodules.push(this.enrichSubmoduleInfo(repoPath, commonDir, currentSub as any)); + pushAndRecurse(currentSub as { name: string; path: string; url: string; branch?: string }); } currentSub = { name: sectionMatch[1] }; continue; @@ -592,7 +732,7 @@ export class GitFsReader { } if (currentSub && currentSub.path && currentSub.url) { - submodules.push(this.enrichSubmoduleInfo(repoPath, commonDir, currentSub as any)); + pushAndRecurse(currentSub as { name: string; path: string; url: string; branch?: string }); } } catch {} @@ -625,18 +765,10 @@ export class GitFsReader { headBranch = head.branch; isDetached = head.isDetached; - // Check if index exists or if uncommitted files exist - const indexFile = path.join(resolved.gitDir, 'index'); - if (fs.existsSync(indexFile)) { - const indexStat = fs.statSync(indexFile); - // If index timestamp differs or unstaged changes, mark dirty check - const headFile = path.join(resolved.gitDir, 'HEAD'); - if (fs.existsSync(headFile)) { - const headStat = fs.statSync(headFile); - if (Math.abs(indexStat.mtimeMs - headStat.mtimeMs) > 1000) { - // Potential working tree difference - } - } + // Check offline working tree cleanliness via .git/index + const status = this.computeOfflineWorkingTreeStatus(fullPath, resolved.gitDir, resolved.commonDir); + if (status.unstagedModified.length > 0) { + isDirty = true; } } } @@ -835,8 +967,8 @@ export class GitFsReader { const header = parts[0]; const actionAndMsg = parts[1]; - // Format: <> - const match = header.match(/^([0-9a-f]{40})\s+([0-9a-f]{40})\s+([^<]+)<([^>]+)>\s+(\d+)\s+([+\-\d]+)$/); + // Format: <> (SHA-1 40-char or SHA-256 64-char) + const match = header.match(/^([0-9a-f]{40}(?:[0-9a-f]{24})?)\s+([0-9a-f]{40}(?:[0-9a-f]{24})?)\s+([^<]+)<([^>]+)>\s+(\d+)\s+([+\-\d]+)$/i); if (match) { const newSha = match[2]; const author = match[3].trim(); @@ -861,4 +993,428 @@ export class GitFsReader { } catch {} return entries; } + + // -------------------------------------------------------------------------- + // Binary Git Object & Index Readers (Loose Objects, Packfiles, Delta, DIRC) + // -------------------------------------------------------------------------- + + /** + * Reads and decompresses a loose Git object from .git/objects/xx/yyyy... + */ + public readLooseObject(commonDir: string, oid: string): OfflineGitObject | null { + const cleanOid = oid.trim().toLowerCase(); + if (!this.isValidObjectId(cleanOid)) return null; + + try { + const objPath = path.join(commonDir, 'objects', cleanOid.slice(0, 2), cleanOid.slice(2)); + if (!fs.existsSync(objPath)) return null; + + const compressed = fs.readFileSync(objPath); + const raw = zlib.inflateSync(compressed); + const nullIdx = raw.indexOf(0); + if (nullIdx === -1) return null; + + const header = raw.subarray(0, nullIdx).toString('utf8'); + const spaceIdx = header.indexOf(' '); + if (spaceIdx === -1) return null; + + const type = header.slice(0, spaceIdx) as OfflineGitObject['type']; + const size = parseInt(header.slice(spaceIdx + 1), 10) || 0; + const data = raw.subarray(nullIdx + 1); + return { type, size, data }; + } catch { + return null; + } + } + + /** + * Decodes Git binary delta stream (OFS_DELTA / REF_DELTA) against base buffer. + */ + public applyGitDelta(base: Buffer, delta: Buffer): Buffer { + let pos = 0; + + const readLebVarint = (): number => { + let result = 0; + let shift = 0; + while (pos < delta.length) { + const b = delta[pos++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + return result >>> 0; + }; + + const baseSize = readLebVarint(); + if (base.length !== baseSize) { + throw new Error(`Delta base size mismatch: expected ${baseSize}, got ${base.length}`); + } + const resultSize = readLebVarint(); + const chunks: Buffer[] = []; + + while (pos < delta.length) { + const cmd = delta[pos++]; + if (cmd & 0x80) { + let copyOffset = 0; + let copySize = 0; + if (cmd & 0x01) copyOffset |= delta[pos++]; + if (cmd & 0x02) copyOffset |= delta[pos++] << 8; + if (cmd & 0x04) copyOffset |= delta[pos++] << 16; + if (cmd & 0x08) copyOffset |= delta[pos++] << 24; + if (cmd & 0x10) copySize |= delta[pos++]; + if (cmd & 0x20) copySize |= delta[pos++] << 8; + if (cmd & 0x40) copySize |= delta[pos++] << 16; + if (copySize === 0) copySize = 0x10000; + chunks.push(base.subarray(copyOffset >>> 0, (copyOffset >>> 0) + copySize)); + } else if (cmd > 0) { + chunks.push(delta.subarray(pos, pos + cmd)); + pos += cmd; + } else { + throw new Error('Unexpected delta opcode 0x00'); + } + } + + const result = Buffer.concat(chunks); + if (result.length !== resultSize) { + throw new Error(`Delta result size mismatch: expected ${resultSize}, got ${result.length}`); + } + return result; + } + + /** + * Looks up a Git object offset in a v2 packfile index (.idx) using binary search. + */ + public readPackIndexV2( + idxBuffer: Buffer, + oid: string, + hashLenBytes = 20 + ): { offset: number } | null { + const cleanOid = oid.trim().toLowerCase(); + if (cleanOid.length !== hashLenBytes * 2 || idxBuffer.length < 1032) { + return null; + } + + // Verify magic 0xff744f63 and version 2 + if ( + idxBuffer.readUInt32BE(0) !== 0xff744f63 || + idxBuffer.readUInt32BE(4) !== 2 + ) { + return null; + } + + const targetBuf = Buffer.from(cleanOid, 'hex'); + const firstByte = targetBuf[0]; + const startIdx = firstByte === 0 ? 0 : idxBuffer.readUInt32BE(8 + (firstByte - 1) * 4); + const endIdx = idxBuffer.readUInt32BE(8 + firstByte * 4); + if (startIdx >= endIdx) return null; + + const totalObjects = idxBuffer.readUInt32BE(8 + 255 * 4); + const oidTableStart = 8 + 256 * 4; + const offsetsTableStart = oidTableStart + totalObjects * (hashLenBytes + 4); + const largeOffsetsStart = offsetsTableStart + totalObjects * 4; + + let lo = startIdx; + let hi = endIdx - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const entryStart = oidTableStart + mid * hashLenBytes; + const cmp = Buffer.compare(targetBuf, idxBuffer.subarray(entryStart, entryStart + hashLenBytes)); + if (cmp === 0) { + const rawOffset = idxBuffer.readUInt32BE(offsetsTableStart + mid * 4); + if ((rawOffset & 0x80000000) === 0) { + return { offset: rawOffset }; + } + const largeIdx = rawOffset & 0x7fffffff; + const hiWord = idxBuffer.readUInt32BE(largeOffsetsStart + largeIdx * 8); + const loWord = idxBuffer.readUInt32BE(largeOffsetsStart + largeIdx * 8 + 4); + return { offset: hiWord * 0x100000000 + loWord }; + } + if (cmp < 0) hi = mid - 1; + else lo = mid + 1; + } + + return null; + } + + /** + * Reads a single object at byte offset inside a Git .pack file (supports OFS_DELTA and REF_DELTA). + */ + public readPackObject( + packBuffer: Buffer, + offset: number, + hashLenBytes = 20, + resolveRef?: (oid: string) => OfflineGitObject | null, + depth = 0 + ): OfflineGitObject | null { + if (depth > 64 || offset < 12 || offset >= packBuffer.length) return null; + + let pos = offset; + let byte = packBuffer[pos++]; + const typeId = (byte >> 4) & 0x07; + let size = byte & 0x0f; + let shift = 4; + while (byte & 0x80) { + byte = packBuffer[pos++]; + size += (byte & 0x7f) * Math.pow(2, shift); + shift += 7; + } + + const typeMap: Record = { + 1: 'commit', + 2: 'tree', + 3: 'blob', + 4: 'tag', + }; + + try { + if (typeMap[typeId]) { + const data = zlib.inflateSync(packBuffer.subarray(pos)); + return { type: typeMap[typeId], size: size || data.length, data }; + } + + if (typeId === 6) { + // OFS_DELTA + let ofsByte = packBuffer[pos++]; + let relOffset = ofsByte & 0x7f; + while (ofsByte & 0x80) { + ofsByte = packBuffer[pos++]; + relOffset = ((relOffset + 1) << 7) | (ofsByte & 0x7f); + } + const deltaData = zlib.inflateSync(packBuffer.subarray(pos)); + const baseObj = this.readPackObject(packBuffer, offset - relOffset, hashLenBytes, resolveRef, depth + 1); + if (!baseObj) return null; + const reconstructed = this.applyGitDelta(baseObj.data, deltaData); + return { type: baseObj.type, size: reconstructed.length, data: reconstructed }; + } + + if (typeId === 7) { + // REF_DELTA + const baseOid = packBuffer.subarray(pos, pos + hashLenBytes).toString('hex'); + pos += hashLenBytes; + const deltaData = zlib.inflateSync(packBuffer.subarray(pos)); + const baseObj = resolveRef ? resolveRef(baseOid) : null; + if (!baseObj) return null; + const reconstructed = this.applyGitDelta(baseObj.data, deltaData); + return { type: baseObj.type, size: reconstructed.length, data: reconstructed }; + } + } catch { + return null; + } + + return null; + } + + /** + * Reads any Git object (loose or packed) by SHA-1 / SHA-256 object ID completely offline. + */ + public readGitObject(commonDir: string, oid: string): OfflineGitObject | null { + const loose = this.readLooseObject(commonDir, oid); + if (loose) return loose; + + const cleanOid = oid.trim().toLowerCase(); + if (!this.isValidObjectId(cleanOid)) return null; + const hashLenBytes = cleanOid.length / 2; + + try { + const packDir = path.join(commonDir, 'objects', 'pack'); + if (!fs.existsSync(packDir)) return null; + + const idxFiles = fs.readdirSync(packDir).filter((f) => f.endsWith('.idx')); + for (const idxFile of idxFiles) { + const idxBuf = fs.readFileSync(path.join(packDir, idxFile)); + const hit = this.readPackIndexV2(idxBuf, cleanOid, hashLenBytes); + if (hit) { + const packPath = path.join(packDir, idxFile.replace(/\.idx$/i, '.pack')); + if (!fs.existsSync(packPath)) continue; + const packBuf = fs.readFileSync(packPath); + return this.readPackObject(packBuf, hit.offset, hashLenBytes, (refOid) => + this.readGitObject(commonDir, refOid) + ); + } + } + } catch {} + + return null; + } + + /** + * Parses raw uncompressed commit object payload into structured commit metadata. + */ + public parseCommitObject(oid: string, raw: Buffer): OfflineCommitObject { + const text = raw.toString('utf8'); + const splitIdx = text.indexOf('\n\n'); + const headerBlock = splitIdx !== -1 ? text.slice(0, splitIdx) : text; + const message = splitIdx !== -1 ? text.slice(splitIdx + 2).trim() : ''; + + let tree = ''; + const parents: string[] = []; + let author = ''; + let committer = ''; + + for (const line of headerBlock.split('\n')) { + if (line.startsWith('tree ')) tree = line.slice(5).trim(); + else if (line.startsWith('parent ')) parents.push(line.slice(7).trim()); + else if (line.startsWith('author ')) author = line.slice(7).trim(); + else if (line.startsWith('committer ')) committer = line.slice(10).trim(); + } + + return { hash: oid, tree, parents, author, committer, message }; + } + + /** + * Parses binary Git tree object entries ( \0). + */ + public parseTreeObject(raw: Buffer, hashLenBytes = 20): OfflineTreeEntry[] { + const entries: OfflineTreeEntry[] = []; + let pos = 0; + + while (pos < raw.length) { + const spaceIdx = raw.indexOf(0x20, pos); + const nullIdx = raw.indexOf(0x00, pos); + if (spaceIdx === -1 || nullIdx === -1 || nullIdx + 1 + hashLenBytes > raw.length) { + break; + } + + const mode = raw.subarray(pos, spaceIdx).toString('utf8'); + const name = raw.subarray(spaceIdx + 1, nullIdx).toString('utf8'); + const hashStart = nullIdx + 1; + const hash = raw.subarray(hashStart, hashStart + hashLenBytes).toString('hex'); + pos = hashStart + hashLenBytes; + + let type: OfflineTreeEntry['type'] = 'blob'; + if (mode === '40000' || mode === '040000') type = 'tree'; + else if (mode === '160000') type = 'commit'; + + entries.push({ mode, type, name, hash }); + } + + return entries; + } + + /** + * Parses the binary .git/index file (DIRC v2, v3, v4) completely offline. + */ + public readIndexEntries(gitDir: string, commonDir?: string): OfflineGitIndexEntry[] { + const entries: OfflineGitIndexEntry[] = []; + try { + const indexFile = path.join(gitDir, 'index'); + if (!fs.existsSync(indexFile)) return entries; + + const buf = fs.readFileSync(indexFile); + if (buf.length < 12 || buf.subarray(0, 4).toString('ascii') !== 'DIRC') { + return entries; + } + + const version = buf.readUInt32BE(4); + const count = buf.readUInt32BE(8); + if (version < 2 || version > 4) return entries; + + const algo = commonDir ? this.detectHashAlgorithm(commonDir) : 'sha1'; + const hashLenBytes = algo === 'sha256' ? 32 : 20; + + let pos = 12; + let prevPath = ''; + + for (let i = 0; i < count && pos + 42 + hashLenBytes <= buf.length; i++) { + const entryStart = pos; + const mtimeSec = buf.readUInt32BE(pos + 8); + const mtimeNano = buf.readUInt32BE(pos + 12); + const mode = buf.readUInt32BE(pos + 24); + const size = buf.readUInt32BE(pos + 36); + + const hashOffset = pos + 40; + const sha1 = buf.subarray(hashOffset, hashOffset + hashLenBytes).toString('hex'); + + const flags = buf.readUInt16BE(hashOffset + hashLenBytes); + const assumeValid = Boolean(flags & 0x8000); + const extended = Boolean(flags & 0x4000); + const stage = (flags >> 12) & 0x03; + + pos = hashOffset + hashLenBytes + 2; + if (version >= 3 && extended && pos + 2 <= buf.length) { + pos += 2; // skip extended flags + } + + let entryPath = ''; + if (version === 4) { + let varByte = buf[pos++]; + let stripLen = varByte & 0x7f; + while (varByte & 0x80) { + varByte = buf[pos++]; + stripLen = ((stripLen + 1) << 7) | (varByte & 0x7f); + } + const nullIdx = buf.indexOf(0x00, pos); + if (nullIdx === -1) break; + const suffix = buf.subarray(pos, nullIdx).toString('utf8'); + entryPath = prevPath.slice(0, Math.max(0, prevPath.length - stripLen)) + suffix; + pos = nullIdx + 1; + } else { + const nullIdx = buf.indexOf(0x00, pos); + if (nullIdx === -1) break; + entryPath = buf.subarray(pos, nullIdx).toString('utf8'); + pos = nullIdx + 1; + const rem = (pos - entryStart) % 8; + if (rem !== 0) { + pos += 8 - rem; + } + } + + prevPath = entryPath; + entries.push({ + path: entryPath, + sha1, + size, + mtimeMs: mtimeSec * 1000 + Math.floor(mtimeNano / 1_000_000), + mode, + flags, + assumeValid, + stage, + }); + } + } catch {} + return entries; + } + + /** + * Computes offline working tree status by comparing .git/index entries with disk stats. + */ + public computeOfflineWorkingTreeStatus( + repoPath: string, + gitDir?: string, + commonDir?: string + ): OfflineWorkingTreeStatus { + const resolved = gitDir && commonDir ? { gitDir, commonDir } : this.resolveGitDir(repoPath); + if (!resolved) { + return { stagedCount: 0, unstagedModified: [], untracked: [], totalIndexEntries: 0 }; + } + + const indexEntries = this.readIndexEntries(resolved.gitDir, resolved.commonDir); + const unstagedModified: string[] = []; + const seen = new Set(); + + for (const entry of indexEntries) { + if (entry.stage !== 0 || seen.has(entry.path)) continue; + seen.add(entry.path); + const absPath = path.join(repoPath, entry.path); + try { + if (!fs.existsSync(absPath)) { + unstagedModified.push(entry.path); + continue; + } + const stat = fs.statSync(absPath); + if (stat.size !== entry.size || Math.abs(stat.mtimeMs - entry.mtimeMs) > 2000) { + unstagedModified.push(entry.path); + } + } catch { + unstagedModified.push(entry.path); + } + } + + return { + stagedCount: indexEntries.filter((e) => e.stage === 0).length, + unstagedModified, + untracked: [], + totalIndexEntries: seen.size, + }; + } } diff --git a/src/git/types.ts b/src/git/types.ts index 65d83e8..6f4d7f7 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -346,10 +346,33 @@ export interface WebviewToExtensionMessage { | 'skipRebase' | 'requestAiKeys' | 'generateAiKey' - | 'revokeAiKey'; + | 'revokeAiKey' + // Advanced DAG Filters & Drag-and-Drop: + | 'applyDagFilters' + | 'dndResetBranchToCommit' + | 'dndCherryPickToBranch' + | 'dndRebaseOntoCommit' + // Conflict Resolution Studio, Conventional Commits & PR Actions: + | 'requestConflictedFiles' + | 'resolveConflictFile' + | 'openMergeEditor' + | 'abortMerge' + | 'requestCommitTemplate' + | 'commitStagedChanges' + | 'requestPrComments' + | 'addPrComment' + | 'approvePullRequest'; payload?: any; } +export interface DagQueryFilters { + firstParent?: boolean; + hideRemotes?: boolean; + since?: string; + until?: string; + filePath?: string; +} + export interface ExtensionToWebviewMessage { command: | 'graphData' @@ -382,6 +405,12 @@ export interface ExtensionToWebviewMessage { | 'aiKeysData' | 'aiKeyGenerated' | 'aiKeyRevoked' + | 'conflictedFilesData' + | 'commitTemplateData' + | 'commitCreated' + | 'prCommentsData' + | 'prCommentAdded' + | 'prApproved' | 'error' | 'loading'; payload?: any; @@ -483,3 +512,11 @@ export interface RebaseSimulationResult { dropCount: number; } +export interface ConflictedFileInfo { + filePath: string; + hunksCount: number; + oursLabel?: string; + theirsLabel?: string; + previewLines?: string[]; +} + diff --git a/src/graph/dagWorkerClient.ts b/src/graph/dagWorkerClient.ts new file mode 100644 index 0000000..bfac8db --- /dev/null +++ b/src/graph/dagWorkerClient.ts @@ -0,0 +1,186 @@ +import { GitCommit, GraphNode } from '../git/types'; +import { GraphLayout } from './graphLayout'; + +export interface MinimapRenderOptions { + width?: number; + height?: number; + selectedIndex?: number; + viewportStartRatio?: number; + viewportEndRatio?: number; +} + +/** + * Off-thread DAG layout & OffscreenCanvas rendering engine with synchronous + * main-thread / Node.js fallback for constrained environments. + */ +export class DagWorkerEngine { + private static instance: DagWorkerEngine; + private worker: Worker | null = null; + private nextRequestId = 1; + private pending = new Map< + number, + { resolve: (nodes: GraphNode[]) => void; reject: (err: Error) => void } + >(); + + public static getInstance(): DagWorkerEngine { + if (!DagWorkerEngine.instance) { + DagWorkerEngine.instance = new DagWorkerEngine(); + } + return DagWorkerEngine.instance; + } + + public isWorkerSupported(): boolean { + return ( + typeof Worker !== 'undefined' && + typeof Blob !== 'undefined' && + typeof URL !== 'undefined' && + typeof URL.createObjectURL === 'function' + ); + } + + public isOffscreenCanvasSupported(): boolean { + return typeof OffscreenCanvas !== 'undefined'; + } + + /** + * Computes topological DAG layout off the UI thread when Web Workers are available, + * seamlessly falling back to direct GraphLayout computation in Node/test runtimes. + */ + public async computeLayoutAsync(commits: GitCommit[], theme: any = 'auto'): Promise { + if (!this.isWorkerSupported()) { + return GraphLayout.compute(commits, theme); + } + + try { + const worker = this.ensureWorker(); + const id = this.nextRequestId++; + return await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + worker.postMessage({ id, commits, theme }); + }); + } catch { + return GraphLayout.compute(commits, theme); + } + } + + /** + * Renders a condensed branch/lane overview onto a Canvas or OffscreenCanvas. + */ + public renderMinimap( + nodes: GraphNode[], + canvas: HTMLCanvasElement | OffscreenCanvas, + options: MinimapRenderOptions = {} + ): void { + const width = options.width ?? (canvas.width || 120); + const height = options.height ?? (canvas.height || 320); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + + const ctx = (canvas as any).getContext?.('2d') as CanvasRenderingContext2D | null; + if (!ctx) return; + + ctx.clearRect(0, 0, width, height); + if (nodes.length === 0) return; + + let maxLane = 1; + for (const node of nodes) { + if (node.lane + 1 > maxLane) maxLane = node.lane + 1; + } + + const total = nodes.length; + const stepY = height / Math.max(total, 1); + const laneStepX = Math.max(4, Math.min(12, (width - 16) / Math.max(maxLane, 1))); + const offsetX = 8; + + for (let i = 0; i < total; i++) { + const node = nodes[i]; + const y = i * stepY + stepY * 0.5; + const x = offsetX + node.lane * laneStepX; + + ctx.fillStyle = node.color || '#38bdf8'; + ctx.beginPath(); + ctx.arc(x, y, Math.max(1.5, Math.min(3, stepY * 0.45)), 0, Math.PI * 2); + ctx.fill(); + + if (node.commit.refs?.some((r) => r.isHead || r.type === 'head')) { + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.stroke(); + } + } + + if ( + typeof options.viewportStartRatio === 'number' && + typeof options.viewportEndRatio === 'number' + ) { + const topY = Math.max(0, options.viewportStartRatio * height); + const bottomY = Math.min(height, options.viewportEndRatio * height); + ctx.fillStyle = 'rgba(56, 189, 248, 0.16)'; + ctx.fillRect(0, topY, width, Math.max(6, bottomY - topY)); + ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)'; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, topY + 0.5, width - 1, Math.max(5, bottomY - topY - 1)); + } + + if (typeof options.selectedIndex === 'number' && options.selectedIndex >= 0 && options.selectedIndex < total) { + const selY = options.selectedIndex * stepY; + ctx.fillStyle = '#f59e0b'; + ctx.fillRect(0, Math.max(0, selY - 1), width, 2); + } + } + + public dispose(): void { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + this.pending.clear(); + } + + private ensureWorker(): Worker { + if (this.worker) return this.worker; + const rawFn = GraphLayout.compute.toString().trim(); + const computeMember = /^(async\s+)?function\b|^\(|^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=>/.test(rawFn) + ? `compute: ${rawFn}` + : rawFn; + const workerScript = ` + const THEME_LANE_COLORS = ${JSON.stringify(GraphLayout.THEME_LANE_COLORS)}; + const GraphLayoutWorker = { THEME_LANE_COLORS, ${computeMember} }; + self.onmessage = function (ev) { + const { id, commits, theme } = ev.data; + try { + const nodes = GraphLayoutWorker.compute(commits, theme); + self.postMessage({ id, nodes }); + } catch (err) { + self.postMessage({ id, error: err && err.message ? err.message : String(err) }); + } + }; + `; + const blob = new Blob([workerScript], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + URL.revokeObjectURL(url); + + worker.onmessage = (ev: MessageEvent) => { + const { id, nodes, error } = ev.data || {}; + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + if (error) entry.reject(new Error(error)); + else entry.resolve(nodes); + }; + + worker.onerror = () => { + for (const [, entry] of this.pending) { + entry.reject(new Error('DAG Web Worker execution failed')); + } + this.pending.clear(); + this.worker = null; + }; + + this.worker = worker; + return worker; + } +} diff --git a/src/graph/graphLayout.ts b/src/graph/graphLayout.ts index fbc1b95..383388d 100644 --- a/src/graph/graphLayout.ts +++ b/src/graph/graphLayout.ts @@ -55,12 +55,13 @@ export class GraphLayout { const commit = commits[i]; let assignedLane = -1; - // 1. Check if this commit was expected in an active lane + // 1. Check if this commit was expected in active lanes and clear all converging pointers for (let l = 0; l < activeLanes.length; l++) { if (activeLanes[l] === commit.hash) { - assignedLane = l; + if (assignedLane === -1) { + assignedLane = l; + } activeLanes[l] = null; - break; } } diff --git a/src/graph/graphWebviewProvider.ts b/src/graph/graphWebviewProvider.ts index ba39d3d..81291fc 100644 --- a/src/graph/graphWebviewProvider.ts +++ b/src/graph/graphWebviewProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import { GitClient } from '../git/gitClient'; +import { GitFsReader } from '../git/gitFsReader'; import { GraphLayout } from './graphLayout'; import { GitContentProvider } from '../git/gitContentProvider'; import { t } from '../i18n/locales'; @@ -9,8 +10,13 @@ import { IssueService } from '../issues/issueService'; import { ForkIntegration } from '../remotes/forkIntegration'; import { PersistenceManager } from '../state/persistenceManager'; import { ApiKeyManager } from '../mcp/apiKeyManager'; +import { AdaptiveDebouncer } from '../git/branchWatcher'; +import { readGitMessageTemplate } from '../git/conventionalCommits'; +import { RemoteManager } from '../remotes/remoteManager'; +import { IpcSchemaValidator } from '../utils/ipcValidator'; import { CommitFlowSettings, + DagQueryFilters, ExtensionToWebviewMessage, LayoutMode, WebviewToExtensionMessage, @@ -29,20 +35,43 @@ export class GraphWebviewProvider private readonly persistence = PersistenceManager.getInstance(); private readonly disposables: vscode.Disposable[] = []; private activeRepoPath: string | null = null; + private readonly headDebouncer = new AdaptiveDebouncer(() => this.refreshData(), { + minDelayMs: 150, + maxDelayMs: 1000, + }); + private refreshInFlight: Promise | null = null; + private refreshPending = false; + private currentDagFilters: DagQueryFilters = {}; + private ipcBatchQueue = new Map(); + private ipcBatchTimer: NodeJS.Timeout | null = null; public async getEffectiveRepoRoot(): Promise { if (this.activeRepoPath && fs.existsSync(this.activeRepoPath)) { return this.activeRepoPath; } + const persistedRepo = this.persistence.getActiveRepository(); + if (persistedRepo && fs.existsSync(persistedRepo) && GitFsReader.getInstance().resolveGitDir(persistedRepo)) { + this.activeRepoPath = persistedRepo; + return persistedRepo; + } return await this.gitClient.getRepoRoot(); } constructor(private readonly extensionUri: vscode.Uri) { - // 1. Watch for Git HEAD changes to auto-refresh when checking out branches + // 1. Watch for Git HEAD changes with adaptive debouncing to coalesce burst storms const watcher = vscode.workspace.createFileSystemWatcher('**/.git/HEAD'); - watcher.onDidChange(() => this.refreshData(), this, this.disposables); - watcher.onDidCreate(() => this.refreshData(), this, this.disposables); + watcher.onDidChange(() => this.headDebouncer.trigger(), this, this.disposables); + watcher.onDidCreate(() => this.headDebouncer.trigger(), this, this.disposables); this.disposables.push(watcher); + this.disposables.push({ + dispose: () => { + this.headDebouncer.dispose(); + if (this.ipcBatchTimer) { + clearTimeout(this.ipcBatchTimer); + this.ipcBatchTimer = null; + } + }, + }); // 2. Multi-root workspace: Refresh when switching active editor to a different root vscode.window.onDidChangeActiveTextEditor( @@ -152,22 +181,69 @@ export class GraphWebviewProvider } /** - * Refreshes the Git data and sends to all active webviews + * Queues an extension-to-webview IPC message for short-window batching. */ - public async refreshData(): Promise { - const activeWebviews = [this.view?.webview, this.panel?.webview].filter( - (w): w is vscode.Webview => w !== undefined - ); + public enqueueWebviewMessage(webview: vscode.Webview, message: ExtensionToWebviewMessage): void { + const existing = this.ipcBatchQueue.get(webview) || []; + const dedupIdx = existing.findIndex((m) => m.command === message.command); + if (dedupIdx >= 0) { + existing[dedupIdx] = message; + } else { + existing.push(message); + } + this.ipcBatchQueue.set(webview, existing); - if (activeWebviews.length === 0) { - return; + if (!this.ipcBatchTimer) { + this.ipcBatchTimer = setTimeout(() => { + this.flushWebviewMessages(); + }, 16); } + } - activeWebviews.forEach((w) => { - try { - w.postMessage({ command: 'loading' } as ExtensionToWebviewMessage); - } catch {} - }); + /** + * Flushes queued IPC messages immediately across registered webviews. + */ + public flushWebviewMessages(): number { + if (this.ipcBatchTimer) { + clearTimeout(this.ipcBatchTimer); + this.ipcBatchTimer = null; + } + let flushedCount = 0; + for (const [webview, msgs] of this.ipcBatchQueue.entries()) { + for (const msg of msgs) { + try { + webview.postMessage(msg); + flushedCount++; + } catch {} + } + } + this.ipcBatchQueue.clear(); + return flushedCount; + } + + /** + * Refreshes the Git data with in-flight coalescing and sends to all active webviews + */ + public async refreshData(): Promise { + if (this.refreshInFlight) { + this.refreshPending = true; + return this.refreshInFlight; + } + + const executeRefresh = async (): Promise => { + const activeWebviews = [this.view?.webview, this.panel?.webview].filter( + (w): w is vscode.Webview => w !== undefined + ); + + if (activeWebviews.length === 0) { + return; + } + + activeWebviews.forEach((w) => { + try { + w.postMessage({ command: 'loading' } as ExtensionToWebviewMessage); + } catch {} + }); try { const root = await this.getEffectiveRepoRoot(); @@ -201,8 +277,9 @@ export class GraphWebviewProvider bisectStatus, repositories, submodules, + conflictedFiles, ] = await Promise.all([ - this.gitClient.getCommits(root, limit), + this.gitClient.getCommits(root, limit, 0, this.currentDagFilters), this.gitClient.getBranches(root), this.gitClient.getCurrentBranch(root), this.gitClient.getGitHubRepoInfo(root), @@ -215,6 +292,7 @@ export class GraphWebviewProvider this.gitClient.getBisectStatus(root), this.gitClient.getWorkspaceRepositories(root), this.gitClient.getSubmodules(root), + this.gitClient.getConflictedFiles(root), ]); const themeConfig = config.get('graph.theme', 'auto'); @@ -280,6 +358,7 @@ export class GraphWebviewProvider bisectStatus, repositories, submodules, + conflictedFiles, activeRepo: root, forkInstalled: this.forkIntegration.findForkExecutable() !== null, forkEnabled: this.persistence.isForkIntegrationEnabled(), @@ -303,10 +382,28 @@ export class GraphWebviewProvider } catch {} }); } + }; + + this.refreshInFlight = executeRefresh(); + try { + await this.refreshInFlight; + } finally { + this.refreshInFlight = null; + if (this.refreshPending) { + this.refreshPending = false; + void this.refreshData(); + } + } } private setupMessageListener(webview: vscode.Webview): void { webview.onDidReceiveMessage(async (msg: WebviewToExtensionMessage) => { + const validation = IpcSchemaValidator.validateWebviewMessage(msg); + if (!validation.valid) { + console.warn(`[CommitFlow IPC] Rejected message: ${validation.reason}`); + return; + } + const root = await this.getEffectiveRepoRoot(); switch (msg.command) { @@ -1135,10 +1232,65 @@ export class GraphWebviewProvider case 'switchRepository': if (msg.payload) { this.activeRepoPath = msg.payload; + await this.persistence.setActiveRepository(msg.payload); await this.refreshData(); } break; + case 'applyDagFilters': + this.currentDagFilters = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {}; + await this.refreshData(); + break; + + case 'dndResetBranchToCommit': + if (root && msg.payload?.branchName && msg.payload?.targetHash) { + try { + const mode = msg.payload.mode || 'mixed'; + await this.gitClient.resetBranchToCommit(root, msg.payload.branchName, msg.payload.targetHash, mode); + vscode.window.showInformationMessage( + `Branch "${msg.payload.branchName}" updated to ${String(msg.payload.targetHash).slice(0, 7)} (--${mode}).` + ); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Drag-and-drop reset failed: ${err.message}`); + } + } + break; + + case 'dndCherryPickToBranch': + if (root && msg.payload?.commitHash) { + try { + if (msg.payload.targetBranch) { + await this.gitClient.checkoutBranch(root, msg.payload.targetBranch); + } + const cpRes = await this.gitClient.cherryPick(root, msg.payload.commitHash); + if (cpRes.success) { + vscode.window.showInformationMessage(`Cherry-picked ${String(msg.payload.commitHash).slice(0, 7)}.`); + } else { + vscode.window.showWarningMessage(`Cherry-pick notice: ${cpRes.message}`); + } + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Drag-and-drop cherry-pick failed: ${err.message}`); + } + } + break; + + case 'dndRebaseOntoCommit': + if (root && msg.payload?.targetHash) { + try { + if (msg.payload.sourceBranch) { + await this.gitClient.checkoutBranch(root, msg.payload.sourceBranch); + } + await this.gitClient.rebase(root, msg.payload.targetHash); + vscode.window.showInformationMessage(`Rebased onto ${String(msg.payload.targetHash).slice(0, 7)}.`); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Drag-and-drop rebase failed: ${err.message}`); + } + } + break; + case 'requestRebaseCommits': if (root && msg.payload?.baseHash) { try { @@ -1253,6 +1405,191 @@ export class GraphWebviewProvider } catch {} break; } + + // --- Conflict Resolution Studio (Issue #14) --- + case 'requestConflictedFiles': { + if (root) { + try { + const files = await this.gitClient.getConflictedFiles(root); + webview.postMessage({ + command: 'conflictedFilesData', + payload: { files }, + } as ExtensionToWebviewMessage); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to inspect conflicts: ${err.message}`); + } + } + break; + } + + case 'resolveConflictFile': { + if (root && msg.payload?.filePath && msg.payload?.strategy) { + try { + await this.gitClient.resolveConflictFile( + root, + msg.payload.filePath, + msg.payload.strategy + ); + const files = await this.gitClient.getConflictedFiles(root); + webview.postMessage({ + command: 'conflictedFilesData', + payload: { files }, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage( + `Resolved conflict in ${msg.payload.filePath} (${msg.payload.strategy}).` + ); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Conflict resolution failed: ${err.message}`); + } + } + break; + } + + case 'openMergeEditor': { + if (root && msg.payload?.filePath) { + try { + const resolvedRoot = path.resolve(root); + const targetAbs = path.resolve(root, String(msg.payload.filePath)); + if (targetAbs === resolvedRoot || targetAbs.startsWith(resolvedRoot + path.sep)) { + const fileUri = vscode.Uri.file(targetAbs); + try { + await vscode.commands.executeCommand('git.openMergeEditor', fileUri); + } catch { + await vscode.window.showTextDocument(fileUri); + } + } + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to open merge editor: ${err.message}`); + } + } + break; + } + + case 'abortMerge': { + if (root) { + try { + await this.gitClient.abortMerge(root); + vscode.window.showInformationMessage('Merge operation aborted.'); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to abort merge: ${err.message}`); + } + } + break; + } + + // --- Conventional Commits Builder & #issue Autocomplete (Issue #15) --- + case 'requestCommitTemplate': { + if (root) { + try { + const template = readGitMessageTemplate(root); + const workingChanges = await this.gitClient.getWorkingTreeChanges(root); + webview.postMessage({ + command: 'commitTemplateData', + payload: { + template, + changesCount: workingChanges.length, + }, + } as ExtensionToWebviewMessage); + } catch { + webview.postMessage({ + command: 'commitTemplateData', + payload: { template: null, changesCount: 0 }, + } as ExtensionToWebviewMessage); + } + } + break; + } + + case 'commitStagedChanges': { + if (root && msg.payload?.message) { + try { + const res = await this.gitClient.commitStagedChanges( + root, + msg.payload.message, + Boolean(msg.payload.stageAll) + ); + webview.postMessage({ + command: 'commitCreated', + payload: res, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage(`Created commit ${res.hash.slice(0, 7)}: ${res.subject}`); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Commit failed: ${err.message}`); + } + } + break; + } + + // --- Provider PR Parity: Comments & Approvals (Issue #16) --- + case 'requestPrComments': { + if (root && msg.payload?.prNumber) { + try { + const remoteInfo = await this.gitClient.getRemoteRepoInfo(root); + const provider = remoteInfo ? RemoteManager.getInstance().getProvider(remoteInfo.provider) : undefined; + const comments = + remoteInfo && provider?.getPullRequestComments + ? await provider.getPullRequestComments(remoteInfo, Number(msg.payload.prNumber)) + : []; + webview.postMessage({ + command: 'prCommentsData', + payload: { prNumber: Number(msg.payload.prNumber), comments }, + } as ExtensionToWebviewMessage); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to load PR comments: ${err.message}`); + } + } + break; + } + + case 'addPrComment': { + if (root && msg.payload?.prNumber && msg.payload?.body) { + try { + const remoteInfo = await this.gitClient.getRemoteRepoInfo(root); + const provider = remoteInfo ? RemoteManager.getInstance().getProvider(remoteInfo.provider) : undefined; + if (!remoteInfo || !provider?.createPullRequestComment) { + throw new Error('PR comments are not supported by the active remote provider.'); + } + const comment = await provider.createPullRequestComment( + remoteInfo, + Number(msg.payload.prNumber), + String(msg.payload.body) + ); + webview.postMessage({ + command: 'prCommentAdded', + payload: { prNumber: Number(msg.payload.prNumber), comment }, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage(`Added comment to PR #${msg.payload.prNumber}`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to add PR comment: ${err.message}`); + } + } + break; + } + + case 'approvePullRequest': { + if (root && msg.payload?.prNumber) { + try { + const remoteInfo = await this.gitClient.getRemoteRepoInfo(root); + const provider = remoteInfo ? RemoteManager.getInstance().getProvider(remoteInfo.provider) : undefined; + if (!remoteInfo || !provider?.approvePullRequest) { + throw new Error('PR approvals are not supported by the active remote provider.'); + } + await provider.approvePullRequest(remoteInfo, Number(msg.payload.prNumber)); + webview.postMessage({ + command: 'prApproved', + payload: { prNumber: Number(msg.payload.prNumber) }, + } as ExtensionToWebviewMessage); + vscode.window.showInformationMessage(`Approved PR #${msg.payload.prNumber}`); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to approve PR: ${err.message}`); + } + } + break; + } } }); } @@ -1494,6 +1831,9 @@ export class GraphWebviewProvider + @@ -1574,6 +1914,9 @@ export class GraphWebviewProvider Rebase en progreso: Paso 1 de ?. Resuelve los conflictos y continúa.
+ @@ -1587,12 +1930,44 @@ export class GraphWebviewProvider
-
-
- -
${tr('graph.loading')}
+ +
+ + + + + + + +
+ +
+ +
+
+
+ +
${tr('graph.loading')}
+
+
+ +
@@ -2184,7 +2559,7 @@ Always check repo state and active branch before making modifications.