Skip to content

Commit ff6c7a7

Browse files
committed
feat: add tab reordering via drag and drop and fix ghost snap-back animation
1 parent b850486 commit ff6c7a7

3 files changed

Lines changed: 217 additions & 22 deletions

File tree

anycode/components/toolbar/Toolbar.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,9 @@
131131
padding-right: 6px;
132132
}
133133

134+
.tab.tab-dragging {
135+
/*opacity: 0.4;*/
136+
/*border-bottom-style: dashed;*/
137+
/*border-bottom-color: var(--theme-accent-background, #7ec8ff);*/
138+
/*background: rgba(255, 255, 255, 0.03);*/
139+
}

anycode/components/toolbar/Toolbar.tsx

Lines changed: 193 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FileState, Terminal, AcpSession } from '../../types';
2-
import { WheelEvent, useState, useMemo } from 'react';
2+
import { WheelEvent, useState, useMemo, useEffect } from 'react';
33
import { loadItem, saveItem } from '../../storage';
44
import { TabContextMenu } from './TabContextMenu';
55
import type { TabMenuAction } from './TabContextMenu';
@@ -11,6 +11,10 @@ const PINNED_FILES_KEY = 'pinnedFiles';
1111
const PINNED_TERMINALS_KEY = 'pinnedTerminals';
1212
const PINNED_AGENTS_KEY = 'pinnedAgents';
1313

14+
const FILE_IDS_ORDER_KEY = 'toolbarFileIdsOrder';
15+
const TERMINAL_IDS_ORDER_KEY = 'toolbarTerminalIdsOrder';
16+
const AGENT_IDS_ORDER_KEY = 'toolbarAgentIdsOrder';
17+
1418
interface ToolbarProps {
1519
files: FileState[];
1620
activeFileId: string | null;
@@ -56,6 +60,33 @@ export const Toolbar = ({
5660
return loadItem<string[]>(PINNED_AGENTS_KEY) ?? [];
5761
});
5862

63+
const [fileIdsOrder, setFileIdsOrder] = useState<string[]>(() => {
64+
return loadItem<string[]>(FILE_IDS_ORDER_KEY) ?? [];
65+
});
66+
const [terminalIdsOrder, setTerminalIdsOrder] = useState<string[]>(() => {
67+
return loadItem<string[]>(TERMINAL_IDS_ORDER_KEY) ?? [];
68+
});
69+
const [agentIdsOrder, setAgentIdsOrder] = useState<string[]>(() => {
70+
return loadItem<string[]>(AGENT_IDS_ORDER_KEY) ?? [];
71+
});
72+
73+
const [draggedItem, setDraggedItem] = useState<{
74+
type: 'file' | 'terminal' | 'agent';
75+
id: string;
76+
} | null>(null);
77+
78+
useEffect(() => {
79+
saveItem(FILE_IDS_ORDER_KEY, fileIdsOrder);
80+
}, [fileIdsOrder]);
81+
82+
useEffect(() => {
83+
saveItem(TERMINAL_IDS_ORDER_KEY, terminalIdsOrder);
84+
}, [terminalIdsOrder]);
85+
86+
useEffect(() => {
87+
saveItem(AGENT_IDS_ORDER_KEY, agentIdsOrder);
88+
}, [agentIdsOrder]);
89+
5990
const togglePinFile = (fileId: string) => {
6091
setPinnedFileIds((prev) => {
6192
const next = prev.includes(fileId)
@@ -87,31 +118,153 @@ export const Toolbar = ({
87118
};
88119

89120
const sortedFiles = useMemo(() => {
90-
const pinned = pinnedFileIds
91-
.map((id) => files.find((f) => f.id === id))
92-
.filter((f): f is FileState => !!f);
93-
const pinnedSet = new Set(pinnedFileIds);
94-
const unpinned = files.filter((f) => !pinnedSet.has(f.id));
95-
return [...pinned, ...unpinned];
96-
}, [files, pinnedFileIds]);
121+
const pinned = files.filter((f) => pinnedFileIds.includes(f.id));
122+
const unpinned = files.filter((f) => !pinnedFileIds.includes(f.id));
123+
124+
const sortFunc = (a: FileState, b: FileState) => {
125+
const indexA = fileIdsOrder.indexOf(a.id);
126+
const indexB = fileIdsOrder.indexOf(b.id);
127+
if (indexA === -1 && indexB === -1) return 0;
128+
if (indexA === -1) return 1;
129+
if (indexB === -1) return -1;
130+
return indexA - indexB;
131+
};
132+
133+
return [...pinned.sort(sortFunc), ...unpinned.sort(sortFunc)];
134+
}, [files, pinnedFileIds, fileIdsOrder]);
97135

98136
const sortedTerminals = useMemo(() => {
99-
const pinned = pinnedTerminalIds
100-
.map((id) => terminals.find((t) => t.id === id))
101-
.filter((t): t is Terminal => !!t);
102-
const pinnedSet = new Set(pinnedTerminalIds);
103-
const unpinned = terminals.filter((t) => !pinnedSet.has(t.id));
104-
return [...pinned, ...unpinned];
105-
}, [terminals, pinnedTerminalIds]);
137+
const pinned = terminals.filter((t) => pinnedTerminalIds.includes(t.id));
138+
const unpinned = terminals.filter((t) => !pinnedTerminalIds.includes(t.id));
139+
140+
const sortFunc = (a: Terminal, b: Terminal) => {
141+
const indexA = terminalIdsOrder.indexOf(a.id);
142+
const indexB = terminalIdsOrder.indexOf(b.id);
143+
if (indexA === -1 && indexB === -1) return 0;
144+
if (indexA === -1) return 1;
145+
if (indexB === -1) return -1;
146+
return indexA - indexB;
147+
};
148+
149+
return [...pinned.sort(sortFunc), ...unpinned.sort(sortFunc)];
150+
}, [terminals, pinnedTerminalIds, terminalIdsOrder]);
106151

107152
const sortedAgentSessions = useMemo(() => {
108-
const pinned = pinnedAgentIds
109-
.map((id) => agentSessions.find((s) => s.agentId === id))
110-
.filter((s): s is AcpSession => !!s);
111-
const pinnedSet = new Set(pinnedAgentIds);
112-
const unpinned = agentSessions.filter((s) => !pinnedSet.has(s.agentId));
113-
return [...pinned, ...unpinned];
114-
}, [agentSessions, pinnedAgentIds]);
153+
const pinned = agentSessions.filter((s) => pinnedAgentIds.includes(s.agentId));
154+
const unpinned = agentSessions.filter((s) => !pinnedAgentIds.includes(s.agentId));
155+
156+
const sortFunc = (a: AcpSession, b: AcpSession) => {
157+
const indexA = agentIdsOrder.indexOf(a.agentId);
158+
const indexB = agentIdsOrder.indexOf(b.agentId);
159+
if (indexA === -1 && indexB === -1) return 0;
160+
if (indexA === -1) return 1;
161+
if (indexB === -1) return -1;
162+
return indexA - indexB;
163+
};
164+
165+
return [...pinned.sort(sortFunc), ...unpinned.sort(sortFunc)];
166+
}, [agentSessions, pinnedAgentIds, agentIdsOrder]);
167+
168+
const handleDragStart = (e: React.DragEvent, type: 'file' | 'terminal' | 'agent', id: string) => {
169+
setDraggedItem({ type, id });
170+
e.dataTransfer.effectAllowed = 'move';
171+
};
172+
173+
const handleDragEnd = () => {
174+
setDraggedItem(null);
175+
};
176+
177+
const handleDrop = (e: React.DragEvent) => {
178+
e.preventDefault();
179+
};
180+
181+
const handleDragOver = (e: React.DragEvent, type: 'file' | 'terminal' | 'agent', targetId: string) => {
182+
if (!draggedItem || draggedItem.type !== type || draggedItem.id === targetId) {
183+
return;
184+
}
185+
186+
e.preventDefault();
187+
188+
if (type === 'file') {
189+
const isDraggedPinned = pinnedFileIds.includes(draggedItem.id);
190+
const isTargetPinned = pinnedFileIds.includes(targetId);
191+
192+
if (isDraggedPinned !== isTargetPinned) {
193+
return;
194+
}
195+
196+
setFileIdsOrder((prev) => {
197+
let nextOrder = [...prev];
198+
const currentSortedIds = sortedFiles.map((f) => f.id);
199+
currentSortedIds.forEach((id) => {
200+
if (!nextOrder.includes(id)) {
201+
nextOrder.push(id);
202+
}
203+
});
204+
205+
const fromIndex = nextOrder.indexOf(draggedItem.id);
206+
const toIndex = nextOrder.indexOf(targetId);
207+
208+
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
209+
nextOrder.splice(fromIndex, 1);
210+
nextOrder.splice(toIndex, 0, draggedItem.id);
211+
}
212+
return nextOrder;
213+
});
214+
} else if (type === 'terminal') {
215+
const isDraggedPinned = pinnedTerminalIds.includes(draggedItem.id);
216+
const isTargetPinned = pinnedTerminalIds.includes(targetId);
217+
218+
if (isDraggedPinned !== isTargetPinned) {
219+
return;
220+
}
221+
222+
setTerminalIdsOrder((prev) => {
223+
let nextOrder = [...prev];
224+
const currentSortedIds = sortedTerminals.map((t) => t.id);
225+
currentSortedIds.forEach((id) => {
226+
if (!nextOrder.includes(id)) {
227+
nextOrder.push(id);
228+
}
229+
});
230+
231+
const fromIndex = nextOrder.indexOf(draggedItem.id);
232+
const toIndex = nextOrder.indexOf(targetId);
233+
234+
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
235+
nextOrder.splice(fromIndex, 1);
236+
nextOrder.splice(toIndex, 0, draggedItem.id);
237+
}
238+
return nextOrder;
239+
});
240+
} else if (type === 'agent') {
241+
const isDraggedPinned = pinnedAgentIds.includes(draggedItem.id);
242+
const isTargetPinned = pinnedAgentIds.includes(targetId);
243+
244+
if (isDraggedPinned !== isTargetPinned) {
245+
return;
246+
}
247+
248+
setAgentIdsOrder((prev) => {
249+
let nextOrder = [...prev];
250+
const currentSortedIds = sortedAgentSessions.map((s) => s.agentId);
251+
currentSortedIds.forEach((id) => {
252+
if (!nextOrder.includes(id)) {
253+
nextOrder.push(id);
254+
}
255+
});
256+
257+
const fromIndex = nextOrder.indexOf(draggedItem.id);
258+
const toIndex = nextOrder.indexOf(targetId);
259+
260+
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
261+
nextOrder.splice(fromIndex, 1);
262+
nextOrder.splice(toIndex, 0, draggedItem.id);
263+
}
264+
return nextOrder;
265+
});
266+
}
267+
};
115268

116269
// Mass tab closing helper handlers
117270
const handleCloseRightFiles = (fileId: string) => {
@@ -275,6 +428,12 @@ export const Toolbar = ({
275428
onSelect={() => onSelectFile(file.id)}
276429
onClose={() => onCloseFile(file.id)}
277430
onContextMenu={(event) => openMenu(event, 'file', file.id)}
431+
draggable={true}
432+
dragging={draggedItem?.type === 'file' && draggedItem?.id === file.id}
433+
onDragStart={(event) => handleDragStart(event, 'file', file.id)}
434+
onDragEnd={handleDragEnd}
435+
onDragOver={(event) => handleDragOver(event, 'file', file.id)}
436+
onDrop={handleDrop}
278437
/>
279438
))}
280439
{sortedTerminals.map((terminal) => (
@@ -288,6 +447,12 @@ export const Toolbar = ({
288447
onSelect={() => onSelectTerminal(terminal.id)}
289448
onClose={() => onCloseTerminal(terminal.id)}
290449
onContextMenu={(event) => openMenu(event, 'terminal', terminal.id)}
450+
draggable={true}
451+
dragging={draggedItem?.type === 'terminal' && draggedItem?.id === terminal.id}
452+
onDragStart={(event) => handleDragStart(event, 'terminal', terminal.id)}
453+
onDragEnd={handleDragEnd}
454+
onDragOver={(event) => handleDragOver(event, 'terminal', terminal.id)}
455+
onDrop={handleDrop}
291456
/>
292457
))}
293458
{sortedAgentSessions.map((session) => (
@@ -301,6 +466,12 @@ export const Toolbar = ({
301466
onSelect={() => onSelectAgent(session.agentId)}
302467
onClose={() => onCloseAgent(session.agentId)}
303468
onContextMenu={(event) => openMenu(event, 'agent', session.agentId)}
469+
draggable={true}
470+
dragging={draggedItem?.type === 'agent' && draggedItem?.id === session.agentId}
471+
onDragStart={(event) => handleDragStart(event, 'agent', session.agentId)}
472+
onDragEnd={handleDragEnd}
473+
onDragOver={(event) => handleDragOver(event, 'agent', session.agentId)}
474+
onDrop={handleDrop}
304475
/>
305476
))}
306477
</div>

anycode/components/toolbar/ToolbarTab.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ type ToolbarTabProps = {
1111
onSelect: () => void;
1212
onClose: () => void;
1313
onContextMenu: (event: ReactMouseEvent) => void;
14+
draggable?: boolean;
15+
dragging?: boolean;
16+
onDragStart?: (event: React.DragEvent) => void;
17+
onDragEnd?: (event: React.DragEvent) => void;
18+
onDragOver?: (event: React.DragEvent) => void;
19+
onDrop?: (event: React.DragEvent) => void;
1420
};
1521

1622
export const ToolbarTab = ({
@@ -23,20 +29,32 @@ export const ToolbarTab = ({
2329
onSelect,
2430
onClose,
2531
onContextMenu,
32+
draggable,
33+
dragging,
34+
onDragStart,
35+
onDragEnd,
36+
onDragOver,
37+
onDrop,
2638
}: ToolbarTabProps) => {
2739
const className = [
2840
'tab',
2941
variant === 'terminal' ? 'tab-terminal' : '',
3042
variant === 'agent' ? 'tab-agent' : '',
3143
active ? 'active' : '',
3244
pinned ? 'tab-pinned' : '',
45+
dragging ? 'tab-dragging' : '',
3346
].filter(Boolean).join(' ');
3447

3548
return (
3649
<div
3750
className={className}
3851
onClick={() => !active && onSelect()}
3952
onContextMenu={onContextMenu}
53+
draggable={draggable}
54+
onDragStart={onDragStart}
55+
onDragEnd={onDragEnd}
56+
onDragOver={onDragOver}
57+
onDrop={onDrop}
4058
>
4159
<span className="tab-filename" title={title}>{label}</span>
4260
{pinned ? (

0 commit comments

Comments
 (0)