mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
actions
This commit is contained in:
parent
15fea4b359
commit
884d336798
13 changed files with 389 additions and 76 deletions
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
114
src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
Normal file
114
src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in a new issue