feat: add draggable file tab reordering (#936)

This commit is contained in:
soulme 2026-05-08 22:21:19 +08:00 committed by GitHub
parent 50b72feffd
commit e3423c2b7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 375 additions and 3 deletions

View file

@ -1,4 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent as ReactDragEvent,
} from 'react';
import { useT } from '../i18n';
import {
deleteProjectFile,
@ -56,6 +62,7 @@ interface SketchState {
}
const DESIGN_FILES_TAB = '__design_files__';
type TabDropEdge = 'before' | 'after';
export function FileWorkspace({
projectId,
@ -88,8 +95,14 @@ export function FileWorkspace({
const [uploadError, setUploadError] = useState<string | null>(null);
const [sketches, setSketches] = useState<Record<string, SketchState>>({});
const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
const [draggedTabName, setDraggedTabName] = useState<string | null>(null);
const [dragOverTab, setDragOverTab] = useState<{
name: string;
edge: TabDropEdge;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const tabsBarRef = useRef<HTMLDivElement | null>(null);
const draggedTabNameRef = useRef<string | null>(null);
const visibleFiles = useMemo(
() => files.filter((file) => !isLiveArtifactImplementationPath(file.name)),
@ -182,6 +195,29 @@ export function FileWorkspace({
});
}
function reorderPersistedTab(
draggedName: string,
targetName: string,
edge: TabDropEdge,
) {
if (draggedName === targetName) return;
if (!persistedTabs.includes(draggedName)) return;
if (!persistedTabs.includes(targetName)) return;
const nextTabs = persistedTabs.filter((name) => name !== draggedName);
const targetIndex = nextTabs.indexOf(targetName);
if (targetIndex === -1) return;
nextTabs.splice(edge === 'after' ? targetIndex + 1 : targetIndex, 0, draggedName);
if (arraysEqual(nextTabs, persistedTabs)) return;
onTabsStateChange({ tabs: nextTabs, active: tabsState.active });
}
function clearTabDragState() {
draggedTabNameRef.current = null;
setDraggedTabName(null);
setDragOverTab(null);
}
async function handleFilePicked(ev: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(ev.target.files ?? []);
ev.target.value = '';
@ -450,6 +486,14 @@ export function FileWorkspace({
className="ws-tabs-bar"
role="tablist"
aria-label={t('workspace.designFiles')}
onDragLeave={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setDragOverTab(null);
}}
onDrop={(event) => {
if (event.target !== event.currentTarget) return;
clearTabDragState();
}}
>
<button
type="button"
@ -485,6 +529,44 @@ export function FileWorkspace({
onClose={() => closeTab(name)}
kind={kind}
liveArtifact={liveArtifact}
draggable={persistedTabs.includes(name)}
dragging={draggedTabName === name}
dragOverEdge={
dragOverTab?.name === name && draggedTabName !== name
? dragOverTab.edge
: null
}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', name);
draggedTabNameRef.current = name;
setDraggedTabName(name);
}}
onDragOver={(event) => {
const currentDraggedName = draggedTabNameRef.current ?? draggedTabName;
if (!currentDraggedName || currentDraggedName === name) return;
if (!persistedTabs.includes(currentDraggedName)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const edge = tabDropEdgeFromEvent(event);
setDragOverTab((current) =>
current?.name === name && current.edge === edge
? current
: { name, edge },
);
}}
onDragLeave={() => {
setDragOverTab((current) => (current?.name === name ? null : current));
}}
onDrop={(event) => {
event.preventDefault();
const draggedName = draggedTabNameRef.current || draggedTabName;
if (draggedName) {
reorderPersistedTab(draggedName, name, tabDropEdgeFromEvent(event));
}
clearTabDragState();
}}
onDragEnd={clearTabDragState}
/>
);
})}
@ -618,6 +700,14 @@ function Tab({
closable = true,
kind,
liveArtifact,
draggable = false,
dragging = false,
dragOverEdge,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
}: {
label: string;
active: boolean;
@ -626,12 +716,27 @@ function Tab({
closable?: boolean;
kind?: ProjectFile['kind'] | 'live-artifact';
liveArtifact?: LiveArtifactWorkspaceEntry;
draggable?: boolean;
dragging?: boolean;
dragOverEdge?: TabDropEdge | null;
onDragStart?: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragOver?: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragLeave?: () => void;
onDrop?: (event: ReactDragEvent<HTMLDivElement>) => void;
onDragEnd?: () => void;
}) {
const t = useT();
const iconName = kindIconName(kind);
return (
<div
className={`ws-tab${kind === 'live-artifact' ? ' live-artifact-tab' : ''} ${active ? 'active' : ''}`}
className={[
'ws-tab',
kind === 'live-artifact' ? 'live-artifact-tab' : '',
active ? 'active' : '',
draggable ? 'draggable' : '',
dragging ? 'dragging' : '',
dragOverEdge ? `drag-over-${dragOverEdge}` : '',
].filter(Boolean).join(' ')}
onClick={onActivate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@ -642,6 +747,12 @@ function Tab({
role="tab"
aria-selected={active}
tabIndex={0}
draggable={draggable}
onDragStart={draggable ? onDragStart : undefined}
onDragOver={draggable ? onDragOver : undefined}
onDragLeave={draggable ? onDragLeave : undefined}
onDrop={draggable ? onDrop : undefined}
onDragEnd={draggable ? onDragEnd : undefined}
>
{iconName ? (
<span className="tab-icon" aria-hidden>
@ -674,6 +785,16 @@ function Tab({
);
}
function tabDropEdgeFromEvent(event: ReactDragEvent<HTMLDivElement>): TabDropEdge {
const rect = event.currentTarget.getBoundingClientRect();
return event.clientX > rect.left + rect.width / 2 ? 'after' : 'before';
}
function arraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
export function scrollWorkspaceTabsWithWheel(
tabBar: Pick<HTMLDivElement, 'clientWidth' | 'scrollLeft' | 'scrollWidth'>,
event: Pick<globalThis.WheelEvent, 'ctrlKey' | 'deltaMode' | 'deltaX' | 'deltaY' | 'preventDefault'>,

View file

@ -6789,6 +6789,21 @@ button.connector-action.is-loading {
max-width: 320px;
}
.ws-tab:hover { background: var(--bg-subtle); color: var(--text); }
.ws-tab.draggable { cursor: grab; }
.ws-tab.dragging {
cursor: grabbing;
opacity: 0.55;
}
.ws-tab.drag-over-before {
box-shadow: inset 2px 0 0 var(--accent);
background: var(--bg-subtle);
color: var(--text);
}
.ws-tab.drag-over-after {
box-shadow: inset -2px 0 0 var(--accent);
background: var(--bg-subtle);
color: var(--text);
}
.ws-tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;

View file

@ -1,8 +1,99 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
import { projectSplitClassName } from '../../src/components/ProjectView';
import type { ProjectFile } from '../../src/types';
let root: Root | null = null;
let host: HTMLDivElement | null = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
host?.remove();
host = null;
});
function workspaceFile(name: string): ProjectFile {
return {
name,
path: name,
type: 'file',
size: 100,
mtime: 1700000000,
kind: name.endsWith('.html') ? 'html' : 'text',
mime: name.endsWith('.html') ? 'text/html' : 'text/plain',
};
}
function renderWorkspace(element: React.ReactElement) {
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
act(() => {
root?.render(element);
});
return host;
}
function getTabByName(container: HTMLElement, name: RegExp): HTMLElement {
const tabs = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]'));
const tab = tabs.find((node) => name.test(node.textContent ?? ''));
if (!tab) throw new Error(`Could not find tab matching ${name}`);
return tab;
}
function createDragDataTransfer() {
const store = new Map<string, string>();
return {
effectAllowed: 'move',
dropEffect: 'move',
getData: vi.fn((type: string) => store.get(type) ?? ''),
setData: vi.fn((type: string, value: string) => {
store.set(type, value);
}),
};
}
function dispatchDragEvent(
target: HTMLElement,
type: string,
dataTransfer = createDragDataTransfer(),
clientX = 0,
relatedTarget: EventTarget | null = null,
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: clientX },
dataTransfer: { value: dataTransfer },
relatedTarget: { value: relatedTarget },
});
target.dispatchEvent(event);
return dataTransfer;
}
function stubTabRect(tab: HTMLElement, left = 0, width = 100) {
tab.getBoundingClientRect = vi.fn(() => ({
x: left,
y: 0,
left,
top: 0,
right: left + width,
bottom: 20,
width,
height: 20,
toJSON: () => ({}),
}));
}
describe('FileWorkspace upload input', () => {
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
@ -82,6 +173,151 @@ describe('FileWorkspace upload input', () => {
});
});
describe('FileWorkspace tab reordering', () => {
it('persists a dragged file tab before the tab it is dropped on', () => {
const onTabsStateChange = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
files={[
workspaceFile('analysis.html'),
workspaceFile('notes.md'),
workspaceFile('summary.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md', 'summary.html'],
active: null,
}}
onTabsStateChange={onTabsStateChange}
/>,
);
const source = getTabByName(container, /summary\.html/i);
const target = getTabByName(container, /analysis\.html/i);
stubTabRect(target);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
act(() => dispatchDragEvent(target, 'drop', dataTransfer));
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['summary.html', 'analysis.html', 'notes.md'],
active: null,
});
});
it('persists a dragged file tab after the tab when dropped on its right side', () => {
const onTabsStateChange = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
files={[
workspaceFile('analysis.html'),
workspaceFile('notes.md'),
workspaceFile('summary.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md', 'summary.html'],
active: null,
}}
onTabsStateChange={onTabsStateChange}
/>,
);
const source = getTabByName(container, /analysis\.html/i);
const target = getTabByName(container, /summary\.html/i);
stubTabRect(target);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(target, 'drop', dataTransfer, 75));
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['notes.md', 'summary.html', 'analysis.html'],
active: null,
});
});
it('does not persist when a tab is dropped on itself', () => {
const onTabsStateChange = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md'],
active: null,
}}
onTabsStateChange={onTabsStateChange}
/>,
);
const tab = getTabByName(container, /analysis\.html/i);
stubTabRect(tab);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(tab, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(tab, 'drop', dataTransfer));
expect(onTabsStateChange).not.toHaveBeenCalled();
});
it('clears the drop indicator when the drag leaves the tab bar', () => {
const container = renderWorkspace(
<FileWorkspace
projectId="project-1"
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['analysis.html', 'notes.md'],
active: null,
}}
onTabsStateChange={vi.fn()}
/>,
);
const source = getTabByName(container, /analysis\.html/i);
const target = getTabByName(container, /notes\.md/i);
const tabBar = container.querySelector<HTMLElement>('.ws-tabs-bar');
if (!tabBar) throw new Error('Could not find tabs bar');
stubTabRect(target);
let dataTransfer = createDragDataTransfer();
act(() => {
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
});
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
expect(target.className).toContain('drag-over-before');
act(() => dispatchDragEvent(tabBar, 'dragleave', dataTransfer, 0, document.body));
expect(target.className).not.toContain('drag-over-before');
expect(target.className).not.toContain('drag-over-after');
});
});
describe('projectSplitClassName', () => {
it('marks the project split as focused so the chat pane can collapse globally', () => {
expect(projectSplitClassName(false)).toBe('split');