This commit is contained in:
Neil 2026-03-18 19:52:48 -07:00
parent 15fea4b359
commit 884d336798
13 changed files with 389 additions and 76 deletions

View file

@ -80,12 +80,15 @@ export function addWorktree(
/**
* Remove a worktree.
*/
export function removeWorktree(repoPath: string, worktreePath: string, force = false): void {
export async function removeWorktree(
repoPath: string,
worktreePath: string,
force = false
): Promise<void> {
const args = ['worktree', 'remove', worktreePath]
if (force) args.push('--force')
execFileSync('git', args, {
await execFileAsync('git', args, {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
encoding: 'utf-8'
})
}

View file

@ -106,7 +106,11 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
}
}
removeWorktree(repo.path, worktreePath, args.force ?? false)
try {
await removeWorktree(repo.path, worktreePath, args.force ?? false)
} catch (error) {
throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false))
}
store.removeWorktreeMeta(args.worktreeId)
notifyWorktreesChanged(mainWindow, repoId)
@ -173,3 +177,18 @@ function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void
mainWindow.webContents.send('worktrees:changed', { repoId })
}
}
function formatWorktreeRemovalError(error: unknown, worktreePath: string, force: boolean): string {
const fallback = force
? `Failed to force delete worktree at ${worktreePath}.`
: `Failed to delete worktree at ${worktreePath}.`
if (!(error instanceof Error)) return fallback
const errorWithStreams = error as Error & { stderr?: string; stdout?: string }
const details = [errorWithStreams.stderr, errorWithStreams.stdout, error.message]
.map((value) => value?.trim())
.find(Boolean)
return details ? `${fallback} ${details}` : fallback
}

View file

@ -16,6 +16,7 @@ export default function Terminal(): React.JSX.Element | null {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
const setTabColor = useAppStore((s) => s.setTabColor)
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
@ -104,10 +105,11 @@ export default function Terminal(): React.JSX.Element | null {
)
const handlePtyExit = useCallback(
(tabId: string) => {
(tabId: string, ptyId: string) => {
if (consumeSuppressedPtyExit(ptyId)) return
handleCloseTab(tabId)
},
[handleCloseTab]
[consumeSuppressedPtyExit, handleCloseTab]
)
const handleCloseOthers = useCallback(
@ -229,7 +231,7 @@ export default function Terminal(): React.JSX.Element | null {
tabId={tab.id}
cwd={cwd}
isActive={tab.id === activeTabId}
onPtyExit={() => handlePtyExit(tab.id)}
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
/>
))}
</div>

View file

@ -72,7 +72,7 @@ function extractLastOscTitle(data: string): string | null {
function createIpcPtyTransport(
cwd?: string,
onPtyExit?: () => void,
onPtyExit?: (ptyId: string) => void,
onTitleChange?: (title: string) => void,
onPtySpawn?: (ptyId: string) => void
): PtyTransport {
@ -116,9 +116,11 @@ function createIpcPtyTransport(
unsubExit = window.api.pty.onExit((payload) => {
if (payload.id === ptyId) {
connected = false
const exitedPtyId = payload.id
storedCallbacks.onExit?.(payload.code)
storedCallbacks.onDisconnect?.()
onPtyExit?.()
ptyId = null
onPtyExit?.(exitedPtyId)
}
})
@ -265,7 +267,7 @@ interface TerminalPaneProps {
tabId: string
cwd?: string
isActive: boolean
onPtyExit: () => void
onPtyExit: (ptyId: string) => void
}
export default function TerminalPane({
@ -427,6 +429,7 @@ export default function TerminalPane({
const updateTabTitle = useAppStore((s) => s.updateTabTitle)
const updateTabPtyId = useAppStore((s) => s.updateTabPtyId)
const clearTabPtyId = useAppStore((s) => s.clearTabPtyId)
// Use a ref so the Restty closure always calls the latest onPtyExit
const onPtyExitRef = useRef(onPtyExit)
@ -459,9 +462,7 @@ export default function TerminalPane({
updateTabTitle(tabId, title)
}
const onPtySpawn = (ptyId: string): void => {
updateTabPtyId(tabId, ptyId)
}
const onPtySpawn = (ptyId: string): void => updateTabPtyId(tabId, ptyId)
let shouldPersistLayout = false
@ -472,11 +473,12 @@ export default function TerminalPane({
shortcuts: { enabled: false },
defaultContextMenu: false,
appOptions: ({ id }) => {
const onExit = (): void => {
const onExit = (ptyId: string): void => {
// Schedule close via parent
const panes = restty.getPanes()
if (panes.length <= 1) {
onPtyExitRef.current()
clearTabPtyId(tabId, ptyId)
onPtyExitRef.current(ptyId)
return
}
restty.closePane(id)
@ -672,6 +674,19 @@ export default function TerminalPane({
return
}
// Cmd+W closes only the active split pane and prevents the tab-level
// handler from closing the entire terminal tab.
if (!e.shiftKey && e.key.toLowerCase() === 'w') {
const panes = restty.getPanes()
if (panes.length < 2) return
e.preventDefault()
e.stopPropagation()
const pane = restty.getActivePane() ?? panes[0]
if (!pane) return
restty.closePane(pane.id)
return
}
// Cmd+D / Cmd+Shift+D split the active pane in the focused tab only.
if (e.key.toLowerCase() === 'd') {
e.preventDefault()

View file

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useMemo } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, LoaderCircle, Trash2 } from 'lucide-react'
import { useAppStore } from '@/store'
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
const closeModal = useAppStore((s) => s.closeModal)
const openModal = useAppStore((s) => s.openModal)
const removeWorktree = useAppStore((s) => s.removeWorktree)
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
const allWorktrees = useAppStore((s) => s.allWorktrees)
const isOpen = activeModal === 'delete-worktree'
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
const worktree = useMemo(
() => (worktreeId ? (allWorktrees().find((item) => item.id === worktreeId) ?? null) : null),
[allWorktrees, worktreeId]
)
const deleteState = useAppStore((s) =>
worktreeId ? s.deleteStateByWorktreeId[worktreeId] : undefined
)
const isDeleting = deleteState?.isDeleting ?? false
const deleteError = deleteState?.error ?? null
const canForceDelete = deleteState?.canForceDelete ?? false
useEffect(() => {
if (isOpen && worktreeId && !worktree && !isDeleting) {
clearWorktreeDeleteState(worktreeId)
closeModal()
}
}, [clearWorktreeDeleteState, closeModal, isDeleting, isOpen, worktree, worktreeId])
const handleOpenChange = useCallback(
(open: boolean) => {
if (open || isDeleting) return
if (worktreeId) clearWorktreeDeleteState(worktreeId)
closeModal()
},
[clearWorktreeDeleteState, closeModal, isDeleting, worktreeId]
)
const handleDelete = useCallback(
async (force = false) => {
if (!worktreeId) return
closeModal()
const result = await removeWorktree(worktreeId, force)
if (!result.ok) {
openModal('delete-worktree', { worktreeId })
return
}
clearWorktreeDeleteState(worktreeId)
},
[clearWorktreeDeleteState, closeModal, openModal, removeWorktree, worktreeId]
)
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">Delete Worktree</DialogTitle>
<DialogDescription className="text-xs">
Remove <span className="font-medium text-foreground">{worktree?.displayName}</span> from
git and delete its working tree folder.
</DialogDescription>
</DialogHeader>
{worktree && (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="font-medium text-foreground">{worktree.displayName}</div>
<div className="mt-1 break-all font-mono text-muted-foreground">{worktree.path}</div>
</div>
)}
{deleteError && (
<div className="rounded-md border border-destructive/40 bg-destructive/8 px-3 py-2 text-xs text-destructive">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{deleteError}</span>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>
Cancel
</Button>
{canForceDelete ? (
<Button variant="destructive" onClick={() => handleDelete(true)} disabled={isDeleting}>
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 />}
{isDeleting ? 'Force Deleting…' : 'Force Delete'}
</Button>
) : (
<Button variant="destructive" onClick={() => handleDelete(false)} disabled={isDeleting}>
{isDeleting ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 />}
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
})
export default DeleteWorktreeDialog

View file

@ -53,8 +53,6 @@ function getFrame(): number {
return _frame
}
// ─────────────────────────────────────────────────────────────────
type Status = 'active' | 'working' | 'permission' | 'inactive'
interface StatusIndicatorProps {
@ -66,8 +64,6 @@ const StatusIndicator = React.memo(function StatusIndicator({
status,
className
}: StatusIndicatorProps) {
// Only subscribes to the shared timer when status === 'working'.
// When not working, the subscribe is a no-op (returns identity unsub).
const frame = useSyncExternalStore(
status === 'working' ? subscribeFrame : noopSubscribe,
getFrame

View file

@ -3,7 +3,7 @@ import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Bell } from 'lucide-react'
import { Bell, LoaderCircle } from 'lucide-react'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import StatusIndicator from './StatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
@ -78,6 +78,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
@ -98,13 +99,19 @@ const WorktreeCard = React.memo(function WorktreeCard({
: null
const hasTerminals = tabs.length > 0
const isDeleting = deleteState?.isDeleting ?? false
// Derive status
const status: Status = useMemo(() => {
if (!hasTerminals) return 'inactive'
if (tabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission')) return 'permission'
if (tabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working')) return 'working'
return tabs.some((t) => t.ptyId) ? 'active' : 'inactive'
const liveTabs = tabs.filter((tab) => tab.ptyId)
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) {
return 'permission'
}
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) {
return 'working'
}
return liveTabs.length > 0 ? 'active' : 'inactive'
}, [hasTerminals, tabs])
// Fetch PR data (debounced via ref guard)
@ -161,10 +168,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
<div
className={cn(
'group relative flex items-start gap-2 px-2.5 py-1.5 rounded-md cursor-pointer transition-colors',
isActive ? 'bg-accent' : 'hover:bg-accent/50'
isActive ? 'bg-accent' : 'hover:bg-accent/50',
isDeleting && 'opacity-70'
)}
onClick={handleClick}
aria-busy={isDeleting}
>
{isDeleting && (
<div className="absolute inset-0 z-10 flex items-center justify-end rounded-md bg-background/45 px-2 backdrop-blur-[1px]">
<div className="inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/90 px-2 py-1 text-[10px] font-medium text-foreground shadow-sm">
<LoaderCircle className="size-3 animate-spin" />
Deleting
</div>
</div>
)}
{/* Status + quick unread bell */}
<div className="flex flex-col items-center self-start pt-1 gap-1.5">
<StatusIndicator status={status} />

View file

@ -6,17 +6,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
FolderOpen,
Copy,
Bell,
BellOff,
Link,
MessageSquare,
XCircle,
Archive,
Trash2
} from 'lucide-react'
import { FolderOpen, Copy, Bell, BellOff, Link, MessageSquare, XCircle, Trash2 } from 'lucide-react'
import { useAppStore } from '@/store'
import type { Worktree } from '../../../../shared/types'
@ -29,11 +19,13 @@ const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, children }: Props) {
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const removeWorktree = useAppStore((s) => s.removeWorktree)
const openModal = useAppStore((s) => s.openModal)
const closeTab = useAppStore((s) => s.closeTab)
const shutdownWorktreeTerminals = useAppStore((s) => s.shutdownWorktreeTerminals)
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const isDeleting = deleteState?.isDeleting ?? false
useEffect(() => {
const closeMenu = (): void => setMenuOpen(false)
@ -62,22 +54,13 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
}, [worktree.id, worktree.comment, openModal])
const handleCloseTerminals = useCallback(() => {
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
for (const tab of tabs) {
if (tab.ptyId) {
window.api.pty.kill(tab.ptyId)
}
closeTab(tab.id)
}
}, [worktree.id, closeTab])
const handleArchive = useCallback(() => {
updateWorktreeMeta(worktree.id, { isArchived: true })
}, [worktree.id, updateWorktreeMeta])
shutdownWorktreeTerminals(worktree.id)
}, [worktree.id, shutdownWorktreeTerminals])
const handleDelete = useCallback(() => {
removeWorktree(worktree.id)
}, [worktree.id, removeWorktree])
clearWorktreeDeleteState(worktree.id)
openModal('delete-worktree', { worktreeId: worktree.id })
}, [worktree.id, clearWorktreeDeleteState, openModal])
return (
<>
@ -104,39 +87,35 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" sideOffset={0} align="start">
<DropdownMenuItem onSelect={handleOpenInFinder}>
<DropdownMenuItem onSelect={handleOpenInFinder} disabled={isDeleting}>
<FolderOpen className="size-3.5" />
Open in Finder
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleCopyPath}>
<DropdownMenuItem onSelect={handleCopyPath} disabled={isDeleting}>
<Copy className="size-3.5" />
Copy Path
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleToggleRead}>
<DropdownMenuItem onSelect={handleToggleRead} disabled={isDeleting}>
{worktree.isUnread ? <BellOff className="size-3.5" /> : <Bell className="size-3.5" />}
{worktree.isUnread ? 'Mark Read' : 'Mark Unread'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleLinkIssue}>
<DropdownMenuItem onSelect={handleLinkIssue} disabled={isDeleting}>
<Link className="size-3.5" />
{worktree.linkedIssue ? 'Edit GH Issue/PR' : 'Link GH Issue/PR'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleComment}>
<DropdownMenuItem onSelect={handleComment} disabled={isDeleting}>
<MessageSquare className="size-3.5" />
{worktree.comment ? 'Edit Comment' : 'Add Comment'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleCloseTerminals}>
<DropdownMenuItem onSelect={handleCloseTerminals} disabled={isDeleting}>
<XCircle className="size-3.5" />
Close Terminals
Shutdown
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleArchive}>
<Archive className="size-3.5" />
Archive
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onSelect={handleDelete}>
<DropdownMenuItem variant="destructive" onSelect={handleDelete} disabled={isDeleting}>
<Trash2 className="size-3.5" />
Delete
{isDeleting ? 'Deleting…' : 'Delete'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -8,6 +8,7 @@ import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import AddWorktreeDialog from './AddWorktreeDialog'
import WorktreeMetaDialog from './WorktreeMetaDialog'
import DeleteWorktreeDialog from './DeleteWorktreeDialog'
const MIN_WIDTH = 220
const MAX_WIDTH = 500
@ -99,6 +100,7 @@ export default function Sidebar(): React.JSX.Element {
{/* Dialog (rendered outside sidebar to avoid clipping) */}
<AddWorktreeDialog />
<WorktreeMetaDialog />
<DeleteWorktreeDialog />
</TooltipProvider>
)
}

View file

@ -48,11 +48,15 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Kill PTYs for all worktrees belonging to this repo
const worktreeIds = (get().worktreesByRepo[repoId] ?? []).map((w) => w.id)
const killedTabIds = new Set<string>()
const killedPtyIds = new Set<string>()
for (const wId of worktreeIds) {
const tabs = get().tabsByWorktree[wId] ?? []
for (const tab of tabs) {
killedTabIds.add(tab.id)
if (tab.ptyId) window.api.pty.kill(tab.ptyId)
for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) {
killedPtyIds.add(ptyId)
window.api.pty.kill(ptyId)
}
}
}
@ -61,17 +65,25 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
delete nextWorktrees[repoId]
const nextTabs = { ...s.tabsByWorktree }
const nextLayouts = { ...s.terminalLayoutsByTabId }
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds }
for (const wId of worktreeIds) {
delete nextTabs[wId]
}
for (const tabId of killedTabIds) {
delete nextLayouts[tabId]
delete nextPtyIdsByTabId[tabId]
}
for (const ptyId of killedPtyIds) {
nextSuppressedPtyExitIds[ptyId] = true
}
return {
repos: s.repos.filter((r) => r.id !== repoId),
activeRepoId: s.activeRepoId === repoId ? null : s.activeRepoId,
worktreesByRepo: nextWorktrees,
tabsByWorktree: nextTabs,
ptyIdsByTabId: nextPtyIdsByTabId,
suppressedPtyExitIds: nextSuppressedPtyExitIds,
terminalLayoutsByTabId: nextLayouts,
activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId
}

View file

@ -5,10 +5,13 @@ import type {
TerminalTab,
WorkspaceSessionState
} from '../../../../shared/types'
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
export interface TerminalSlice {
tabsByWorktree: Record<string, TerminalTab[]>
activeTabId: string | null
ptyIdsByTabId: Record<string, string[]>
suppressedPtyExitIds: Record<string, true>
expandedPaneByTabId: Record<string, boolean>
canExpandPaneByTabId: Record<string, boolean>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
@ -21,15 +24,20 @@ export interface TerminalSlice {
setTabCustomTitle: (tabId: string, title: string | null) => void
setTabColor: (tabId: string, color: string | null) => void
updateTabPtyId: (tabId: string, ptyId: string) => void
clearTabPtyId: (tabId: string, ptyId?: string) => void
shutdownWorktreeTerminals: (worktreeId: string) => Promise<void>
consumeSuppressedPtyExit: (ptyId: string) => boolean
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void
hydrateWorkspaceSession: (session: WorkspaceSessionState) => void
}
export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> = (set) => ({
export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice> = (set, get) => ({
tabsByWorktree: {},
activeTabId: null,
ptyIdsByTabId: {},
suppressedPtyExitIds: {},
expandedPaneByTabId: {},
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
@ -56,6 +64,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
[worktreeId]: [...existing, tab]
},
activeTabId: tab.id,
ptyIdsByTabId: { ...s.ptyIdsByTabId, [tab.id]: [] },
terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tab.id]: emptyLayoutSnapshot() }
}
})
@ -78,9 +87,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
delete nextCanExpand[tabId]
const nextLayouts = { ...s.terminalLayoutsByTabId }
delete nextLayouts[tabId]
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
delete nextPtyIdsByTabId[tabId]
return {
tabsByWorktree: next,
activeTabId: s.activeTabId === tabId ? null : s.activeTabId,
ptyIdsByTabId: nextPtyIdsByTabId,
expandedPaneByTabId: nextExpanded,
canExpandPaneByTabId: nextCanExpand,
terminalLayoutsByTabId: nextLayouts
@ -142,10 +154,86 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => (t.id === tabId ? { ...t, ptyId } : t))
}
return { tabsByWorktree: next }
const existingPtyIds = s.ptyIdsByTabId[tabId] ?? []
return {
tabsByWorktree: next,
ptyIdsByTabId: {
...s.ptyIdsByTabId,
[tabId]: existingPtyIds.includes(ptyId) ? existingPtyIds : [...existingPtyIds, ptyId]
}
}
})
},
clearTabPtyId: (tabId, ptyId) => {
set((s) => {
const next = { ...s.tabsByWorktree }
for (const wId of Object.keys(next)) {
next[wId] = next[wId].map((t) => {
if (t.id !== tabId) return t
const remainingPtyIds = ptyId
? (s.ptyIdsByTabId[tabId] ?? []).filter((id) => id !== ptyId)
: []
return { ...t, ptyId: remainingPtyIds[remainingPtyIds.length - 1] ?? null }
})
}
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
if (ptyId) {
nextPtyIdsByTabId[tabId] = (nextPtyIdsByTabId[tabId] ?? []).filter((id) => id !== ptyId)
} else {
nextPtyIdsByTabId[tabId] = []
}
return { tabsByWorktree: next, ptyIdsByTabId: nextPtyIdsByTabId }
})
},
shutdownWorktreeTerminals: async (worktreeId) => {
const tabs = get().tabsByWorktree[worktreeId] ?? []
const tabIds = new Set(tabs.map((tab) => tab.id))
const ptyIds = tabs.flatMap((tab) => get().ptyIdsByTabId[tab.id] ?? [])
set((s) => {
const nextTabsByWorktree = {
...s.tabsByWorktree,
[worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((tab, index) =>
clearTransientTerminalState(tab, index)
)
}
const nextPtyIdsByTabId = {
...s.ptyIdsByTabId,
...Object.fromEntries(tabs.map((tab) => [tab.id, [] as string[]] as const))
}
const nextSuppressedPtyExitIds = {
...s.suppressedPtyExitIds,
...Object.fromEntries(ptyIds.map((ptyId) => [ptyId, true] as const))
}
return {
tabsByWorktree: nextTabsByWorktree,
ptyIdsByTabId: nextPtyIdsByTabId,
suppressedPtyExitIds: nextSuppressedPtyExitIds,
activeWorktreeId: s.activeWorktreeId === worktreeId ? null : s.activeWorktreeId,
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId
}
})
if (ptyIds.length === 0) return
await Promise.allSettled(ptyIds.map((ptyId) => window.api.pty.kill(ptyId)))
},
consumeSuppressedPtyExit: (ptyId) => {
let wasSuppressed = false
set((s) => {
if (!s.suppressedPtyExitIds[ptyId]) return {}
wasSuppressed = true
const next = { ...s.suppressedPtyExitIds }
delete next[ptyId]
return { suppressedPtyExitIds: next }
})
return wasSuppressed
},
setTabPaneExpanded: (tabId, expanded) => {
set((s) => ({
expandedPaneByTabId: { ...s.expandedPaneByTabId, [tabId]: expanded }
@ -182,8 +270,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
[...tabs]
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
.map((tab, index) => ({
...tab,
ptyId: null,
...clearTransientTerminalState(tab, index),
sortOrder: index
}))
])
@ -211,6 +298,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
activeWorktreeId,
activeTabId,
tabsByWorktree,
ptyIdsByTabId: Object.fromEntries(
Object.values(tabsByWorktree)
.flat()
.map((tab) => [tab.id, []] as const)
),
terminalLayoutsByTabId: Object.fromEntries(
Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => validTabIds.has(tabId))
),
@ -227,3 +319,16 @@ function emptyLayoutSnapshot(): TerminalLayoutSnapshot {
expandedLeafId: null
}
}
function clearTransientTerminalState(tab: TerminalTab, index: number): TerminalTab {
return {
...tab,
ptyId: null,
title: getResetTitle(tab, index)
}
}
function getResetTitle(tab: TerminalTab, index: number): string {
const fallbackTitle = tab.customTitle?.trim() || `Terminal ${index + 1}`
return detectAgentStatusFromTitle(tab.title) ? fallbackTitle : tab.title
}

View file

@ -8,7 +8,7 @@ export interface UISlice {
setSidebarWidth: (width: number) => void
activeView: 'terminal' | 'settings'
setActiveView: (view: UISlice['activeView']) => void
activeModal: 'none' | 'create-worktree' | 'link-issue' | 'edit-comment'
activeModal: 'none' | 'create-worktree' | 'link-issue' | 'edit-comment' | 'delete-worktree'
modalData: Record<string, unknown>
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
closeModal: () => void

View file

@ -2,13 +2,24 @@ import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { Worktree, WorktreeMeta } from '../../../../shared/types'
export interface WorktreeDeleteState {
isDeleting: boolean
error: string | null
canForceDelete: boolean
}
export interface WorktreeSlice {
worktreesByRepo: Record<string, Worktree[]>
activeWorktreeId: string | null
deleteStateByWorktreeId: Record<string, WorktreeDeleteState>
fetchWorktrees: (repoId: string) => Promise<void>
fetchAllWorktrees: () => Promise<void>
createWorktree: (repoId: string, name: string, baseBranch?: string) => Promise<Worktree | null>
removeWorktree: (worktreeId: string, force?: boolean) => Promise<void>
removeWorktree: (
worktreeId: string,
force?: boolean
) => Promise<{ ok: true } | { ok: false; error: string }>
clearWorktreeDeleteState: (worktreeId: string) => void
updateWorktreeMeta: (worktreeId: string, updates: Partial<WorktreeMeta>) => Promise<void>
setActiveWorktree: (worktreeId: string | null) => void
allWorktrees: () => Worktree[]
@ -17,6 +28,7 @@ export interface WorktreeSlice {
export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({
worktreesByRepo: {},
activeWorktreeId: null,
deleteStateByWorktreeId: {},
fetchWorktrees: async (repoId) => {
try {
@ -51,14 +63,22 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
},
removeWorktree: async (worktreeId, force) => {
set((s) => ({
deleteStateByWorktreeId: {
...s.deleteStateByWorktreeId,
[worktreeId]: {
isDeleting: true,
error: null,
canForceDelete: false
}
}
}))
try {
await window.api.worktrees.remove({ worktreeId, force })
// Kill PTYs for tabs belonging to this worktree
await get().shutdownWorktreeTerminals(worktreeId)
const tabs = get().tabsByWorktree[worktreeId] ?? []
const tabIds = new Set(tabs.map((t) => t.id))
for (const tab of tabs) {
if (tab.ptyId) window.api.pty.kill(tab.ptyId)
}
set((s) => {
const next = { ...s.worktreesByRepo }
@ -68,22 +88,50 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const nextTabs = { ...s.tabsByWorktree }
delete nextTabs[worktreeId]
const nextLayouts = { ...s.terminalLayoutsByTabId }
const nextPtyIdsByTabId = { ...s.ptyIdsByTabId }
for (const tabId of tabIds) {
delete nextLayouts[tabId]
delete nextPtyIdsByTabId[tabId]
}
const nextDeleteState = { ...s.deleteStateByWorktreeId }
delete nextDeleteState[worktreeId]
return {
worktreesByRepo: next,
tabsByWorktree: nextTabs,
ptyIdsByTabId: nextPtyIdsByTabId,
terminalLayoutsByTabId: nextLayouts,
deleteStateByWorktreeId: nextDeleteState,
activeWorktreeId: s.activeWorktreeId === worktreeId ? null : s.activeWorktreeId,
activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId
}
})
return { ok: true as const }
} catch (err) {
console.error('Failed to remove worktree:', err)
const error = err instanceof Error ? err.message : String(err)
set((s) => ({
deleteStateByWorktreeId: {
...s.deleteStateByWorktreeId,
[worktreeId]: {
isDeleting: false,
error,
canForceDelete: !(force ?? false)
}
}
}))
return { ok: false as const, error }
}
},
clearWorktreeDeleteState: (worktreeId) => {
set((s) => {
if (!s.deleteStateByWorktreeId[worktreeId]) return {}
const next = { ...s.deleteStateByWorktreeId }
delete next[worktreeId]
return { deleteStateByWorktreeId: next }
})
},
updateWorktreeMeta: async (worktreeId, updates) => {
try {
await window.api.worktrees.updateMeta({ worktreeId, updates })