Skip to content

Commit ae2c58a

Browse files
Add tab and connection drag-and-drop reordering
1 parent b7b98d4 commit ae2c58a

3 files changed

Lines changed: 91 additions & 18 deletions

File tree

src/renderer/src/components/sidebar/Sidebar.tsx

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export function Sidebar(): JSX.Element {
3030
connections, groups, sessions, activeSessionId,
3131
sidebarWidth, setSidebarWidth,
3232
setConnectionDialogOpen, setQuickConnectOpen,
33-
openSession, openSftpSession, exportConnections, importConnections
33+
openSession, openSftpSession, exportConnections, importConnections,
34+
saveConnection,
3435
} = useAppStore()
3536

3637
const [importMsg, setImportMsg] = useState<string | null>(null)
@@ -39,6 +40,21 @@ export function Sidebar(): JSX.Element {
3940
const [exportDialogOpen, setExportDialogOpen] = useState(false)
4041
const [importDialogOpen, setImportDialogOpen] = useState(false)
4142

43+
// DnD state for connections → groups
44+
const dragConnId = useRef<string | null>(null)
45+
const [dropTargetId, setDropTargetId] = useState<string | null>(null) // group id or 'ungrouped'
46+
47+
const handleConnDrop = useCallback(async (targetGroupId: string | undefined) => {
48+
const connId = dragConnId.current
49+
if (!connId) return
50+
const conn = connections.find((c) => c.id === connId)
51+
if (conn && conn.groupId !== targetGroupId) {
52+
await saveConnection({ ...conn, groupId: targetGroupId })
53+
}
54+
dragConnId.current = null
55+
setDropTargetId(null)
56+
}, [connections, saveConnection])
57+
4258
const [search, setSearch] = useState('')
4359
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
4460
const [groupDialog, setGroupDialog] = useState<{ open: boolean; group?: ConnectionGroup }>({ open: false })
@@ -150,8 +166,15 @@ export function Sidebar(): JSX.Element {
150166
if (search && groupConns.length === 0) return null
151167
const isCollapsed = collapsedGroups.has(group.id)
152168
const groupColor = group.color || GROUP_COLORS[0]
169+
const isDropTarget = dropTargetId === group.id
153170
return (
154-
<div key={group.id}>
171+
<div
172+
key={group.id}
173+
onDragOver={(e) => { e.preventDefault(); setDropTargetId(group.id) }}
174+
onDragLeave={() => setDropTargetId(null)}
175+
onDrop={(e) => { e.preventDefault(); handleConnDrop(group.id) }}
176+
className={cn('rounded-lg transition-colors', isDropTarget && 'ring-1 ring-primary/50 bg-primary/5')}
177+
>
155178
<div className="flex items-center group/grp hover:bg-sidebar-accent/50 mx-1 rounded-lg transition-colors">
156179
<button
157180
onClick={() => toggleGroup(group.id)}
@@ -197,24 +220,33 @@ export function Sidebar(): JSX.Element {
197220
onConnect={() => openSession(conn)}
198221
onOpenSftp={() => openSftpSession(conn)}
199222
onEdit={() => setConnectionDialogOpen(true, conn)}
223+
onDragStart={() => { dragConnId.current = conn.id }}
200224
/>
201225
))}
202226
</div>
203227
)
204228
})}
205229

206-
{/* Ungrouped */}
207-
{ungrouped.map((conn) => (
208-
<ConnectionItem
209-
key={conn.id}
210-
connection={conn}
211-
sessions={sessions}
212-
activeSessionId={activeSessionId}
213-
onConnect={() => openSession(conn)}
214-
onOpenSftp={() => openSftpSession(conn)}
215-
onEdit={() => setConnectionDialogOpen(true, conn)}
216-
/>
217-
))}
230+
{/* Ungrouped — also a drop target */}
231+
<div
232+
onDragOver={(e) => { e.preventDefault(); setDropTargetId('ungrouped') }}
233+
onDragLeave={() => setDropTargetId(null)}
234+
onDrop={(e) => { e.preventDefault(); handleConnDrop(undefined) }}
235+
className={cn('rounded-lg transition-colors', dropTargetId === 'ungrouped' && 'ring-1 ring-primary/50 bg-primary/5')}
236+
>
237+
{ungrouped.map((conn) => (
238+
<ConnectionItem
239+
key={conn.id}
240+
connection={conn}
241+
sessions={sessions}
242+
activeSessionId={activeSessionId}
243+
onConnect={() => openSession(conn)}
244+
onOpenSftp={() => openSftpSession(conn)}
245+
onEdit={() => setConnectionDialogOpen(true, conn)}
246+
onDragStart={() => { dragConnId.current = conn.id }}
247+
/>
248+
))}
249+
</div>
218250
</div>
219251

220252
{/* Footer actions */}
@@ -309,10 +341,11 @@ interface ConnectionItemProps {
309341
onConnect: () => void
310342
onOpenSftp: () => void
311343
onEdit: () => void
344+
onDragStart?: () => void
312345
}
313346

314347
function ConnectionItem({
315-
connection, indent = false, sessions, activeSessionId, onConnect, onOpenSftp, onEdit
348+
connection, indent = false, sessions, activeSessionId, onConnect, onOpenSftp, onEdit, onDragStart
316349
}: ConnectionItemProps): JSX.Element {
317350
const { deleteConnection, saveConnection } = useAppStore()
318351

@@ -338,6 +371,8 @@ function ConnectionItem({
338371
return (
339372
<>
340373
<button
374+
draggable
375+
onDragStart={(e) => { e.dataTransfer.effectAllowed = 'move'; onDragStart?.() }}
341376
onDoubleClick={onConnect}
342377
onContextMenu={handleContextMenu}
343378
className={cn(

src/renderer/src/components/terminal/TabBar.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function TabBar(): JSX.Element {
1616
aiPanelOpen, setAiPanelOpen, activeForwardIds,
1717
licenseValid, errorAlertSessionId, setErrorAlert,
1818
themePanelOpen, setThemePanelOpen,
19-
renameSession, pinSession, unpinSession, duplicateSession,
19+
renameSession, pinSession, unpinSession, duplicateSession, reorderSessions,
2020
} = useAppStore()
2121

2222
// Sort: pinned tabs first, then by insertion order
@@ -26,6 +26,10 @@ export function TabBar(): JSX.Element {
2626
return 0
2727
})
2828

29+
// DnD state — tracked via refs to avoid re-renders during drag
30+
const dragFromId = useRef<string | null>(null)
31+
const [dragOverId, setDragOverId] = useState<string | null>(null)
32+
2933
const hasErrorAlert = errorAlertSessionId === activeSessionId
3034

3135
const [splitMenuOpen, setSplitMenuOpen] = useState(false)
@@ -94,6 +98,15 @@ export function TabBar(): JSX.Element {
9498
onCloseOthers={() => {
9599
sessions.filter(s => s.id !== session.id && !s.pinned).forEach(s => closeSession(s.id))
96100
}}
101+
isDragOver={dragOverId === session.id}
102+
onDragStart={() => { dragFromId.current = session.id }}
103+
onDragOver={() => setDragOverId(session.id)}
104+
onDrop={() => {
105+
if (dragFromId.current) reorderSessions(dragFromId.current, session.id)
106+
dragFromId.current = null
107+
setDragOverId(null)
108+
}}
109+
onDragEnd={() => { dragFromId.current = null; setDragOverId(null) }}
97110
/>
98111
))}
99112

@@ -260,9 +273,14 @@ interface TabProps {
260273
onRename: (label: string) => void
261274
onDuplicate: () => void
262275
onCloseOthers: () => void
276+
isDragOver: boolean
277+
onDragStart: () => void
278+
onDragOver: () => void
279+
onDrop: () => void
280+
onDragEnd: () => void
263281
}
264282

265-
function Tab({ session, isActive, isSplit, onActivate, onClose, onPin, onRename, onDuplicate, onCloseOthers }: TabProps): JSX.Element {
283+
function Tab({ session, isActive, isSplit, onActivate, onClose, onPin, onRename, onDuplicate, onCloseOthers, isDragOver, onDragStart, onDragOver, onDrop, onDragEnd }: TabProps): JSX.Element {
266284
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
267285
const [renaming, setRenaming] = useState(false)
268286
const [renameVal, setRenameVal] = useState('')
@@ -293,15 +311,21 @@ function Tab({ session, isActive, isSplit, onActivate, onClose, onPin, onRename,
293311
return (
294312
<>
295313
<div
314+
draggable
296315
onClick={onActivate}
297316
onContextMenu={(e) => { e.preventDefault(); setMenuPos({ x: e.clientX, y: e.clientY }) }}
317+
onDragStart={(e) => { e.dataTransfer.effectAllowed = 'move'; onDragStart() }}
318+
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; onDragOver() }}
319+
onDrop={(e) => { e.preventDefault(); onDrop() }}
320+
onDragEnd={onDragEnd}
298321
className={cn(
299322
'relative flex items-center gap-1.5 px-3 h-9 cursor-pointer shrink-0 group max-w-52 select-none',
300323
'rounded-t-md border border-b-0 transition-all',
301324
isActive
302325
? 'bg-background border-border text-foreground z-10'
303326
: 'bg-transparent border-transparent text-muted-foreground hover:text-foreground hover:bg-background/40',
304-
isSplit && !isActive && 'border-primary/30 bg-primary/5 text-primary/80'
327+
isSplit && !isActive && 'border-primary/30 bg-primary/5 text-primary/80',
328+
isDragOver && !isActive && 'border-primary/60 bg-primary/10'
305329
)}
306330
>
307331
{/* Active tab — colored top bar */}

src/renderer/src/store/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ interface AppState {
113113
pinSession: (sessionId: string) => void
114114
unpinSession: (sessionId: string) => void
115115
duplicateSession: (sessionId: string) => void
116+
reorderSessions: (fromId: string, toId: string) => void
116117

117118
// Actions - Settings
118119
loadSettings: () => Promise<void>
@@ -692,6 +693,19 @@ export const useAppStore = create<AppState>((set, get) => ({
692693
if (session) openSession(session.connection)
693694
},
694695

696+
reorderSessions: (fromId, toId) => {
697+
if (fromId === toId) return
698+
set((state) => {
699+
const sessions = [...state.sessions]
700+
const fromIdx = sessions.findIndex((s) => s.id === fromId)
701+
const toIdx = sessions.findIndex((s) => s.id === toId)
702+
if (fromIdx === -1 || toIdx === -1) return {}
703+
const [moved] = sessions.splice(fromIdx, 1)
704+
sessions.splice(toIdx, 0, moved)
705+
return { sessions }
706+
})
707+
},
708+
695709
// ── Port Forwarding ──────────────────────────────────────────────────────────
696710

697711
savePortForwardRule: (rule) => {

0 commit comments

Comments
 (0)