From 6d74fd13db614be4ca0589fb09de3fde1bfebd67 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Sat, 23 May 2026 21:14:56 +0200 Subject: [PATCH] feat(worktree): rich delete confirmation dialog with dirty-file status - Add worktree-status IPC in main.js: runs git status --porcelain for the worktree path, returns { ok, dirty, total } - Expose as window.api.worktreeStatus() in preload.js - Replace bare confirm() in sidebar.js with showDeleteWorktreeDialog(): - Modal shows worktree name, permanent-loss warning, and dirty-file list - Fetches git status asynchronously while modal is open - Lists first 10 dirty files in
, '+ N more' if truncated
  - Clean worktree shows green 'no uncommitted changes' message
  - IPC error shows red 'Unable to read worktree status' message
  - Cancel / Delete anyway buttons; Escape key closes; overlay click cancels
- Add CSS for .delete-worktree-dialog, .delete-worktree-warning,
  .delete-worktree-status, .dwt-* classes matching existing dark theme
---
 main.js           | 20 ++++++++++++
 preload.js        |  1 +
 public/sidebar.js | 75 +++++++++++++++++++++++++++++++++++++++++++-
 public/style.css  | 80 +++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 175 insertions(+), 1 deletion(-)

diff --git a/main.js b/main.js
index 19800549..fdf58491 100644
--- a/main.js
+++ b/main.js
@@ -444,6 +444,26 @@ ipcMain.handle('delete-worktree', (_event, worktreePath) => {
   });
 });
 
+// --- IPC: worktree-status ---
+ipcMain.handle('worktree-status', (_event, worktreePath) => {
+  return new Promise((resolve) => {
+    const normalizedPath = worktreePath.replace(/\/$/, '');
+    const match = normalizedPath.match(WORKTREE_PATH_RE);
+    if (!match) {
+      return resolve({ ok: false, error: 'Path does not match a recognized worktree layout' });
+    }
+    const parentRepo = match[1];
+
+    execFile('git', ['-C', parentRepo, '-C', normalizedPath, 'status', '--porcelain'], (err, stdout, stderr) => {
+      if (err) {
+        return resolve({ ok: false, error: (stderr || err.message || String(err)).trim() });
+      }
+      const dirty = stdout.split('\n').map(l => l.trimEnd()).filter(Boolean);
+      resolve({ ok: true, dirty, total: dirty.length });
+    });
+  });
+});
+
 // --- IPC: get-projects ---
 ipcMain.handle('open-external', (_event, url) => {
   log.info('[open-external IPC]', url);
diff --git a/preload.js b/preload.js
index db71964c..6961f56d 100644
--- a/preload.js
+++ b/preload.js
@@ -42,6 +42,7 @@ contextBridge.exposeInMainWorld('api', {
   addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath),
   removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath),
   deleteWorktree: (worktreePath) => ipcRenderer.invoke('delete-worktree', worktreePath),
+  worktreeStatus: (worktreePath) => ipcRenderer.invoke('worktree-status', worktreePath),
   openExternal: (url) => ipcRenderer.invoke('open-external', url),
 
   // Send (fire-and-forget)
diff --git a/public/sidebar.js b/public/sidebar.js
index 7ca2bc42..7827f80d 100644
--- a/public/sidebar.js
+++ b/public/sidebar.js
@@ -699,7 +699,8 @@ function rebindSidebarEvents(projects) {
       wtDeleteBtn.onclick = async (e) => {
         e.stopPropagation();
         const name = wtProject.projectPath.split('/').pop();
-        if (!confirm(`Delete worktree "${name}" from disk?\n\nThis runs "git worktree remove -f" and permanently removes the working tree. This cannot be undone.`)) return;
+        const confirmed = await showDeleteWorktreeDialog(name, wtProject.projectPath);
+        if (!confirmed) return;
         const result = await window.api.deleteWorktree(wtProject.projectPath);
         if (result && result.ok) {
           loadProjects();
@@ -998,3 +999,75 @@ function startRename(summaryEl, session) {
     }
   });
 }
+
+// --- Delete worktree confirmation dialog ---
+// Returns a Promise — true if the user confirmed deletion.
+async function showDeleteWorktreeDialog(name, worktreePath) {
+  // Fetch worktree status (dirty files) while the dialog is shown
+  const statusPromise = window.api.worktreeStatus(worktreePath);
+
+  return new Promise((resolve) => {
+    const overlay = document.createElement('div');
+    overlay.className = 'new-session-overlay';
+
+    const dialog = document.createElement('div');
+    dialog.className = 'new-session-dialog delete-worktree-dialog';
+
+    dialog.innerHTML = `
+      

Delete worktree "${escapeHtml(name)}"?

+
+ + Any uncommitted changes in this worktree will be permanently lost. +
+
+ Checking worktree status… +
+
+ + +
+ `; + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + const statusEl = dialog.querySelector('#dwt-status'); + + // Populate status once the IPC resolves + statusPromise.then((status) => { + if (!overlay.isConnected) return; // dialog already closed + if (!status || !status.ok) { + const errMsg = (status && status.error) ? escapeHtml(status.error) : 'Unknown error'; + statusEl.innerHTML = `Unable to read worktree status: ${errMsg}`; + return; + } + if (status.total === 0) { + statusEl.innerHTML = `Worktree is clean — no uncommitted changes.`; + return; + } + const shown = status.dirty.slice(0, 10); + const overflow = status.total - shown.length; + const lines = shown.map(l => escapeHtml(l)).join('\n'); + const extra = overflow > 0 ? `\n+ ${overflow} more…` : ''; + statusEl.innerHTML = `
${status.total} uncommitted file${status.total !== 1 ? 's' : ''}:
${lines}${extra}
`; + }).catch((err) => { + if (!overlay.isConnected) return; + statusEl.innerHTML = `Unable to read worktree status: ${escapeHtml(String(err))}`; + }); + + function close(confirmed) { + overlay.remove(); + document.removeEventListener('keydown', onKey); + resolve(confirmed); + } + + dialog.querySelector('#dwt-cancel').onclick = () => close(false); + dialog.querySelector('#dwt-confirm').onclick = () => close(true); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); }); + + function onKey(e) { + if (e.key === 'Escape') close(false); + } + document.addEventListener('keydown', onKey); + }); +} diff --git a/public/style.css b/public/style.css index 31c85dad..9b88cc9b 100644 --- a/public/style.css +++ b/public/style.css @@ -3583,6 +3583,86 @@ body { display: flex; flex-direction: column; } border-color: rgba(62,207,90,0.5); } +/* ========== DELETE WORKTREE DIALOG ========== */ +.delete-worktree-dialog { + width: 460px; +} + +.delete-worktree-warning { + display: flex; + align-items: flex-start; + gap: 8px; + background: rgba(224,80,112,0.08); + border: 1px solid rgba(224,80,112,0.25); + border-radius: 8px; + padding: 10px 14px; + font-size: 13px; + color: #e05070; + margin-bottom: 14px; + line-height: 1.4; +} + +.delete-worktree-status { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 8px; + padding: 10px 14px; + font-size: 12px; + color: #9090a8; + min-height: 36px; + margin-bottom: 4px; +} + +.dwt-loading { + font-style: italic; + color: #606078; +} + +.dwt-clean { + color: #3ecf82; +} + +.dwt-error { + color: #e07050; + word-break: break-word; +} + +.dwt-dirty-label { + color: #e0a050; + margin-bottom: 6px; + font-weight: 600; +} + +.dwt-dirty-list { + margin: 0; + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; + font-size: 11px; + color: #c0c0d8; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.5; + max-height: 160px; + overflow-y: auto; +} + +.delete-worktree-confirm-btn { + background: rgba(224,80,112,0.12); + border: 1px solid rgba(224,80,112,0.35); + color: #e05070; + font-size: 13px; + padding: 8px 20px; + border-radius: 6px; + cursor: pointer; + font-family: inherit; + font-weight: 600; + transition: all 0.15s; +} + +.delete-worktree-confirm-btn:hover { + background: rgba(224,80,112,0.22); + border-color: rgba(224,80,112,0.55); +} + /* ========== NEW SESSION POPOVER ========== */ .new-session-popover { position: fixed;