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