diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 329f647decd..24b8865ead4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9924,6 +9924,56 @@ export class OrcaRuntimeService { return { base, behind: drift.behind, recentSubjects } } + private async ensureWorktreeInstanceId(worktree: Worktree): Promise { + if (worktree.instanceId) { + return + } + + const instanceId = randomUUID() + const now = Date.now() + + const metaUpdates: Partial = { + instanceId, + createdAt: now, + orcaCreatedAt: now + } + + try { + const dotOrcaPath = join(worktree.path, '.orca') + const jsonPath = join(dotOrcaPath, 'worktree.json') + const jsonContent = JSON.stringify(metaUpdates, null, 2) + + const repo = this.store?.getRepos().find((r) => r.id === worktree.repoId) + if (repo?.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + throw new Error('SSH filesystem provider not found') + } + try { + await fsProvider.createDir(dotOrcaPath) + } catch { + // ignore if exists + } + await fsProvider.writeFile(jsonPath, jsonContent) + } else { + const { writeFile } = require('fs/promises') + await mkdir(dotOrcaPath, { recursive: true }) + await writeFile(jsonPath, jsonContent, 'utf-8') + } + } catch (err) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_MISSING', + 'Workspace directory is not writable. Cannot manage this external worktree.', + err + ) + } + + if (this.store) { + this.store.setWorktreeMeta(worktree.id, metaUpdates) + } + worktree.instanceId = instanceId + } + async updateManagedWorktreeMeta( worktreeSelector: string, updates: Partial & { @@ -9942,6 +9992,10 @@ export class OrcaRuntimeService { this.store.removeWorktreeLineage?.(worktree.id) } else if (lineage?.parentWorktree) { const parent = await this.resolveWorktreeSelector(lineage.parentWorktree) + + await this.ensureWorktreeInstanceId(worktree) + await this.ensureWorktreeInstanceId(parent) + this.validateLineageParent(worktree, parent) if (!worktree.instanceId || !parent.instanceId) { throw new RuntimeLineageError( diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index a1aa6af479b..91ba87e216a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -76,6 +76,7 @@ type WorktreeCardProps = { lineageCollapsed?: boolean lineageChildren?: React.ReactNode onLineageToggle?: (event: React.MouseEvent) => void + isLineageDropTarget?: boolean onActivate?: () => void onImmediateActivate?: (worktreeId: string) => void onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean @@ -152,7 +153,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ lineageChildCount = 0, lineageCollapsed = false, lineageChildren, - onLineageToggle + onLineageToggle, + isLineageDropTarget = false }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const openTaskPage = useAppStore((s) => s.openTaskPage) @@ -801,11 +803,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ 'group relative flex items-start gap-0.5 pl-0 pr-1.5 pt-1.5 pb-2 cursor-pointer transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none', flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1', isMultiSelected ? 'rounded-sm' : 'rounded-lg', - isActiveSurface - ? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]' - : isMultiSelected - ? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30' - : 'border border-transparent worktree-sidebar-card-hover', + isLineageDropTarget + ? 'bg-accent/80 border-accent-foreground/20' + : isActiveSurface + ? 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]' + : isMultiSelected + ? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30' + : 'border border-transparent worktree-sidebar-card-hover', isActiveSurface && isMultiSelected && 'ring-1 ring-worktree-sidebar-ring/35', revealHighlight && [ 'scroll-to-current-workspace-reveal-highlight', diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index da049255fd1..56ed1bbbc4c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react' +import { toast } from 'sonner' import { measureElement as measureVirtualElementSize, useVirtualizer @@ -521,11 +522,11 @@ function areWorktreeDragPreviewOffsetsEqual( function updateLatestWorktreeStatusDropTarget( drag: WorktreePointerDrag, - target: WorktreeSidebarStatusDropTarget, + target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }, preview: WorktreeSidebarDropPreview | null ): void { drag.latestStatusDropTarget = - target.status || target.isPinDrop + target.status || target.isPinDrop || target.lineageParentId ? { target, preview, @@ -544,14 +545,19 @@ function getPointerDropStatusTarget(args: { container: HTMLElement x: number y: number -}): WorktreeSidebarStatusDropTarget { +}): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } { const target = document.elementFromPoint(args.x, args.y) if (!(target instanceof Element) || !args.container.contains(target)) { - return { status: null, isPinDrop: false } + return { status: null, isPinDrop: false, lineageParentId: null } } const pinTarget = target.closest('[data-workspace-pin-drop-target]') if (pinTarget && args.container.contains(pinTarget)) { - return { status: null, isPinDrop: true } + return { status: null, isPinDrop: true, lineageParentId: null } + } + const rowTarget = target.closest('[data-worktree-drag-id]') + let lineageParentId: string | null = null + if (rowTarget && args.container.contains(rowTarget)) { + lineageParentId = rowTarget.dataset.worktreeDragId ?? null } const statusTarget = target.closest('[data-workspace-status-drop-target]') return { @@ -559,7 +565,8 @@ function getPointerDropStatusTarget(args: { statusTarget && args.container.contains(statusTarget) ? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null) : null, - isPinDrop: false + isPinDrop: false, + lineageParentId } } @@ -764,6 +771,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -1690,7 +1698,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp x: drag.currentX, y: drag.currentY }) - : { status: null, isPinDrop: false } + : { status: null, isPinDrop: false, lineageParentId: null } if ( shouldPreferSidebarStatusDropTarget({ sourceGroupKey: drag.sourceGroupKey, @@ -2036,7 +2044,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp x: event.clientX, y: event.clientY }) - : { status: null, isPinDrop: false } + : { status: null, isPinDrop: false, lineageParentId: null } if ( shouldPreferSidebarStatusDropTarget({ sourceGroupKey: drag.sourceGroupKey, @@ -2315,13 +2323,47 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearWorktreeDrag() return } + const target = getPointerDropStatusTarget({ + container: event.currentTarget, + x: event.clientX, + y: event.clientY + }) + if (target.lineageParentId && !session.draggedIds.includes(target.lineageParentId)) { + const parentId = target.lineageParentId + const isDescendantDrop = session.draggedIds.some((draggedId) => { + let current: string | undefined = parentId + while (current) { + if (current === draggedId) { + return true + } + const lineage = worktreeLineageById[current] + current = lineage?.parentWorktreeId + } + return false + }) + + if (!isDescendantDrop) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setWorktreeDragState((prev) => + prev.dropIndex === null && + prev.dropIndicatorY === null && + prev.previewOffsetsByWorktreeId.size === 0 + ? prev + : { + ...prev, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: event.clientY + } + ) + return + } + } + const drop = computeWorktreeDrop(event.clientY) if (!drop) { - const target = getPointerDropStatusTarget({ - container: event.currentTarget, - x: event.clientX, - y: event.clientY - }) const statusDrop = target.status ? computeWorktreeStatusDrop({ pointerY: event.clientY, @@ -2377,7 +2419,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp computeWorktreeDrop, computeWorktreeStatusDrop, refreshWorktreeDragSession, - startWorktreeNativeAutoscroll + startWorktreeNativeAutoscroll, + worktreeLineageById ] ) @@ -2396,16 +2439,49 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearWorktreeDrag() return } + + const container = scrollRef.current + const target = container + ? getPointerDropStatusTarget({ + container, + x: event.clientX, + y: event.clientY + }) + : { status: null, isPinDrop: false, lineageParentId: null } + + if (target.lineageParentId && !session.draggedIds.includes(target.lineageParentId)) { + event.preventDefault() + event.stopPropagation() + + const parentId = target.lineageParentId + const isDescendantDrop = session.draggedIds.some((draggedId) => { + let current: string | undefined = parentId + while (current) { + if (current === draggedId) { + return true + } + const lineage = worktreeLineageById[current] + current = lineage?.parentWorktreeId + } + return false + }) + + if (isDescendantDrop) { + clearWorktreeDrag() + return + } + + void Promise.all( + session.draggedIds.map((id) => updateWorktreeLineage(id, { parentWorktreeId: parentId })) + ).catch((err) => { + toast.error(err instanceof Error ? err.message : 'Failed to nest workspace') + }) + clearWorktreeDrag() + return + } + const drop = computeWorktreeDrop(event.clientY) if (!drop) { - const container = scrollRef.current - const target = container - ? getPointerDropStatusTarget({ - container, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false } const statusDrop = target.status ? computeWorktreeStatusDrop({ pointerY: event.clientY, @@ -2448,8 +2524,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onMoveWorktreesToStatusAtIndex, onReorderWorktrees, refreshWorktreeDragSession, + updateWorktreeLineage, worktreeDragGroups, - worktreeDragUnitGroups + worktreeDragUnitGroups, + worktreeLineageById ] ) @@ -2635,7 +2713,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp x: event.clientX, y: event.clientY }) - : { status: null, isPinDrop: false } + : { status: null, isPinDrop: false, lineageParentId: null } const statusDrop = target.status ? computeWorktreeStatusDrop({ pointerY: event.clientY, @@ -3216,6 +3294,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id) const revealHighlightTone = agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default' + const isLineageDropTarget = + worktreeDragState.draggingWorktreeId && + worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === + itemRow.worktree.id + return (
{ it('uses the current status target when pointerup hit-testing succeeds', () => { expect( resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget: { status: 'completed', isPinDrop: false }, + currentTarget: { status: 'completed', isPinDrop: false, lineageParentId: null }, currentPreview: preview, latestTrackedTarget: { - target: { status: 'in-progress', isPinDrop: false }, + target: { status: 'in-progress', isPinDrop: false, lineageParentId: null }, preview: null, x: 100, y: 100 @@ -62,7 +62,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { y: 100 }) ).toEqual({ - target: { status: 'completed', isPinDrop: false }, + target: { status: 'completed', isPinDrop: false, lineageParentId: null }, preview }) }) @@ -70,10 +70,10 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { it('reuses the latest status target when pointerup hit-testing blanks at the same point', () => { expect( resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget: { status: null, isPinDrop: false }, + currentTarget: { status: null, isPinDrop: false, lineageParentId: null }, currentPreview: null, latestTrackedTarget: { - target: { status: 'completed', isPinDrop: false }, + target: { status: 'completed', isPinDrop: false, lineageParentId: null }, preview, x: 100, y: 100 @@ -82,7 +82,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { y: 101 }) ).toEqual({ - target: { status: 'completed', isPinDrop: false }, + target: { status: 'completed', isPinDrop: false, lineageParentId: null }, preview }) }) @@ -90,10 +90,10 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { it('does not reuse a stale status target after the pointer has moved away', () => { expect( resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget: { status: null, isPinDrop: false }, + currentTarget: { status: null, isPinDrop: false, lineageParentId: null }, currentPreview: null, latestTrackedTarget: { - target: { status: 'completed', isPinDrop: false }, + target: { status: 'completed', isPinDrop: false, lineageParentId: null }, preview, x: 100, y: 100 @@ -102,7 +102,7 @@ describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { y: 100 }) ).toEqual({ - target: { status: null, isPinDrop: false }, + target: { status: null, isPinDrop: false, lineageParentId: null }, preview: null }) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index 703b1378114..db0b1d4d2b3 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -8,6 +8,7 @@ export type WorktreeSidebarDropPreview = { dropIndex: number dropIndicatorY: number previewOffsetsByWorktreeId: ReadonlyMap + lineageParentId?: string } export type WorktreeSidebarStatusDropTarget = { @@ -16,7 +17,7 @@ export type WorktreeSidebarStatusDropTarget = { } export type WorktreeSidebarTrackedStatusDropTarget = { - target: WorktreeSidebarStatusDropTarget + target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } preview: WorktreeSidebarDropPreview | null x: number y: number @@ -24,18 +25,20 @@ export type WorktreeSidebarTrackedStatusDropTarget = { const STATUS_DROP_TARGET_FALLBACK_TOLERANCE_PX = 6 -function hasWorktreeSidebarStatusDropTarget(target: WorktreeSidebarStatusDropTarget): boolean { - return target.isPinDrop || target.status !== null +function hasWorktreeSidebarStatusDropTarget( + target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null } +): boolean { + return target.isPinDrop || target.status !== null || (target.lineageParentId ?? null) !== null } export function resolveWorktreeSidebarStatusDropCommitTarget(args: { - currentTarget: WorktreeSidebarStatusDropTarget + currentTarget: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null } currentPreview: WorktreeSidebarDropPreview | null latestTrackedTarget: WorktreeSidebarTrackedStatusDropTarget | null x: number y: number }): { - target: WorktreeSidebarStatusDropTarget + target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null } preview: WorktreeSidebarDropPreview | null } { if (hasWorktreeSidebarStatusDropTarget(args.currentTarget)) {