This commit is contained in:
Anish Lakhwara 2025-11-08 21:58:34 -08:00
parent 35983681e4
commit f75c054e65
11 changed files with 445 additions and 10 deletions

20
.beads/.gitignore vendored Normal file
View file

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

56
.beads/config.yaml Normal file
View file

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

5
.beads/metadata.json Normal file
View file

@ -0,0 +1,5 @@
{
"database": "beads.db",
"version": "0.23.0",
"jsonl_export": "beads.jsonl"
}

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
# Use bd merge for beads JSONL files
.beads/beads.jsonl merge=beads

View file

@ -1,5 +1,27 @@
{ {
"nodes": { "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": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@ -18,6 +40,24 @@
"type": "github" "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": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1760038930, "lastModified": 1760038930,
@ -36,7 +76,8 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "beads": "beads",
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
}, },
@ -54,6 +95,21 @@
"repo": "default", "repo": "default",
"type": "github" "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", "root": "root",

View file

@ -4,9 +4,13 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; 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: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
@ -25,6 +29,9 @@
(python3.withPackages (ps: with ps; [ setuptools ])) (python3.withPackages (ps: with ps; [ setuptools ]))
pkg-config pkg-config
# Beads - AI code review tool
beads.packages.${system}.default
# Native build dependencies for node-pty and Electron # Native build dependencies for node-pty and Electron
gcc gcc
gnumake gnumake

View file

@ -201,12 +201,21 @@
<div class="diff-viewer-modal"> <div class="diff-viewer-modal">
<div class="diff-viewer-header"> <div class="diff-viewer-header">
<h2 class="modal-title" id="diff-viewer-title">Git Diff Viewer</h2> <h2 class="modal-title" id="diff-viewer-title">Git Diff Viewer</h2>
<div class="diff-viewer-actions">
<button id="merge-to-parent-btn" class="btn-merge" disabled title="Merge worktree branch into parent">
Merge to Parent
</button>
<button id="close-diff-viewer" class="diff-viewer-close" title="Close">×</button> <button id="close-diff-viewer" class="diff-viewer-close" title="Close">×</button>
</div> </div>
</div>
<div class="diff-viewer-content"> <div class="diff-viewer-content">
<!-- File List Sidebar --> <!-- File List Sidebar -->
<div class="diff-file-list" id="diff-file-list"> <div class="diff-file-list" id="diff-file-list">
<button id="toggle-diff-sidebar" class="diff-sidebar-toggle">
<span class="toggle-expand">&gt;&gt;</span>
<span class="toggle-collapse">&lt;&lt;</span>
</button>
<div class="diff-loading">Loading diff...</div> <div class="diff-loading">Loading diff...</div>
</div> </div>

View file

@ -1,6 +1,6 @@
import {FitAddon} from "@xterm/addon-fit"; import {FitAddon} from "@xterm/addon-fit";
import {Terminal} from "xterm"; import {Terminal} from "xterm";
import {PersistedSession, SessionConfig, FileDiff} from "./types"; import {PersistedSession, SessionConfig, FileDiff, MergeStatus, MergeResult} from "./types";
import {isClaudeSessionReady} from "./terminal-utils"; import {isClaudeSessionReady} from "./terminal-utils";
import {ipcAdapter} from "./ipc-adapter"; import {ipcAdapter} from "./ipc-adapter";
@ -1522,6 +1522,8 @@ document.getElementById("session-list")?.addEventListener("click", (e) => {
// Diff Viewer // Diff Viewer
let currentDiffFiles: FileDiff[] = []; let currentDiffFiles: FileDiff[] = [];
let selectedFileIndex: number = -1; let selectedFileIndex: number = -1;
let currentDiffSessionId: string = '';
let diffSidebarCollapsed: boolean = false;
let pierreLib: any = null; let pierreLib: any = null;
let shikiLib: any = null; let shikiLib: any = null;
let highlighter: any = null; let highlighter: any = null;
@ -1530,14 +1532,31 @@ async function openDiffViewer(sessionId: string) {
const modal = document.getElementById("diff-viewer-modal"); const modal = document.getElementById("diff-viewer-modal");
const fileList = document.getElementById("diff-file-list"); const fileList = document.getElementById("diff-file-list");
const diffDisplay = document.getElementById("diff-display"); const diffDisplay = document.getElementById("diff-display");
const mergeBtn = document.getElementById("merge-to-parent-btn") as HTMLButtonElement;
if (!modal || !fileList || !diffDisplay) return; 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 // Show modal with loading state
modal.classList.remove("hidden"); modal.classList.remove("hidden");
fileList.innerHTML = '<div class="diff-loading">Loading diff...</div>'; fileList.innerHTML = '<div class="diff-loading">Loading diff...</div>';
diffDisplay.innerHTML = '<div class="diff-empty">Loading...</div>'; diffDisplay.innerHTML = '<div class="diff-empty">Loading...</div>';
if (mergeBtn) {
mergeBtn.disabled = true;
mergeBtn.textContent = "Merge to Parent";
}
// Load libraries dynamically (only in browser) // Load libraries dynamically (only in browser)
if (!pierreLib) { if (!pierreLib) {
try { 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("get-session-diff", sessionId);
ipcAdapter.send("check-merge-status", sessionId);
} }
function renderDiffFileList() { function renderDiffFileList() {
@ -1569,11 +1589,11 @@ function renderDiffFileList() {
if (!fileList) return; if (!fileList) return;
if (currentDiffFiles.length === 0) { if (currentDiffFiles.length === 0) {
fileList.innerHTML = '<div class="diff-loading">No changes found</div>'; fileList.innerHTML = '<button id="toggle-diff-sidebar" class="diff-sidebar-toggle"><span class="toggle-expand">&gt;&gt;</span><span class="toggle-collapse">&lt;&lt;</span></button><div class="diff-loading">No changes found</div>';
return; return;
} }
fileList.innerHTML = ''; fileList.innerHTML = '<button id="toggle-diff-sidebar" class="diff-sidebar-toggle"><span class="toggle-expand">&gt;&gt;</span><span class="toggle-collapse">&lt;&lt;</span></button>';
currentDiffFiles.forEach((file, index) => { currentDiffFiles.forEach((file, index) => {
const item = document.createElement("div"); const item = document.createElement("div");
@ -1615,12 +1635,37 @@ function renderDiffFileList() {
item.addEventListener("click", () => { item.addEventListener("click", () => {
selectedFileIndex = index; 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(); renderDiffFileList();
renderSelectedDiff(); renderSelectedDiff();
}); });
fileList.appendChild(item); 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() { async function renderSelectedDiff() {
@ -1681,12 +1726,33 @@ async function renderSelectedDiff() {
} }
} }
// Close diff viewer // Close diff viewer
document.getElementById("close-diff-viewer")?.addEventListener("click", () => { document.getElementById("close-diff-viewer")?.addEventListener("click", () => {
const modal = document.getElementById("diff-viewer-modal"); const modal = document.getElementById("diff-viewer-modal");
modal?.classList.add("hidden"); modal?.classList.add("hidden");
currentDiffFiles = []; currentDiffFiles = [];
selectedFileIndex = -1; 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 // Handle diff data from server
@ -1709,3 +1775,53 @@ ipcAdapter.on("session-diff-error", (_event, sessionId: string, error: string) =
diffDisplay.innerHTML = `<div class="diff-empty">Failed to load diff</div>`; diffDisplay.innerHTML = `<div class="diff-empty">Failed to load diff</div>`;
} }
}); });
// 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";
}
}
});

103
server.ts
View file

@ -8,7 +8,7 @@ import * as path from "path";
import { simpleGit } from "simple-git"; import { simpleGit } from "simple-git";
import { promisify } from "util"; import { promisify } from "util";
import { v4 as uuidv4 } from "uuid"; 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 { isTerminalReady } from "./terminal-utils";
import cors from "cors"; 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", () => { socket.on("disconnect", () => {
// Clean up socket references for this client // Clean up socket references for this client
// Note: We don't kill the PTYs - they keep running for reconnection // Note: We don't kill the PTYs - they keep running for reconnection

View file

@ -349,6 +349,18 @@
@apply flex items-center justify-between p-4 border-b border-gray-700; @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 { .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; @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 { .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 { .diff-loading {
@ -409,8 +425,42 @@
@apply text-red-400; @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 { .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-empty {

View file

@ -28,3 +28,15 @@ export interface FileDiff {
deletions: number; deletions: number;
binary?: boolean; binary?: boolean;
} }
export interface MergeStatus {
hasCommits: boolean;
commitsAhead: number;
hasUncommittedChanges: boolean;
}
export interface MergeResult {
success: boolean;
conflicts?: string[];
message: string;
}