From 249c303ae293908f2eec34d06567744a3d3c6372 Mon Sep 17 00:00:00 2001 From: Kainoa Date: Fri, 12 Dec 2025 20:54:44 -0800 Subject: [PATCH 1/8] Add delete GitHub branch option --- src/main/preload.ts | 4 + src/main/services/WorktreeService.ts | 269 +++++++++++++++--- src/main/services/worktreeIpc.ts | 4 +- src/renderer/App.tsx | 3 +- src/renderer/components/LeftSidebar.tsx | 11 +- src/renderer/components/ProjectMainView.tsx | 40 ++- .../components/WorkspaceDeleteButton.tsx | 36 ++- src/renderer/components/WorkspaceItem.tsx | 7 +- src/renderer/types/electron-api.d.ts | 2 + src/test/main/WorktreeService.test.ts | 210 ++++++++++++++ 10 files changed, 535 insertions(+), 51 deletions(-) create mode 100644 src/test/main/WorktreeService.test.ts diff --git a/src/main/preload.ts b/src/main/preload.ts index 36efbb15..c4380a2c 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 }) => @@ -434,6 +435,9 @@ export interface ElectronAPI { worktreeRemove: (args: { projectPath: string; worktreeId: string; + worktreePath?: string; + branch?: string; + deleteRemoteBranch?: boolean; }) => Promise<{ success: boolean; error?: string }>; worktreeStatus: (args: { worktreePath: string; diff --git a/src/main/services/WorktreeService.ts b/src/main/services/WorktreeService.ts index 8356c1ee..80d16bb0 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,63 @@ 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; + 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 workspace name to make it shell-safe */ @@ -279,13 +337,18 @@ export class WorktreeService { projectPath: string, worktreeId: string, worktreePath?: string, - branch?: string + branch?: string, + opts?: { deleteRemoteBranch?: boolean } ): Promise { try { + const errors: string[] = []; + let localBranchDeleted: boolean | null = null; + let remoteBranchDeleted: boolean | null = null; 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 +357,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 +379,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 +409,65 @@ 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,39 +476,101 @@ 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) { + log.warn('Skipped deleting remote branch: branch name unavailable'); } 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) { + log.warn(`Refusing to delete default branch '${defaultBranch}' on ${remoteAlias}`); + // Continue task deletion; just skip remote deletion. + remoteBranchName = ''; + } + + // Extra safety for weird refs + if (remoteBranchName === 'HEAD') { + log.warn(`Refusing to delete branch named 'HEAD' on ${remoteAlias}`); + // 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; + 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; + errors.push(`GitHub branch delete failed: ${msg}`); + log.warn( + `Failed to delete GitHub branch ${remoteAlias}/${remoteBranchName}:`, + ghErr + ); + } + } + } + } + } } } } @@ -395,6 +581,19 @@ export class WorktreeService { } else { log.info(`Removed worktree ${worktreeId}`); } + + 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}`); + } } catch (error) { log.error('Failed to remove worktree:', error); throw new Error(`Failed to remove worktree: ${error}`); @@ -461,9 +660,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 { @@ -490,7 +687,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/worktreeIpc.ts b/src/main/services/worktreeIpc.ts index 83f0002e..4bab64cb 100644 --- a/src/main/services/worktreeIpc.ts +++ b/src/main/services/worktreeIpc.ts @@ -50,6 +50,7 @@ export function registerWorktreeIpc(): void { worktreeId: string; worktreePath?: string; branch?: string; + deleteRemoteBranch?: boolean; } ) => { try { @@ -57,7 +58,8 @@ export function registerWorktreeIpc(): void { args.projectPath, args.worktreeId, args.worktreePath, - args.branch + args.branch, + { deleteRemoteBranch: args.deleteRemoteBranch } ); return { success: true }; } catch (error) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9fe98e3b..b22db61c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1202,7 +1202,7 @@ const AppContent: React.FC = () => { const handleDeleteWorkspace = async ( targetProject: Project, workspace: Workspace, - options?: { silent?: boolean } + options?: { silent?: boolean; deleteRemoteBranch?: boolean } ): Promise => { if (deletingWorkspaceIdsRef.current.has(workspace.id)) { toast({ @@ -1267,6 +1267,7 @@ const AppContent: React.FC = () => { worktreeId: workspace.id, worktreePath: workspace.path, branch: workspace.branch, + deleteRemoteBranch: options?.deleteRemoteBranch, }), window.electronAPI.deleteWorkspace(workspace.id), ]); diff --git a/src/renderer/components/LeftSidebar.tsx b/src/renderer/components/LeftSidebar.tsx index a3406fb3..ad243e64 100644 --- a/src/renderer/components/LeftSidebar.tsx +++ b/src/renderer/components/LeftSidebar.tsx @@ -46,7 +46,11 @@ interface LeftSidebarProps { }) => void; onCreateWorkspaceForProject?: (project: Project) => void; isCreatingWorkspace?: boolean; - onDeleteWorkspace?: (project: Project, workspace: Workspace) => void | Promise; + onDeleteWorkspace?: ( + project: Project, + workspace: Workspace, + options?: { silent?: boolean; deleteRemoteBranch?: boolean } + ) => void | Promise; onDeleteProject?: (project: Project) => void | Promise; isHomeView?: boolean; } @@ -302,7 +306,10 @@ const LeftSidebar: React.FC = ({ showDelete onDelete={ onDeleteWorkspace - ? () => onDeleteWorkspace(typedProject, workspace) + ? (opts) => + onDeleteWorkspace(typedProject, workspace, { + deleteRemoteBranch: opts?.deleteRemoteBranch, + }) : undefined } /> diff --git a/src/renderer/components/ProjectMainView.tsx b/src/renderer/components/ProjectMainView.tsx index b1be1f5a..dc3910b1 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'; @@ -56,7 +57,7 @@ function WorkspaceRow({ ws: Workspace; active: boolean; onClick: () => void; - onDelete: () => void | Promise; + onDelete: (opts?: { deleteRemoteBranch?: boolean }) => void | Promise; isSelectMode?: boolean; isSelected?: boolean; onToggleSelect?: () => void; @@ -353,10 +354,11 @@ function WorkspaceRow({ workspaceName={ws.name} workspaceId={ws.id} workspacePath={ws.path} - onConfirm={async () => { + workspaceBranch={ws.branch} + onConfirm={async (opts) => { try { setIsDeleting(true); - await onDelete(); + await onDelete(opts); } finally { setIsDeleting(false); } @@ -393,7 +395,7 @@ interface ProjectMainViewProps { onDeleteWorkspace: ( project: Project, workspace: Workspace, - options?: { silent?: boolean } + options?: { silent?: boolean; deleteRemoteBranch?: boolean } ) => void | Promise; isCreatingWorkspace?: boolean; onDeleteProject?: (project: Project) => void | Promise; @@ -424,6 +426,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 workspaces = project.workspaces ?? []; const selectedCount = selectedIds.size; @@ -516,7 +519,10 @@ const ProjectMainView: React.FC = ({ const deletedNames: string[] = []; for (const ws of toDelete) { try { - const result = await onDeleteWorkspace(project, ws, { silent: true }); + const result = await onDeleteWorkspace(project, ws, { + silent: true, + deleteRemoteBranch: alsoDeleteRemoteBranches, + }); if (result !== false) { deletedNames.push(ws.name); } @@ -554,6 +560,7 @@ const ProjectMainView: React.FC = ({ if (!showDeleteDialog) { setDeleteStatus({}); setAcknowledgeDirtyDelete(false); + setAlsoDeleteRemoteBranches(false); return; } @@ -853,7 +860,11 @@ const ProjectMainView: React.FC = ({ onToggleSelect={() => toggleSelect(ws.id)} active={activeWorkspace?.id === ws.id} onClick={() => onSelectWorkspace(ws)} - onDelete={() => onDeleteWorkspace(project, ws)} + onDelete={(opts) => + onDeleteWorkspace(project, ws, { + deleteRemoteBranch: opts?.deleteRemoteBranch, + }) + } /> ))} @@ -955,6 +966,23 @@ const ProjectMainView: React.FC = ({ ) : null} + + + + Also delete GitHub branches + + + Cancel diff --git a/src/renderer/components/WorkspaceDeleteButton.tsx b/src/renderer/components/WorkspaceDeleteButton.tsx index c6c026de..46303355 100644 --- a/src/renderer/components/WorkspaceDeleteButton.tsx +++ b/src/renderer/components/WorkspaceDeleteButton.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, @@ -22,7 +23,8 @@ type Props = { workspaceName: string; workspaceId: string; workspacePath: string; - onConfirm: () => void | Promise; + workspaceBranch?: string; + onConfirm: (opts?: { deleteRemoteBranch?: boolean }) => void | Promise; className?: string; 'aria-label'?: string; isDeleting?: boolean; @@ -32,6 +34,7 @@ export const WorkspaceDeleteButton: React.FC = ({ workspaceName, workspaceId, workspacePath, + workspaceBranch, onConfirm, className, 'aria-label': ariaLabel = 'Delete Task', @@ -39,6 +42,7 @@ export const WorkspaceDeleteButton: React.FC = ({ }) => { const [open, setOpen] = React.useState(false); const [acknowledge, setAcknowledge] = React.useState(false); + const [deleteRemoteBranch, setDeleteRemoteBranch] = React.useState(false); const targets = useMemo( () => [{ id: workspaceId, name: workspaceName, path: workspacePath }], [workspaceId, workspaceName, workspacePath] @@ -66,9 +70,15 @@ export const WorkspaceDeleteButton: React.FC = ({ React.useEffect(() => { if (!open) { setAcknowledge(false); + setDeleteRemoteBranch(false); } }, [open]); + const remoteBranchLabel = (workspaceBranch || '') + .replace(/^refs\/heads\//, '') + .replace(/^refs\/remotes\/origin\//, 'origin/') + .replace(/^origin\//, ''); + return ( @@ -175,6 +185,28 @@ export const WorkspaceDeleteButton: React.FC = ({ ) : null} + {workspaceBranch ? ( + + + + Also delete GitHub branch + + + {remoteBranchLabel} + + + + + ) : null} Cancel = ({ e.stopPropagation(); setOpen(false); try { - await onConfirm(); + await onConfirm({ deleteRemoteBranch }); } catch {} }} > diff --git a/src/renderer/components/WorkspaceItem.tsx b/src/renderer/components/WorkspaceItem.tsx index 92eed736..fac9de53 100644 --- a/src/renderer/components/WorkspaceItem.tsx +++ b/src/renderer/components/WorkspaceItem.tsx @@ -19,7 +19,7 @@ interface Workspace { interface WorkspaceItemProps { workspace: Workspace; - onDelete?: () => void | Promise; + onDelete?: (opts?: { deleteRemoteBranch?: boolean }) => void | Promise; showDelete?: boolean; } @@ -53,10 +53,11 @@ export const WorkspaceItem: React.FC = ({ workspaceName={workspace.name} workspaceId={workspace.id} workspacePath={workspace.path} - onConfirm={async () => { + workspaceBranch={workspace.branch} + onConfirm={async (opts) => { try { setIsDeleting(true); - await onDelete(); + await onDelete(opts); } finally { setIsDeleting(false); } diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 92e56e6d..3cd98996 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -163,6 +163,7 @@ declare global { worktreeId: string; worktreePath?: string; branch?: string; + deleteRemoteBranch?: boolean; }) => Promise<{ success: boolean; error?: string }>; worktreeStatus: (args: { worktreePath: string; @@ -654,6 +655,7 @@ export interface ElectronAPI { worktreeId: string; worktreePath?: string; branch?: string; + deleteRemoteBranch?: boolean; }) => Promise<{ success: boolean; error?: string }>; worktreeStatus: (args: { worktreePath: string; diff --git a/src/test/main/WorktreeService.test.ts b/src/test/main/WorktreeService.test.ts new file mode 100644 index 00000000..124fe99c --- /dev/null +++ b/src/test/main/WorktreeService.test.ts @@ -0,0 +1,210 @@ +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); + }); +}); From ab4c36b8d0f50fa622e772bb392d18cfeb18f599 Mon Sep 17 00:00:00 2001 From: Kainoa Date: Fri, 12 Dec 2025 21:48:11 -0800 Subject: [PATCH 2/8] Improve remote branch deletion feedback --- src/main/preload.ts | 8 +++- src/main/services/WorktreeService.ts | 54 ++++++++++++++++++++------- src/main/services/worktreeIpc.ts | 32 ++++++++-------- src/renderer/App.tsx | 17 ++++++++- src/renderer/types/electron-api.d.ts | 16 +++++++- src/test/main/WorktreeService.test.ts | 6 +++ 6 files changed, 100 insertions(+), 33 deletions(-) diff --git a/src/main/preload.ts b/src/main/preload.ts index c4380a2c..077ffac4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -438,7 +438,13 @@ export interface ElectronAPI { worktreePath?: string; branch?: string; deleteRemoteBranch?: boolean; - }) => Promise<{ success: boolean; error?: string }>; + }) => 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/WorktreeService.ts b/src/main/services/WorktreeService.ts index 80d16bb0..5cff4c1a 100644 --- a/src/main/services/WorktreeService.ts +++ b/src/main/services/WorktreeService.ts @@ -74,7 +74,12 @@ export class WorktreeService { private parseGitHubNameWithOwner(remoteUrl: string): string | null { const url = String(remoteUrl || '').trim(); if (!url) return null; - const m = url.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/i); + // 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(); @@ -339,11 +344,16 @@ export class WorktreeService { worktreePath?: string, branch?: string, opts?: { deleteRemoteBranch?: boolean } - ): Promise { + ): 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; @@ -495,19 +505,25 @@ export class WorktreeService { .replace(/^origin\//, ''); if (!remoteBranchName) { - log.warn('Skipped deleting remote branch: branch name unavailable'); + remoteBranchDeleted = false; + remoteBranchDeleteError = 'Skipped deleting remote branch: branch name unavailable'; + log.warn(remoteBranchDeleteError); } else { // Safety: never delete the default branch const defaultBranch = await this.getDefaultBranch(projectPath); if (remoteBranchName === defaultBranch) { - log.warn(`Refusing to delete default branch '${defaultBranch}' on ${remoteAlias}`); + 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') { - log.warn(`Refusing to delete branch named 'HEAD' on ${remoteAlias}`); + remoteBranchDeleted = false; + remoteBranchDeleteError = `Refusing to delete branch named 'HEAD' on ${remoteAlias}`; + log.warn(remoteBranchDeleteError); // Continue task deletion; just skip remote deletion. remoteBranchName = ''; } @@ -533,8 +549,12 @@ export class WorktreeService { 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); + log.warn( + `Failed to delete remote branch ${remoteAlias}/${remoteBranchName}:`, + remoteError + ); } } @@ -561,6 +581,7 @@ export class WorktreeService { 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}:`, @@ -568,6 +589,10 @@ export class WorktreeService { ); } } + } else { + remoteBranchDeleted = false; + remoteBranchDeleteError = + 'Could not determine GitHub repo name from origin URL; skipping GitHub API branch deletion.'; } } } @@ -575,13 +600,6 @@ export class WorktreeService { } } - if (worktree) { - this.worktrees.delete(worktreeId); - log.info(`Removed worktree: ${worktree.name}`); - } else { - log.info(`Removed worktree ${worktreeId}`); - } - if (fs.existsSync(pathToRemove)) { const hint = process.platform === 'darwin' @@ -594,6 +612,16 @@ export class WorktreeService { 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}`); diff --git a/src/main/services/worktreeIpc.ts b/src/main/services/worktreeIpc.ts index 4bab64cb..dfa52d2a 100644 --- a/src/main/services/worktreeIpc.ts +++ b/src/main/services/worktreeIpc.ts @@ -52,22 +52,22 @@ export function registerWorktreeIpc(): void { branch?: string; deleteRemoteBranch?: boolean; } - ) => { - try { - await worktreeService.removeWorktree( - args.projectPath, - args.worktreeId, - args.worktreePath, - args.branch, - { deleteRemoteBranch: args.deleteRemoteBranch } - ); - return { success: true }; - } catch (error) { - console.error('Failed to remove worktree:', error); - return { success: false, error: (error as Error).message }; - } - } - ); + ) => { + try { + const result = await worktreeService.removeWorktree( + args.projectPath, + args.worktreeId, + args.worktreePath, + args.branch, + { deleteRemoteBranch: args.deleteRemoteBranch } + ); + return { success: true, ...result }; + } catch (error) { + console.error('Failed to remove worktree:', error); + return { success: false, error: (error as Error).message }; + } + } + ); // Get worktree status ipcMain.handle('worktree:status', async (event, args: { worktreePath: string }) => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b22db61c..51a7c2ab 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1288,15 +1288,30 @@ const AppContent: React.FC = () => { throw new Error(errorMsg); } + const remoteBranchWarning = + !!options?.deleteRemoteBranch && removeResult.status === 'fulfilled' + ? removeResult.value?.remoteBranchDeleted === false + : false; + // Track workspace deletion const { captureTelemetry } = await import('./lib/telemetryClient'); captureTelemetry('workspace_deleted'); if (!options?.silent) { toast({ - title: 'Task deleted', + title: remoteBranchWarning ? 'Task deleted (branch not deleted)' : 'Task deleted', description: workspace.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) { diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 3cd98996..80f77184 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -164,7 +164,13 @@ declare global { worktreePath?: string; branch?: string; deleteRemoteBranch?: boolean; - }) => Promise<{ success: boolean; error?: string }>; + }) => Promise<{ + success: boolean; + error?: string; + localBranchDeleted?: boolean | null; + remoteBranchDeleted?: boolean | null; + remoteBranchDeleteError?: string; + }>; worktreeStatus: (args: { worktreePath: string; }) => Promise<{ success: boolean; status?: any; error?: string }>; @@ -656,7 +662,13 @@ export interface ElectronAPI { worktreePath?: string; branch?: string; deleteRemoteBranch?: boolean; - }) => Promise<{ success: boolean; error?: string }>; + }) => 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/test/main/WorktreeService.test.ts b/src/test/main/WorktreeService.test.ts index 124fe99c..90194cd4 100644 --- a/src/test/main/WorktreeService.test.ts +++ b/src/test/main/WorktreeService.test.ts @@ -207,4 +207,10 @@ describe('WorktreeService.removeWorktree remote deletion', () => { 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'); + }); }); From 6991cc7e6ee11edf282259aed30816a5b20995ff Mon Sep 17 00:00:00 2001 From: Kainoa Date: Sat, 13 Dec 2025 13:52:55 -0800 Subject: [PATCH 3/8] chore: cleanup hooks and gh auth fallback --- .eslintrc.json | 4 +- src/main/services/GitHubService.ts | 36 +++++- src/main/services/TerminalConfigParser.ts | 17 +-- src/main/services/hostPreviewService.ts | 2 +- src/renderer/App.tsx | 10 +- src/renderer/components/ChangesDiffModal.tsx | 4 +- src/renderer/components/ChatInterface.tsx | 24 ++-- src/renderer/components/FeedbackModal.tsx | 2 +- .../components/GithubConnectionCard.tsx | 7 -- .../components/GithubDeviceFlowModal.tsx | 111 +++++++++--------- src/renderer/components/JiraIssueSelector.tsx | 2 +- .../components/MultiProviderDropdown.tsx | 3 - src/renderer/components/ProjectMainView.tsx | 4 +- .../components/RequirementsNotice.tsx | 2 +- src/renderer/components/TerminalPane.tsx | 1 + src/renderer/components/VersionCard.tsx | 29 ++--- .../components/WorkspaceDeleteButton.tsx | 2 +- src/renderer/components/WorkspaceModal.tsx | 1 - src/renderer/components/WorkspacePorts.tsx | 24 ++-- .../components/kanban/KanbanBoard.tsx | 8 +- src/renderer/components/ui/resizable.tsx | 6 +- src/renderer/hooks/useCreatePR.tsx | 2 +- src/renderer/hooks/useGithubAuth.ts | 2 +- .../hooks/useInitialPromptInjection.ts | 10 +- src/renderer/hooks/usePlanActivation.ts | 2 +- src/renderer/hooks/usePlanMode.ts | 2 +- src/renderer/lib/activityStore.ts | 16 +-- src/renderer/lib/probePreview.ts | 2 +- src/renderer/lib/workspaceStatus.ts | 1 - src/shared/container/portManager.test.ts | 2 +- 30 files changed, 168 insertions(+), 170 deletions(-) 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/services/GitHubService.ts b/src/main/services/GitHubService.ts index d098054a..81ad4bf4 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/hostPreviewService.ts b/src/main/services/hostPreviewService.ts index 837a2ca2..3942f291 100644 --- a/src/main/services/hostPreviewService.ts +++ b/src/main/services/hostPreviewService.ts @@ -220,7 +220,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/renderer/App.tsx b/src/renderer/App.tsx index 51a7c2ab..47f1af58 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1728,10 +1728,7 @@ const AppContent: React.FC = () => { isHomeView={showHomeView} /> - + { {renderMainContent()} - + = ({ // Handle pane resizing events useEffect(() => { + const diffContainerNode = diffContainerRef.current; + const handlePointerMove = (event: PointerEvent) => { if (!isResizingRef.current) return; scheduleSplitUpdate(event.clientX); @@ -193,7 +195,7 @@ export const ChangesDiffModal: React.FC = ({ window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', stopResizing); document.body.style.cursor = ''; - diffContainerRef.current?.classList.remove('select-none'); + diffContainerNode?.classList.remove('select-none'); if (resizeRafRef.current !== null) { window.cancelAnimationFrame(resizeRafRef.current); resizeRafRef.current = null; diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 5e1099a8..809bb732 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { ExternalLink, Globe, Database, Server, ChevronDown } from 'lucide-react'; import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; import ContainerStatusBadge from './ContainerStatusBadge'; -import { useToast } from '../hooks/use-toast'; import { useTheme } from '../hooks/useTheme'; import { TerminalPane } from './TerminalPane'; import InstallBanner from './InstallBanner'; @@ -44,7 +43,6 @@ const ChatInterface: React.FC = ({ className, initialProvider, }) => { - const { toast } = useToast(); const { effectiveTheme } = useTheme(); const [isProviderInstalled, setIsProviderInstalled] = useState(null); const [providerStatuses, setProviderStatuses] = useState< @@ -469,6 +467,8 @@ const ChatInterface: React.FC = ({ }; }, [workspace.id]); + const multiAgentEnabled = workspace.metadata?.multiAgent?.enabled === true; + const containerStatusNode = useMemo(() => { const state = containerState; if (!state?.runId) return null; @@ -490,7 +490,11 @@ const ChatInterface: React.FC = ({ return a.host - b.host; }); - const ServiceIcon: React.FC<{ name: string; port: number }> = ({ name, port }) => { + const ServiceIcon: React.FC<{ name: string; port: number; workspacePath: string }> = ({ + name, + port, + workspacePath, + }) => { const [src, setSrc] = React.useState(null); React.useEffect(() => { let cancelled = false; @@ -502,7 +506,7 @@ const ChatInterface: React.FC = ({ const res = await api.resolveServiceIcon({ service: name, allowNetwork: true, - workspacePath: workspace.path, + workspacePath, }); if (!cancelled && res?.ok && typeof res.dataUrl === 'string') setSrc(res.dataUrl); } catch {} @@ -510,7 +514,7 @@ const ChatInterface: React.FC = ({ return () => { cancelled = true; }; - }, [name]); + }, [name, workspacePath]); if (src) return ; const webPorts = new Set([80, 443, 3000, 5173, 8080, 8000]); const dbPorts = new Set([5432, 3306, 27017, 1433, 1521]); @@ -518,7 +522,7 @@ const ChatInterface: React.FC = ({ if (dbPorts.has(port)) return { Date: Fri, 19 Dec 2025 12:55:16 -0800 Subject: [PATCH 8/8] Run prettier --- src/renderer/App.tsx | 8 ++------ src/renderer/components/ProjectMainView.tsx | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 33581f43..b2eb9d29 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1986,9 +1986,7 @@ const AppContent: React.FC = () => { isHomeView={showHomeView} /> - + { {renderMainContent()} - + = ({ [selectedIds, tasksInProject] ); const remoteBranchSummary = - selectedCount === 1 ? normalizeBranchLabel(selectedTasks[0]?.branch) : `${selectedCount} branches`; + selectedCount === 1 + ? normalizeBranchLabel(selectedTasks[0]?.branch) + : `${selectedCount} branches`; const [deleteStatus, setDeleteStatus] = useState< Record< string,