From c27324f00b304eca51bb8d905b6f8a65699e4855 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 14 Nov 2025 17:41:28 -0800 Subject: [PATCH 1/5] refactor --- .../components/WorktreeItem/WorktreeItem.tsx | 135 +++++++++++++++--- 1 file changed, 119 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx index adb52e83b28..392eed0aeb9 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx @@ -1,4 +1,8 @@ -import { useDroppable } from "@dnd-kit/core"; +import { + DndContext, + type DragEndEvent, + useDroppable, +} from "@dnd-kit/core"; import { SortableContext, useSortable, @@ -121,7 +125,6 @@ function DroppableGroupTab({ onTabSelect, onUngroupTab, onRenameGroup, - isOver, }: { tab: Tab; worktreeId: string; @@ -133,13 +136,12 @@ function DroppableGroupTab({ onTabSelect: (worktreeId: string, tabId: string, shiftKey: boolean) => void; onUngroupTab: (groupTabId: string) => void; onRenameGroup: (groupTabId: string, newName: string) => void; - isOver: boolean; }) { const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(tab.name); const inputRef = useRef(null); - const { setNodeRef } = useDroppable({ + const { setNodeRef, isOver } = useDroppable({ id: `group-${tab.id}`, data: { type: "group", @@ -202,7 +204,7 @@ function DroppableGroupTab({ className={`group flex items-center gap-1.5 w-full h-7 px-2.5 text-xs rounded-md transition-all ${isSelected ? "bg-neutral-800/80 text-neutral-200" : isOver - ? "bg-blue-900/40 text-blue-200" + ? "bg-blue-900/40 text-blue-200 border-l-2 border-blue-500" : "hover:bg-neutral-800/40 text-neutral-400" }`} style={{ paddingLeft: `${level * 12 + 10}px` }} @@ -245,14 +247,12 @@ function DroppableGroupTab({ // Droppable area wrapper for the expanded group tab content function DroppableGroupArea({ groupTabId, - isOver, children, }: { groupTabId: string; - isOver: boolean; children: React.ReactNode; }) { - const { setNodeRef } = useDroppable({ + const { setNodeRef, isOver } = useDroppable({ id: `group-area-${groupTabId}`, data: { type: "group-area", @@ -1084,6 +1084,108 @@ export function WorktreeItem({ const allTabsFlat = getAllTabs(tabs); const allTabIds = allTabsFlat.map((item) => item.tab.id); + // Handle drag end - move tab to group or reorder + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + + if (!over) return; + + const draggedTabId = active.id as string; + const draggedTab = findTabById(tabs, draggedTabId); + + if (!draggedTab || draggedTab.type === "group") { + // Don't allow dragging group tabs + return; + } + + const overId = over.id as string; + const overData = over.data.current; + + // Check if dropped on a group tab header + if (overId.startsWith("group-") && overData?.type === "group") { + const targetGroupId = overData.groupTabId as string; + + // Find source parent + const sourceParent = findParentGroupTab(tabs, draggedTabId); + const sourceParentTabId = sourceParent?.id; + + // Don't move if already in this group + if (sourceParentTabId === targetGroupId) { + return; + } + + // Expand the target group + setExpandedGroupTabs((prev) => new Set(prev).add(targetGroupId)); + + // Move tab into the group + try { + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: draggedTabId, + sourceParentTabId: sourceParentTabId || undefined, + targetParentTabId: targetGroupId, + targetIndex: 0, // Add to end of group + }); + + if (result.success) { + onReload(); + // Select the moved tab + onTabSelect(worktree.id, draggedTabId); + } else { + console.error("Failed to move tab to group:", result.error); + } + } catch (error) { + console.error("Error moving tab to group:", error); + } + return; + } + + // Check if dropped on a group area (expanded group content) + if ( + overId.startsWith("group-area-") && + overData?.type === "group-area" + ) { + const targetGroupId = overData.groupTabId as string; + + // Find source parent + const sourceParent = findParentGroupTab(tabs, draggedTabId); + const sourceParentTabId = sourceParent?.id; + + // Don't move if already in this group + if (sourceParentTabId === targetGroupId) { + return; + } + + // Move tab into the group + try { + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: draggedTabId, + sourceParentTabId: sourceParentTabId || undefined, + targetParentTabId: targetGroupId, + targetIndex: 0, // Add to end of group + }); + + if (result.success) { + onReload(); + // Select the moved tab + onTabSelect(worktree.id, draggedTabId); + } else { + console.error("Failed to move tab to group:", result.error); + } + } catch (error) { + console.error("Error moving tab to group:", error); + } + return; + } + + // Handle reordering within the same parent (if dropped on another tab) + // This is handled by SortableContext's built-in reordering + // We could add custom reordering logic here if needed + }; + // Toggle group tab expansion const toggleGroupTab = (groupTabId: string) => { setExpandedGroupTabs((prev) => { @@ -1115,12 +1217,11 @@ export function WorktreeItem({ onTabSelect={handleTabSelect} onUngroupTab={handleUngroupTab} onRenameGroup={handleRenameGroup} - isOver={false} /> {/* Nested Tabs - Make the entire area droppable */} {isExpanded && tab.tabs && ( - +
{tab.tabs.map((childTab) => renderTab(childTab, tab.id, level + 1), @@ -1163,12 +1264,14 @@ export function WorktreeItem({ {/* Tabs List */}
{/* Render tabs with collapsible groups */} - - {tabs.map((tab) => renderTab(tab, undefined, 0))} - + + + {tabs.map((tab) => renderTab(tab, undefined, 0))} + +
{/* Remove Worktree Confirmation Dialog */} From 1b225173df78e32d68c1201d0d508baa5414433b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 14 Nov 2025 17:53:34 -0800 Subject: [PATCH 2/5] merge main --- .../WorktreeList/components/WorktreeItem/WorktreeItem.tsx | 7 ++++++- .../WorktreeItem/components/TabItem/TabItem.tsx | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx index 392eed0aeb9..739ea5859ce 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx @@ -94,7 +94,12 @@ function SortableTab({ }; return ( -
+
{ + // Stop propagation to prevent drag from starting when clicking the button + e.stopPropagation(); + }; + const handleClick = (e: React.MouseEvent) => { + // Stop propagation to prevent drag from starting + e.stopPropagation(); if (!isEditing) { onTabSelect(worktreeId, tab.id, e.shiftKey); } @@ -144,6 +151,7 @@ export function TabItem({ ? "bg-blue-900/30 text-blue-200" : "hover:bg-neutral-800/40 text-neutral-400 hover:text-neutral-300" }`} + onMouseDown={handleMouseDown} onClick={handleClick} onDoubleClick={handleDoubleClick} > From d41885e5fb6b1d790d616056341cb946f85b47fc Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 14 Nov 2025 18:12:33 -0800 Subject: [PATCH 3/5] react arbonist for tree --- apps/desktop/package.json | 1 + .../WorktreeItem/WorktreeItem.backup.tsx | 1555 +++++++++++++++++ .../components/WorktreeItem/WorktreeItem.tsx | 657 +++---- .../WorktreeItem/WorktreeItemArborist.tsx | 450 +++++ bun.lock | 97 +- 5 files changed, 2352 insertions(+), 408 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 809450f0e7a..362db622841 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -48,6 +48,7 @@ "lucide-react": "^0.553.0", "node-pty": "1.1.0-beta30", "react": "^19.1.1", + "react-arborist": "^3.4.3", "react-dom": "^19.1.1", "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx new file mode 100644 index 00000000000..e48493aa4d4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx @@ -0,0 +1,1555 @@ +import { + DndContext, + type DragEndEvent, + useDroppable, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Button } from "@superset/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + ChevronRight, + Edit2, + FolderOpen, +} from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import type { MosaicNode } from "react-mosaic-component"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "renderer/components/ui/dialog"; +import type { Tab, Worktree } from "shared/types"; +import { WorktreePortsList } from "../WorktreePortsList"; +import { GitStatusDialog } from "./components/GitStatusDialog"; +import { TabItem } from "./components/TabItem"; + +interface ProxyStatus { + canonical: number; + target?: number; + service?: string; + active: boolean; +} + +// Sortable wrapper for tabs +function SortableTab({ + tab, + worktreeId, + worktree, + workspaceId, + parentTabId, + selectedTabId, + selectedTabIds, + onTabSelect, + onTabRemove, + onGroupTabs, + onMoveOutOfGroup, + onTabRename, +}: { + tab: Tab; + worktreeId: string; + worktree: Worktree; + workspaceId: string; + parentTabId?: string; // Optional parent group tab ID + selectedTabId?: string; + selectedTabIds: Set; + onTabSelect: (worktreeId: string, tabId: string, shiftKey: boolean) => void; + onTabRemove: (tabId: string) => void; + onGroupTabs: (tabIds: string[]) => void; + onMoveOutOfGroup: (tabId: string, parentTabId: string) => void; + onTabRename: (tabId: string, newName: string) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: tab.id, + data: { + type: "tab", + parentTabId, + worktreeId, + }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + // Create custom drag handlers that don't interfere with button clicks + const handleMouseDown = (e: React.MouseEvent) => { + // Don't start drag if clicking on a button, input, or their children + const target = e.target as HTMLElement; + if ( + target.tagName === "BUTTON" || + target.tagName === "INPUT" || + target.closest("button") || + target.closest("input") + ) { + // Let the click handlers on buttons/inputs work normally + return; + } + // Otherwise, allow drag to start by calling the native handler + // Convert React synthetic event to native event + if (listeners?.onMouseDown) { + const nativeEvent = e.nativeEvent; + listeners.onMouseDown(nativeEvent); + } + }; + + return ( +
| undefined} + > + +
+ ); +} + +// Droppable wrapper for group tabs +function DroppableGroupTab({ + tab, + worktreeId, + workspaceId: _workspaceId, + selectedTabId, + isExpanded, + level, + onToggle, + onTabSelect, + onUngroupTab, + onRenameGroup, +}: { + tab: Tab; + worktreeId: string; + workspaceId: string; + selectedTabId?: string; + isExpanded: boolean; + level: number; + onToggle: (groupTabId: string) => void; + onTabSelect: (worktreeId: string, tabId: string, shiftKey: boolean) => void; + onUngroupTab: (groupTabId: string) => void; + onRenameGroup: (groupTabId: string, newName: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(tab.name); + const inputRef = useRef(null); + + const { setNodeRef, isOver } = useDroppable({ + id: `group-${tab.id}`, + data: { + type: "group", + groupTabId: tab.id, + }, + }); + + // Focus input when entering edit mode + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const isSelected = selectedTabId === tab.id; + + const handleClick = (e: React.MouseEvent) => { + if (!isEditing) { + onTabSelect(worktreeId, tab.id, e.shiftKey); + onToggle(tab.id); + } + }; + + const handleStartRename = () => { + setEditName(tab.name); + setIsEditing(true); + }; + + const handleSaveRename = () => { + const trimmedName = editName.trim(); + if (trimmedName !== "" && trimmedName !== tab.name) { + onRenameGroup(tab.id, trimmedName); + } + setIsEditing(false); + }; + + const handleCancelRename = () => { + setEditName(tab.name); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancelRename(); + } + }; + + return ( +
+ + + + + + + + Rename + + onUngroupTab(tab.id)}> + + Ungroup Tabs + + + +
+ ); +} + +// Droppable area wrapper for the expanded group tab content +function DroppableGroupArea({ + groupTabId, + children, +}: { + groupTabId: string; + children: React.ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: `group-area-${groupTabId}`, + data: { + type: "group-area", + groupTabId, + }, + }); + + return ( +
+ {children} + {isOver && ( +
+ Drop here to add to group +
+ )} +
+ ); +} + +// Droppable area wrapper for worktree level tabs (to drag tabs out of groups) +function DroppableWorktreeArea({ + children, +}: { + children: React.ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: "worktree-tabs", + data: { + type: "worktree", + }, + }); + + return ( +
+ {children} + {isOver && ( +
+ Drop here to move out of group +
+ )} +
+ ); +} + +interface WorktreeItemProps { + worktree: Worktree; + workspaceId: string; + activeWorktreeId: string | null; + onTabSelect: (worktreeId: string, tabId: string) => void; + onReload: () => void; + onUpdateWorktree: (updatedWorktree: Worktree) => void; + selectedTabId: string | undefined; + hasPortForwarding?: boolean; + onCloneWorktree: () => void; +} + +export function WorktreeItem({ + worktree, + workspaceId, + activeWorktreeId, + onTabSelect, + onReload, + onUpdateWorktree, + selectedTabId, + hasPortForwarding = false, + onCloneWorktree: _onCloneWorktree, +}: WorktreeItemProps) { + // Track expanded group tabs + const [expandedGroupTabs, setExpandedGroupTabs] = useState>( + new Set(), + ); + + // Track multi-selected tabs + const [selectedTabIds, setSelectedTabIds] = useState>(new Set()); + const [lastClickedTabId, setLastClickedTabId] = useState(null); + + // Track if merge is disabled (when this is the active worktree) + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const [isMergeDisabled, setIsMergeDisabled] = useState(false); + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const [mergeDisabledReason, setMergeDisabledReason] = useState(""); + const [targetWorktreeId, setTargetWorktreeId] = useState(""); + const [targetBranch, setTargetBranch] = useState(""); + const [availableWorktrees, setAvailableWorktrees] = useState< + Array<{ id: string; branch: string }> + >([]); + + // Dialog states + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const [showMergeDialog, setShowMergeDialog] = useState(false); + const [showErrorDialog, setShowErrorDialog] = useState(false); + const [showGitStatusDialog, setShowGitStatusDialog] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [errorTitle, setErrorTitle] = useState(""); + const [mergeWarning, setMergeWarning] = useState(""); + const [removeWarning, setRemoveWarning] = useState(""); + + // Track if this worktree is active + const isActive = activeWorktreeId === worktree.id; + + // Generate ID for select element (must be called before conditional return) + const targetBranchSelectId = useId(); + + // Auto-expand group tabs that contain the selected tab + // biome-ignore lint/correctness/useExhaustiveDependencies: findParentGroupTab is stable + useEffect(() => { + if (!selectedTabId) return; + + const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; + const parentGroupTab = findParentGroupTab(tabs, selectedTabId); + + if (parentGroupTab) { + setExpandedGroupTabs((prev) => { + const next = new Set(prev); + next.add(parentGroupTab.id); + return next; + }); + } + }, [selectedTabId, worktree.tabs]); + + // Helper: recursively find a tab by ID + const findTabById = (tabs: Tab[], tabId: string): Tab | null => { + for (const tab of tabs) { + if (tab.id === tabId) return tab; + if (tab.type === "group" && tab.tabs) { + const found = findTabById(tab.tabs, tabId); + if (found) return found; + } + } + return null; + }; + + // Helper: recursively find parent group tab containing a specific tab + const findParentGroupTab = (tabs: Tab[], tabId: string): Tab | null => { + for (const tab of tabs) { + if (tab.type === "group" && tab.tabs) { + if (tab.tabs.some((t) => t.id === tabId)) return tab; + const found = findParentGroupTab(tab.tabs, tabId); + if (found) return found; + } + } + return null; + }; + + // Helper: Remove tab ID from mosaic tree + const removeTabFromMosaicTree = ( + tree: MosaicNode, + tabId: string, + ): MosaicNode | null => { + if (typeof tree === "string") { + // If this is the tab to remove, return null + return tree === tabId ? null : tree; + } + + // Recursively remove from branches + const newFirst = removeTabFromMosaicTree(tree.first, tabId); + const newSecond = removeTabFromMosaicTree(tree.second, tabId); + + // If both branches are gone, return null + if (!newFirst && !newSecond) { + return null; + } + + // If one branch is gone, return the other + if (!newFirst) { + return newSecond; + } + if (!newSecond) { + return newFirst; + } + + // Both branches exist, return the updated tree + return { + ...tree, + first: newFirst, + second: newSecond, + }; + }; + + // Helper: recursively get all tabs as flat array with their parent IDs + const getAllTabs = ( + tabs: Tab[], + parentTabId?: string, + ): Array<{ tab: Tab; parentTabId?: string }> => { + const result: Array<{ tab: Tab; parentTabId?: string }> = []; + for (const tab of tabs) { + result.push({ tab, parentTabId }); + if (tab.type === "group" && tab.tabs) { + result.push(...getAllTabs(tab.tabs, tab.id)); + } + } + return result; + }; + + // Helper: get all non-group tabs at the same level (for shift-click range selection) + const getTabsAtSameLevel = ( + tabs: Tab[], + targetTabId: string, + _parentTabId?: string, + ): Tab[] => { + // Find which level the target tab is at + for (const tab of tabs) { + if (tab.id === targetTabId) { + // Found at current level - return all tabs at this level (excluding groups) + return tabs.filter((t) => t.type !== "group"); + } + if (tab.type === "group" && tab.tabs) { + const found = getTabsAtSameLevel(tab.tabs, targetTabId, tab.id); + if (found.length > 0) return found; + } + } + return []; + }; + + // Handle tab selection with shift-click support + const handleTabSelect = ( + worktreeId: string, + tabId: string, + shiftKey: boolean, + ) => { + if (shiftKey && lastClickedTabId) { + // Shift-click: select range + const tabsAtLevel = getTabsAtSameLevel(tabs, tabId); + const lastIndex = tabsAtLevel.findIndex((t) => t.id === lastClickedTabId); + const currentIndex = tabsAtLevel.findIndex((t) => t.id === tabId); + + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + const rangeTabIds = tabsAtLevel.slice(start, end + 1).map((t) => t.id); + + setSelectedTabIds(new Set(rangeTabIds)); + } + } else { + // Normal click: single selection + setSelectedTabIds(new Set([tabId])); + setLastClickedTabId(tabId); + } + + // Always update the main selected tab + onTabSelect(worktreeId, tabId); + }; + + // Handle grouping selected tabs + const handleGroupTabs = async (tabIds: string[]) => { + try { + // Create a new group tab + const result = await window.ipcRenderer.invoke("tab-create", { + workspaceId, + worktreeId: worktree.id, + name: `Tab Group`, + type: "group", + }); + + if (!result.success || !result.tab) { + console.error("Failed to create group tab:", result.error); + return; + } + + const groupTabId = result.tab.id; + + // Move each selected tab into the group + for (const tabId of tabIds) { + const tab = findTabById(tabs, tabId); + if (!tab || tab.type === "group") continue; // Skip group tabs + + // Use tab-move to move the tab into the group + await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId, + targetParentTabId: groupTabId, + targetIndex: 0, // Add to end + }); + } + + // Reload to show the updated structure + onReload(); + + // Expand the new group tab to show its contents + setExpandedGroupTabs((prev) => new Set(prev).add(groupTabId)); + + // Select the new group tab + onTabSelect(worktree.id, groupTabId); + + // Clear selection + setSelectedTabIds(new Set()); + setLastClickedTabId(null); + } catch (error) { + console.error("Error grouping tabs:", error); + } + }; + + // Handle ungrouping a group tab + const handleUngroupTab = async (groupTabId: string) => { + try { + const groupTab = findTabById(tabs, groupTabId); + if (!groupTab || groupTab.type !== "group" || !groupTab.tabs) { + console.error("Invalid group tab"); + return; + } + + // Move each child tab back to the worktree level + for (const childTab of groupTab.tabs) { + await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: childTab.id, + sourceParentTabId: groupTabId, // Move from the group + targetParentTabId: undefined, // Move to worktree level + targetIndex: 0, // Add to end of worktree tabs + }); + } + + // Delete the now-empty group tab + await window.ipcRenderer.invoke("tab-delete", { + workspaceId, + worktreeId: worktree.id, + tabId: groupTabId, + }); + + // Reload to show the updated structure + onReload(); + } catch (error) { + console.error("Error ungrouping tab:", error); + } + }; + + // Handle renaming a group tab + const handleRenameGroup = async (groupTabId: string, newName: string) => { + try { + const result = await window.ipcRenderer.invoke("tab-update-name", { + workspaceId, + worktreeId: worktree.id, + tabId: groupTabId, + name: newName, + }); + + if (result.success) { + // Optimistically update the local worktree data + const updatedTabs = updateTabNameRecursive( + worktree.tabs, + groupTabId, + newName, + ); + const updatedWorktree = { ...worktree, tabs: updatedTabs }; + onUpdateWorktree(updatedWorktree); + } else { + alert(`Failed to rename group: ${result.error}`); + } + } catch (error) { + console.error("Error renaming group:", error); + alert("Failed to rename group"); + } + }; + + // Handle moving a tab out of its group + const handleMoveOutOfGroup = async (tabId: string, parentTabId: string) => { + try { + const tab = findTabById(tabs, tabId); + const parentTab = findTabById(tabs, parentTabId); + + if (!tab || !parentTab || parentTab.type !== "group") { + console.error("Invalid tab or parent group"); + return; + } + + // Move the tab to worktree level + const moveResult = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId, + sourceParentTabId: parentTabId, + targetParentTabId: undefined, // Move to worktree level + targetIndex: tabs.length, // Add to end of worktree tabs + }); + + if (!moveResult.success) { + console.error("Failed to move tab out of group:", moveResult.error); + onReload(); + return; + } + + // Update the parent group's mosaic tree to remove this tab + if (parentTab.mosaicTree) { + const updatedMosaicTree = removeTabFromMosaicTree( + parentTab.mosaicTree, + tabId, + ); + + await window.ipcRenderer.invoke("tab-update-mosaic-tree", { + workspaceId, + worktreeId: worktree.id, + tabId: parentTabId, + mosaicTree: updatedMosaicTree, + }); + } + + // Reload to show the updated structure + // Note: Backend automatically cleans up empty groups via cleanupEmptyGroupsInWorktree() + onReload(); + + // Select the moved tab + onTabSelect(worktree.id, tabId); + } catch (error) { + console.error("Error moving tab out of group:", error); + } + }; + + // Load available worktrees on mount + useEffect(() => { + const loadWorktrees = async () => { + // Get the workspace to find available worktrees + const workspace = await window.ipcRenderer.invoke( + "workspace-get", + workspaceId, + ); + + if (workspace) { + // Get all worktrees except the current one + const otherWorktrees = workspace.worktrees + .filter((wt: { id: string }) => wt.id !== worktree.id) + .map((wt: { id: string; branch: string }) => ({ + id: wt.id, + branch: wt.branch, + })); + setAvailableWorktrees(otherWorktrees); + + // Disable merge only if there are no other worktrees to merge into + if (otherWorktrees.length === 0) { + setIsMergeDisabled(true); + setMergeDisabledReason("No other worktrees available"); + } else { + setIsMergeDisabled(false); + setMergeDisabledReason(""); + + // Set default target to active worktree if it exists and is not this worktree + const activeWorktree = workspace.worktrees.find( + (wt: { id: string }) => wt.id === workspace.activeWorktreeId, + ); + if (activeWorktree && activeWorktree.id !== worktree.id) { + setTargetWorktreeId(activeWorktree.id); + setTargetBranch(activeWorktree.branch); + } else if (otherWorktrees.length > 0) { + // If active worktree is this worktree, default to first available worktree + setTargetWorktreeId(otherWorktrees[0].id); + setTargetBranch(otherWorktrees[0].branch); + } + } + } + }; + + loadWorktrees(); + }, [workspaceId, worktree.id]); + + // Only render tabs for the active worktree + if (!isActive) { + return null; + } + + // Context menu handlers (unused but kept for potential future use) + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const handleCopyPath = async () => { + const path = await window.ipcRenderer.invoke("worktree-get-path", { + workspaceId, + worktreeId: worktree.id, + }); + if (path) { + navigator.clipboard.writeText(path); + } + }; + + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const handleRemoveWorktree = async () => { + // Check if the worktree has uncommitted changes + const canRemoveResult = await window.ipcRenderer.invoke( + "worktree-can-remove", + { + workspaceId, + worktreeId: worktree.id, + }, + ); + + // Build warning message if there are uncommitted changes + let warning = ""; + if (canRemoveResult.hasUncommittedChanges) { + warning = `Warning: This worktree (${worktree.branch}) has uncommitted changes. Removing it will delete these changes permanently.`; + } + + setRemoveWarning(warning); + setShowRemoveDialog(true); + }; + + const confirmRemoveWorktree = async () => { + setShowRemoveDialog(false); + setRemoveWarning(""); + + const result = await window.ipcRenderer.invoke("worktree-remove", { + workspaceId, + worktreeId: worktree.id, + }); + + if (result.success) { + // Backend removes from config first, then git worktree in background + // This provides immediate UI feedback + onReload(); + } else { + setErrorTitle("Failed to Remove Worktree"); + setErrorMessage( + result.error || + "An unknown error occurred while removing the worktree.", + ); + setShowErrorDialog(true); + } + }; + + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const handleMergeWorktree = async () => { + // Check if can merge with the selected target + const canMergeResult = await window.ipcRenderer.invoke( + "worktree-can-merge", + { + workspaceId, + worktreeId: worktree.id, + targetWorktreeId: targetWorktreeId || undefined, + }, + ); + + if (!canMergeResult.canMerge) { + setErrorTitle("Cannot Merge"); + setErrorMessage(canMergeResult.reason || "Unknown error"); + setShowErrorDialog(true); + return; + } + + // Build warning message if there are uncommitted changes + let warning = ""; + const warnings = []; + + if (canMergeResult.targetHasUncommittedChanges) { + const targetBranchText = targetBranch ? ` (${targetBranch})` : ""; + warnings.push( + `The target worktree${targetBranchText} has uncommitted changes.`, + ); + } + + if (canMergeResult.sourceHasUncommittedChanges) { + warnings.push( + `The source worktree (${worktree.branch}) has uncommitted changes.`, + ); + } + + if (warnings.length > 0) { + warning = `Warning: ${warnings.join(" ")} The merge will proceed anyway.`; + } + + setMergeWarning(warning); + setShowMergeDialog(true); + }; + + // Handler for when target worktree changes + const handleTargetWorktreeChange = async (newTargetId: string) => { + setTargetWorktreeId(newTargetId); + + // Update target branch display + const targetWorktree = availableWorktrees.find( + (wt) => wt.id === newTargetId, + ); + if (targetWorktree) { + setTargetBranch(targetWorktree.branch); + } + + // Re-check merge status with new target + const canMergeResult = await window.ipcRenderer.invoke( + "worktree-can-merge", + { + workspaceId, + worktreeId: worktree.id, + targetWorktreeId: newTargetId, + }, + ); + + // Update warning message + let warning = ""; + const warnings = []; + + if (canMergeResult.targetHasUncommittedChanges) { + const targetBranchText = targetWorktree?.branch + ? ` (${targetWorktree.branch})` + : ""; + warnings.push( + `The target worktree${targetBranchText} has uncommitted changes.`, + ); + } + + if (canMergeResult.sourceHasUncommittedChanges) { + warnings.push( + `The source worktree (${worktree.branch}) has uncommitted changes.`, + ); + } + + if (warnings.length > 0) { + warning = `Warning: ${warnings.join(" ")} The merge will proceed anyway.`; + } + + setMergeWarning(warning); + }; + + const confirmMergeWorktree = async () => { + setShowMergeDialog(false); + setMergeWarning(""); + + const result = await window.ipcRenderer.invoke("worktree-merge", { + workspaceId, + worktreeId: worktree.id, + targetWorktreeId: targetWorktreeId || undefined, + }); + + if (result.success) { + onReload(); + } else { + setErrorTitle("Failed to Merge"); + setErrorMessage( + result.error || "An unknown error occurred while merging the worktree.", + ); + setShowErrorDialog(true); + } + }; + + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const handleOpenInCursor = async () => { + const path = await window.ipcRenderer.invoke("worktree-get-path", { + workspaceId, + worktreeId: worktree.id, + }); + if (path) { + // Use Cursor's deeplink protocol: cursor://file/{path} + await window.ipcRenderer.invoke("open-external", `cursor://file/${path}`); + } + }; + + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const handleOpenSettings = async () => { + // First, check if settings folder exists + const checkResult = await window.ipcRenderer.invoke( + "worktree-check-settings", + { + workspaceId, + worktreeId: worktree.id, + }, + ); + + if (!checkResult.success) { + setErrorTitle("Failed to Check Settings"); + setErrorMessage( + checkResult.error || + "An unknown error occurred while checking settings.", + ); + setShowErrorDialog(true); + return; + } + + // Open (and create if needed) + const result = await window.ipcRenderer.invoke("worktree-open-settings", { + workspaceId, + worktreeId: worktree.id, + createIfMissing: true, + }); + + if (result.success && result.created) { + console.log(".superset folder created and opened in Cursor"); + } else if (!result.success) { + setErrorTitle("Failed to Open Settings"); + setErrorMessage( + result.error || "An unknown error occurred while opening settings.", + ); + setShowErrorDialog(true); + } + }; + + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const handleAddTab = async () => { + try { + const result = await window.ipcRenderer.invoke("tab-create", { + workspaceId, + worktreeId: worktree.id, + // No parentTabId - create at worktree level + name: "New Terminal", + type: "terminal", + }); + + if (result.success) { + const newTabId = result.tab?.id; + // Auto-select the new tab first (before reload) + if (newTabId) { + handleTabSelect(worktree.id, newTabId, false); + } + onReload(); + } else { + console.error("Failed to create tab:", result.error); + } + } catch (error) { + console.error("Error creating tab:", error); + } + }; + + // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use + const handleAddPreview = async () => { + try { + const previewTabs = Array.isArray(worktree.tabs) + ? worktree.tabs.filter((tab) => tab.type === "preview") + : []; + const previewIndex = previewTabs.length + 1; + + const detectedPorts = worktree.detectedPorts || {}; + const portEntries = Object.entries(detectedPorts); + + let initialUrl: string | undefined; + let previewLabel = + previewIndex > 1 ? `Preview ${previewIndex}` : "Preview"; + + if (portEntries.length > 0) { + const [service, port] = portEntries[0]; + + try { + const status = (await window.ipcRenderer.invoke( + "proxy-get-status", + )) as ProxyStatus[]; + const activeProxies = (status || []).filter( + (item) => item.active && typeof item.target === "number", + ); + const proxyMap = new Map( + activeProxies.map((item) => [ + item.target as number, + item.canonical, + ]), + ); + + const canonicalPort = proxyMap.get(port); + const resolvedPort = canonicalPort ?? port; + initialUrl = `http://localhost:${resolvedPort}`; + } catch (error) { + console.error("Failed to determine proxied port:", error); + initialUrl = `http://localhost:${port}`; + } + + if (service) { + previewLabel = + previewIndex > 1 + ? `Preview ${previewIndex} – ${service}` + : `Preview – ${service}`; + } + } + + const result = await window.ipcRenderer.invoke("tab-create", { + workspaceId, + worktreeId: worktree.id, + name: previewLabel, + type: "preview", + url: initialUrl, + }); + + if (result.success) { + const newTabId = result.tab?.id; + if (newTabId) { + handleTabSelect(worktree.id, newTabId, false); + } + onReload(); + } else { + console.error("Failed to create preview tab:", result.error); + } + } catch (error) { + console.error("Error creating preview tab:", error); + } + }; + + const handleTabRemove = async (tabId: string) => { + try { + const result = await window.ipcRenderer.invoke("tab-delete", { + workspaceId, + worktreeId: worktree.id, + tabId, + }); + + if (result.success) { + // Backend automatically cleans up empty groups via cleanupEmptyGroupsInWorktree() + onReload(); // Refresh the workspace to show the updated tab list + } else { + console.error("Failed to delete tab:", result.error); + } + } catch (error) { + console.error("Error deleting tab:", error); + } + }; + + const handleTabRename = async (tabId: string, newName: string) => { + try { + const result = await window.ipcRenderer.invoke("tab-update-name", { + workspaceId, + worktreeId: worktree.id, + tabId, + name: newName, + }); + + if (result.success) { + // Optimistically update the local worktree data + const updatedTabs = updateTabNameRecursive( + worktree.tabs, + tabId, + newName, + ); + const updatedWorktree = { ...worktree, tabs: updatedTabs }; + onUpdateWorktree(updatedWorktree); + } else { + alert(`Failed to rename tab: ${result.error}`); + } + } catch (error) { + console.error("Error renaming tab:", error); + alert("Failed to rename tab"); + } + }; + + // Helper to recursively update tab name + const updateTabNameRecursive = ( + tabs: Tab[], + tabId: string, + newName: string, + ): Tab[] => { + return tabs.map((tab) => { + if (tab.id === tabId) { + return { ...tab, name: newName }; + } + if (tab.type === "group" && tab.tabs) { + return { + ...tab, + tabs: updateTabNameRecursive(tab.tabs, tabId, newName), + }; + } + return tab; + }); + }; + + // Get all tabs for sortable context (including nested) + // Defensive: ensure worktree.tabs exists and is an array + const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; + + // Handle drag end - move tab to group, out of group, or reorder + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + + if (!over) return; + + const draggedTabId = active.id as string; + const draggedTab = findTabById(tabs, draggedTabId); + + if (!draggedTab || draggedTab.type === "group") { + // Don't allow dragging group tabs + return; + } + + const overId = over.id as string; + const overData = over.data.current; + + // Find source parent + const sourceParent = findParentGroupTab(tabs, draggedTabId); + const sourceParentTabId = sourceParent?.id; + + // Check if dropped on worktree level (to move out of group) + if (overId === "worktree-tabs" || overData?.type === "worktree") { + // Only move if currently in a group + if (sourceParentTabId) { + try { + // Find target index (end of worktree tabs) + const targetIndex = tabs.length; + + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: draggedTabId, + sourceParentTabId: sourceParentTabId, + targetParentTabId: undefined, // Move to worktree level + targetIndex, + }); + + if (result.success) { + onReload(); + onTabSelect(worktree.id, draggedTabId); + } else { + console.error("Failed to move tab out of group:", result.error); + } + } catch (error) { + console.error("Error moving tab out of group:", error); + } + } + return; + } + + // Check if dropped on a group tab header + if (overId.startsWith("group-") && overData?.type === "group") { + const targetGroupId = overData.groupTabId as string; + + // Don't move if already in this group + if (sourceParentTabId === targetGroupId) { + return; + } + + // Expand the target group + setExpandedGroupTabs((prev) => new Set(prev).add(targetGroupId)); + + // Move tab into the group + try { + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: draggedTabId, + sourceParentTabId: sourceParentTabId || undefined, + targetParentTabId: targetGroupId, + targetIndex: 0, // Add to end of group + }); + + if (result.success) { + onReload(); + onTabSelect(worktree.id, draggedTabId); + } else { + console.error("Failed to move tab to group:", result.error); + } + } catch (error) { + console.error("Error moving tab to group:", error); + } + return; + } + + // Check if dropped on a group area (expanded group content) + if ( + overId.startsWith("group-area-") && + overData?.type === "group-area" + ) { + const targetGroupId = overData.groupTabId as string; + + // Don't move if already in this group + if (sourceParentTabId === targetGroupId) { + return; + } + + // Move tab into the group + try { + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: draggedTabId, + sourceParentTabId: sourceParentTabId || undefined, + targetParentTabId: targetGroupId, + targetIndex: 0, // Add to end of group + }); + + if (result.success) { + onReload(); + onTabSelect(worktree.id, draggedTabId); + } else { + console.error("Failed to move tab to group:", result.error); + } + } catch (error) { + console.error("Error moving tab to group:", error); + } + return; + } + + // Check if dropped on another tab (for reordering) + // This could be within the same parent or moving between parents + const overTabId = overId; + const overTab = findTabById(tabs, overTabId); + + if (overTab && overTab.type !== "group") { + // Find the parent of the tab we're dropping on + const targetParent = findParentGroupTab(tabs, overTabId); + const targetParentTabId = targetParent?.id; + + // If moving to a different parent, use tab-move + if (sourceParentTabId !== targetParentTabId) { + // Find target index + const targetTabs = targetParentTabId + ? targetParent.tabs || [] + : tabs; + const targetIndex = targetTabs.findIndex((t) => t.id === overTabId); + + try { + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: draggedTabId, + sourceParentTabId: sourceParentTabId || undefined, + targetParentTabId: targetParentTabId || undefined, + targetIndex: targetIndex >= 0 ? targetIndex : 0, + }); + + if (result.success) { + onReload(); + onTabSelect(worktree.id, draggedTabId); + } else { + console.error("Failed to move tab:", result.error); + } + } catch (error) { + console.error("Error moving tab:", error); + } + return; + } + + // Same parent - handle reordering + const parentTabs = sourceParentTabId + ? sourceParent.tabs || [] + : tabs; + + // Get current order + const currentOrder = parentTabs.map((t) => t.id); + const draggedIndex = currentOrder.indexOf(draggedTabId); + const targetIndex = currentOrder.indexOf(overTabId); + + if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) { + // Reorder + const newOrder = [...currentOrder]; + newOrder.splice(draggedIndex, 1); + newOrder.splice(targetIndex, 0, draggedTabId); + + try { + const result = await window.ipcRenderer.invoke("tab-reorder", { + workspaceId, + worktreeId: worktree.id, + parentTabId: sourceParentTabId || undefined, + tabIds: newOrder, + }); + + if (result.success) { + onReload(); + } else { + console.error("Failed to reorder tabs:", result.error); + } + } catch (error) { + console.error("Error reordering tabs:", error); + } + } + } + }; + + // Toggle group tab expansion + const toggleGroupTab = (groupTabId: string) => { + setExpandedGroupTabs((prev) => { + const next = new Set(prev); + if (next.has(groupTabId)) { + next.delete(groupTabId); + } else { + next.add(groupTabId); + } + return next; + }); + }; + + // Render a single tab or group tab with nesting + const renderTab = (tab: Tab, parentTabId?: string, level = 0) => { + if (tab.type === "group") { + const isExpanded = expandedGroupTabs.has(tab.id); + return ( +
+ {/* Group Tab Header */} + + + {/* Nested Tabs - Make the entire area droppable with SortableContext */} + {isExpanded && tab.tabs && ( + + t.id)} + strategy={verticalListSortingStrategy} + > +
+ {tab.tabs.map((childTab) => + renderTab(childTab, tab.id, level + 1), + )} +
+
+
+ )} +
+ ); + } + + // Regular tab (terminal, editor, etc.) + return ( +
+ +
+ ); + }; + + return ( +
+ {/* Ports List - shown inline if port forwarding is configured */} + {hasPortForwarding && ( + + )} + + {/* Tabs List */} +
+ {/* Render tabs with collapsible groups */} + + {/* Droppable area for worktree level (to drag tabs out of groups) */} + + t.id)} + strategy={verticalListSortingStrategy} + > + {tabs.map((tab) => renderTab(tab, undefined, 0))} + + + +
+ + {/* Remove Worktree Confirmation Dialog */} + + + + Remove Worktree + + Are you sure you want to remove the worktree "{worktree.branch}"? + This action cannot be undone. + + + + {/* Warning Message */} + {removeWarning && ( +
+ {removeWarning} +
+ )} + + + + + +
+
+ + {/* Merge Worktree Confirmation Dialog */} + + + + Merge Worktree + + Merge "{worktree.branch}" into the selected target branch. + + + + {/* Target Branch Selector */} +
+ + +
+ + {/* Warning Message */} + {mergeWarning && ( +
+ {mergeWarning} +
+ )} + + + + + +
+
+ + {/* Error Dialog */} + + + + {errorTitle} + +
+ {errorMessage} +
+
+
+ + + +
+
+ + {/* Git Status Dialog */} + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx index 739ea5859ce..6bffcccc3fe 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx @@ -1,14 +1,3 @@ -import { - DndContext, - type DragEndEvent, - useDroppable, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import { Button } from "@superset/ui/button"; import { ContextMenu, @@ -16,12 +5,10 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { - ChevronRight, - Edit2, - FolderOpen, -} from "lucide-react"; -import { useEffect, useId, useRef, useState } from "react"; +import { ChevronRight, Edit2, FolderOpen } from "lucide-react"; +import { useEffect, useId, useMemo, useState } from "react"; +import type { NodeApi, TreeApi } from "react-arborist"; +import { Tree } from "react-arborist"; import type { MosaicNode } from "react-mosaic-component"; import { Dialog, @@ -43,246 +30,31 @@ interface ProxyStatus { active: boolean; } -// Sortable wrapper for tabs -function SortableTab({ - tab, - worktreeId, - worktree, - workspaceId, - parentTabId, - selectedTabId, - selectedTabIds, - onTabSelect, - onTabRemove, - onGroupTabs, - onMoveOutOfGroup, - onTabRename, -}: { +// Convert Tab[] to react-arborist format +function convertTabsToTreeData( + tabs: Tab[], +): Array<{ + id: string; + name: string; tab: Tab; - worktreeId: string; - worktree: Worktree; - workspaceId: string; - parentTabId?: string; // Optional parent group tab ID - selectedTabId?: string; - selectedTabIds: Set; - onTabSelect: (worktreeId: string, tabId: string, shiftKey: boolean) => void; - onTabRemove: (tabId: string) => void; - onGroupTabs: (tabIds: string[]) => void; - onMoveOutOfGroup: (tabId: string, parentTabId: string) => void; - onTabRename: (tabId: string, newName: string) => void; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: tab.id, - data: { - type: "tab", - parentTabId, - worktreeId, - }, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- -
- ); -} - -// Droppable wrapper for group tabs -function DroppableGroupTab({ - tab, - worktreeId, - workspaceId: _workspaceId, - selectedTabId, - isExpanded, - level, - onToggle, - onTabSelect, - onUngroupTab, - onRenameGroup, -}: { - tab: Tab; - worktreeId: string; - workspaceId: string; - selectedTabId?: string; - isExpanded: boolean; - level: number; - onToggle: (groupTabId: string) => void; - onTabSelect: (worktreeId: string, tabId: string, shiftKey: boolean) => void; - onUngroupTab: (groupTabId: string) => void; - onRenameGroup: (groupTabId: string, newName: string) => void; -}) { - const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(tab.name); - const inputRef = useRef(null); - - const { setNodeRef, isOver } = useDroppable({ - id: `group-${tab.id}`, - data: { - type: "group", - groupTabId: tab.id, - }, - }); - - // Focus input when entering edit mode - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - const isSelected = selectedTabId === tab.id; - - const handleClick = (e: React.MouseEvent) => { - if (!isEditing) { - onTabSelect(worktreeId, tab.id, e.shiftKey); - onToggle(tab.id); - } - }; - - const handleStartRename = () => { - setEditName(tab.name); - setIsEditing(true); - }; - - const handleSaveRename = () => { - const trimmedName = editName.trim(); - if (trimmedName !== "" && trimmedName !== tab.name) { - onRenameGroup(tab.id, trimmedName); - } - setIsEditing(false); - }; - - const handleCancelRename = () => { - setEditName(tab.name); - setIsEditing(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSaveRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - handleCancelRename(); + children?: Array<{ id: string; name: string; tab: Tab }>; +}> { + return tabs.map((tab) => { + const node: { + id: string; + name: string; + tab: Tab; + children?: Array<{ id: string; name: string; tab: Tab }>; + } = { + id: tab.id, + name: tab.name, + tab, + }; + if (tab.type === "group" && tab.tabs) { + node.children = convertTabsToTreeData(tab.tabs); } - }; - - return ( -
- - - - - - - - Rename - - onUngroupTab(tab.id)}> - - Ungroup Tabs - - - -
- ); -} - -// Droppable area wrapper for the expanded group tab content -function DroppableGroupArea({ - groupTabId, - children, -}: { - groupTabId: string; - children: React.ReactNode; -}) { - const { setNodeRef, isOver } = useDroppable({ - id: `group-area-${groupTabId}`, - data: { - type: "group-area", - groupTabId, - }, + return node; }); - - return ( -
- {children} - {isOver && ( -
- Drop here to add to group -
- )} -
- ); } interface WorktreeItemProps { @@ -308,10 +80,23 @@ export function WorktreeItem({ hasPortForwarding = false, onCloneWorktree: _onCloneWorktree, }: WorktreeItemProps) { - // Track expanded group tabs - const [expandedGroupTabs, setExpandedGroupTabs] = useState>( - new Set(), - ); + // Track expanded group tabs - initialize with all group tabs expanded by default + const [expandedGroupTabs, setExpandedGroupTabs] = useState>(() => { + const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; + const groupTabIds = new Set(); + const collectGroupTabs = (tabList: Tab[]) => { + for (const tab of tabList) { + if (tab.type === "group") { + groupTabIds.add(tab.id); + if (tab.tabs) { + collectGroupTabs(tab.tabs); + } + } + } + }; + collectGroupTabs(tabs); + return groupTabIds; + }); // Track multi-selected tabs const [selectedTabIds, setSelectedTabIds] = useState>(new Set()); @@ -420,20 +205,6 @@ export function WorktreeItem({ }; }; - // Helper: recursively get all tabs as flat array with their parent IDs - const getAllTabs = ( - tabs: Tab[], - parentTabId?: string, - ): Array<{ tab: Tab; parentTabId?: string }> => { - const result: Array<{ tab: Tab; parentTabId?: string }> = []; - for (const tab of tabs) { - result.push({ tab, parentTabId }); - if (tab.type === "group" && tab.tabs) { - result.push(...getAllTabs(tab.tabs, tab.id)); - } - } - return result; - }; // Helper: get all non-group tabs at the same level (for shift-click range selection) const getTabsAtSameLevel = ( @@ -696,6 +467,46 @@ export function WorktreeItem({ loadWorktrees(); }, [workspaceId, worktree.id]); + // Get all tabs for sortable context (including nested) + // Defensive: ensure worktree.tabs exists and is an array + const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; + const treeData = convertTabsToTreeData(tabs); + + // Calculate responsive height based on visible items + // Must be before early return to satisfy React hooks rules + const treeHeight = useMemo(() => { + const rowHeight = 28; + const minHeight = 10; + const maxHeight = 600; + + // Count visible nodes (including expanded children) + const countVisibleNodes = ( + nodes: Array<{ + id: string; + name: string; + tab: Tab; + children?: Array<{ id: string; name: string; tab: Tab }>; + }>, + ): number => { + let count = 0; + for (const node of nodes) { + count += 1; // Count the node itself + if ( + node.tab.type === "group" && + node.children && + expandedGroupTabs.has(node.id) + ) { + count += countVisibleNodes(node.children); + } + } + return count; + }; + + const visibleCount = countVisibleNodes(treeData); + const calculatedHeight = visibleCount * rowHeight; + return Math.max(minHeight, Math.min(maxHeight, calculatedHeight)); + }, [treeData, expandedGroupTabs]); + // Only render tabs for the active worktree if (!isActive) { return null; @@ -1083,173 +894,185 @@ export function WorktreeItem({ }); }; - // Get all tabs for sortable context (including nested) - // Defensive: ensure worktree.tabs exists and is an array - const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; - const allTabsFlat = getAllTabs(tabs); - const allTabIds = allTabsFlat.map((item) => item.tab.id); - - // Handle drag end - move tab to group or reorder - const handleDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; - - if (!over) return; - - const draggedTabId = active.id as string; - const draggedTab = findTabById(tabs, draggedTabId); - - if (!draggedTab || draggedTab.type === "group") { - // Don't allow dragging group tabs - return; - } - - const overId = over.id as string; - const overData = over.data.current; - - // Check if dropped on a group tab header - if (overId.startsWith("group-") && overData?.type === "group") { - const targetGroupId = overData.groupTabId as string; - - // Find source parent - const sourceParent = findParentGroupTab(tabs, draggedTabId); - const sourceParentTabId = sourceParent?.id; - - // Don't move if already in this group - if (sourceParentTabId === targetGroupId) { - return; - } - - // Expand the target group - setExpandedGroupTabs((prev) => new Set(prev).add(targetGroupId)); - - // Move tab into the group + // Handle drag and drop (move) using react-arborist + const handleMove = async (args: { + dragIds: string[]; + dragNodes: NodeApi<{ + id: string; + name: string; + tab: Tab; + children?: Array<{ id: string; name: string; tab: Tab }>; + }>[]; + parentId: string | null; + parentNode: NodeApi<{ + id: string; + name: string; + tab: Tab; + children?: Array<{ id: string; name: string; tab: Tab }>; + }> | null; + index: number; + }) => { + if (args.dragNodes.length === 0) return; + + const draggedNode = args.dragNodes[0]; + const draggedTab = draggedNode.data.tab as Tab; + + // Don't allow dragging group tabs + if (!draggedTab || draggedTab.type === "group") return; + + const draggedTabId = draggedTab.id; + const sourceParent = draggedNode.parent; + const sourceParentTabId = + sourceParent?.data.tab?.type === "group" ? sourceParent.id : null; + const targetParentTabId = + args.parentNode?.data.tab?.type === "group" ? args.parentNode.id : null; + + // If moving to a different parent, use tab-move + if (sourceParentTabId !== targetParentTabId) { try { const result = await window.ipcRenderer.invoke("tab-move", { workspaceId, worktreeId: worktree.id, tabId: draggedTabId, sourceParentTabId: sourceParentTabId || undefined, - targetParentTabId: targetGroupId, - targetIndex: 0, // Add to end of group + targetParentTabId: targetParentTabId || undefined, + targetIndex: args.index, }); if (result.success) { onReload(); - // Select the moved tab onTabSelect(worktree.id, draggedTabId); } else { - console.error("Failed to move tab to group:", result.error); + console.error("Failed to move tab:", result.error); } } catch (error) { - console.error("Error moving tab to group:", error); + console.error("Error moving tab:", error); } return; } - // Check if dropped on a group area (expanded group content) - if ( - overId.startsWith("group-area-") && - overData?.type === "group-area" - ) { - const targetGroupId = overData.groupTabId as string; + // Same parent - handle reordering + const parentTabs = sourceParentTabId + ? (sourceParent?.data.tab as Tab).tabs || [] + : tabs; - // Find source parent - const sourceParent = findParentGroupTab(tabs, draggedTabId); - const sourceParentTabId = sourceParent?.id; + if (!parentTabs || parentTabs.length === 0) return; - // Don't move if already in this group - if (sourceParentTabId === targetGroupId) { - return; - } + // Get current order + const currentOrder = parentTabs.map((t) => t.id); + const draggedIndex = currentOrder.indexOf(draggedTabId); + const targetIndex = args.index; + + if ( + draggedIndex !== -1 && + targetIndex !== -1 && + draggedIndex !== targetIndex + ) { + // Reorder + const newOrder = [...currentOrder]; + newOrder.splice(draggedIndex, 1); + newOrder.splice(targetIndex, 0, draggedTabId); - // Move tab into the group try { - const result = await window.ipcRenderer.invoke("tab-move", { + const result = await window.ipcRenderer.invoke("tab-reorder", { workspaceId, worktreeId: worktree.id, - tabId: draggedTabId, - sourceParentTabId: sourceParentTabId || undefined, - targetParentTabId: targetGroupId, - targetIndex: 0, // Add to end of group + parentTabId: sourceParentTabId || undefined, + tabIds: newOrder, }); if (result.success) { onReload(); - // Select the moved tab - onTabSelect(worktree.id, draggedTabId); } else { - console.error("Failed to move tab to group:", result.error); + console.error("Failed to reorder tabs:", result.error); } } catch (error) { - console.error("Error moving tab to group:", error); + console.error("Error reordering tabs:", error); } - return; } - - // Handle reordering within the same parent (if dropped on another tab) - // This is handled by SortableContext's built-in reordering - // We could add custom reordering logic here if needed - }; - - // Toggle group tab expansion - const toggleGroupTab = (groupTabId: string) => { - setExpandedGroupTabs((prev) => { - const next = new Set(prev); - if (next.has(groupTabId)) { - next.delete(groupTabId); - } else { - next.add(groupTabId); - } - return next; - }); }; - // Render a single tab or group tab with nesting - const renderTab = (tab: Tab, parentTabId?: string, level = 0) => { - if (tab.type === "group") { - const isExpanded = expandedGroupTabs.has(tab.id); + // Render node content for react-arborist Tree + const renderNode = (props: { + node: NodeApi<{ + id: string; + name: string; + tab: Tab; + children?: Array<{ id: string; name: string; tab: Tab }>; + }>; + style: React.CSSProperties; + tree: TreeApi<{ + id: string; + name: string; + tab: Tab; + children?: Array<{ id: string; name: string; tab: Tab }>; + }>; + dragHandle?: (el: HTMLDivElement | null) => void; + preview?: boolean; + }) => { + const { node, style, dragHandle } = props; + const tab = node.data.tab as Tab; + const isGroup = tab.type === "group"; + const isSelected = selectedTabId === tab.id; + const isExpanded = node.isOpen; + + if (isGroup) { return ( -
- {/* Group Tab Header */} - - - {/* Nested Tabs - Make the entire area droppable */} - {isExpanded && tab.tabs && ( - -
- {tab.tabs.map((childTab) => - renderTab(childTab, tab.id, level + 1), - )} -
-
- )} +
+ + + + + + handleRenameGroup(tab.id, tab.name)} + > + + Rename + + handleUngroupTab(tab.id)}> + + Ungroup Tabs + + +
); } - // Regular tab (terminal, editor, etc.) + // Regular tab - attach drag handle for dragging return ( -
- + { + handleTabSelect(wtId, tabId, shiftKey); + }} onTabRemove={handleTabRemove} onGroupTabs={handleGroupTabs} onMoveOutOfGroup={handleMoveOutOfGroup} @@ -1268,15 +1091,47 @@ export function WorktreeItem({ {/* Tabs List */}
- {/* Render tabs with collapsible groups */} - - - {tabs.map((tab) => renderTab(tab, undefined, 0))} - - + { + if (nodes.length > 0) { + const node = nodes[0]; + handleTabSelect(worktree.id, node.id, false); + } + }} + onToggle={(id) => { + const node = treeData.find((item) => item.id === id); + if (node && node.tab.type === "group") { + setExpandedGroupTabs((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + }} + openByDefault={true} + initialOpenState={Object.fromEntries( + treeData + .filter((item) => item.tab.type === "group") + .map((item) => [item.id, true]), + )} + rowHeight={28} + indent={12} + disableDrag={(node) => { + // Disable dragging for group tabs + const tab = node.tab as Tab; + return tab.type === "group"; + }} + > + {renderNode} +
{/* Remove Worktree Confirmation Dialog */} diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx new file mode 100644 index 00000000000..6f359f5d066 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx @@ -0,0 +1,450 @@ +import { Tree } from "react-arborist"; +import type { NodeApi } from "react-arborist"; +import type { TreeApi } from "react-arborist"; +import { Button } from "@superset/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + ChevronRight, + Edit2, + FolderOpen, +} from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import type { Tab, Worktree } from "shared/types"; +import { WorktreePortsList } from "../WorktreePortsList"; +import { GitStatusDialog } from "./components/GitStatusDialog"; +import { TabItem } from "./components/TabItem"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "renderer/components/ui/dialog"; + +interface WorktreeItemProps { + worktree: Worktree; + workspaceId: string; + activeWorktreeId: string | null; + onTabSelect: (worktreeId: string, tabId: string) => void; + onReload: () => void; + onUpdateWorktree: (updatedWorktree: Worktree) => void; + selectedTabId: string | undefined; + hasPortForwarding?: boolean; + onCloneWorktree: () => void; +} + +// Convert Tab[] to react-arborist format +function convertTabsToTreeData(tabs: Tab[]): Array<{ id: string; name: string; tab: Tab; children?: Array<{ id: string; name: string; tab: Tab }> }> { + return tabs.map((tab) => { + const node: { id: string; name: string; tab: Tab; children?: Array<{ id: string; name: string; tab: Tab }> } = { + id: tab.id, + name: tab.name, + tab, + }; + if (tab.type === "group" && tab.tabs) { + node.children = convertTabsToTreeData(tab.tabs); + } + return node; + }); +} + +// Convert react-arborist data back to Tab[] +function convertTreeDataToTabs(nodes: NodeApi[]): Tab[] { + return nodes.map((node) => { + const tab = node.data.tab as Tab; + if (tab.type === "group" && node.children && node.children.length > 0) { + return { + ...tab, + tabs: convertTreeDataToTabs(node.children), + }; + } + return tab; + }); +} + +export function WorktreeItem({ + worktree, + workspaceId, + activeWorktreeId, + onTabSelect, + onReload, + onUpdateWorktree, + selectedTabId, + hasPortForwarding = false, + onCloneWorktree: _onCloneWorktree, +}: WorktreeItemProps) { + const [expandedGroupTabs, setExpandedGroupTabs] = useState>( + new Set(), + ); + const [selectedTabIds, setSelectedTabIds] = useState>(new Set()); + const [lastClickedTabId, setLastClickedTabId] = useState(null); + + // Dialog states + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const [showMergeDialog, setShowMergeDialog] = useState(false); + const [showErrorDialog, setShowErrorDialog] = useState(false); + const [showGitStatusDialog, setShowGitStatusDialog] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const [errorTitle, setErrorTitle] = useState(""); + const [mergeWarning, setMergeWarning] = useState(""); + const [removeWarning, setRemoveWarning] = useState(""); + + const isActive = activeWorktreeId === worktree.id; + const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; + const treeData = convertTabsToTreeData(tabs); + + // Auto-expand group tabs that contain the selected tab + useEffect(() => { + if (!selectedTabId) return; + const findParentGroup = (tabs: Tab[], tabId: string): Tab | null => { + for (const tab of tabs) { + if (tab.type === "group" && tab.tabs) { + if (tab.tabs.some((t) => t.id === tabId)) return tab; + const found = findParentGroup(tab.tabs, tabId); + if (found) return found; + } + } + return null; + }; + const parentGroup = findParentGroup(tabs, selectedTabId); + if (parentGroup) { + setExpandedGroupTabs((prev) => new Set(prev).add(parentGroup.id)); + } + }, [selectedTabId, tabs]); + + // Handle tab selection + const handleTabSelect = ( + worktreeId: string, + tabId: string, + shiftKey: boolean, + ) => { + if (shiftKey && lastClickedTabId) { + // Shift-click: select range + const allTabs = tabs.flatMap((t) => + t.type === "group" && t.tabs ? t.tabs : [t], + ); + const lastIndex = allTabs.findIndex((t) => t.id === lastClickedTabId); + const currentIndex = allTabs.findIndex((t) => t.id === tabId); + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + const rangeTabIds = allTabs.slice(start, end + 1).map((t) => t.id); + setSelectedTabIds(new Set(rangeTabIds)); + } + } else { + setSelectedTabIds(new Set([tabId])); + setLastClickedTabId(tabId); + } + onTabSelect(worktreeId, tabId); + }; + + // Handle drag and drop (move) + const handleMove = async (args: { + dragIds: string[]; + dragNodes: NodeApi<{ id: string; name: string; tab: Tab; children?: Array<{ id: string; name: string; tab: Tab }> }>[]; + parentId: string | null; + parentNode: NodeApi<{ id: string; name: string; tab: Tab; children?: Array<{ id: string; name: string; tab: Tab }> }> | null; + index: number; + }) => { + if (args.dragNodes.length === 0) return; + + const draggedNode = args.dragNodes[0]; + const draggedTab = draggedNode.data.tab as Tab; + + if (!draggedTab || draggedTab.type === "group") return; + + const draggedTabId = draggedTab.id; + const sourceParent = draggedNode.parent; + const sourceParentTabId = sourceParent?.data.tab?.type === "group" ? sourceParent.id : null; + const targetParentTabId = args.parentNode?.data.tab?.type === "group" ? args.parentNode.id : null; + + // Don't move if already in the same position + if (sourceParentTabId === targetParentTabId) { + return; + } + + try { + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: draggedTabId, + sourceParentTabId: sourceParentTabId || undefined, + targetParentTabId: targetParentTabId || undefined, + targetIndex: args.index, + }); + + if (result.success) { + onReload(); + onTabSelect(worktree.id, draggedTabId); + } else { + console.error("Failed to move tab:", result.error); + } + } catch (error) { + console.error("Error moving tab:", error); + } + }; + + // Handle tab removal + const handleTabRemove = async (tabId: string) => { + try { + const result = await window.ipcRenderer.invoke("tab-delete", { + workspaceId, + worktreeId: worktree.id, + tabId, + }); + + if (result.success) { + onReload(); + } else { + console.error("Failed to delete tab:", result.error); + } + } catch (error) { + console.error("Error deleting tab:", error); + } + }; + + // Handle tab rename + const handleTabRename = async (tabId: string, newName: string) => { + try { + const result = await window.ipcRenderer.invoke("tab-update-name", { + workspaceId, + worktreeId: worktree.id, + tabId, + name: newName, + }); + + if (result.success) { + onReload(); + } else { + alert(`Failed to rename tab: ${result.error}`); + } + } catch (error) { + console.error("Error renaming tab:", error); + alert("Failed to rename tab"); + } + }; + + // Handle group rename + const handleRenameGroup = async (groupTabId: string, newName: string) => { + await handleTabRename(groupTabId, newName); + }; + + // Handle ungroup + const handleUngroupTab = async (groupTabId: string) => { + const groupTab = tabs.find((t) => t.id === groupTabId); + if (!groupTab || groupTab.type !== "group" || !groupTab.tabs) return; + + for (const childTab of groupTab.tabs) { + await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId: childTab.id, + sourceParentTabId: groupTabId, + targetParentTabId: undefined, + targetIndex: tabs.length, + }); + } + + await window.ipcRenderer.invoke("tab-delete", { + workspaceId, + worktreeId: worktree.id, + tabId: groupTabId, + }); + + onReload(); + }; + + // Handle grouping selected tabs + const handleGroupTabs = async (tabIds: string[]) => { + try { + const result = await window.ipcRenderer.invoke("tab-create", { + workspaceId, + worktreeId: worktree.id, + name: `Tab Group`, + type: "group", + }); + + if (!result.success || !result.tab) { + console.error("Failed to create group tab:", result.error); + return; + } + + const groupTabId = result.tab.id; + + for (const tabId of tabIds) { + await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId, + targetParentTabId: groupTabId, + targetIndex: 0, + }); + } + + onReload(); + setExpandedGroupTabs((prev) => new Set(prev).add(groupTabId)); + onTabSelect(worktree.id, groupTabId); + setSelectedTabIds(new Set()); + setLastClickedTabId(null); + } catch (error) { + console.error("Error grouping tabs:", error); + } + }; + + // Handle moving tab out of group + const handleMoveOutOfGroup = async (tabId: string, parentTabId: string) => { + try { + const result = await window.ipcRenderer.invoke("tab-move", { + workspaceId, + worktreeId: worktree.id, + tabId, + sourceParentTabId: parentTabId, + targetParentTabId: undefined, + targetIndex: tabs.length, + }); + + if (result.success) { + onReload(); + onTabSelect(worktree.id, tabId); + } else { + console.error("Failed to move tab out of group:", result.error); + } + } catch (error) { + console.error("Error moving tab out of group:", error); + } + }; + + if (!isActive) { + return null; + } + + // Render node content + const renderNode = (props: { + node: NodeApi<{ id: string; name: string; tab: Tab; children?: Array<{ id: string; name: string; tab: Tab }> }>; + style: React.CSSProperties; + tree: TreeApi<{ id: string; name: string; tab: Tab; children?: Array<{ id: string; name: string; tab: Tab }> }>; + dragHandle?: (el: HTMLDivElement | null) => void; + preview?: boolean; + }) => { + const { node, style } = props; + const tab = node.data.tab as Tab; + const isGroup = tab.type === "group"; + const isSelected = selectedTabId === tab.id; + const isExpanded = node.isOpen; + + if (isGroup) { + return ( +
+ + + + + + handleRenameGroup(tab.id, tab.name)}> + + Rename + + handleUngroupTab(tab.id)}> + + Ungroup Tabs + + + +
+ ); + } + + return ( +
+ { + handleTabSelect(wtId, tabId, shiftKey); + }} + onTabRemove={handleTabRemove} + onGroupTabs={handleGroupTabs} + onMoveOutOfGroup={handleMoveOutOfGroup} + onTabRename={handleTabRename} + /> +
+ ); + }; + + return ( +
+ {hasPortForwarding && ( + + )} + +
+ { + if (nodes.length > 0) { + const node = nodes[0]; + handleTabSelect(worktree.id, node.id, false); + } + }} + onToggle={(id) => { + const node = treeData.find((item) => item.id === id); + if (node && node.tab.type === "group") { + setExpandedGroupTabs((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + }} + openByDefault={false} + initialOpenState={Object.fromEntries( + treeData + .filter((item) => item.tab.type === "group" && expandedGroupTabs.has(item.id)) + .map((item) => [item.id, true]) + )} + > + {renderNode} + +
+ + {/* Dialogs remain the same - keeping them for now */} +
+ ); +} + diff --git a/bun.lock b/bun.lock index 0078f7c349d..65ec080782f 100644 --- a/bun.lock +++ b/bun.lock @@ -63,6 +63,7 @@ "lucide-react": "^0.553.0", "node-pty": "1.1.0-beta30", "react": "^19.1.1", + "react-arborist": "^3.4.3", "react-dom": "^19.1.1", "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", @@ -799,11 +800,11 @@ "@react-aria/utils": ["@react-aria/utils@3.31.0", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", "@react-stately/utils": "^3.10.8", "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig=="], - "@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + "@react-dnd/asap": ["@react-dnd/asap@4.0.1", "", {}, "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="], - "@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + "@react-dnd/invariant": ["@react-dnd/invariant@2.0.0", "", {}, "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="], - "@react-dnd/shallowequal": ["@react-dnd/shallowequal@4.0.2", "", {}, "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="], + "@react-dnd/shallowequal": ["@react-dnd/shallowequal@2.0.0", "", {}, "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="], "@react-stately/flags": ["@react-stately/flags@3.1.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg=="], @@ -1537,7 +1538,7 @@ "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], - "dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + "dnd-core": ["dnd-core@14.0.1", "", { "dependencies": { "@react-dnd/asap": "^4.0.0", "@react-dnd/invariant": "^2.0.0", "redux": "^4.1.1" } }, "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A=="], "dnd-multi-backend": ["dnd-multi-backend@8.1.2", "", { "peerDependencies": { "dnd-core": "^16.0.1" } }, "sha512-KPDVEsiM+6gNEegqZYTWJQgJxYV4vB91tUrvoKJjaS0wwWqT/jNU0P7xJAwCue/cbasJNvk2dFZH7tC+bjX1Rg=="], @@ -2107,6 +2108,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -2423,11 +2426,13 @@ "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react-arborist": ["react-arborist@3.4.3", "", { "dependencies": { "react-dnd": "^14.0.3", "react-dnd-html5-backend": "^14.0.3", "react-window": "^1.8.11", "redux": "^5.0.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": ">= 16.14", "react-dom": ">= 16.14" } }, "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ=="], + "react-compiler-runtime": ["react-compiler-runtime@19.1.0-rc.1-rc-af1b7da-20250421", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-Til/juI+Zfq+eYpGYn9lFxqW5RyJDs3ThOxmg0757aMrPpfx/Zb0SnGMVJhF3vw+bEQjJiD+xPFD3+kE0WbyeA=="], - "react-dnd": ["react-dnd@16.0.1", "", { "dependencies": { "@react-dnd/invariant": "^4.0.1", "@react-dnd/shallowequal": "^4.0.1", "dnd-core": "^16.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q=="], + "react-dnd": ["react-dnd@14.0.5", "", { "dependencies": { "@react-dnd/invariant": "^2.0.0", "@react-dnd/shallowequal": "^2.0.0", "dnd-core": "14.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A=="], - "react-dnd-html5-backend": ["react-dnd-html5-backend@16.0.1", "", { "dependencies": { "dnd-core": "^16.0.1" } }, "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw=="], + "react-dnd-html5-backend": ["react-dnd-html5-backend@14.1.0", "", { "dependencies": { "dnd-core": "14.0.1" } }, "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw=="], "react-dnd-multi-backend": ["react-dnd-multi-backend@8.1.2", "", { "dependencies": { "dnd-multi-backend": "^8.1.2", "react-dnd-preview": "^8.1.2" }, "peerDependencies": { "dnd-core": "^16.0.1", "react": "^16.14.0 || ^17.0.2 || ^18.0.0", "react-dnd": "^16.0.1", "react-dom": "^16.14.0 || ^17.0.2 || ^18.0.0" } }, "sha512-Ecj+gwr5B7zRiWqkDU5sUvUmufcu97WnsZFHnqHrWFJhTXAXQnhrperHLFktNP2CnQYtAgbucodr1if0MWpEaA=="], @@ -2463,6 +2468,8 @@ "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + "react-window": ["react-window@1.8.11", "", { "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" }, "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ=="], + "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -2477,7 +2484,7 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], - "redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], @@ -3071,6 +3078,10 @@ "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + + "dnd-multi-backend/dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "electron/@types/node": ["@types/node@22.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA=="], @@ -3207,8 +3218,24 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "rdndmb-html5-to-touch/react-dnd-html5-backend": ["react-dnd-html5-backend@16.0.1", "", { "dependencies": { "dnd-core": "^16.0.1" } }, "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw=="], + + "react-dnd-multi-backend/dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + + "react-dnd-multi-backend/react-dnd": ["react-dnd@16.0.1", "", { "dependencies": { "@react-dnd/invariant": "^4.0.1", "@react-dnd/shallowequal": "^4.0.1", "dnd-core": "^16.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q=="], + + "react-dnd-preview/react-dnd": ["react-dnd@16.0.1", "", { "dependencies": { "@react-dnd/invariant": "^4.0.1", "@react-dnd/shallowequal": "^4.0.1", "dnd-core": "^16.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q=="], + + "react-dnd-touch-backend/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "react-dnd-touch-backend/dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + "react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "react-mosaic-component/react-dnd": ["react-dnd@16.0.1", "", { "dependencies": { "@react-dnd/invariant": "^4.0.1", "@react-dnd/shallowequal": "^4.0.1", "dnd-core": "^16.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q=="], + + "react-mosaic-component/react-dnd-html5-backend": ["react-dnd-html5-backend@16.0.1", "", { "dependencies": { "dnd-core": "^16.0.1" } }, "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw=="], + "remark-reading-time/estree-util-is-identifier-name": ["estree-util-is-identifier-name@2.1.0", "", {}, "sha512-bEN9VHRyXAUOjkKVQVvArFym08BTWB0aJPppZZr0UNyAqWsLaVfAqP7hbaTJjzHifmB5ebnR8Wm7r7yGN/HonQ=="], "remark-reading-time/unist-util-visit": ["unist-util-visit@3.1.0", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^5.0.0", "unist-util-visit-parents": "^4.0.0" } }, "sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA=="], @@ -3401,6 +3428,12 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "dnd-multi-backend/dnd-core/@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + + "dnd-multi-backend/dnd-core/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "dnd-multi-backend/dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + "electron-builder/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "electron-publish/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3477,6 +3510,36 @@ "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "rdndmb-html5-to-touch/react-dnd-html5-backend/dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + + "react-dnd-multi-backend/dnd-core/@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + + "react-dnd-multi-backend/dnd-core/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "react-dnd-multi-backend/dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + + "react-dnd-multi-backend/react-dnd/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "react-dnd-multi-backend/react-dnd/@react-dnd/shallowequal": ["@react-dnd/shallowequal@4.0.2", "", {}, "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="], + + "react-dnd-preview/react-dnd/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "react-dnd-preview/react-dnd/@react-dnd/shallowequal": ["@react-dnd/shallowequal@4.0.2", "", {}, "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="], + + "react-dnd-preview/react-dnd/dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + + "react-dnd-touch-backend/dnd-core/@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + + "react-dnd-touch-backend/dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + + "react-mosaic-component/react-dnd/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "react-mosaic-component/react-dnd/@react-dnd/shallowequal": ["@react-dnd/shallowequal@4.0.2", "", {}, "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="], + + "react-mosaic-component/react-dnd/dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + + "react-mosaic-component/react-dnd-html5-backend/dnd-core": ["dnd-core@16.0.1", "", { "dependencies": { "@react-dnd/asap": "^5.0.1", "@react-dnd/invariant": "^4.0.1", "redux": "^4.2.0" } }, "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng=="], + "remark-reading-time/unist-util-visit/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "remark-reading-time/unist-util-visit/unist-util-is": ["unist-util-is@5.2.1", "", { "dependencies": { "@types/unist": "^2.0.0" } }, "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw=="], @@ -3531,6 +3594,26 @@ "jest-runtime/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "rdndmb-html5-to-touch/react-dnd-html5-backend/dnd-core/@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + + "rdndmb-html5-to-touch/react-dnd-html5-backend/dnd-core/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "rdndmb-html5-to-touch/react-dnd-html5-backend/dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + + "react-dnd-preview/react-dnd/dnd-core/@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + + "react-dnd-preview/react-dnd/dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + + "react-mosaic-component/react-dnd-html5-backend/dnd-core/@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + + "react-mosaic-component/react-dnd-html5-backend/dnd-core/@react-dnd/invariant": ["@react-dnd/invariant@4.0.2", "", {}, "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="], + + "react-mosaic-component/react-dnd-html5-backend/dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + + "react-mosaic-component/react-dnd/dnd-core/@react-dnd/asap": ["@react-dnd/asap@5.0.2", "", {}, "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="], + + "react-mosaic-component/react-dnd/dnd-core/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + "temp/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], From 496cf5387f0da1769807485ef32019267d61ae86 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 14 Nov 2025 18:15:13 -0800 Subject: [PATCH 4/5] dragging --- .../WorktreeItem/WorktreeItem.backup.tsx | 1555 ----------------- .../components/WorktreeItem/WorktreeItem.tsx | 220 +-- 2 files changed, 95 insertions(+), 1680 deletions(-) delete mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx deleted file mode 100644 index e48493aa4d4..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.backup.tsx +++ /dev/null @@ -1,1555 +0,0 @@ -import { - DndContext, - type DragEndEvent, - useDroppable, -} from "@dnd-kit/core"; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { Button } from "@superset/ui/button"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { - ChevronRight, - Edit2, - FolderOpen, -} from "lucide-react"; -import { useEffect, useId, useRef, useState } from "react"; -import type { MosaicNode } from "react-mosaic-component"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "renderer/components/ui/dialog"; -import type { Tab, Worktree } from "shared/types"; -import { WorktreePortsList } from "../WorktreePortsList"; -import { GitStatusDialog } from "./components/GitStatusDialog"; -import { TabItem } from "./components/TabItem"; - -interface ProxyStatus { - canonical: number; - target?: number; - service?: string; - active: boolean; -} - -// Sortable wrapper for tabs -function SortableTab({ - tab, - worktreeId, - worktree, - workspaceId, - parentTabId, - selectedTabId, - selectedTabIds, - onTabSelect, - onTabRemove, - onGroupTabs, - onMoveOutOfGroup, - onTabRename, -}: { - tab: Tab; - worktreeId: string; - worktree: Worktree; - workspaceId: string; - parentTabId?: string; // Optional parent group tab ID - selectedTabId?: string; - selectedTabIds: Set; - onTabSelect: (worktreeId: string, tabId: string, shiftKey: boolean) => void; - onTabRemove: (tabId: string) => void; - onGroupTabs: (tabIds: string[]) => void; - onMoveOutOfGroup: (tabId: string, parentTabId: string) => void; - onTabRename: (tabId: string, newName: string) => void; -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: tab.id, - data: { - type: "tab", - parentTabId, - worktreeId, - }, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - // Create custom drag handlers that don't interfere with button clicks - const handleMouseDown = (e: React.MouseEvent) => { - // Don't start drag if clicking on a button, input, or their children - const target = e.target as HTMLElement; - if ( - target.tagName === "BUTTON" || - target.tagName === "INPUT" || - target.closest("button") || - target.closest("input") - ) { - // Let the click handlers on buttons/inputs work normally - return; - } - // Otherwise, allow drag to start by calling the native handler - // Convert React synthetic event to native event - if (listeners?.onMouseDown) { - const nativeEvent = e.nativeEvent; - listeners.onMouseDown(nativeEvent); - } - }; - - return ( -
| undefined} - > - -
- ); -} - -// Droppable wrapper for group tabs -function DroppableGroupTab({ - tab, - worktreeId, - workspaceId: _workspaceId, - selectedTabId, - isExpanded, - level, - onToggle, - onTabSelect, - onUngroupTab, - onRenameGroup, -}: { - tab: Tab; - worktreeId: string; - workspaceId: string; - selectedTabId?: string; - isExpanded: boolean; - level: number; - onToggle: (groupTabId: string) => void; - onTabSelect: (worktreeId: string, tabId: string, shiftKey: boolean) => void; - onUngroupTab: (groupTabId: string) => void; - onRenameGroup: (groupTabId: string, newName: string) => void; -}) { - const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(tab.name); - const inputRef = useRef(null); - - const { setNodeRef, isOver } = useDroppable({ - id: `group-${tab.id}`, - data: { - type: "group", - groupTabId: tab.id, - }, - }); - - // Focus input when entering edit mode - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - const isSelected = selectedTabId === tab.id; - - const handleClick = (e: React.MouseEvent) => { - if (!isEditing) { - onTabSelect(worktreeId, tab.id, e.shiftKey); - onToggle(tab.id); - } - }; - - const handleStartRename = () => { - setEditName(tab.name); - setIsEditing(true); - }; - - const handleSaveRename = () => { - const trimmedName = editName.trim(); - if (trimmedName !== "" && trimmedName !== tab.name) { - onRenameGroup(tab.id, trimmedName); - } - setIsEditing(false); - }; - - const handleCancelRename = () => { - setEditName(tab.name); - setIsEditing(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSaveRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - handleCancelRename(); - } - }; - - return ( -
- - - - - - - - Rename - - onUngroupTab(tab.id)}> - - Ungroup Tabs - - - -
- ); -} - -// Droppable area wrapper for the expanded group tab content -function DroppableGroupArea({ - groupTabId, - children, -}: { - groupTabId: string; - children: React.ReactNode; -}) { - const { setNodeRef, isOver } = useDroppable({ - id: `group-area-${groupTabId}`, - data: { - type: "group-area", - groupTabId, - }, - }); - - return ( -
- {children} - {isOver && ( -
- Drop here to add to group -
- )} -
- ); -} - -// Droppable area wrapper for worktree level tabs (to drag tabs out of groups) -function DroppableWorktreeArea({ - children, -}: { - children: React.ReactNode; -}) { - const { setNodeRef, isOver } = useDroppable({ - id: "worktree-tabs", - data: { - type: "worktree", - }, - }); - - return ( -
- {children} - {isOver && ( -
- Drop here to move out of group -
- )} -
- ); -} - -interface WorktreeItemProps { - worktree: Worktree; - workspaceId: string; - activeWorktreeId: string | null; - onTabSelect: (worktreeId: string, tabId: string) => void; - onReload: () => void; - onUpdateWorktree: (updatedWorktree: Worktree) => void; - selectedTabId: string | undefined; - hasPortForwarding?: boolean; - onCloneWorktree: () => void; -} - -export function WorktreeItem({ - worktree, - workspaceId, - activeWorktreeId, - onTabSelect, - onReload, - onUpdateWorktree, - selectedTabId, - hasPortForwarding = false, - onCloneWorktree: _onCloneWorktree, -}: WorktreeItemProps) { - // Track expanded group tabs - const [expandedGroupTabs, setExpandedGroupTabs] = useState>( - new Set(), - ); - - // Track multi-selected tabs - const [selectedTabIds, setSelectedTabIds] = useState>(new Set()); - const [lastClickedTabId, setLastClickedTabId] = useState(null); - - // Track if merge is disabled (when this is the active worktree) - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const [isMergeDisabled, setIsMergeDisabled] = useState(false); - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const [mergeDisabledReason, setMergeDisabledReason] = useState(""); - const [targetWorktreeId, setTargetWorktreeId] = useState(""); - const [targetBranch, setTargetBranch] = useState(""); - const [availableWorktrees, setAvailableWorktrees] = useState< - Array<{ id: string; branch: string }> - >([]); - - // Dialog states - const [showRemoveDialog, setShowRemoveDialog] = useState(false); - const [showMergeDialog, setShowMergeDialog] = useState(false); - const [showErrorDialog, setShowErrorDialog] = useState(false); - const [showGitStatusDialog, setShowGitStatusDialog] = useState(false); - const [errorMessage, setErrorMessage] = useState(""); - const [errorTitle, setErrorTitle] = useState(""); - const [mergeWarning, setMergeWarning] = useState(""); - const [removeWarning, setRemoveWarning] = useState(""); - - // Track if this worktree is active - const isActive = activeWorktreeId === worktree.id; - - // Generate ID for select element (must be called before conditional return) - const targetBranchSelectId = useId(); - - // Auto-expand group tabs that contain the selected tab - // biome-ignore lint/correctness/useExhaustiveDependencies: findParentGroupTab is stable - useEffect(() => { - if (!selectedTabId) return; - - const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; - const parentGroupTab = findParentGroupTab(tabs, selectedTabId); - - if (parentGroupTab) { - setExpandedGroupTabs((prev) => { - const next = new Set(prev); - next.add(parentGroupTab.id); - return next; - }); - } - }, [selectedTabId, worktree.tabs]); - - // Helper: recursively find a tab by ID - const findTabById = (tabs: Tab[], tabId: string): Tab | null => { - for (const tab of tabs) { - if (tab.id === tabId) return tab; - if (tab.type === "group" && tab.tabs) { - const found = findTabById(tab.tabs, tabId); - if (found) return found; - } - } - return null; - }; - - // Helper: recursively find parent group tab containing a specific tab - const findParentGroupTab = (tabs: Tab[], tabId: string): Tab | null => { - for (const tab of tabs) { - if (tab.type === "group" && tab.tabs) { - if (tab.tabs.some((t) => t.id === tabId)) return tab; - const found = findParentGroupTab(tab.tabs, tabId); - if (found) return found; - } - } - return null; - }; - - // Helper: Remove tab ID from mosaic tree - const removeTabFromMosaicTree = ( - tree: MosaicNode, - tabId: string, - ): MosaicNode | null => { - if (typeof tree === "string") { - // If this is the tab to remove, return null - return tree === tabId ? null : tree; - } - - // Recursively remove from branches - const newFirst = removeTabFromMosaicTree(tree.first, tabId); - const newSecond = removeTabFromMosaicTree(tree.second, tabId); - - // If both branches are gone, return null - if (!newFirst && !newSecond) { - return null; - } - - // If one branch is gone, return the other - if (!newFirst) { - return newSecond; - } - if (!newSecond) { - return newFirst; - } - - // Both branches exist, return the updated tree - return { - ...tree, - first: newFirst, - second: newSecond, - }; - }; - - // Helper: recursively get all tabs as flat array with their parent IDs - const getAllTabs = ( - tabs: Tab[], - parentTabId?: string, - ): Array<{ tab: Tab; parentTabId?: string }> => { - const result: Array<{ tab: Tab; parentTabId?: string }> = []; - for (const tab of tabs) { - result.push({ tab, parentTabId }); - if (tab.type === "group" && tab.tabs) { - result.push(...getAllTabs(tab.tabs, tab.id)); - } - } - return result; - }; - - // Helper: get all non-group tabs at the same level (for shift-click range selection) - const getTabsAtSameLevel = ( - tabs: Tab[], - targetTabId: string, - _parentTabId?: string, - ): Tab[] => { - // Find which level the target tab is at - for (const tab of tabs) { - if (tab.id === targetTabId) { - // Found at current level - return all tabs at this level (excluding groups) - return tabs.filter((t) => t.type !== "group"); - } - if (tab.type === "group" && tab.tabs) { - const found = getTabsAtSameLevel(tab.tabs, targetTabId, tab.id); - if (found.length > 0) return found; - } - } - return []; - }; - - // Handle tab selection with shift-click support - const handleTabSelect = ( - worktreeId: string, - tabId: string, - shiftKey: boolean, - ) => { - if (shiftKey && lastClickedTabId) { - // Shift-click: select range - const tabsAtLevel = getTabsAtSameLevel(tabs, tabId); - const lastIndex = tabsAtLevel.findIndex((t) => t.id === lastClickedTabId); - const currentIndex = tabsAtLevel.findIndex((t) => t.id === tabId); - - if (lastIndex !== -1 && currentIndex !== -1) { - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - const rangeTabIds = tabsAtLevel.slice(start, end + 1).map((t) => t.id); - - setSelectedTabIds(new Set(rangeTabIds)); - } - } else { - // Normal click: single selection - setSelectedTabIds(new Set([tabId])); - setLastClickedTabId(tabId); - } - - // Always update the main selected tab - onTabSelect(worktreeId, tabId); - }; - - // Handle grouping selected tabs - const handleGroupTabs = async (tabIds: string[]) => { - try { - // Create a new group tab - const result = await window.ipcRenderer.invoke("tab-create", { - workspaceId, - worktreeId: worktree.id, - name: `Tab Group`, - type: "group", - }); - - if (!result.success || !result.tab) { - console.error("Failed to create group tab:", result.error); - return; - } - - const groupTabId = result.tab.id; - - // Move each selected tab into the group - for (const tabId of tabIds) { - const tab = findTabById(tabs, tabId); - if (!tab || tab.type === "group") continue; // Skip group tabs - - // Use tab-move to move the tab into the group - await window.ipcRenderer.invoke("tab-move", { - workspaceId, - worktreeId: worktree.id, - tabId, - targetParentTabId: groupTabId, - targetIndex: 0, // Add to end - }); - } - - // Reload to show the updated structure - onReload(); - - // Expand the new group tab to show its contents - setExpandedGroupTabs((prev) => new Set(prev).add(groupTabId)); - - // Select the new group tab - onTabSelect(worktree.id, groupTabId); - - // Clear selection - setSelectedTabIds(new Set()); - setLastClickedTabId(null); - } catch (error) { - console.error("Error grouping tabs:", error); - } - }; - - // Handle ungrouping a group tab - const handleUngroupTab = async (groupTabId: string) => { - try { - const groupTab = findTabById(tabs, groupTabId); - if (!groupTab || groupTab.type !== "group" || !groupTab.tabs) { - console.error("Invalid group tab"); - return; - } - - // Move each child tab back to the worktree level - for (const childTab of groupTab.tabs) { - await window.ipcRenderer.invoke("tab-move", { - workspaceId, - worktreeId: worktree.id, - tabId: childTab.id, - sourceParentTabId: groupTabId, // Move from the group - targetParentTabId: undefined, // Move to worktree level - targetIndex: 0, // Add to end of worktree tabs - }); - } - - // Delete the now-empty group tab - await window.ipcRenderer.invoke("tab-delete", { - workspaceId, - worktreeId: worktree.id, - tabId: groupTabId, - }); - - // Reload to show the updated structure - onReload(); - } catch (error) { - console.error("Error ungrouping tab:", error); - } - }; - - // Handle renaming a group tab - const handleRenameGroup = async (groupTabId: string, newName: string) => { - try { - const result = await window.ipcRenderer.invoke("tab-update-name", { - workspaceId, - worktreeId: worktree.id, - tabId: groupTabId, - name: newName, - }); - - if (result.success) { - // Optimistically update the local worktree data - const updatedTabs = updateTabNameRecursive( - worktree.tabs, - groupTabId, - newName, - ); - const updatedWorktree = { ...worktree, tabs: updatedTabs }; - onUpdateWorktree(updatedWorktree); - } else { - alert(`Failed to rename group: ${result.error}`); - } - } catch (error) { - console.error("Error renaming group:", error); - alert("Failed to rename group"); - } - }; - - // Handle moving a tab out of its group - const handleMoveOutOfGroup = async (tabId: string, parentTabId: string) => { - try { - const tab = findTabById(tabs, tabId); - const parentTab = findTabById(tabs, parentTabId); - - if (!tab || !parentTab || parentTab.type !== "group") { - console.error("Invalid tab or parent group"); - return; - } - - // Move the tab to worktree level - const moveResult = await window.ipcRenderer.invoke("tab-move", { - workspaceId, - worktreeId: worktree.id, - tabId, - sourceParentTabId: parentTabId, - targetParentTabId: undefined, // Move to worktree level - targetIndex: tabs.length, // Add to end of worktree tabs - }); - - if (!moveResult.success) { - console.error("Failed to move tab out of group:", moveResult.error); - onReload(); - return; - } - - // Update the parent group's mosaic tree to remove this tab - if (parentTab.mosaicTree) { - const updatedMosaicTree = removeTabFromMosaicTree( - parentTab.mosaicTree, - tabId, - ); - - await window.ipcRenderer.invoke("tab-update-mosaic-tree", { - workspaceId, - worktreeId: worktree.id, - tabId: parentTabId, - mosaicTree: updatedMosaicTree, - }); - } - - // Reload to show the updated structure - // Note: Backend automatically cleans up empty groups via cleanupEmptyGroupsInWorktree() - onReload(); - - // Select the moved tab - onTabSelect(worktree.id, tabId); - } catch (error) { - console.error("Error moving tab out of group:", error); - } - }; - - // Load available worktrees on mount - useEffect(() => { - const loadWorktrees = async () => { - // Get the workspace to find available worktrees - const workspace = await window.ipcRenderer.invoke( - "workspace-get", - workspaceId, - ); - - if (workspace) { - // Get all worktrees except the current one - const otherWorktrees = workspace.worktrees - .filter((wt: { id: string }) => wt.id !== worktree.id) - .map((wt: { id: string; branch: string }) => ({ - id: wt.id, - branch: wt.branch, - })); - setAvailableWorktrees(otherWorktrees); - - // Disable merge only if there are no other worktrees to merge into - if (otherWorktrees.length === 0) { - setIsMergeDisabled(true); - setMergeDisabledReason("No other worktrees available"); - } else { - setIsMergeDisabled(false); - setMergeDisabledReason(""); - - // Set default target to active worktree if it exists and is not this worktree - const activeWorktree = workspace.worktrees.find( - (wt: { id: string }) => wt.id === workspace.activeWorktreeId, - ); - if (activeWorktree && activeWorktree.id !== worktree.id) { - setTargetWorktreeId(activeWorktree.id); - setTargetBranch(activeWorktree.branch); - } else if (otherWorktrees.length > 0) { - // If active worktree is this worktree, default to first available worktree - setTargetWorktreeId(otherWorktrees[0].id); - setTargetBranch(otherWorktrees[0].branch); - } - } - } - }; - - loadWorktrees(); - }, [workspaceId, worktree.id]); - - // Only render tabs for the active worktree - if (!isActive) { - return null; - } - - // Context menu handlers (unused but kept for potential future use) - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const handleCopyPath = async () => { - const path = await window.ipcRenderer.invoke("worktree-get-path", { - workspaceId, - worktreeId: worktree.id, - }); - if (path) { - navigator.clipboard.writeText(path); - } - }; - - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const handleRemoveWorktree = async () => { - // Check if the worktree has uncommitted changes - const canRemoveResult = await window.ipcRenderer.invoke( - "worktree-can-remove", - { - workspaceId, - worktreeId: worktree.id, - }, - ); - - // Build warning message if there are uncommitted changes - let warning = ""; - if (canRemoveResult.hasUncommittedChanges) { - warning = `Warning: This worktree (${worktree.branch}) has uncommitted changes. Removing it will delete these changes permanently.`; - } - - setRemoveWarning(warning); - setShowRemoveDialog(true); - }; - - const confirmRemoveWorktree = async () => { - setShowRemoveDialog(false); - setRemoveWarning(""); - - const result = await window.ipcRenderer.invoke("worktree-remove", { - workspaceId, - worktreeId: worktree.id, - }); - - if (result.success) { - // Backend removes from config first, then git worktree in background - // This provides immediate UI feedback - onReload(); - } else { - setErrorTitle("Failed to Remove Worktree"); - setErrorMessage( - result.error || - "An unknown error occurred while removing the worktree.", - ); - setShowErrorDialog(true); - } - }; - - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const handleMergeWorktree = async () => { - // Check if can merge with the selected target - const canMergeResult = await window.ipcRenderer.invoke( - "worktree-can-merge", - { - workspaceId, - worktreeId: worktree.id, - targetWorktreeId: targetWorktreeId || undefined, - }, - ); - - if (!canMergeResult.canMerge) { - setErrorTitle("Cannot Merge"); - setErrorMessage(canMergeResult.reason || "Unknown error"); - setShowErrorDialog(true); - return; - } - - // Build warning message if there are uncommitted changes - let warning = ""; - const warnings = []; - - if (canMergeResult.targetHasUncommittedChanges) { - const targetBranchText = targetBranch ? ` (${targetBranch})` : ""; - warnings.push( - `The target worktree${targetBranchText} has uncommitted changes.`, - ); - } - - if (canMergeResult.sourceHasUncommittedChanges) { - warnings.push( - `The source worktree (${worktree.branch}) has uncommitted changes.`, - ); - } - - if (warnings.length > 0) { - warning = `Warning: ${warnings.join(" ")} The merge will proceed anyway.`; - } - - setMergeWarning(warning); - setShowMergeDialog(true); - }; - - // Handler for when target worktree changes - const handleTargetWorktreeChange = async (newTargetId: string) => { - setTargetWorktreeId(newTargetId); - - // Update target branch display - const targetWorktree = availableWorktrees.find( - (wt) => wt.id === newTargetId, - ); - if (targetWorktree) { - setTargetBranch(targetWorktree.branch); - } - - // Re-check merge status with new target - const canMergeResult = await window.ipcRenderer.invoke( - "worktree-can-merge", - { - workspaceId, - worktreeId: worktree.id, - targetWorktreeId: newTargetId, - }, - ); - - // Update warning message - let warning = ""; - const warnings = []; - - if (canMergeResult.targetHasUncommittedChanges) { - const targetBranchText = targetWorktree?.branch - ? ` (${targetWorktree.branch})` - : ""; - warnings.push( - `The target worktree${targetBranchText} has uncommitted changes.`, - ); - } - - if (canMergeResult.sourceHasUncommittedChanges) { - warnings.push( - `The source worktree (${worktree.branch}) has uncommitted changes.`, - ); - } - - if (warnings.length > 0) { - warning = `Warning: ${warnings.join(" ")} The merge will proceed anyway.`; - } - - setMergeWarning(warning); - }; - - const confirmMergeWorktree = async () => { - setShowMergeDialog(false); - setMergeWarning(""); - - const result = await window.ipcRenderer.invoke("worktree-merge", { - workspaceId, - worktreeId: worktree.id, - targetWorktreeId: targetWorktreeId || undefined, - }); - - if (result.success) { - onReload(); - } else { - setErrorTitle("Failed to Merge"); - setErrorMessage( - result.error || "An unknown error occurred while merging the worktree.", - ); - setShowErrorDialog(true); - } - }; - - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const handleOpenInCursor = async () => { - const path = await window.ipcRenderer.invoke("worktree-get-path", { - workspaceId, - worktreeId: worktree.id, - }); - if (path) { - // Use Cursor's deeplink protocol: cursor://file/{path} - await window.ipcRenderer.invoke("open-external", `cursor://file/${path}`); - } - }; - - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const handleOpenSettings = async () => { - // First, check if settings folder exists - const checkResult = await window.ipcRenderer.invoke( - "worktree-check-settings", - { - workspaceId, - worktreeId: worktree.id, - }, - ); - - if (!checkResult.success) { - setErrorTitle("Failed to Check Settings"); - setErrorMessage( - checkResult.error || - "An unknown error occurred while checking settings.", - ); - setShowErrorDialog(true); - return; - } - - // Open (and create if needed) - const result = await window.ipcRenderer.invoke("worktree-open-settings", { - workspaceId, - worktreeId: worktree.id, - createIfMissing: true, - }); - - if (result.success && result.created) { - console.log(".superset folder created and opened in Cursor"); - } else if (!result.success) { - setErrorTitle("Failed to Open Settings"); - setErrorMessage( - result.error || "An unknown error occurred while opening settings.", - ); - setShowErrorDialog(true); - } - }; - - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const handleAddTab = async () => { - try { - const result = await window.ipcRenderer.invoke("tab-create", { - workspaceId, - worktreeId: worktree.id, - // No parentTabId - create at worktree level - name: "New Terminal", - type: "terminal", - }); - - if (result.success) { - const newTabId = result.tab?.id; - // Auto-select the new tab first (before reload) - if (newTabId) { - handleTabSelect(worktree.id, newTabId, false); - } - onReload(); - } else { - console.error("Failed to create tab:", result.error); - } - } catch (error) { - console.error("Error creating tab:", error); - } - }; - - // biome-ignore lint/correctness/noUnusedVariables: kept for potential future use - const handleAddPreview = async () => { - try { - const previewTabs = Array.isArray(worktree.tabs) - ? worktree.tabs.filter((tab) => tab.type === "preview") - : []; - const previewIndex = previewTabs.length + 1; - - const detectedPorts = worktree.detectedPorts || {}; - const portEntries = Object.entries(detectedPorts); - - let initialUrl: string | undefined; - let previewLabel = - previewIndex > 1 ? `Preview ${previewIndex}` : "Preview"; - - if (portEntries.length > 0) { - const [service, port] = portEntries[0]; - - try { - const status = (await window.ipcRenderer.invoke( - "proxy-get-status", - )) as ProxyStatus[]; - const activeProxies = (status || []).filter( - (item) => item.active && typeof item.target === "number", - ); - const proxyMap = new Map( - activeProxies.map((item) => [ - item.target as number, - item.canonical, - ]), - ); - - const canonicalPort = proxyMap.get(port); - const resolvedPort = canonicalPort ?? port; - initialUrl = `http://localhost:${resolvedPort}`; - } catch (error) { - console.error("Failed to determine proxied port:", error); - initialUrl = `http://localhost:${port}`; - } - - if (service) { - previewLabel = - previewIndex > 1 - ? `Preview ${previewIndex} – ${service}` - : `Preview – ${service}`; - } - } - - const result = await window.ipcRenderer.invoke("tab-create", { - workspaceId, - worktreeId: worktree.id, - name: previewLabel, - type: "preview", - url: initialUrl, - }); - - if (result.success) { - const newTabId = result.tab?.id; - if (newTabId) { - handleTabSelect(worktree.id, newTabId, false); - } - onReload(); - } else { - console.error("Failed to create preview tab:", result.error); - } - } catch (error) { - console.error("Error creating preview tab:", error); - } - }; - - const handleTabRemove = async (tabId: string) => { - try { - const result = await window.ipcRenderer.invoke("tab-delete", { - workspaceId, - worktreeId: worktree.id, - tabId, - }); - - if (result.success) { - // Backend automatically cleans up empty groups via cleanupEmptyGroupsInWorktree() - onReload(); // Refresh the workspace to show the updated tab list - } else { - console.error("Failed to delete tab:", result.error); - } - } catch (error) { - console.error("Error deleting tab:", error); - } - }; - - const handleTabRename = async (tabId: string, newName: string) => { - try { - const result = await window.ipcRenderer.invoke("tab-update-name", { - workspaceId, - worktreeId: worktree.id, - tabId, - name: newName, - }); - - if (result.success) { - // Optimistically update the local worktree data - const updatedTabs = updateTabNameRecursive( - worktree.tabs, - tabId, - newName, - ); - const updatedWorktree = { ...worktree, tabs: updatedTabs }; - onUpdateWorktree(updatedWorktree); - } else { - alert(`Failed to rename tab: ${result.error}`); - } - } catch (error) { - console.error("Error renaming tab:", error); - alert("Failed to rename tab"); - } - }; - - // Helper to recursively update tab name - const updateTabNameRecursive = ( - tabs: Tab[], - tabId: string, - newName: string, - ): Tab[] => { - return tabs.map((tab) => { - if (tab.id === tabId) { - return { ...tab, name: newName }; - } - if (tab.type === "group" && tab.tabs) { - return { - ...tab, - tabs: updateTabNameRecursive(tab.tabs, tabId, newName), - }; - } - return tab; - }); - }; - - // Get all tabs for sortable context (including nested) - // Defensive: ensure worktree.tabs exists and is an array - const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; - - // Handle drag end - move tab to group, out of group, or reorder - const handleDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; - - if (!over) return; - - const draggedTabId = active.id as string; - const draggedTab = findTabById(tabs, draggedTabId); - - if (!draggedTab || draggedTab.type === "group") { - // Don't allow dragging group tabs - return; - } - - const overId = over.id as string; - const overData = over.data.current; - - // Find source parent - const sourceParent = findParentGroupTab(tabs, draggedTabId); - const sourceParentTabId = sourceParent?.id; - - // Check if dropped on worktree level (to move out of group) - if (overId === "worktree-tabs" || overData?.type === "worktree") { - // Only move if currently in a group - if (sourceParentTabId) { - try { - // Find target index (end of worktree tabs) - const targetIndex = tabs.length; - - const result = await window.ipcRenderer.invoke("tab-move", { - workspaceId, - worktreeId: worktree.id, - tabId: draggedTabId, - sourceParentTabId: sourceParentTabId, - targetParentTabId: undefined, // Move to worktree level - targetIndex, - }); - - if (result.success) { - onReload(); - onTabSelect(worktree.id, draggedTabId); - } else { - console.error("Failed to move tab out of group:", result.error); - } - } catch (error) { - console.error("Error moving tab out of group:", error); - } - } - return; - } - - // Check if dropped on a group tab header - if (overId.startsWith("group-") && overData?.type === "group") { - const targetGroupId = overData.groupTabId as string; - - // Don't move if already in this group - if (sourceParentTabId === targetGroupId) { - return; - } - - // Expand the target group - setExpandedGroupTabs((prev) => new Set(prev).add(targetGroupId)); - - // Move tab into the group - try { - const result = await window.ipcRenderer.invoke("tab-move", { - workspaceId, - worktreeId: worktree.id, - tabId: draggedTabId, - sourceParentTabId: sourceParentTabId || undefined, - targetParentTabId: targetGroupId, - targetIndex: 0, // Add to end of group - }); - - if (result.success) { - onReload(); - onTabSelect(worktree.id, draggedTabId); - } else { - console.error("Failed to move tab to group:", result.error); - } - } catch (error) { - console.error("Error moving tab to group:", error); - } - return; - } - - // Check if dropped on a group area (expanded group content) - if ( - overId.startsWith("group-area-") && - overData?.type === "group-area" - ) { - const targetGroupId = overData.groupTabId as string; - - // Don't move if already in this group - if (sourceParentTabId === targetGroupId) { - return; - } - - // Move tab into the group - try { - const result = await window.ipcRenderer.invoke("tab-move", { - workspaceId, - worktreeId: worktree.id, - tabId: draggedTabId, - sourceParentTabId: sourceParentTabId || undefined, - targetParentTabId: targetGroupId, - targetIndex: 0, // Add to end of group - }); - - if (result.success) { - onReload(); - onTabSelect(worktree.id, draggedTabId); - } else { - console.error("Failed to move tab to group:", result.error); - } - } catch (error) { - console.error("Error moving tab to group:", error); - } - return; - } - - // Check if dropped on another tab (for reordering) - // This could be within the same parent or moving between parents - const overTabId = overId; - const overTab = findTabById(tabs, overTabId); - - if (overTab && overTab.type !== "group") { - // Find the parent of the tab we're dropping on - const targetParent = findParentGroupTab(tabs, overTabId); - const targetParentTabId = targetParent?.id; - - // If moving to a different parent, use tab-move - if (sourceParentTabId !== targetParentTabId) { - // Find target index - const targetTabs = targetParentTabId - ? targetParent.tabs || [] - : tabs; - const targetIndex = targetTabs.findIndex((t) => t.id === overTabId); - - try { - const result = await window.ipcRenderer.invoke("tab-move", { - workspaceId, - worktreeId: worktree.id, - tabId: draggedTabId, - sourceParentTabId: sourceParentTabId || undefined, - targetParentTabId: targetParentTabId || undefined, - targetIndex: targetIndex >= 0 ? targetIndex : 0, - }); - - if (result.success) { - onReload(); - onTabSelect(worktree.id, draggedTabId); - } else { - console.error("Failed to move tab:", result.error); - } - } catch (error) { - console.error("Error moving tab:", error); - } - return; - } - - // Same parent - handle reordering - const parentTabs = sourceParentTabId - ? sourceParent.tabs || [] - : tabs; - - // Get current order - const currentOrder = parentTabs.map((t) => t.id); - const draggedIndex = currentOrder.indexOf(draggedTabId); - const targetIndex = currentOrder.indexOf(overTabId); - - if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) { - // Reorder - const newOrder = [...currentOrder]; - newOrder.splice(draggedIndex, 1); - newOrder.splice(targetIndex, 0, draggedTabId); - - try { - const result = await window.ipcRenderer.invoke("tab-reorder", { - workspaceId, - worktreeId: worktree.id, - parentTabId: sourceParentTabId || undefined, - tabIds: newOrder, - }); - - if (result.success) { - onReload(); - } else { - console.error("Failed to reorder tabs:", result.error); - } - } catch (error) { - console.error("Error reordering tabs:", error); - } - } - } - }; - - // Toggle group tab expansion - const toggleGroupTab = (groupTabId: string) => { - setExpandedGroupTabs((prev) => { - const next = new Set(prev); - if (next.has(groupTabId)) { - next.delete(groupTabId); - } else { - next.add(groupTabId); - } - return next; - }); - }; - - // Render a single tab or group tab with nesting - const renderTab = (tab: Tab, parentTabId?: string, level = 0) => { - if (tab.type === "group") { - const isExpanded = expandedGroupTabs.has(tab.id); - return ( -
- {/* Group Tab Header */} - - - {/* Nested Tabs - Make the entire area droppable with SortableContext */} - {isExpanded && tab.tabs && ( - - t.id)} - strategy={verticalListSortingStrategy} - > -
- {tab.tabs.map((childTab) => - renderTab(childTab, tab.id, level + 1), - )} -
-
-
- )} -
- ); - } - - // Regular tab (terminal, editor, etc.) - return ( -
- -
- ); - }; - - return ( -
- {/* Ports List - shown inline if port forwarding is configured */} - {hasPortForwarding && ( - - )} - - {/* Tabs List */} -
- {/* Render tabs with collapsible groups */} - - {/* Droppable area for worktree level (to drag tabs out of groups) */} - - t.id)} - strategy={verticalListSortingStrategy} - > - {tabs.map((tab) => renderTab(tab, undefined, 0))} - - - -
- - {/* Remove Worktree Confirmation Dialog */} - - - - Remove Worktree - - Are you sure you want to remove the worktree "{worktree.branch}"? - This action cannot be undone. - - - - {/* Warning Message */} - {removeWarning && ( -
- {removeWarning} -
- )} - - - - - -
-
- - {/* Merge Worktree Confirmation Dialog */} - - - - Merge Worktree - - Merge "{worktree.branch}" into the selected target branch. - - - - {/* Target Branch Selector */} -
- - -
- - {/* Warning Message */} - {mergeWarning && ( -
- {mergeWarning} -
- )} - - - - - -
-
- - {/* Error Dialog */} - - - - {errorTitle} - -
- {errorMessage} -
-
-
- - - -
-
- - {/* Git Status Dialog */} - -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx index 6bffcccc3fe..27f6fe2704f 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx @@ -30,22 +30,23 @@ interface ProxyStatus { active: boolean; } -// Convert Tab[] to react-arborist format -function convertTabsToTreeData( - tabs: Tab[], -): Array<{ +// Tree node type for react-arborist +type TreeNode = { id: string; name: string; tab: Tab; - children?: Array<{ id: string; name: string; tab: Tab }>; -}> { + children?: TreeNode[]; +}; + +// Constants +const TREE_ROW_HEIGHT = 28; +const TREE_MIN_HEIGHT = 10; +const TREE_MAX_HEIGHT = 600; + +// Convert Tab[] to react-arborist format +function convertTabsToTreeData(tabs: Tab[]): TreeNode[] { return tabs.map((tab) => { - const node: { - id: string; - name: string; - tab: Tab; - children?: Array<{ id: string; name: string; tab: Tab }>; - } = { + const node: TreeNode = { id: tab.id, name: tab.name, tab, @@ -57,6 +58,48 @@ function convertTabsToTreeData( }); } +// Helper: Collect all group tab IDs recursively +function collectGroupTabIds(tabs: Tab[]): Set { + const groupTabIds = new Set(); + const collect = (tabList: Tab[]) => { + for (const tab of tabList) { + if (tab.type === "group") { + groupTabIds.add(tab.id); + if (tab.tabs) { + collect(tab.tabs); + } + } + } + }; + collect(tabs); + return groupTabIds; +} + +// Helper: Build merge warning message +function buildMergeWarning( + canMergeResult: { + targetHasUncommittedChanges?: boolean; + sourceHasUncommittedChanges?: boolean; + }, + sourceBranch: string, + targetBranch?: string, +): string { + const warnings: string[] = []; + + if (canMergeResult.targetHasUncommittedChanges) { + const targetBranchText = targetBranch ? ` (${targetBranch})` : ""; + warnings.push(`The target worktree${targetBranchText} has uncommitted changes.`); + } + + if (canMergeResult.sourceHasUncommittedChanges) { + warnings.push(`The source worktree (${sourceBranch}) has uncommitted changes.`); + } + + return warnings.length > 0 + ? `Warning: ${warnings.join(" ")} The merge will proceed anyway.` + : ""; +} + interface WorktreeItemProps { worktree: Worktree; workspaceId: string; @@ -81,22 +124,10 @@ export function WorktreeItem({ onCloneWorktree: _onCloneWorktree, }: WorktreeItemProps) { // Track expanded group tabs - initialize with all group tabs expanded by default - const [expandedGroupTabs, setExpandedGroupTabs] = useState>(() => { - const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; - const groupTabIds = new Set(); - const collectGroupTabs = (tabList: Tab[]) => { - for (const tab of tabList) { - if (tab.type === "group") { - groupTabIds.add(tab.id); - if (tab.tabs) { - collectGroupTabs(tab.tabs); - } - } - } - }; - collectGroupTabs(tabs); - return groupTabIds; - }); + const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; + const [expandedGroupTabs, setExpandedGroupTabs] = useState>(() => + collectGroupTabIds(tabs), + ); // Track multi-selected tabs const [selectedTabIds, setSelectedTabIds] = useState>(new Set()); @@ -467,27 +498,13 @@ export function WorktreeItem({ loadWorktrees(); }, [workspaceId, worktree.id]); - // Get all tabs for sortable context (including nested) - // Defensive: ensure worktree.tabs exists and is an array - const tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; - const treeData = convertTabsToTreeData(tabs); // Calculate responsive height based on visible items // Must be before early return to satisfy React hooks rules + const treeData = useMemo(() => convertTabsToTreeData(tabs), [tabs]); const treeHeight = useMemo(() => { - const rowHeight = 28; - const minHeight = 10; - const maxHeight = 600; - // Count visible nodes (including expanded children) - const countVisibleNodes = ( - nodes: Array<{ - id: string; - name: string; - tab: Tab; - children?: Array<{ id: string; name: string; tab: Tab }>; - }>, - ): number => { + const countVisibleNodes = (nodes: TreeNode[]): number => { let count = 0; for (const node of nodes) { count += 1; // Count the node itself @@ -503,8 +520,8 @@ export function WorktreeItem({ }; const visibleCount = countVisibleNodes(treeData); - const calculatedHeight = visibleCount * rowHeight; - return Math.max(minHeight, Math.min(maxHeight, calculatedHeight)); + const calculatedHeight = visibleCount * TREE_ROW_HEIGHT; + return Math.max(TREE_MIN_HEIGHT, Math.min(TREE_MAX_HEIGHT, calculatedHeight)); }, [treeData, expandedGroupTabs]); // Only render tabs for the active worktree @@ -587,28 +604,9 @@ export function WorktreeItem({ return; } - // Build warning message if there are uncommitted changes - let warning = ""; - const warnings = []; - - if (canMergeResult.targetHasUncommittedChanges) { - const targetBranchText = targetBranch ? ` (${targetBranch})` : ""; - warnings.push( - `The target worktree${targetBranchText} has uncommitted changes.`, - ); - } - - if (canMergeResult.sourceHasUncommittedChanges) { - warnings.push( - `The source worktree (${worktree.branch}) has uncommitted changes.`, - ); - } - - if (warnings.length > 0) { - warning = `Warning: ${warnings.join(" ")} The merge will proceed anyway.`; - } - - setMergeWarning(warning); + setMergeWarning( + buildMergeWarning(canMergeResult, worktree.branch, targetBranch), + ); setShowMergeDialog(true); }; @@ -634,30 +632,13 @@ export function WorktreeItem({ }, ); - // Update warning message - let warning = ""; - const warnings = []; - - if (canMergeResult.targetHasUncommittedChanges) { - const targetBranchText = targetWorktree?.branch - ? ` (${targetWorktree.branch})` - : ""; - warnings.push( - `The target worktree${targetBranchText} has uncommitted changes.`, - ); - } - - if (canMergeResult.sourceHasUncommittedChanges) { - warnings.push( - `The source worktree (${worktree.branch}) has uncommitted changes.`, - ); - } - - if (warnings.length > 0) { - warning = `Warning: ${warnings.join(" ")} The merge will proceed anyway.`; - } - - setMergeWarning(warning); + setMergeWarning( + buildMergeWarning( + canMergeResult, + worktree.branch, + targetWorktree?.branch, + ), + ); }; const confirmMergeWorktree = async () => { @@ -897,19 +878,9 @@ export function WorktreeItem({ // Handle drag and drop (move) using react-arborist const handleMove = async (args: { dragIds: string[]; - dragNodes: NodeApi<{ - id: string; - name: string; - tab: Tab; - children?: Array<{ id: string; name: string; tab: Tab }>; - }>[]; + dragNodes: NodeApi[]; parentId: string | null; - parentNode: NodeApi<{ - id: string; - name: string; - tab: Tab; - children?: Array<{ id: string; name: string; tab: Tab }>; - }> | null; + parentNode: NodeApi | null; index: number; }) => { if (args.dragNodes.length === 0) return; @@ -917,10 +888,10 @@ export function WorktreeItem({ const draggedNode = args.dragNodes[0]; const draggedTab = draggedNode.data.tab as Tab; - // Don't allow dragging group tabs - if (!draggedTab || draggedTab.type === "group") return; + if (!draggedTab) return; const draggedTabId = draggedTab.id; + const isGroupTab = draggedTab.type === "group"; const sourceParent = draggedNode.parent; const sourceParentTabId = sourceParent?.data.tab?.type === "group" ? sourceParent.id : null; @@ -929,6 +900,11 @@ export function WorktreeItem({ // If moving to a different parent, use tab-move if (sourceParentTabId !== targetParentTabId) { + // Prevent group tabs from being moved into other groups + if (isGroupTab && targetParentTabId) { + return; + } + try { const result = await window.ipcRenderer.invoke("tab-move", { workspaceId, @@ -951,7 +927,7 @@ export function WorktreeItem({ return; } - // Same parent - handle reordering + // Same parent - handle reordering (works for both regular tabs and group tabs) const parentTabs = sourceParentTabId ? (sourceParent?.data.tab as Tab).tabs || [] : tabs; @@ -994,19 +970,9 @@ export function WorktreeItem({ // Render node content for react-arborist Tree const renderNode = (props: { - node: NodeApi<{ - id: string; - name: string; - tab: Tab; - children?: Array<{ id: string; name: string; tab: Tab }>; - }>; + node: NodeApi; style: React.CSSProperties; - tree: TreeApi<{ - id: string; - name: string; - tab: Tab; - children?: Array<{ id: string; name: string; tab: Tab }>; - }>; + tree: TreeApi; dragHandle?: (el: HTMLDivElement | null) => void; preview?: boolean; }) => { @@ -1018,7 +984,7 @@ export function WorktreeItem({ if (isGroup) { return ( -
+