267 lines
8.8 KiB
TypeScript
267 lines
8.8 KiB
TypeScript
/**
|
|
* Tab bar for the project editor.
|
|
* Shows open files as tabs with dirty indicator (dot), close button,
|
|
* right-click context menu (close others, close to left/right, close all),
|
|
* and drag-and-drop reordering via @dnd-kit.
|
|
*/
|
|
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
|
|
import { useAppTranslation } from '@features/localization/renderer';
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import { horizontalListSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { useStore } from '@renderer/store';
|
|
import { X } from 'lucide-react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import { EditorTabContextMenu } from './EditorTabContextMenu';
|
|
import { FileIcon } from './FileIcon';
|
|
|
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
|
import type { EditorFileTab } from '@shared/types/editor';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface EditorTabBarProps {
|
|
/** Called instead of direct closeTab — allows parent to intercept dirty tabs */
|
|
onRequestCloseTab: (tabId: string) => void;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
export const EditorTabBar = ({
|
|
onRequestCloseTab,
|
|
}: EditorTabBarProps): React.ReactElement | null => {
|
|
const { tabs, activeTabId, modifiedFiles } = useStore(
|
|
useShallow((s) => ({
|
|
tabs: s.editorOpenTabs,
|
|
activeTabId: s.editorActiveTabId,
|
|
modifiedFiles: s.editorModifiedFiles,
|
|
}))
|
|
);
|
|
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
|
|
const reorderEditorTabs = useStore((s) => s.reorderEditorTabs);
|
|
const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs);
|
|
const closeEditorTabsToLeft = useStore((s) => s.closeEditorTabsToLeft);
|
|
const closeEditorTabsToRight = useStore((s) => s.closeEditorTabsToRight);
|
|
const closeAllEditorTabs = useStore((s) => s.closeAllEditorTabs);
|
|
|
|
const [draggedTab, setDraggedTab] = useState<EditorFileTab | null>(null);
|
|
|
|
const tabIds = useMemo(() => tabs.map((t) => t.id), [tabs]);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 5 },
|
|
})
|
|
);
|
|
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
const tab = tabs.find((t) => t.id === event.active.id);
|
|
setDraggedTab(tab ?? null);
|
|
},
|
|
[tabs]
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
setDraggedTab(null);
|
|
const { active, over } = event;
|
|
if (over && active.id !== over.id) {
|
|
reorderEditorTabs(String(active.id), String(over.id));
|
|
}
|
|
},
|
|
[reorderEditorTabs]
|
|
);
|
|
|
|
const handleDragCancel = useCallback(() => {
|
|
setDraggedTab(null);
|
|
}, []);
|
|
|
|
if (tabs.length === 0) return null;
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
>
|
|
<div
|
|
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
|
|
role="tablist"
|
|
>
|
|
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
|
{tabs.map((tab, index) => (
|
|
<SortableEditorTab
|
|
key={tab.id}
|
|
tab={tab}
|
|
tabIndex={index}
|
|
totalTabs={tabs.length}
|
|
isActive={tab.id === activeTabId}
|
|
isModified={!!modifiedFiles[tab.filePath]}
|
|
onActivate={() => setActiveEditorTab(tab.id)}
|
|
onRequestClose={onRequestCloseTab}
|
|
onCloseOthers={closeOtherEditorTabs}
|
|
onCloseToLeft={closeEditorTabsToLeft}
|
|
onCloseToRight={closeEditorTabsToRight}
|
|
onCloseAll={closeAllEditorTabs}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={null}>
|
|
{draggedTab && <EditorTabOverlay tab={draggedTab} />}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Sortable tab item
|
|
// =============================================================================
|
|
|
|
interface SortableEditorTabProps {
|
|
tab: EditorFileTab;
|
|
tabIndex: number;
|
|
totalTabs: number;
|
|
isActive: boolean;
|
|
isModified: boolean;
|
|
onActivate: () => void;
|
|
onRequestClose: (tabId: string) => void;
|
|
onCloseOthers: (tabId: string) => void;
|
|
onCloseToLeft: (tabId: string) => void;
|
|
onCloseToRight: (tabId: string) => void;
|
|
onCloseAll: () => void;
|
|
}
|
|
|
|
const SortableEditorTab = ({
|
|
tab,
|
|
tabIndex,
|
|
totalTabs,
|
|
isActive,
|
|
isModified,
|
|
onActivate,
|
|
onRequestClose,
|
|
onCloseOthers,
|
|
onCloseToLeft,
|
|
onCloseToRight,
|
|
onCloseAll,
|
|
}: SortableEditorTabProps): React.ReactElement => {
|
|
const { t } = useAppTranslation('team');
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
id: tab.id,
|
|
});
|
|
|
|
const style: React.CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition: isDragging ? 'none' : transition,
|
|
opacity: isDragging ? 0.3 : 1,
|
|
};
|
|
|
|
const handleClose = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onRequestClose(tab.id);
|
|
};
|
|
|
|
const handleAuxClick = (e: React.MouseEvent) => {
|
|
if (e.button === 1) {
|
|
e.preventDefault();
|
|
onRequestClose(tab.id);
|
|
}
|
|
};
|
|
|
|
return (
|
|
// Sortable wrapper — must be the outermost element so @dnd-kit controls its position.
|
|
// ContextMenu.Trigger inside EditorTabContextMenu adds an extra <div>,
|
|
// so the useSortable ref/transform CANNOT live on the inner <button>.
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className="flex h-full shrink-0"
|
|
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
|
{...attributes}
|
|
// eslint-disable-next-line react/jsx-props-no-spreading -- @dnd-kit useSortable requires prop spreading
|
|
{...listeners}
|
|
>
|
|
<EditorTabContextMenu
|
|
tabId={tab.id}
|
|
tabIndex={tabIndex}
|
|
totalTabs={totalTabs}
|
|
onClose={onRequestClose}
|
|
onCloseOthers={onCloseOthers}
|
|
onCloseToLeft={onCloseToLeft}
|
|
onCloseToRight={onCloseToRight}
|
|
onCloseAll={onCloseAll}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={onActivate}
|
|
onAuxClick={handleAuxClick}
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
className={`group flex h-full shrink-0 cursor-grab items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
|
isActive
|
|
? 'bg-surface text-text'
|
|
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
|
}`}
|
|
>
|
|
{isModified && (
|
|
<span
|
|
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
|
aria-label={t('editor.unsavedChanges')}
|
|
/>
|
|
)}
|
|
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
|
<span className="max-w-40 truncate">
|
|
{tab.fileName}
|
|
{tab.disambiguatedLabel && (
|
|
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
|
)}
|
|
</span>
|
|
<span
|
|
onClick={handleClose}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
|
role="button"
|
|
aria-label={`Close ${tab.fileName}`}
|
|
tabIndex={-1}
|
|
>
|
|
<X className="size-3" />
|
|
</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
|
</Tooltip>
|
|
</EditorTabContextMenu>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Drag overlay (ghost shown while dragging)
|
|
// =============================================================================
|
|
|
|
const EditorTabOverlay = ({ tab }: { tab: EditorFileTab }): React.ReactElement => (
|
|
<div className="flex items-center gap-1.5 rounded border border-border-emphasis bg-surface-raised px-3 py-1 text-xs text-text shadow-lg">
|
|
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
|
<span className="max-w-40 truncate">{tab.fileName}</span>
|
|
</div>
|
|
);
|