Skip to content

Commit 5dab8fd

Browse files
MarkShawn2020claude
andcommitted
feat(header): 优化 Feature/Project 拖拽排序与归档交互
- FeatureTabGroup 支持拖拽重排序,drag handle 仅在 project logo 上 - FeatureTab 支持在 group 内拖拽重排序,drag handle 仅在 feature 名称上 - 使用 closestCenter 碰撞检测,提升拖拽交互体验 - 移除 Status 子菜单(状态由程序自动管理) - Archive 改为子菜单:Completed/Cancelled/On Hold - 移除未使用的 StatusIcon 组件和相关导入 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8961b1b commit 5dab8fd

3 files changed

Lines changed: 208 additions & 168 deletions

File tree

src/components/GlobalHeader/FeatureTab.tsx

Lines changed: 25 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import { CSS } from "@dnd-kit/utilities";
55
import {
66
Cross2Icon,
77
CheckCircledIcon,
8-
UpdateIcon,
9-
ExclamationTriangleIcon,
108
TimerIcon,
119
DrawingPinFilledIcon,
1210
Pencil1Icon,
@@ -25,27 +23,15 @@ import {
2523
} from "@/components/ui/context-menu";
2624
import { workspaceDataAtom } from "@/store";
2725
import { invoke } from "@tauri-apps/api/core";
28-
import type { Feature, FeatureStatus, WorkspaceData } from "@/views/Workspace/types";
26+
import type { Feature, WorkspaceData } from "@/views/Workspace/types";
2927

3028
interface FeatureTabProps {
3129
feature: Feature;
3230
projectId: string;
3331
isActive: boolean;
3432
onSelect: () => void;
3533
isDragging?: boolean;
36-
}
37-
38-
function StatusIcon({ status }: { status: FeatureStatus }) {
39-
switch (status) {
40-
case "pending":
41-
return <TimerIcon className="w-3 h-3 text-muted-foreground" />;
42-
case "running":
43-
return <UpdateIcon className="w-3 h-3 text-blue-500" />;
44-
case "completed":
45-
return <CheckCircledIcon className="w-3 h-3 text-green-500" />;
46-
case "needs-review":
47-
return <ExclamationTriangleIcon className="w-3 h-3 text-amber-500" />;
48-
}
34+
dragHandleProps?: ReturnType<typeof useSortable>["listeners"];
4935
}
5036

5137
export function FeatureTab({
@@ -54,6 +40,7 @@ export function FeatureTab({
5440
isActive,
5541
onSelect,
5642
isDragging,
43+
dragHandleProps,
5744
}: FeatureTabProps) {
5845
const [workspace, setWorkspace] = useAtom(workspaceDataAtom);
5946
const [isRenaming, setIsRenaming] = useState(false);
@@ -100,7 +87,7 @@ export function FeatureTab({
10087
setIsRenaming(false);
10188
};
10289

103-
const handleArchive = async () => {
90+
const handleArchive = async (note?: string) => {
10491
if (!workspace) return;
10592

10693
const newProjects = workspace.projects.map((p) => {
@@ -109,7 +96,7 @@ export function FeatureTab({
10996
return {
11097
...p,
11198
features: p.features.map((f) =>
112-
f.id === feature.id ? { ...f, archived: true } : f
99+
f.id === feature.id ? { ...f, archived: true, archived_note: note } : f
113100
),
114101
active_feature_id:
115102
p.active_feature_id === feature.id
@@ -158,23 +145,6 @@ export function FeatureTab({
158145
await saveWorkspace({ ...workspace, projects: newProjects });
159146
};
160147

161-
const handleStatusChange = async (status: FeatureStatus) => {
162-
if (!workspace) return;
163-
164-
const newProjects = workspace.projects.map((p) =>
165-
p.id === projectId
166-
? {
167-
...p,
168-
features: p.features.map((f) =>
169-
f.id === feature.id ? { ...f, status } : f
170-
),
171-
}
172-
: p
173-
);
174-
175-
await saveWorkspace({ ...workspace, projects: newProjects });
176-
};
177-
178148
const handleDoubleClick = (e: React.MouseEvent) => {
179149
e.stopPropagation();
180150
setRenameValue(feature.name);
@@ -209,10 +179,6 @@ export function FeatureTab({
209179
{feature.pinned && (
210180
<DrawingPinFilledIcon className="w-2.5 h-2.5 text-primary/70 flex-shrink-0" />
211181
)}
212-
<span className="flex-shrink-0"><StatusIcon status={feature.status} /></span>
213-
{feature.seq > 0 && (
214-
<span className="text-[10px] text-muted-foreground/60 flex-shrink-0">#{feature.seq}</span>
215-
)}
216182
{isRenaming ? (
217183
<input
218184
ref={inputRef}
@@ -226,7 +192,11 @@ export function FeatureTab({
226192
className="w-16 text-xs bg-card border border-border rounded px-1 outline-none focus:border-primary flex-shrink-0"
227193
/>
228194
) : (
229-
<span className="text-xs truncate min-w-0" title={feature.name}>
195+
<span
196+
className="text-xs truncate min-w-0 cursor-grab active:cursor-grabbing"
197+
title={feature.name}
198+
{...dragHandleProps}
199+
>
230200
{feature.name}
231201
</span>
232202
)}
@@ -259,51 +229,36 @@ export function FeatureTab({
259229
<DrawingPinFilledIcon className="w-3.5 h-3.5" />
260230
{feature.pinned ? "Unpin" : "Pin"}
261231
</ContextMenuItem>
232+
<ContextMenuSeparator />
262233
<ContextMenuSub>
263234
<ContextMenuSubTrigger className="gap-2">
264-
<StatusIcon status={feature.status} />
265-
Status
235+
<ArchiveIcon className="w-3.5 h-3.5" />
236+
Archive
266237
</ContextMenuSubTrigger>
267238
<ContextMenuSubContent className="min-w-[120px]">
268239
<ContextMenuItem
269-
onClick={() => handleStatusChange("pending")}
270-
disabled={feature.status === "pending"}
240+
onClick={() => handleArchive("completed")}
271241
className="gap-2 cursor-pointer"
272242
>
273-
<TimerIcon className="w-3.5 h-3.5 text-muted-foreground" />
274-
Pending
275-
</ContextMenuItem>
276-
<ContextMenuItem
277-
onClick={() => handleStatusChange("running")}
278-
disabled={feature.status === "running"}
279-
className="gap-2 cursor-pointer"
280-
>
281-
<UpdateIcon className="w-3.5 h-3.5 text-blue-500" />
282-
Running
243+
<CheckCircledIcon className="w-3.5 h-3.5 text-green-500" />
244+
Completed
283245
</ContextMenuItem>
284246
<ContextMenuItem
285-
onClick={() => handleStatusChange("completed")}
286-
disabled={feature.status === "completed"}
247+
onClick={() => handleArchive("cancelled")}
287248
className="gap-2 cursor-pointer"
288249
>
289-
<CheckCircledIcon className="w-3.5 h-3.5 text-green-500" />
290-
Completed
250+
<Cross2Icon className="w-3.5 h-3.5 text-muted-foreground" />
251+
Cancelled
291252
</ContextMenuItem>
292253
<ContextMenuItem
293-
onClick={() => handleStatusChange("needs-review")}
294-
disabled={feature.status === "needs-review"}
254+
onClick={() => handleArchive("on-hold")}
295255
className="gap-2 cursor-pointer"
296256
>
297-
<ExclamationTriangleIcon className="w-3.5 h-3.5 text-amber-500" />
298-
Needs Review
257+
<TimerIcon className="w-3.5 h-3.5 text-amber-500" />
258+
On Hold
299259
</ContextMenuItem>
300260
</ContextMenuSubContent>
301261
</ContextMenuSub>
302-
<ContextMenuSeparator />
303-
<ContextMenuItem onClick={handleArchive} className="gap-2 cursor-pointer">
304-
<ArchiveIcon className="w-3.5 h-3.5" />
305-
Archive
306-
</ContextMenuItem>
307262
<ContextMenuItem
308263
onClick={handleDelete}
309264
className="gap-2 cursor-pointer text-destructive focus:text-destructive"
@@ -317,7 +272,7 @@ export function FeatureTab({
317272
}
318273

319274
// Sortable wrapper for drag-and-drop
320-
export function SortableFeatureTab(props: Omit<FeatureTabProps, "isDragging">) {
275+
export function SortableFeatureTab(props: Omit<FeatureTabProps, "isDragging" | "dragHandleProps">) {
321276
const {
322277
attributes,
323278
listeners,
@@ -333,8 +288,8 @@ export function SortableFeatureTab(props: Omit<FeatureTabProps, "isDragging">) {
333288
};
334289

335290
return (
336-
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="flex-shrink-0">
337-
<FeatureTab {...props} isDragging={isDragging} />
291+
<div ref={setNodeRef} style={style} {...attributes} className="flex-shrink-0">
292+
<FeatureTab {...props} isDragging={isDragging} dragHandleProps={listeners} />
338293
</div>
339294
);
340295
}

src/components/GlobalHeader/FeatureTabGroup.tsx

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useState, useEffect } from "react";
22
import { useAtom } from "jotai";
33
import { PlusIcon, ArchiveIcon, DashboardIcon } from "@radix-ui/react-icons";
4-
import { SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
4+
import { SortableContext, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
5+
import { CSS } from "@dnd-kit/utilities";
56
import { workspaceDataAtom, collapsedProjectGroupsAtom, viewAtom } from "@/store";
67
import { invoke } from "@tauri-apps/api/core";
78
import {
@@ -24,13 +25,17 @@ interface FeatureTabGroupProps {
2425
features: Feature[];
2526
isActiveProject: boolean;
2627
isCollapsed: boolean;
28+
isDragging?: boolean;
29+
dragHandleProps?: ReturnType<typeof useSortable>["listeners"];
2730
}
2831

2932
export function FeatureTabGroup({
3033
project,
3134
features,
3235
isActiveProject,
3336
isCollapsed,
37+
isDragging,
38+
dragHandleProps,
3439
}: FeatureTabGroupProps) {
3540
const [workspace, setWorkspace] = useAtom(workspaceDataAtom);
3641
const [collapsedGroups, setCollapsedGroups] = useAtom(collapsedProjectGroupsAtom);
@@ -249,16 +254,20 @@ export function FeatureTabGroup({
249254
// Collapsed view: just project name with count
250255
return (
251256
<>
252-
<div className="flex items-center flex-shrink-0">
257+
<div className={`flex items-center flex-shrink-0 ${isDragging ? "opacity-50" : ""}`}>
253258
<ContextMenu>
254259
<ContextMenuTrigger asChild>
255260
<button
256261
onClick={features.length > 0 ? toggleCollapsed : handleSelectProject}
257-
onPointerDown={(e) => e.stopPropagation()}
258-
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg transition-colors ${
259-
isActiveProject ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-ink hover:bg-card-alt"
262+
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg transition-colors cursor-grab active:cursor-grabbing ${
263+
isDragging
264+
? "bg-primary/20 shadow-lg"
265+
: isActiveProject
266+
? "bg-primary/10 text-primary"
267+
: "text-muted-foreground hover:text-ink hover:bg-card-alt"
260268
}`}
261269
title={projectDisplayName}
270+
{...dragHandleProps}
262271
>
263272
<ProjectLogo projectPath={project.path} size="sm" />
264273
{!hasLogo && (
@@ -286,22 +295,26 @@ export function FeatureTabGroup({
286295
// Expanded view: project header + tabs with underline indicator
287296
return (
288297
<>
289-
<div className="flex items-center flex-shrink-0">
298+
<div className={`flex items-center flex-shrink-0 ${isDragging ? "opacity-50" : ""}`}>
290299
<div
291300
className={`relative flex items-center gap-0.5 px-1 pb-1 after:absolute after:bottom-0 after:left-1 after:right-1 after:h-0.5 after:rounded-full ${
292-
isActiveProject ? "after:bg-primary" : "after:bg-border"
301+
isDragging
302+
? "bg-primary/10 rounded-lg after:bg-primary"
303+
: isActiveProject
304+
? "after:bg-primary"
305+
: "after:bg-border"
293306
}`}
294307
>
295308
{/* Project header with context menu */}
296309
<ContextMenu>
297310
<ContextMenuTrigger asChild>
298311
<button
299312
onClick={features.length > 0 ? toggleCollapsed : handleSelectProject}
300-
onPointerDown={(e) => e.stopPropagation()}
301-
className={`flex items-center px-1 py-1 rounded transition-colors flex-shrink-0 ${
313+
className={`flex items-center px-1 py-1 rounded transition-colors flex-shrink-0 cursor-grab active:cursor-grabbing ${
302314
isActiveProject ? "text-primary" : "text-muted-foreground hover:text-ink"
303315
}`}
304316
title={projectDisplayName}
317+
{...dragHandleProps}
305318
>
306319
<ProjectLogo projectPath={project.path} size="sm" />
307320
</button>
@@ -354,3 +367,26 @@ export function FeatureTabGroup({
354367
</>
355368
);
356369
}
370+
371+
// Sortable wrapper for drag-and-drop project groups
372+
export function SortableFeatureTabGroup(props: Omit<FeatureTabGroupProps, "isDragging" | "dragHandleProps">) {
373+
const {
374+
attributes,
375+
listeners,
376+
setNodeRef,
377+
transform,
378+
transition,
379+
isDragging,
380+
} = useSortable({ id: `project-${props.project.id}` });
381+
382+
const style = {
383+
transform: CSS.Transform.toString(transform),
384+
transition,
385+
};
386+
387+
return (
388+
<div ref={setNodeRef} style={style} {...attributes} className="flex-shrink-0">
389+
<FeatureTabGroup {...props} isDragging={isDragging} dragHandleProps={listeners} />
390+
</div>
391+
);
392+
}

0 commit comments

Comments
 (0)