diff --git a/.eslintrc.json b/.eslintrc.json index 634d89c2..cc920c87 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,12 +10,12 @@ "varsIgnorePattern": "^_" } ], - "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-empty-function": "off", "prefer-const": "warn", - "react-hooks/set-state-in-effect": "warn" + "react-hooks/set-state-in-effect": "off" }, "overrides": [ { diff --git a/src/main/ipc/appIpc.ts b/src/main/ipc/appIpc.ts index 6b4bb44e..d1c2b5cc 100644 --- a/src/main/ipc/appIpc.ts +++ b/src/main/ipc/appIpc.ts @@ -32,7 +32,8 @@ export function registerAppIpc() { } try { const platform = process.platform; - const quoted = (p: string) => `'${p.replace(/'/g, "'\\''")}'`; + const quotedPosix = (p: string) => `'${p.replace(/'/g, "'\\''")}'`; + const quotedWin = (p: string) => `"${p.replace(/"/g, '""')}"`; if (which === 'warp') { const urls = [ @@ -58,11 +59,11 @@ export function registerAppIpc() { switch (which) { case 'finder': // Open directory in Finder - command = `open ${quoted(target)}`; + command = `open ${quotedPosix(target)}`; break; case 'cursor': // Prefer CLI when available to ensure the folder opens in-app - command = `command -v cursor >/dev/null 2>&1 && cursor ${quoted(target)} || open -a "Cursor" ${quoted(target)}`; + command = `command -v cursor >/dev/null 2>&1 && cursor ${quotedPosix(target)} || open -a "Cursor" ${quotedPosix(target)}`; break; case 'vscode': command = [ @@ -74,14 +75,14 @@ export function registerAppIpc() { case 'terminal': // Open Terminal app at the target directory // This should open a new tab/window with CWD set to target - command = `open -a Terminal ${quoted(target)}`; + command = `open -a Terminal ${quotedPosix(target)}`; break; case 'iterm2': // iTerm2 by bundle id, then by app name command = [ - `open -b com.googlecode.iterm2 ${quoted(target)}`, - `open -a "iTerm" ${quoted(target)}`, - `open -a "iTerm2" ${quoted(target)}`, + `open -b com.googlecode.iterm2 ${quotedPosix(target)}`, + `open -a "iTerm" ${quotedPosix(target)}`, + `open -a "iTerm2" ${quotedPosix(target)}`, ].join(' || '); break; case 'ghostty': @@ -93,46 +94,47 @@ export function registerAppIpc() { ].join(' || '); break; case 'zed': - command = `command -v zed >/dev/null 2>&1 && zed ${quoted(target)} || open -a "Zed" ${quoted(target)}`; + command = `command -v zed >/dev/null 2>&1 && zed ${quotedPosix(target)} || open -a "Zed" ${quotedPosix(target)}`; break; } } else if (platform === 'win32') { switch (which) { case 'finder': - command = `explorer ${quoted(target)}`; + command = `explorer ${quotedWin(target)}`; break; case 'cursor': - command = `start "" cursor ${quoted(target)}`; + command = `start "" cursor ${quotedWin(target)}`; break; case 'vscode': - command = `start "" code ${quoted(target)} || start "" code-insiders ${quoted(target)}`; + command = `start "" code ${quotedWin(target)} || start "" code-insiders ${quotedWin(target)}`; break; case 'terminal': - command = `wt -d ${quoted(target)} || start cmd /K "cd /d ${target}"`; + command = `wt -d ${quotedWin(target)} || start cmd /K "cd /d ${quotedWin(target)}"`; break; case 'ghostty': case 'zed': return { success: false, error: `${which} is not supported on Windows` } as any; } } else { + // Linux: use proper quoting for shell commands switch (which) { case 'finder': - command = `xdg-open ${quoted(target)}`; + command = `xdg-open ${quotedPosix(target)}`; break; case 'cursor': - command = `cursor ${quoted(target)}`; + command = `cursor ${quotedPosix(target)}`; break; case 'vscode': - command = `code ${quoted(target)} || code-insiders ${quoted(target)}`; + command = `code ${quotedPosix(target)} || code-insiders ${quotedPosix(target)}`; break; case 'terminal': - command = `x-terminal-emulator --working-directory=${quoted(target)} || gnome-terminal --working-directory=${quoted(target)} || konsole --workdir ${quoted(target)}`; + command = `x-terminal-emulator --working-directory=${quotedPosix(target)} || gnome-terminal --working-directory=${quotedPosix(target)} || konsole --workdir ${quotedPosix(target)}`; break; case 'ghostty': command = `ghostty --working-directory=${quoted(target)} || x-terminal-emulator --working-directory=${quoted(target)}`; break; case 'zed': - command = `zed ${quoted(target)} || xdg-open ${quoted(target)}`; + command = `zed ${quotedPosix(target)} || xdg-open ${quotedPosix(target)}`; break; case 'iterm2': return { success: false, error: 'iTerm2 is only available on macOS' } as any; @@ -186,22 +188,42 @@ export function registerAppIpc() { // App metadata ipcMain.handle('app:getAppVersion', () => { try { - // Try multiple possible paths for package.json - const possiblePaths = [ - join(__dirname, '../../package.json'), // from dist/main/ipc - join(__dirname, '../../../package.json'), // alternative path - join(app.getAppPath(), 'package.json'), // production build - ]; - - for (const packageJsonPath of possiblePaths) { + const readVersion = (packageJsonPath: string) => { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); if (packageJson.name === 'emdash' && packageJson.version) { - return packageJson.version; + return packageJson.version as string; } } catch { - continue; + // ignore parse/fs errors and continue + } + return null; + }; + + // Walk upward from a base directory to find package.json (helps in dev where the + // compiled code lives in dist/main/main/**). + const collectCandidates = (base: string | undefined, maxDepth = 6) => { + if (!base) return []; + const paths: string[] = []; + let current = base; + for (let i = 0; i < maxDepth; i++) { + paths.push(join(current, 'package.json')); + const parent = join(current, '..'); + if (parent === current) break; + current = parent; } + return paths; + }; + + const possiblePaths = [ + ...collectCandidates(__dirname), + ...collectCandidates(app.getAppPath()), + join(process.cwd(), 'package.json'), + ]; + + for (const packageJsonPath of possiblePaths) { + const version = readVersion(packageJsonPath); + if (version) return version; } return app.getVersion(); } catch { diff --git a/src/main/preload.ts b/src/main/preload.ts index ed35128c..3fa31a3b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -95,6 +95,7 @@ contextBridge.exposeInMainWorld('electronAPI', { worktreeId: string; worktreePath?: string; branch?: string; + deleteRemoteBranch?: boolean; }) => ipcRenderer.invoke('worktree:remove', args), worktreeStatus: (args: { worktreePath: string }) => ipcRenderer.invoke('worktree:status', args), worktreeMerge: (args: { projectPath: string; worktreeId: string }) => @@ -374,7 +375,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // Type definitions for the exposed API export interface ElectronAPI { // App info - getVersion: () => Promise; + getAppVersion: () => Promise; + getElectronVersion: () => Promise; getPlatform: () => Promise; // Updater checkForUpdates: () => Promise<{ success: boolean; result?: any; error?: string }>; @@ -444,7 +446,16 @@ export interface ElectronAPI { worktreeRemove: (args: { projectPath: string; worktreeId: string; - }) => Promise<{ success: boolean; error?: string }>; + worktreePath?: string; + branch?: string; + deleteRemoteBranch?: boolean; + }) => Promise<{ + success: boolean; + error?: string; + localBranchDeleted?: boolean | null; + remoteBranchDeleted?: boolean | null; + remoteBranchDeleteError?: string; + }>; worktreeStatus: (args: { worktreePath: string; }) => Promise<{ success: boolean; status?: any; error?: string }>; diff --git a/src/main/services/GitHubService.ts b/src/main/services/GitHubService.ts index f4baec65..06725027 100644 --- a/src/main/services/GitHubService.ts +++ b/src/main/services/GitHubService.ts @@ -592,14 +592,40 @@ export class GitHubService { try { const token = await this.getStoredToken(); - if (!token) { - // No stored token, user needs to authenticate + if (token) { + // Test the token by making a simple API call + const user = await this.getUserInfo(token); + if (user) return true; + + // Stored token is invalid; clear it and fall through to CLI auth check. + try { + await this.logout(); + } catch {} + } + + // No stored token (or token invalid). If the user is already logged into the GitHub CLI, + // treat it as authenticated and (best-effort) persist the token for future sessions. + try { + await execAsync('gh auth status', { encoding: 'utf8' }); + } catch { return false; } - // Test the token by making a simple API call - const user = await this.getUserInfo(token); - return !!user; + try { + const { stdout } = await execAsync('gh auth token', { encoding: 'utf8' }); + const cliToken = String(stdout || '').trim(); + if (cliToken) { + try { + await this.storeToken(cliToken); + } catch (storeErr) { + console.warn('Failed to store GitHub token from gh CLI:', storeErr); + } + } + } catch { + // Token retrieval is optional; auth status already succeeded. + } + + return true; } catch (error) { console.error('Authentication check failed:', error); return false; diff --git a/src/main/services/TerminalConfigParser.ts b/src/main/services/TerminalConfigParser.ts index b86e1bff..77d0c57f 100644 --- a/src/main/services/TerminalConfigParser.ts +++ b/src/main/services/TerminalConfigParser.ts @@ -282,10 +282,10 @@ function loadiTerm2Config(): TerminalConfig | null { */ function loadiTerm2ConfigXML(plistPath: string): TerminalConfig | null { try { - const xmlContent = readFileSync(plistPath, 'utf8'); + const _xmlContent = readFileSync(plistPath, 'utf8'); // Simple XML parsing for color values // This is a basic implementation - could be improved - const colorRegex = + const _colorRegex = /([^<]+)<\/key>\s*[\s\S]*?Red Component<\/key>\s*([\d.]+)<\/real>[\s\S]*?Green Component<\/key>\s*([\d.]+)<\/real>[\s\S]*?Blue Component<\/key>\s*([\d.]+)<\/real>/g; // This is complex - for now, return null and rely on JSON conversion return null; @@ -456,19 +456,6 @@ function parseAlacrittyTOML(content: string): TerminalConfig | null { theme.cursor = cursorMatch[1]; } - // Parse ANSI colors (simplified - Alacritty uses nested structure) - const ansiColors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']; - const brightColors = [ - 'bright_black', - 'bright_red', - 'bright_green', - 'bright_yellow', - 'bright_blue', - 'bright_magenta', - 'bright_cyan', - 'bright_white', - ]; - const colorMap: Record = { black: 'black', red: 'red', diff --git a/src/main/services/WorktreeService.ts b/src/main/services/WorktreeService.ts index 5e406b4a..378e5b00 100644 --- a/src/main/services/WorktreeService.ts +++ b/src/main/services/WorktreeService.ts @@ -3,6 +3,7 @@ import { log } from '../lib/logger'; import { promisify } from 'util'; import path from 'path'; import fs from 'fs'; +import os from 'os'; import crypto from 'crypto'; import { projectSettingsService } from './ProjectSettingsService'; @@ -24,6 +25,66 @@ export interface WorktreeInfo { export class WorktreeService { private worktrees = new Map(); + private gitCwdFallback(): string { + // Pick a directory that should exist and be accessible, even when the repo/worktree + // folder itself is blocked by macOS sandbox/TCC. + try { + return os.tmpdir() || '/'; + } catch { + return '/'; + } + } + + private resolveGitDir(projectPath: string): string { + const gitMeta = path.join(projectPath, '.git'); + try { + const st = fs.statSync(gitMeta); + if (st.isDirectory()) return gitMeta; + if (st.isFile()) { + const content = fs.readFileSync(gitMeta, 'utf8'); + const m = content.match(/gitdir:\s*(.*)\s*$/i); + if (m?.[1]) return path.resolve(projectPath, m[1].trim()); + } + } catch {} + return gitMeta; + } + + private async execGit( + projectPath: string, + args: string[] + ): Promise<{ stdout: string; stderr: string }> { + const gitDir = this.resolveGitDir(projectPath); + return await execFileAsync('git', ['--git-dir', gitDir, '--work-tree', projectPath, ...args], { + cwd: this.gitCwdFallback(), + }); + } + + private async getOriginUrl(projectPath: string): Promise { + try { + const { stdout } = await this.execGit(projectPath, ['remote', 'get-url', 'origin']); + const url = String(stdout || '').trim(); + return url || null; + } catch { + return null; + } + } + + private parseGitHubNameWithOwner(remoteUrl: string): string | null { + const url = String(remoteUrl || '').trim(); + if (!url) return null; + // Support https/ssh remotes and repo names containing dots. + // Examples: + // - git@github.com:owner/repo.git + // - https://github.com/owner/repo + // - https://github.com/owner/re.po.git + const m = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/i); + if (!m) return null; + const owner = (m[1] || '').trim(); + const repo = (m[2] || '').trim(); + if (!owner || !repo) return null; + return `${owner}/${repo}`; + } + /** * Slugify task name to make it shell-safe */ @@ -279,13 +340,23 @@ export class WorktreeService { projectPath: string, worktreeId: string, worktreePath?: string, - branch?: string - ): Promise { + branch?: string, + opts?: { deleteRemoteBranch?: boolean } + ): Promise<{ + localBranchDeleted: boolean | null; + remoteBranchDeleted: boolean | null; + remoteBranchDeleteError?: string; + }> { try { + const errors: string[] = []; + let localBranchDeleted: boolean | null = null; + let remoteBranchDeleted: boolean | null = null; + let remoteBranchDeleteError: string | undefined; const worktree = this.worktrees.get(worktreeId); const pathToRemove = worktree?.path ?? worktreePath; const branchToDelete = worktree?.branch ?? branch; + const deleteRemoteBranch = opts?.deleteRemoteBranch === true; if (!pathToRemove) { throw new Error('Worktree path not provided'); @@ -294,17 +365,17 @@ export class WorktreeService { // Remove the worktree directory via git first try { // Use --force to remove even when there are untracked/modified files - await execFileAsync('git', ['worktree', 'remove', '--force', pathToRemove], { - cwd: projectPath, - }); + await this.execGit(projectPath, ['worktree', 'remove', '--force', pathToRemove]); } catch (gitError) { + errors.push(`git worktree remove failed: ${this.extractErrorMessage(gitError)}`); console.warn('git worktree remove failed, attempting filesystem cleanup', gitError); } // Best-effort prune to clear any stale worktree metadata that can keep a branch "checked out" try { - await execFileAsync('git', ['worktree', 'prune', '--verbose'], { cwd: projectPath }); + await this.execGit(projectPath, ['worktree', 'prune', '--verbose']); } catch (pruneErr) { + errors.push(`git worktree prune failed: ${this.extractErrorMessage(pruneErr)}`); console.warn('git worktree prune failed (continuing):', pruneErr); } @@ -316,6 +387,24 @@ export class WorktreeService { // Handle permission issues by making files writable, then retry if (rmErr && (rmErr.code === 'EACCES' || rmErr.code === 'EPERM')) { try { + if (process.platform === 'darwin') { + try { + await execFileAsync('chflags', ['-R', 'nouchg', 'noschg', pathToRemove], { + cwd: this.gitCwdFallback(), + }); + } catch (flagErr) { + console.warn('Failed to clear file flags for worktree cleanup:', flagErr); + } + } + + // If direct removal fails (common under macOS sandbox/TCC), try moving to Trash. + try { + const electron = await import('electron'); + await electron.shell.trashItem(pathToRemove); + } catch { + // ignore and continue + } + if (process.platform === 'win32') { // Remove read-only attribute recursively on Windows await execFileAsync('cmd', [ @@ -328,22 +417,69 @@ export class WorktreeService { ]); } else { // Make everything writable on POSIX - await execFileAsync('chmod', ['-R', 'u+w', pathToRemove]); + await execFileAsync('chmod', ['-R', 'u+w', pathToRemove], { + cwd: this.gitCwdFallback(), + }); } } catch (permErr) { console.warn('Failed to adjust permissions for worktree cleanup:', permErr); } // Retry removal once after permissions adjusted - await fs.promises.rm(pathToRemove, { recursive: true, force: true }); + try { + await fs.promises.rm(pathToRemove, { recursive: true, force: true }); + } catch (retryRmErr: any) { + errors.push( + `filesystem cleanup failed: ${String(retryRmErr?.code || '')} ${String( + retryRmErr?.message || retryRmErr + )}`.trim() + ); + } } else { throw rmErr; } } } + // After filesystem cleanup, prune again to clear any stale worktree metadata + // that can keep a branch "checked out". + try { + await this.execGit(projectPath, ['worktree', 'prune', '--verbose']); + } catch (pruneErr) { + errors.push(`git worktree prune failed: ${this.extractErrorMessage(pruneErr)}`); + console.warn('git worktree prune failed (continuing):', pruneErr); + } + if (branchToDelete) { - const tryDeleteBranch = async () => - await execFileAsync('git', ['branch', '-D', branchToDelete!], { cwd: projectPath }); + const normalizedLocalBranch = String(branchToDelete) + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^refs\/remotes\/origin\//, 'origin/'); + + const candidates = Array.from( + new Set( + [ + normalizedLocalBranch, + normalizedLocalBranch.startsWith('origin/') + ? normalizedLocalBranch.replace(/^origin\//, '') + : null, + ].filter(Boolean) as string[] + ) + ); + + const tryDeleteBranch = async () => { + let lastErr: unknown; + for (const name of candidates) { + try { + await this.execGit(projectPath, ['branch', '-D', name]); + localBranchDeleted = true; + return; + } catch (e) { + lastErr = e; + } + } + localBranchDeleted = false; + throw lastErr; + }; try { await tryDeleteBranch(); } catch (branchError: any) { @@ -352,49 +488,154 @@ export class WorktreeService { // prune and retry once more. if (/checked out at /.test(msg)) { try { - await execFileAsync('git', ['worktree', 'prune', '--verbose'], { cwd: projectPath }); + await this.execGit(projectPath, ['worktree', 'prune', '--verbose']); await tryDeleteBranch(); } catch (retryErr) { + errors.push( + `git branch delete failed after prune: ${this.extractErrorMessage(retryErr)}` + ); console.warn(`Failed to delete branch ${branchToDelete} after prune:`, retryErr); } } else { + errors.push(`git branch delete failed: ${this.extractErrorMessage(branchError)}`); console.warn(`Failed to delete branch ${branchToDelete}:`, branchError); } } - const remoteAlias = 'origin'; - let remoteBranchName = branchToDelete; - if (branchToDelete.startsWith('origin/')) { - remoteBranchName = branchToDelete.replace(/^origin\//, ''); - } - try { - await execFileAsync('git', ['push', remoteAlias, '--delete', remoteBranchName], { - cwd: projectPath, - }); - log.info(`Deleted remote branch ${remoteAlias}/${remoteBranchName}`); - } catch (remoteError: any) { - const msg = String(remoteError?.stderr || remoteError?.message || remoteError); - if ( - /remote ref does not exist/i.test(msg) || - /unknown revision/i.test(msg) || - /not found/i.test(msg) - ) { - log.info(`Remote branch ${remoteAlias}/${remoteBranchName} already absent`); + if (deleteRemoteBranch) { + const remoteAlias = 'origin'; + let remoteBranchName = normalizedLocalBranch + .replace(/^refs\/remotes\/origin\//, '') + .replace(/^origin\//, ''); + + if (!remoteBranchName) { + remoteBranchDeleted = false; + remoteBranchDeleteError = 'Skipped deleting remote branch: branch name unavailable'; + log.warn(remoteBranchDeleteError); } else { - log.warn( - `Failed to delete remote branch ${remoteAlias}/${remoteBranchName}:`, - remoteError - ); + // Safety: never delete the default branch + const defaultBranch = await this.getDefaultBranch(projectPath); + if (remoteBranchName === defaultBranch) { + remoteBranchDeleted = false; + remoteBranchDeleteError = `Refusing to delete default branch '${defaultBranch}' on ${remoteAlias}`; + log.warn(remoteBranchDeleteError); + // Continue task deletion; just skip remote deletion. + remoteBranchName = ''; + } + + // Extra safety for weird refs + if (remoteBranchName === 'HEAD') { + remoteBranchDeleted = false; + remoteBranchDeleteError = `Refusing to delete branch named 'HEAD' on ${remoteAlias}`; + log.warn(remoteBranchDeleteError); + // Continue task deletion; just skip remote deletion. + remoteBranchName = ''; + } + + if (remoteBranchName) { + let deletedRemotely = false; + + // Prefer Git-native deletion (works even without GitHub CLI). + try { + await this.execGit(projectPath, [ + 'push', + remoteAlias, + '--delete', + remoteBranchName, + ]); + deletedRemotely = true; + remoteBranchDeleted = true; + log.info(`Deleted remote branch ${remoteAlias}/${remoteBranchName}`); + } catch (remoteError: any) { + const msg = String(remoteError?.stderr || remoteError?.message || remoteError); + if ( + /remote ref does not exist/i.test(msg) || + /unknown revision/i.test(msg) || + /not found/i.test(msg) + ) { + deletedRemotely = true; + remoteBranchDeleted = true; + log.info(`Remote branch ${remoteAlias}/${remoteBranchName} already absent`); + } else { + remoteBranchDeleted = false; + remoteBranchDeleteError = msg; + errors.push(`Remote branch delete failed: ${msg}`); + log.warn( + `Failed to delete remote branch ${remoteAlias}/${remoteBranchName}:`, + remoteError + ); + } + } + + // Fallback to GitHub API deletion if Git push failed (e.g., auth issues). + if (!deletedRemotely) { + const originUrl = await this.getOriginUrl(projectPath); + const nameWithOwner = originUrl ? this.parseGitHubNameWithOwner(originUrl) : null; + if (nameWithOwner) { + try { + const encodedRef = encodeURIComponent(remoteBranchName); + await execFileAsync( + 'gh', + [ + 'api', + '-X', + 'DELETE', + `repos/${nameWithOwner}/git/refs/heads/${encodedRef}`, + ], + { cwd: this.gitCwdFallback() } + ); + deletedRemotely = true; + remoteBranchDeleted = true; + log.info(`Deleted GitHub branch ${remoteAlias}/${remoteBranchName}`); + } catch (ghErr: any) { + const msg = String(ghErr?.stderr || ghErr?.message || ghErr); + if (/not found/i.test(msg) || /404/i.test(msg)) { + deletedRemotely = true; + remoteBranchDeleted = true; + log.info(`GitHub branch ${remoteAlias}/${remoteBranchName} already absent`); + } else { + remoteBranchDeleted = false; + remoteBranchDeleteError = msg; + errors.push(`GitHub branch delete failed: ${msg}`); + log.warn( + `Failed to delete GitHub branch ${remoteAlias}/${remoteBranchName}:`, + ghErr + ); + } + } + } else { + remoteBranchDeleted = false; + remoteBranchDeleteError = + 'Could not determine GitHub repo name from origin URL; skipping GitHub API branch deletion.'; + } + } + } } } } + if (fs.existsSync(pathToRemove)) { + const hint = + process.platform === 'darwin' + ? `macOS blocked access to the worktree folder (${pathToRemove}). If this repo was opened via a file picker, make sure emdash has access to the parent folder that contains "worktrees/".` + : `Failed to remove the worktree folder (${pathToRemove}).`; + const statusLine = + typeof localBranchDeleted === 'boolean' || typeof remoteBranchDeleted === 'boolean' + ? `\n\nBranch cleanup:\n- local branch deleted: ${localBranchDeleted ?? 'unknown'}\n- remote branch deleted: ${remoteBranchDeleted ?? 'unknown'}` + : ''; + const details = errors.length ? `\n\nDetails:\n- ${errors.join('\n- ')}` : ''; + throw new Error(`${hint}${statusLine}${details}`); + } + + // Only update in-memory state after confirming the folder is actually gone. if (worktree) { this.worktrees.delete(worktreeId); log.info(`Removed worktree: ${worktree.name}`); } else { log.info(`Removed worktree ${worktreeId}`); } + + return { localBranchDeleted, remoteBranchDeleted, remoteBranchDeleteError }; } catch (error) { log.error('Failed to remove worktree:', error); throw new Error(`Failed to remove worktree: ${error}`); @@ -465,9 +706,7 @@ export class WorktreeService { */ private async getDefaultBranch(projectPath: string): Promise { try { - const { stdout } = await execFileAsync('git', ['remote', 'show', 'origin'], { - cwd: projectPath, - }); + const { stdout } = await this.execGit(projectPath, ['remote', 'show', 'origin']); const match = stdout.match(/HEAD branch:\s*(\S+)/); return match ? match[1] : 'main'; } catch { @@ -494,7 +733,7 @@ export class WorktreeService { // If not, treat the entire string as a local branch name if (projectPath) { try { - const { stdout } = await execFileAsync('git', ['remote'], { cwd: projectPath }); + const { stdout } = await this.execGit(projectPath, ['remote']); const remotes = (stdout || '').trim().split('\n').filter(Boolean); if (!remotes.includes(remote)) { // 'remote' is not a valid git remote, treat entire string as local branch diff --git a/src/main/services/hostPreviewService.ts b/src/main/services/hostPreviewService.ts index f59227ee..adf5c973 100644 --- a/src/main/services/hostPreviewService.ts +++ b/src/main/services/hostPreviewService.ts @@ -217,7 +217,7 @@ class HostPreviewService extends EventEmitter { } catch {} } const cmd = pm; - let args: string[] = pm === 'npm' ? ['run', script] : [script]; + const args: string[] = pm === 'npm' ? ['run', script] : [script]; const env = { ...process.env } as Record; // Auto-install if package.json exists and node_modules is missing diff --git a/src/main/services/worktreeIpc.ts b/src/main/services/worktreeIpc.ts index 237b96e0..564c9ebb 100644 --- a/src/main/services/worktreeIpc.ts +++ b/src/main/services/worktreeIpc.ts @@ -50,16 +50,18 @@ export function registerWorktreeIpc(): void { worktreeId: string; worktreePath?: string; branch?: string; + deleteRemoteBranch?: boolean; } ) => { try { - await worktreeService.removeWorktree( + const result = await worktreeService.removeWorktree( args.projectPath, args.worktreeId, args.worktreePath, - args.branch + args.branch, + { deleteRemoteBranch: args.deleteRemoteBranch } ); - return { success: true }; + return { success: true, ...result }; } catch (error) { console.error('Failed to remove worktree:', error); return { success: false, error: (error as Error).message }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4dc18bf7..b2eb9d29 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1448,7 +1448,7 @@ const AppContent: React.FC = () => { const handleDeleteTask = async ( targetProject: Project, task: Task, - options?: { silent?: boolean } + options?: { silent?: boolean; deleteRemoteBranch?: boolean } ): Promise => { if (deletingTaskIdsRef.current.has(task.id)) { toast({ @@ -1513,6 +1513,7 @@ const AppContent: React.FC = () => { worktreeId: task.id, worktreePath: task.path, branch: task.branch, + deleteRemoteBranch: options?.deleteRemoteBranch, }), window.electronAPI.deleteTask(task.id), ]); @@ -1533,15 +1534,30 @@ const AppContent: React.FC = () => { throw new Error(errorMsg); } + const remoteBranchWarning = + !!options?.deleteRemoteBranch && removeResult.status === 'fulfilled' + ? removeResult.value?.remoteBranchDeleted === false + : false; + // Track task deletion const { captureTelemetry } = await import('./lib/telemetryClient'); captureTelemetry('task_deleted'); if (!options?.silent) { toast({ - title: 'Task deleted', + title: remoteBranchWarning ? 'Task deleted (branch not deleted)' : 'Task deleted', description: task.name, }); + if (remoteBranchWarning) { + toast({ + title: 'Could not delete remote branch', + description: + (removeResult.status === 'fulfilled' + ? removeResult.value?.remoteBranchDeleteError + : null) || 'Check GitHub authentication and try again.', + variant: 'destructive', + }); + } } return true; } catch (error) { @@ -1970,10 +1986,7 @@ const AppContent: React.FC = () => { isHomeView={showHomeView} /> - + { {renderMainContent()} - + = ({ isOpen, onClose, githubUs } finally { setSubmitting(false); } - }, [attachments, contactEmail, feedbackDetails, githubUser, onClose, submitting]); + }, [attachments, contactEmail, feedbackDetails, githubUser, onClose, submitting, toast]); const handleFormSubmit = useCallback( async (event: React.FormEvent) => { diff --git a/src/renderer/components/GithubConnectionCard.tsx b/src/renderer/components/GithubConnectionCard.tsx index da2fc1e6..8f9a206f 100644 --- a/src/renderer/components/GithubConnectionCard.tsx +++ b/src/renderer/components/GithubConnectionCard.tsx @@ -17,7 +17,6 @@ const GithubConnectionCard: React.FC = ({ onStatusCha const [message, setMessage] = useState(null); const [isError, setIsError] = useState(false); const [isInstalling, setIsInstalling] = useState(false); - const [cliInstalled, setCLIInstalled] = useState(true); const status: GithubConnectionStatus = useMemo(() => { if (!installed) { @@ -30,11 +29,6 @@ const GithubConnectionCard: React.FC = ({ onStatusCha onStatusChange?.(status); }, [status, onStatusChange]); - useEffect(() => { - // Check if CLI is installed on mount - window.electronAPI.githubCheckCLIInstalled().then(setCLIInstalled); - }, []); - useEffect(() => { if (status === 'connected') { const handle = setTimeout(() => setMessage(null), 4000); @@ -68,7 +62,6 @@ const GithubConnectionCard: React.FC = ({ onStatusCha } setMessage('GitHub CLI installed successfully!'); - setCLIInstalled(true); } // Proceed with OAuth authentication diff --git a/src/renderer/components/GithubDeviceFlowModal.tsx b/src/renderer/components/GithubDeviceFlowModal.tsx index bad554c2..e1772847 100644 --- a/src/renderer/components/GithubDeviceFlowModal.tsx +++ b/src/renderer/components/GithubDeviceFlowModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; import { Button } from './ui/button'; import { Spinner } from './ui/spinner'; @@ -24,7 +24,6 @@ export function GithubDeviceFlowModal({ // Presentational state - updated via IPC events from main process const [userCode, setUserCode] = useState(''); const [verificationUri, setVerificationUri] = useState(''); - const [expiresIn, setExpiresIn] = useState(900); const [timeRemaining, setTimeRemaining] = useState(900); const [copied, setCopied] = useState(false); const [success, setSuccess] = useState(false); @@ -37,6 +36,59 @@ export function GithubDeviceFlowModal({ const hasAutocopied = useRef(false); const hasOpenedBrowser = useRef(false); + const copyToClipboard = useCallback( + async (code: string, isAutomatic = false) => { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(code); + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = code; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + + setCopied(true); + + if (!isAutomatic) { + toast({ + title: '✓ Code copied', + description: 'Paste it in GitHub to authorize', + }); + } + + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + if (!isAutomatic) { + toast({ + title: 'Copy failed', + description: 'Please copy the code manually', + variant: 'destructive', + }); + } + } + }, + [toast] + ); + + const openGitHub = useCallback(() => { + if (verificationUri) { + window.electronAPI.openExternal(verificationUri); + } + }, [verificationUri]); + + const handleClose = useCallback(() => { + // Cancel auth flow in main process (polling continues in background) + window.electronAPI.githubCancelAuth(); + onClose(); + }, [onClose]); + // Subscribe to auth events from main process useEffect(() => { if (!open) return; @@ -45,7 +97,6 @@ export function GithubDeviceFlowModal({ const cleanupDeviceCode = window.electronAPI.onGithubAuthDeviceCode((data) => { setUserCode(data.userCode); setVerificationUri(data.verificationUri); - setExpiresIn(data.expiresIn); setTimeRemaining(data.expiresIn); // Auto-copy code @@ -115,7 +166,7 @@ export function GithubDeviceFlowModal({ cleanupSuccess(); cleanupError(); }; - }, [open, onSuccess, onError, onClose, toast]); + }, [copyToClipboard, open, onSuccess, onError, onClose, toast]); // Countdown timer for code expiration useEffect(() => { @@ -152,56 +203,6 @@ export function GithubDeviceFlowModal({ } }, [open]); - const copyToClipboard = async (code: string, isAutomatic = false) => { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(code); - } else { - // Fallback for older browsers - const textArea = document.createElement('textarea'); - textArea.value = code; - textArea.style.position = 'fixed'; - textArea.style.left = '-999999px'; - document.body.appendChild(textArea); - textArea.select(); - document.execCommand('copy'); - document.body.removeChild(textArea); - } - - setCopied(true); - - if (!isAutomatic) { - toast({ - title: '✓ Code copied', - description: 'Paste it in GitHub to authorize', - }); - } - - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - if (!isAutomatic) { - toast({ - title: 'Copy failed', - description: 'Please copy the code manually', - variant: 'destructive', - }); - } - } - }; - - const openGitHub = () => { - if (verificationUri) { - window.electronAPI.openExternal(verificationUri); - } - }; - - const handleClose = () => { - // Cancel auth flow in main process (polling continues in background) - window.electronAPI.githubCancelAuth(); - onClose(); - }; - const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; @@ -228,7 +229,7 @@ export function GithubDeviceFlowModal({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [open, userCode]); + }, [copyToClipboard, handleClose, open, openGitHub, userCode]); if (!open) return null; diff --git a/src/renderer/components/JiraIssueSelector.tsx b/src/renderer/components/JiraIssueSelector.tsx index 13e08a2a..b592dae6 100644 --- a/src/renderer/components/JiraIssueSelector.tsx +++ b/src/renderer/components/JiraIssueSelector.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Input } from './ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Select, SelectContent, SelectItem, SelectTrigger } from './ui/select'; import { Search } from 'lucide-react'; import jiraLogo from '../../assets/images/jira.png'; import { type JiraIssueSummary } from '../types/jira'; diff --git a/src/renderer/components/LeftSidebar.tsx b/src/renderer/components/LeftSidebar.tsx index 19b47f5e..452f8e44 100644 --- a/src/renderer/components/LeftSidebar.tsx +++ b/src/renderer/components/LeftSidebar.tsx @@ -48,7 +48,11 @@ interface LeftSidebarProps { }) => void; onCreateTaskForProject?: (project: Project) => void; isCreatingTask?: boolean; - onDeleteTask?: (project: Project, task: Task) => void | Promise; + onDeleteTask?: ( + project: Project, + task: Task, + options?: { silent?: boolean; deleteRemoteBranch?: boolean } + ) => void | Promise; onDeleteProject?: (project: Project) => void | Promise; isHomeView?: boolean; } @@ -310,7 +314,7 @@ const LeftSidebar: React.FC = ({ showDelete onDelete={ onDeleteTask - ? () => onDeleteTask(typedProject, task) + ? (opts) => onDeleteTask(typedProject, task, opts) : undefined } /> diff --git a/src/renderer/components/MultiProviderDropdown.tsx b/src/renderer/components/MultiProviderDropdown.tsx index a952f8f6..ede76d08 100644 --- a/src/renderer/components/MultiProviderDropdown.tsx +++ b/src/renderer/components/MultiProviderDropdown.tsx @@ -9,8 +9,6 @@ import { providerConfig } from '../lib/providerConfig'; import { ProviderInfoCard } from './ProviderInfoCard'; import type { UiProvider } from '@/providers/meta'; -const MAX_RUNS = 4; - interface MultiProviderDropdownProps { providerRuns: ProviderRun[]; onChange: (providerRuns: ProviderRun[]) => void; @@ -35,7 +33,6 @@ export const MultiProviderDropdown: React.FC = ({ const [runsSelectOpenFor, setRunsSelectOpenFor] = useState(null); const selectedProviders = new Set(providerRuns.map((pr) => pr.provider)); - const totalRuns = providerRuns.reduce((sum, pr) => sum + pr.runs, 0); // Checkbox: always add/remove (multi-select) const toggleProvider = (provider: Provider) => { diff --git a/src/renderer/components/ProjectMainView.tsx b/src/renderer/components/ProjectMainView.tsx index 67fcd447..032edca8 100644 --- a/src/renderer/components/ProjectMainView.tsx +++ b/src/renderer/components/ProjectMainView.tsx @@ -21,6 +21,7 @@ import { AlertDialogTitle, } from './ui/alert-dialog'; import { Checkbox } from './ui/checkbox'; +import { Switch } from './ui/switch'; import BaseBranchControls, { RemoteBranchOption } from './BaseBranchControls'; import { useToast } from '../hooks/use-toast'; import ContainerStatusBadge from './ContainerStatusBadge'; @@ -46,6 +47,14 @@ const normalizeBaseRef = (ref?: string | null): string | undefined => { return trimmed.length > 0 ? trimmed : undefined; }; +const normalizeBranchLabel = (branch?: string | null): string => { + return String(branch || '') + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^refs\/remotes\/origin\//, 'origin/') + .replace(/^origin\//, ''); +}; + function TaskRow({ ws, active, @@ -58,7 +67,7 @@ function TaskRow({ ws: Task; active: boolean; onClick: () => void; - onDelete: () => void | Promise; + onDelete: (opts?: { deleteRemoteBranch?: boolean }) => void | Promise; isSelectMode?: boolean; isSelected?: boolean; onToggleSelect?: () => void; @@ -352,10 +361,11 @@ function TaskRow({ taskName={ws.name} taskId={ws.id} taskPath={ws.path} - onConfirm={async () => { + taskBranch={ws.branch} + onConfirm={async (opts) => { try { setIsDeleting(true); - await onDelete(); + await onDelete(opts); } finally { setIsDeleting(false); } @@ -392,7 +402,7 @@ interface ProjectMainViewProps { onDeleteTask: ( project: Project, task: Task, - options?: { silent?: boolean } + options?: { silent?: boolean; deleteRemoteBranch?: boolean } ) => void | Promise; isCreatingTask?: boolean; onDeleteProject?: (project: Project) => void | Promise; @@ -423,6 +433,7 @@ const ProjectMainView: React.FC = ({ const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [acknowledgeDirtyDelete, setAcknowledgeDirtyDelete] = useState(false); + const [alsoDeleteRemoteBranches, setAlsoDeleteRemoteBranches] = useState(false); const tasksInProject = project.tasks ?? []; const selectedCount = selectedIds.size; @@ -430,6 +441,10 @@ const ProjectMainView: React.FC = ({ () => tasksInProject.filter((ws) => selectedIds.has(ws.id)), [selectedIds, tasksInProject] ); + const remoteBranchSummary = + selectedCount === 1 + ? normalizeBranchLabel(selectedTasks[0]?.branch) + : `${selectedCount} branches`; const [deleteStatus, setDeleteStatus] = useState< Record< string, @@ -509,7 +524,10 @@ const ProjectMainView: React.FC = ({ const deletedNames: string[] = []; for (const ws of toDelete) { try { - const result = await onDeleteTask(project, ws, { silent: true }); + const result = await onDeleteTask(project, ws, { + silent: true, + deleteRemoteBranch: alsoDeleteRemoteBranches, + }); if (result !== false) { deletedNames.push(ws.name); } @@ -547,6 +565,7 @@ const ProjectMainView: React.FC = ({ if (!showDeleteDialog) { setDeleteStatus({}); setAcknowledgeDirtyDelete(false); + setAlsoDeleteRemoteBranches(false); return; } @@ -847,7 +866,7 @@ const ProjectMainView: React.FC = ({ onToggleSelect={() => toggleSelect(ws.id)} active={activeTask?.id === ws.id} onClick={() => onSelectTask(ws)} - onDelete={() => onDeleteTask(project, ws)} + onDelete={(opts) => onDeleteTask(project, ws, opts)} /> ))} @@ -958,6 +977,33 @@ const ProjectMainView: React.FC = ({ ) : null} + + + {selectedCount > 0 ? ( + + + + Also delete GitHub branches + + + {remoteBranchSummary} + + + + + ) : null} + Cancel diff --git a/src/renderer/components/RequirementsNotice.tsx b/src/renderer/components/RequirementsNotice.tsx index 7ad13411..1e7a6378 100644 --- a/src/renderer/components/RequirementsNotice.tsx +++ b/src/renderer/components/RequirementsNotice.tsx @@ -8,7 +8,7 @@ type Props = { const RequirementsNotice: React.FC = ({ showGithubRequirement, - needsGhInstall, + needsGhInstall: _needsGhInstall, needsGhAuth, }) => { return ( diff --git a/src/renderer/components/TaskDeleteButton.tsx b/src/renderer/components/TaskDeleteButton.tsx index 12ac4aef..cd18745f 100644 --- a/src/renderer/components/TaskDeleteButton.tsx +++ b/src/renderer/components/TaskDeleteButton.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Trash, Folder } from 'lucide-react'; import { Spinner } from './ui/spinner'; +import { Switch } from './ui/switch'; import { AlertDialog, AlertDialogAction, @@ -23,7 +24,8 @@ type Props = { taskName: string; taskId: string; taskPath: string; - onConfirm: () => void | Promise; + taskBranch?: string; + onConfirm: (opts?: { deleteRemoteBranch?: boolean }) => void | Promise; className?: string; 'aria-label'?: string; isDeleting?: boolean; @@ -33,6 +35,7 @@ export const TaskDeleteButton: React.FC = ({ taskName, taskId, taskPath, + taskBranch, onConfirm, className, 'aria-label': ariaLabel = 'Delete Task', @@ -40,6 +43,7 @@ export const TaskDeleteButton: React.FC = ({ }) => { const [open, setOpen] = React.useState(false); const [acknowledge, setAcknowledge] = React.useState(false); + const [deleteRemoteBranch, setDeleteRemoteBranch] = React.useState(false); const targets = useMemo( () => [{ id: taskId, name: taskName, path: taskPath }], [taskId, taskName, taskPath] @@ -67,9 +71,15 @@ export const TaskDeleteButton: React.FC = ({ React.useEffect(() => { if (!open) { setAcknowledge(false); + setDeleteRemoteBranch(false); } }, [open]); + const remoteBranchLabel = (taskBranch || '') + .replace(/^refs\/heads\//, '') + .replace(/^refs\/remotes\/origin\//, 'origin/') + .replace(/^origin\//, ''); + return ( @@ -176,6 +186,28 @@ export const TaskDeleteButton: React.FC = ({ ) : null} + {taskBranch ? ( + + + + Also delete GitHub branch + + + {remoteBranchLabel} + + + + + ) : null} Cancel = ({ e.stopPropagation(); setOpen(false); try { - await onConfirm(); + await onConfirm({ deleteRemoteBranch }); } catch {} }} > diff --git a/src/renderer/components/TaskItem.tsx b/src/renderer/components/TaskItem.tsx index 2e763849..18c744e8 100644 --- a/src/renderer/components/TaskItem.tsx +++ b/src/renderer/components/TaskItem.tsx @@ -19,7 +19,7 @@ interface Task { interface TaskItemProps { task: Task; - onDelete?: () => void | Promise; + onDelete?: (opts?: { deleteRemoteBranch?: boolean }) => void | Promise; showDelete?: boolean; } @@ -46,10 +46,11 @@ export const TaskItem: React.FC = ({ task, onDelete, showDelete } taskName={task.name} taskId={task.id} taskPath={task.path} - onConfirm={async () => { + taskBranch={task.branch} + onConfirm={async (opts) => { try { setIsDeleting(true); - await onDelete(); + await onDelete(opts); } finally { setIsDeleting(false); } diff --git a/src/renderer/components/TerminalPane.tsx b/src/renderer/components/TerminalPane.tsx index 2db0cb6a..bd41d47a 100644 --- a/src/renderer/components/TerminalPane.tsx +++ b/src/renderer/components/TerminalPane.tsx @@ -104,6 +104,7 @@ const TerminalPaneComponent: React.FC = ({ rows, theme, autoApprove, + initialPrompt, onActivity, onStartError, onStartSuccess, diff --git a/src/renderer/components/VersionCard.tsx b/src/renderer/components/VersionCard.tsx index 1ead845d..c1af74a2 100644 --- a/src/renderer/components/VersionCard.tsx +++ b/src/renderer/components/VersionCard.tsx @@ -42,6 +42,18 @@ const VersionCard: React.FC = () => { }; }, []); + useEffect(() => { + if (update.status === 'not-available' && userInitiatedRef.current) { + userInitiatedRef.current = false; + try { + toast({ title: 'You’re up to date', description: 'You are on the latest version.' }); + } catch {} + } + if (update.status !== 'checking' && update.status !== 'idle') { + userInitiatedRef.current = false; + } + }, [toast, update.status]); + return (
@@ -148,23 +160,6 @@ const VersionCard: React.FC = () => { })() : null}
- - {(() => { - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (update.status === 'not-available' && userInitiatedRef.current) { - userInitiatedRef.current = false; - try { - toast({ title: 'You’re up to date', description: 'You are on the latest version.' }); - } catch {} - } - if (update.status !== 'checking' && update.status !== 'idle') { - // Reset guard if state moves elsewhere without landing on not-available - userInitiatedRef.current = false; - } - }, [update.status]); - return null; - })()}
); }; diff --git a/src/renderer/components/ui/resizable.tsx b/src/renderer/components/ui/resizable.tsx index 221f688f..f9aa21e8 100644 --- a/src/renderer/components/ui/resizable.tsx +++ b/src/renderer/components/ui/resizable.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { GripVertical } from 'lucide-react'; import * as ResizablePrimitive from 'react-resizable-panels'; import { cn } from '@/lib/utils'; @@ -17,12 +16,9 @@ const ResizablePanelGroup = ({ const ResizablePanel = ResizablePrimitive.Panel; const ResizableHandle = ({ - withHandle, className, ...props -}: React.ComponentProps & { - withHandle?: boolean; -}) => ( +}: React.ComponentProps) => ( { try { diff --git a/src/renderer/hooks/useInitialPromptInjection.ts b/src/renderer/hooks/useInitialPromptInjection.ts index d57f4b94..eec16778 100644 --- a/src/renderer/hooks/useInitialPromptInjection.ts +++ b/src/renderer/hooks/useInitialPromptInjection.ts @@ -23,7 +23,6 @@ export function useInitialPromptInjection(opts: { const ptyId = `${providerId}-main-${taskId}`; let sent = false; - let idleSeen = false; let silenceTimer: any = null; const send = () => { try { @@ -45,7 +44,6 @@ export function useInitialPromptInjection(opts: { try { const signal = classifyActivity(providerId, chunk); if (signal === 'idle' && !sent) { - idleSeen = true; setTimeout(send, 250); } } catch { diff --git a/src/renderer/hooks/usePlanMode.ts b/src/renderer/hooks/usePlanMode.ts index 3cdf05c9..6f98fade 100644 --- a/src/renderer/hooks/usePlanMode.ts +++ b/src/renderer/hooks/usePlanMode.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { PLANNING_MD } from '@/lib/planRules'; import { log } from '@/lib/logger'; import { logPlanEvent } from '@/lib/planLogs'; diff --git a/src/renderer/lib/probePreview.ts b/src/renderer/lib/probePreview.ts index eb2e9d1f..4a3bcf2c 100644 --- a/src/renderer/lib/probePreview.ts +++ b/src/renderer/lib/probePreview.ts @@ -10,7 +10,7 @@ export async function probeLocalUrls( const c = new AbortController(); const t = setTimeout(() => c.abort(), perProbeMs); try { - const res = await fetch(u, { method: 'GET', mode: 'no-cors', signal: c.signal }); + await fetch(u, { method: 'GET', mode: 'no-cors', signal: c.signal }); // mode: 'no-cors' will resolve regardless of CORS; if it resolves, the server is there. clearTimeout(t); return true; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 4e00c2ba..f42e235a 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -176,7 +176,14 @@ declare global { worktreeId: string; worktreePath?: string; branch?: string; - }) => Promise<{ success: boolean; error?: string }>; + deleteRemoteBranch?: boolean; + }) => Promise<{ + success: boolean; + error?: string; + localBranchDeleted?: boolean | null; + remoteBranchDeleted?: boolean | null; + remoteBranchDeleteError?: string; + }>; worktreeStatus: (args: { worktreePath: string; }) => Promise<{ success: boolean; status?: any; error?: string }>; @@ -643,7 +650,8 @@ declare global { // Explicit type export for better TypeScript recognition export interface ElectronAPI { // App info - getVersion: () => Promise; + getAppVersion: () => Promise; + getElectronVersion: () => Promise; getPlatform: () => Promise; // Updater checkForUpdates: () => Promise<{ success: boolean; result?: any; error?: string }>; @@ -699,7 +707,14 @@ export interface ElectronAPI { worktreeId: string; worktreePath?: string; branch?: string; - }) => Promise<{ success: boolean; error?: string }>; + deleteRemoteBranch?: boolean; + }) => Promise<{ + success: boolean; + error?: string; + localBranchDeleted?: boolean | null; + remoteBranchDeleted?: boolean | null; + remoteBranchDeleteError?: string; + }>; worktreeStatus: (args: { worktreePath: string; }) => Promise<{ success: boolean; status?: any; error?: string }>; diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 90116c98..4b83e951 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -13,7 +13,8 @@ type ProjectSettingsPayload = { declare global { interface Window { electronAPI: { - getVersion: () => Promise; + getAppVersion: () => Promise; + getElectronVersion: () => Promise; getPlatform: () => Promise; // PTY management ptyStart: (opts: { diff --git a/src/shared/container/portManager.test.ts b/src/shared/container/portManager.test.ts index cf7ddeb6..6bd55ff5 100644 --- a/src/shared/container/portManager.test.ts +++ b/src/shared/container/portManager.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import net from 'node:net'; import type { ResolvedContainerPortConfig } from './config'; diff --git a/src/test/main/WorktreeService.test.ts b/src/test/main/WorktreeService.test.ts new file mode 100644 index 00000000..90194cd4 --- /dev/null +++ b/src/test/main/WorktreeService.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'util'; + +type ExecFileCall = { file: string; args: string[] }; +const execFileCalls: ExecFileCall[] = []; + +let ghRepoViewError: any | null = null; +let ghApiDeleteError: any | null = null; +let gitPushDeleteError: any | null = null; +let defaultBranch = 'main'; +let originUrl = 'git@github.com:test-owner/test-repo.git'; + +vi.mock('child_process', () => { + const execFileImpl = (file: string, args?: any, options?: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + const argv = Array.isArray(args) ? args : []; + execFileCalls.push({ file, args: [...argv] }); + + const respond = (stdout: string, stderr = '') => { + setImmediate(() => cb?.(null, stdout, stderr)); + }; + + const respondError = (err: any, stderr = '') => { + const e = err instanceof Error ? err : new Error(String(err || 'error')); + (e as any).stderr = (e as any).stderr ?? stderr; + setImmediate(() => cb?.(e, '', String((e as any).stderr || stderr || ''))); + }; + + const cmdOffset = + file === 'git' && argv[0] === '--git-dir' && argv[2] === '--work-tree' ? 4 : 0; + const cmd = argv[cmdOffset]; + const sub = argv[cmdOffset + 1]; + + if (file === 'git' && cmd === 'remote' && sub === 'show' && argv[cmdOffset + 2] === 'origin') { + respond(`* remote origin\n HEAD branch: ${defaultBranch}\n`); + } else if ( + file === 'git' && + cmd === 'remote' && + sub === 'get-url' && + argv[cmdOffset + 2] === 'origin' + ) { + respond(`${originUrl}\n`); + } else if (file === 'git' && cmd === 'push' && argv.includes('--delete')) { + if (gitPushDeleteError) { + respondError(gitPushDeleteError, (gitPushDeleteError as any)?.stderr); + } else { + respond(''); + } + } else if (file === 'gh' && argv[0] === 'repo' && argv[1] === 'view') { + if (ghRepoViewError) { + respondError(ghRepoViewError); + } else { + respond('test-owner/test-repo\n'); + } + } else if (file === 'gh' && argv[0] === 'api' && argv.includes('DELETE')) { + if (ghApiDeleteError) { + respondError(ghApiDeleteError, (ghApiDeleteError as any)?.stderr); + } else { + respond(''); + } + } else { + respond(''); + } + + return { kill: vi.fn() }; + }; + + (execFileImpl as any)[promisify.custom] = (file: string, args?: any, options?: any) => { + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFileImpl(file, args, options, (err: any, stdout: string, stderr: string) => { + if (err) { + reject(err); + return; + } + resolve({ stdout, stderr }); + }); + }); + }; + + return { execFile: execFileImpl }; +}); + +vi.mock('../../main/services/ProjectSettingsService', () => { + return { + projectSettingsService: { + getBaseRef: vi.fn(), + setBaseRef: vi.fn(), + getSettings: vi.fn(), + updateSettings: vi.fn(), + }, + }; +}); + +// eslint-disable-next-line import/first +import { WorktreeService } from '../../main/services/WorktreeService'; + +describe('WorktreeService.removeWorktree remote deletion', () => { + beforeEach(() => { + execFileCalls.length = 0; + ghRepoViewError = null; + ghApiDeleteError = null; + gitPushDeleteError = null; + defaultBranch = 'main'; + originUrl = 'git@github.com:test-owner/test-repo.git'; + }); + + it('does not delete a GitHub branch unless explicitly requested', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + const worktreePath = path.join(projectPath, 'worktree-to-delete'); + fs.mkdirSync(worktreePath, { recursive: true }); + + const service = new WorktreeService(); + await service.removeWorktree(projectPath, 'wt-test', worktreePath, 'feature/test'); + + expect(fs.existsSync(worktreePath)).toBe(false); + expect(execFileCalls.some((c) => c.file === 'gh')).toBe(false); + expect(execFileCalls.some((c) => c.file === 'git' && c.args[0] === 'push')).toBe(false); + }); + + it('prefers git push --delete for remote branch deletion', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + const worktreePath = path.join(projectPath, 'worktree-to-delete'); + fs.mkdirSync(worktreePath, { recursive: true }); + + const service = new WorktreeService(); + await service.removeWorktree(projectPath, 'wt-test', worktreePath, 'origin/feature/test', { + deleteRemoteBranch: true, + }); + + expect(execFileCalls.some((c) => c.file === 'git' && c.args.includes('push'))).toBe(true); + expect(execFileCalls.some((c) => c.file === 'gh' && c.args[0] === 'api')).toBe(false); + }); + + it('uses GitHub API fallback when git push fails, and encodes slashes', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + const worktreePath = path.join(projectPath, 'worktree-to-delete'); + fs.mkdirSync(worktreePath, { recursive: true }); + + gitPushDeleteError = Object.assign(new Error('permission denied'), { + stderr: 'permission denied', + }); + + const service = new WorktreeService(); + await service.removeWorktree(projectPath, 'wt-test', worktreePath, 'refs/heads/feature/test', { + deleteRemoteBranch: true, + }); + + const ghApiCall = execFileCalls.find( + (c) => c.file === 'gh' && c.args[0] === 'api' && c.args.includes('DELETE') + ); + expect(ghApiCall).toBeDefined(); + expect(ghApiCall?.args.join(' ')).toContain('heads/feature%2Ftest'); + }); + + it('skips remote deletion for the default branch even when requested', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + const worktreePath = path.join(projectPath, 'worktree-to-delete'); + fs.mkdirSync(worktreePath, { recursive: true }); + + defaultBranch = 'main'; + + const service = new WorktreeService(); + await service.removeWorktree(projectPath, 'wt-test', worktreePath, 'main', { + deleteRemoteBranch: true, + }); + + expect(execFileCalls.some((c) => c.file === 'gh')).toBe(false); + expect(execFileCalls.some((c) => c.file === 'git' && c.args[0] === 'push')).toBe(false); + }); + + it('handles git push failure and gh missing without throwing', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + const worktreePath = path.join(projectPath, 'worktree-to-delete'); + fs.mkdirSync(worktreePath, { recursive: true }); + + gitPushDeleteError = Object.assign(new Error('permission denied'), { + stderr: 'permission denied', + }); + // gh api should fail (simulate missing gh) + ghApiDeleteError = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }); + + const service = new WorktreeService(); + await service.removeWorktree(projectPath, 'wt-test', worktreePath, 'feature/test', { + deleteRemoteBranch: true, + }); + + expect(execFileCalls.some((c) => c.file === 'git' && c.args.includes('push'))).toBe(true); + }); + + it('treats GitHub 404 as already deleted (when using API fallback)', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + const worktreePath = path.join(projectPath, 'worktree-to-delete'); + fs.mkdirSync(worktreePath, { recursive: true }); + + ghApiDeleteError = Object.assign(new Error('HTTP 404: Not Found'), { stderr: 'HTTP 404' }); + gitPushDeleteError = Object.assign(new Error('permission denied'), { + stderr: 'permission denied', + }); + + const service = new WorktreeService(); + await service.removeWorktree(projectPath, 'wt-test', worktreePath, 'feature/test', { + deleteRemoteBranch: true, + }); + + expect(execFileCalls.some((c) => c.file === 'gh' && c.args[0] === 'api')).toBe(true); + }); + + it('parses GitHub repo names containing dots for API fallback', () => { + const service: any = new WorktreeService(); + expect(service.parseGitHubNameWithOwner('git@github.com:foo/bar.baz.git')).toBe('foo/bar.baz'); + expect(service.parseGitHubNameWithOwner('https://github.com/foo/bar.baz')).toBe('foo/bar.baz'); + }); +});