diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 809450f0e7a..2ba6080ab12 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -48,6 +48,9 @@ "lucide-react": "^0.553.0", "node-pty": "1.1.0-beta30", "react": "^19.1.1", + "react-arborist": "^3.4.3", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "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/MainScreen.tsx b/apps/desktop/src/renderer/screens/main/MainScreen.tsx index e96b7e07323..fb15c6b9409 100644 --- a/apps/desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/desktop/src/renderer/screens/main/MainScreen.tsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; import { AddTaskModal } from "./components/Layout/AddTaskModal"; @@ -81,7 +83,7 @@ export function MainScreen() { } = useTaskContext(); return ( - <> + {/* Hover trigger area when sidebar is hidden */} @@ -167,6 +169,6 @@ export function MainScreen() { onCreateWorkspace={handleCreateWorkspaceFromModal} /> )} - + ); } 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..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 @@ -1,10 +1,3 @@ -import { 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, @@ -12,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, @@ -39,245 +30,74 @@ interface ProxyStatus { active: boolean; } -// Sortable wrapper for tabs -function SortableTab({ - tab, - worktreeId, - worktree, - workspaceId, - parentTabId, - selectedTabId, - selectedTabIds, - onTabSelect, - onTabRemove, - onGroupTabs, - onMoveOutOfGroup, - onTabRename, -}: { +// Tree node type for react-arborist +type TreeNode = { + 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, - }, + 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: TreeNode = { + id: tab.id, + name: tab.name, + tab, + }; + if (tab.type === "group" && tab.tabs) { + node.children = convertTabsToTreeData(tab.tabs); + } + return node; }); - - 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, - isOver, -}: { - 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; - isOver: boolean; -}) { - const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(tab.name); - const inputRef = useRef(null); - - const { setNodeRef } = 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(); +// 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); + } + } } }; - - return ( -
- - - - - - - - Rename - - onUngroupTab(tab.id)}> - - Ungroup Tabs - - - -
- ); + collect(tabs); + return groupTabIds; } -// Droppable area wrapper for the expanded group tab content -function DroppableGroupArea({ - groupTabId, - isOver, - children, -}: { - groupTabId: string; - isOver: boolean; - children: React.ReactNode; -}) { - const { setNodeRef } = useDroppable({ - id: `group-area-${groupTabId}`, - data: { - type: "group-area", - groupTabId, - }, - }); +// 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.`); + } - return ( -
- {children} - {isOver && ( -
- Drop here to add to group -
- )} -
- ); + 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 { @@ -303,9 +123,10 @@ 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 tabs = Array.isArray(worktree.tabs) ? worktree.tabs : []; + const [expandedGroupTabs, setExpandedGroupTabs] = useState>(() => + collectGroupTabIds(tabs), ); // Track multi-selected tabs @@ -415,20 +236,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 = ( @@ -691,6 +498,32 @@ export function WorktreeItem({ loadWorktrees(); }, [workspaceId, worktree.id]); + + // 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(() => { + // Count visible nodes (including expanded children) + const countVisibleNodes = (nodes: TreeNode[]): 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 * TREE_ROW_HEIGHT; + return Math.max(TREE_MIN_HEIGHT, Math.min(TREE_MAX_HEIGHT, calculatedHeight)); + }, [treeData, expandedGroupTabs]); + // Only render tabs for the active worktree if (!isActive) { return null; @@ -771,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); }; @@ -818,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 () => { @@ -1078,72 +875,170 @@ 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); - - // 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); + // Handle drag and drop (move) using react-arborist + const handleMove = async (args: { + dragIds: string[]; + dragNodes: NodeApi[]; + parentId: string | null; + parentNode: NodeApi | null; + index: number; + }) => { + if (args.dragNodes.length === 0) return; + + const draggedNode = args.dragNodes[0]; + const draggedTab = draggedNode.data.tab as Tab; + + 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; + const targetParentTabId = + args.parentNode?.data.tab?.type === "group" ? args.parentNode.id : null; + + // 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; } - return next; - }); + + 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); + } + return; + } + + // Same parent - handle reordering (works for both regular tabs and group tabs) + const parentTabs = sourceParentTabId + ? (sourceParent?.data.tab as Tab).tabs || [] + : tabs; + + if (!parentTabs || parentTabs.length === 0) 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); + + 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); + } + } }; - // 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; + style: React.CSSProperties; + tree: TreeApi; + 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} @@ -1162,13 +1057,51 @@ export function WorktreeItem({ {/* Tabs List */}
- {/* Render tabs with collapsible groups */} - { + 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={TREE_ROW_HEIGHT} + indent={12} + disableDrop={(args) => { + // Prevent dropping group tabs into other groups + const draggedTab = args.dragNodes[0]?.data.tab as Tab; + const targetParentTab = args.parentNode?.data.tab as Tab; + return ( + draggedTab?.type === "group" && + targetParentTab?.type === "group" + ); + }} > - {tabs.map((tab) => renderTab(tab, undefined, 0))} - + {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/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/components/TabItem/TabItem.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/components/TabItem/TabItem.tsx index 2c983d4f94e..72ff0e0209f 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/components/TabItem/TabItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/components/TabItem/TabItem.tsx @@ -63,7 +63,14 @@ export function TabItem({ onTabRemove?.(tab.id); }; + const handleMouseDown = (e: React.MouseEvent) => { + // 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} > diff --git a/bun.lock b/bun.lock index 0078f7c349d..49e7a46b588 100644 --- a/bun.lock +++ b/bun.lock @@ -63,6 +63,9 @@ "lucide-react": "^0.553.0", "node-pty": "1.1.0-beta30", "react": "^19.1.1", + "react-arborist": "^3.4.3", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.1.1", "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", @@ -2107,6 +2110,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,6 +2428,8 @@ "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=="], @@ -2463,6 +2470,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 +2486,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 +3080,8 @@ "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=="], + "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,6 +3218,10 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-arborist/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-arborist/react-dnd-html5-backend": ["react-dnd-html5-backend@14.1.0", "", { "dependencies": { "dnd-core": "14.0.1" } }, "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw=="], + "react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "remark-reading-time/estree-util-is-identifier-name": ["estree-util-is-identifier-name@2.1.0", "", {}, "sha512-bEN9VHRyXAUOjkKVQVvArFym08BTWB0aJPppZZr0UNyAqWsLaVfAqP7hbaTJjzHifmB5ebnR8Wm7r7yGN/HonQ=="], @@ -3477,6 +3492,14 @@ "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "react-arborist/react-dnd/@react-dnd/invariant": ["@react-dnd/invariant@2.0.0", "", {}, "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="], + + "react-arborist/react-dnd/@react-dnd/shallowequal": ["@react-dnd/shallowequal@2.0.0", "", {}, "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="], + + "react-arborist/react-dnd/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=="], + + "react-arborist/react-dnd-html5-backend/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=="], + "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 +3554,16 @@ "jest-runtime/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "react-arborist/react-dnd-html5-backend/dnd-core/@react-dnd/asap": ["@react-dnd/asap@4.0.1", "", {}, "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="], + + "react-arborist/react-dnd-html5-backend/dnd-core/@react-dnd/invariant": ["@react-dnd/invariant@2.0.0", "", {}, "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="], + + "react-arborist/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-arborist/react-dnd/dnd-core/@react-dnd/asap": ["@react-dnd/asap@4.0.1", "", {}, "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="], + + "react-arborist/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=="],