From f75c054e658a192a443c45295e14a51574896748 Mon Sep 17 00:00:00 2001 From: Anish Lakhwara Date: Sat, 8 Nov 2025 21:58:34 -0800 Subject: [PATCH] update --- .beads/.gitignore | 20 +++++++ .beads/config.yaml | 56 +++++++++++++++++++ .beads/metadata.json | 5 ++ .gitattributes | 3 ++ flake.lock | 58 +++++++++++++++++++- flake.nix | 9 +++- index.html | 11 +++- renderer.ts | 124 +++++++++++++++++++++++++++++++++++++++++-- server.ts | 103 ++++++++++++++++++++++++++++++++++- styles.css | 54 ++++++++++++++++++- types.ts | 12 +++++ 11 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 .beads/.gitignore create mode 100644 .beads/config.yaml create mode 100644 .beads/metadata.json create mode 100644 .gitattributes diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..921b468 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,20 @@ +# SQLite databases +*.db +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock + +# Legacy database files +db.sqlite +bd.db + +# Keep JSONL exports and config (source of truth for git) +!*.jsonl +!metadata.json +!config.json diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..95c5f3e --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,56 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo +# - sync.branch - Git branch for beads commits (use BEADS_SYNC_BRANCH env var or bd config set) diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..b69cc33 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,5 @@ +{ + "database": "beads.db", + "version": "0.23.0", + "jsonl_export": "beads.jsonl" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..851960f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/beads.jsonl merge=beads diff --git a/flake.lock b/flake.lock index 9aa7f11..83b5fb5 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,27 @@ { "nodes": { + "beads": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1762602968, + "narHash": "sha256-3F7sv4mAKBQ4YBSABgp9bendgHopObNivO5NQhJYUek=", + "owner": "steveyegge", + "repo": "beads", + "rev": "367bf077e10695976480bba097e2ae47593a4d82", + "type": "github" + }, + "original": { + "owner": "steveyegge", + "ref": "v0.23.0", + "repo": "beads", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,6 +40,24 @@ "type": "github" } }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1760038930, @@ -36,7 +76,8 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", + "beads": "beads", + "flake-utils": "flake-utils_2", "nixpkgs": "nixpkgs" } }, @@ -54,6 +95,21 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index bbdb621..a43e0dc 100644 --- a/flake.nix +++ b/flake.nix @@ -4,9 +4,13 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + beads = { + url = "github:steveyegge/beads/v0.23.0"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, beads }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; @@ -25,6 +29,9 @@ (python3.withPackages (ps: with ps; [ setuptools ])) pkg-config + # Beads - AI code review tool + beads.packages.${system}.default + # Native build dependencies for node-pty and Electron gcc gnumake diff --git a/index.html b/index.html index f1c7c0a..737709c 100644 --- a/index.html +++ b/index.html @@ -201,12 +201,21 @@
- +
+ + +
+
Loading diff...
diff --git a/renderer.ts b/renderer.ts index 9415dc0..885a2e2 100644 --- a/renderer.ts +++ b/renderer.ts @@ -1,6 +1,6 @@ import {FitAddon} from "@xterm/addon-fit"; import {Terminal} from "xterm"; -import {PersistedSession, SessionConfig, FileDiff} from "./types"; +import {PersistedSession, SessionConfig, FileDiff, MergeStatus, MergeResult} from "./types"; import {isClaudeSessionReady} from "./terminal-utils"; import {ipcAdapter} from "./ipc-adapter"; @@ -1522,6 +1522,8 @@ document.getElementById("session-list")?.addEventListener("click", (e) => { // Diff Viewer let currentDiffFiles: FileDiff[] = []; let selectedFileIndex: number = -1; +let currentDiffSessionId: string = ''; +let diffSidebarCollapsed: boolean = false; let pierreLib: any = null; let shikiLib: any = null; let highlighter: any = null; @@ -1530,14 +1532,31 @@ async function openDiffViewer(sessionId: string) { const modal = document.getElementById("diff-viewer-modal"); const fileList = document.getElementById("diff-file-list"); const diffDisplay = document.getElementById("diff-display"); + const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; if (!modal || !fileList || !diffDisplay) return; + currentDiffSessionId = sessionId; + + // Auto-collapse sidebar on mobile + const isMobile = window.innerWidth < 768; + diffSidebarCollapsed = isMobile; + if (diffSidebarCollapsed) { + fileList.classList.add("collapsed"); + } else { + fileList.classList.remove("collapsed"); + } + // Show modal with loading state modal.classList.remove("hidden"); fileList.innerHTML = '
Loading diff...
'; diffDisplay.innerHTML = '
Loading...
'; + if (mergeBtn) { + mergeBtn.disabled = true; + mergeBtn.textContent = "Merge to Parent"; + } + // Load libraries dynamically (only in browser) if (!pierreLib) { try { @@ -1560,8 +1579,9 @@ async function openDiffViewer(sessionId: string) { } } - // Request diff from server + // Request diff and merge status from server ipcAdapter.send("get-session-diff", sessionId); + ipcAdapter.send("check-merge-status", sessionId); } function renderDiffFileList() { @@ -1569,11 +1589,11 @@ function renderDiffFileList() { if (!fileList) return; if (currentDiffFiles.length === 0) { - fileList.innerHTML = '
No changes found
'; + fileList.innerHTML = '
No changes found
'; return; } - fileList.innerHTML = ''; + fileList.innerHTML = ''; currentDiffFiles.forEach((file, index) => { const item = document.createElement("div"); @@ -1615,12 +1635,37 @@ function renderDiffFileList() { item.addEventListener("click", () => { selectedFileIndex = index; + + // Auto-collapse sidebar on mobile when selecting a file + const isMobile = window.innerWidth < 768; + if (isMobile) { + const fileListEl = document.getElementById("diff-file-list"); + if (fileListEl) { + diffSidebarCollapsed = true; + fileListEl.classList.add("collapsed"); + } + } + renderDiffFileList(); renderSelectedDiff(); }); fileList.appendChild(item); }); + + // Re-attach toggle button event listener + document.getElementById("toggle-diff-sidebar")?.replaceWith(document.getElementById("toggle-diff-sidebar")!.cloneNode(true)); + document.getElementById("toggle-diff-sidebar")?.addEventListener("click", () => { + const fileListEl = document.getElementById("diff-file-list"); + if (!fileListEl) return; + + diffSidebarCollapsed = !diffSidebarCollapsed; + if (diffSidebarCollapsed) { + fileListEl.classList.add("collapsed"); + } else { + fileListEl.classList.remove("collapsed"); + } + }); } async function renderSelectedDiff() { @@ -1681,12 +1726,33 @@ async function renderSelectedDiff() { } } + + // Close diff viewer document.getElementById("close-diff-viewer")?.addEventListener("click", () => { const modal = document.getElementById("diff-viewer-modal"); modal?.classList.add("hidden"); currentDiffFiles = []; selectedFileIndex = -1; + currentDiffSessionId = ''; + diffSidebarCollapsed = false; +}); + +// Merge to parent button +document.getElementById("merge-to-parent-btn")?.addEventListener("click", () => { + if (!currentDiffSessionId) return; + + const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; + if (!mergeBtn || mergeBtn.disabled) return; + + if (!confirm("Merge this worktree branch into the parent branch? This cannot be undone.")) { + return; + } + + mergeBtn.disabled = true; + mergeBtn.textContent = "Merging..."; + + ipcAdapter.send("merge-to-parent", currentDiffSessionId); }); // Handle diff data from server @@ -1709,3 +1775,53 @@ ipcAdapter.on("session-diff-error", (_event, sessionId: string, error: string) = diffDisplay.innerHTML = `
Failed to load diff
`; } }); + +// Handle merge status from server +ipcAdapter.on("merge-status-data", (_event, sessionId: string, status: MergeStatus) => { + const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; + if (!mergeBtn) return; + + if (status.hasCommits) { + mergeBtn.disabled = false; + const commitText = status.commitsAhead === 1 ? '1 commit' : `${status.commitsAhead} commits`; + mergeBtn.title = `Merge ${commitText} into parent branch`; + if (status.hasUncommittedChanges) { + mergeBtn.title += ' (has uncommitted changes)'; + } + } else { + mergeBtn.disabled = true; + mergeBtn.title = "No commits to merge"; + } +}); + +// Handle merge status error +ipcAdapter.on("merge-status-error", (_event, sessionId: string, error: string) => { + console.error("Merge status error:", error); + const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; + if (mergeBtn) { + mergeBtn.disabled = true; + mergeBtn.title = `Error checking merge status: ${error}`; + } +}); + +// Handle merge result +ipcAdapter.on("merge-result", (_event, sessionId: string, result: MergeResult) => { + const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement; + + if (result.success) { + alert(`✓ ${result.message}`); + if (mergeBtn) { + mergeBtn.disabled = true; + mergeBtn.textContent = "Merged"; + } + // Refresh diff and status + ipcAdapter.send("get-session-diff", sessionId); + ipcAdapter.send("check-merge-status", sessionId); + } else { + alert(`✗ Merge failed: ${result.message}`); + if (mergeBtn) { + mergeBtn.disabled = false; + mergeBtn.textContent = "Merge to Parent"; + } + } +}); diff --git a/server.ts b/server.ts index 04d0a05..af84584 100644 --- a/server.ts +++ b/server.ts @@ -8,7 +8,7 @@ import * as path from "path"; import { simpleGit } from "simple-git"; import { promisify } from "util"; import { v4 as uuidv4 } from "uuid"; -import { PersistedSession, SessionConfig, FileDiff } from "./types"; +import { PersistedSession, SessionConfig, FileDiff, MergeStatus, MergeResult } from "./types"; import { isTerminalReady } from "./terminal-utils"; import cors from "cors"; @@ -630,6 +630,107 @@ io.on("connection", (socket) => { } }); + socket.on("check-merge-status", async (sessionId: string) => { + try { + const sessions = getPersistedSessions(); + const session = sessions.find(s => s.id === sessionId); + + if (!session) { + socket.emit("merge-status-error", sessionId, "Session not found"); + return; + } + + const git = simpleGit(session.worktreePath); + const parentBranch = session.config.parentBranch; + const currentBranch = session.gitBranch || (await git.revparse(['--abbrev-ref', 'HEAD'])); + + // Check how many commits ahead of parent + const revListOutput = await git.raw([ + 'rev-list', + '--count', + `${parentBranch}..${currentBranch}` + ]); + const commitsAhead = parseInt(revListOutput.trim(), 10); + + // Check for uncommitted changes + const statusSummary = await git.status(); + const hasUncommittedChanges = !statusSummary.isClean(); + + const mergeStatus: MergeStatus = { + hasCommits: commitsAhead > 0, + commitsAhead, + hasUncommittedChanges + }; + + console.log(`[${sessionId}] Merge status: ${JSON.stringify(mergeStatus)}`); + socket.emit("merge-status-data", sessionId, mergeStatus); + } catch (error) { + console.error("Error checking merge status:", error); + socket.emit("merge-status-error", sessionId, error instanceof Error ? error.message : String(error)); + } + }); + + socket.on("merge-to-parent", async (sessionId: string) => { + try { + const sessions = getPersistedSessions(); + const session = sessions.find(s => s.id === sessionId); + + if (!session) { + socket.emit("merge-result", sessionId, { + success: false, + message: "Session not found" + } as MergeResult); + return; + } + + const projectDir = session.config.projectDir; + const parentBranch = session.config.parentBranch; + const worktreeBranch = session.gitBranch || (await simpleGit(session.worktreePath).revparse(['--abbrev-ref', 'HEAD'])); + + console.log(`[${sessionId}] Starting merge: ${worktreeBranch} -> ${parentBranch} in ${projectDir}`); + + const git = simpleGit(projectDir); + + // Checkout parent branch + await git.checkout(parentBranch); + console.log(`[${sessionId}] Checked out ${parentBranch}`); + + // Attempt merge + try { + await git.merge([worktreeBranch]); + console.log(`[${sessionId}] Merge successful`); + + socket.emit("merge-result", sessionId, { + success: true, + message: `Successfully merged ${worktreeBranch} into ${parentBranch}` + } as MergeResult); + } catch (mergeError: any) { + // Check if it's a merge conflict + const status = await git.status(); + if (status.conflicted.length > 0) { + console.log(`[${sessionId}] Merge conflicts detected: ${status.conflicted.join(', ')}`); + + // Abort the merge + await git.raw(['merge', '--abort']); + + socket.emit("merge-result", sessionId, { + success: false, + conflicts: status.conflicted, + message: `Merge conflicts in: ${status.conflicted.join(', ')}` + } as MergeResult); + } else { + throw mergeError; + } + } + } catch (error) { + console.error("Error merging to parent:", error); + socket.emit("merge-result", sessionId, { + success: false, + message: error instanceof Error ? error.message : String(error) + } as MergeResult); + } + }); + socket.on("disconnect", () => { // Clean up socket references for this client // Note: We don't kill the PTYs - they keep running for reconnection diff --git a/styles.css b/styles.css index 01faf60..b47a91e 100644 --- a/styles.css +++ b/styles.css @@ -349,6 +349,18 @@ @apply flex items-center justify-between p-4 border-b border-gray-700; } + .diff-viewer-actions { + @apply flex items-center gap-3; + } + + .btn-merge { + @apply px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded font-medium transition-colors; + } + + .btn-merge:disabled { + @apply bg-gray-700 text-gray-500 cursor-not-allowed; + } + .diff-viewer-close { @apply text-gray-400 hover:text-white text-3xl font-bold w-10 h-10 flex items-center justify-center rounded transition-colors hover:bg-gray-800; } @@ -358,7 +370,11 @@ } .diff-file-list { - @apply w-80 border-r border-gray-700 overflow-y-auto bg-gray-800; + @apply w-80 border-r border-gray-700 overflow-y-auto bg-gray-800 transition-all duration-300 relative; + } + + .diff-file-list.collapsed { + @apply w-12 overflow-hidden; } .diff-loading { @@ -409,8 +425,42 @@ @apply text-red-400; } + .diff-sidebar-toggle { + @apply sticky top-0 left-0 w-full bg-gray-700 hover:bg-gray-600 text-gray-300 py-2 px-3 text-sm font-mono transition-colors z-10 border-b border-gray-600 flex items-center justify-center; + } + + .diff-file-list.collapsed .diff-sidebar-toggle { + @apply px-0; + } + + .toggle-collapse { + @apply inline-block; + } + + .diff-file-list.collapsed .toggle-collapse { + @apply hidden; + } + + .toggle-expand { + @apply hidden; + } + + .diff-file-list.collapsed .toggle-expand { + @apply inline-block; + } + + @media (min-width: 768px) { + .diff-sidebar-toggle { + @apply hidden; + } + + .diff-file-list.collapsed { + @apply w-80; + } + } + .diff-display { - @apply flex-1 overflow-auto bg-gray-900 p-4; + @apply flex-1 overflow-auto bg-gray-900 p-4 relative; } .diff-empty { diff --git a/types.ts b/types.ts index 19bc775..c075b73 100644 --- a/types.ts +++ b/types.ts @@ -28,3 +28,15 @@ export interface FileDiff { deletions: number; binary?: boolean; } + +export interface MergeStatus { + hasCommits: boolean; + commitsAhead: number; + hasUncommittedChanges: boolean; +} + +export interface MergeResult { + success: boolean; + conflicts?: string[]; + message: string; +}