From f83bd0de41d1afc91c0c16ae0b8920b060c42e95 Mon Sep 17 00:00:00 2001 From: kewton Date: Tue, 14 Apr 2026 10:54:38 +0900 Subject: [PATCH] feat(sidebar): differentiate sidebar background and add DnD group reordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change sidebar nav bg from gray-900 to gray-800 for contrast vs main content - Darken branch item hover/selected states (gray-700→600) to match new base - Darken search input and sort dropdown backgrounds (gray-800→700) - Add drag-and-drop reordering of repository groups in grouped view - @dnd-kit/core + sortable + utilities for accessible DnD - PointerSensor with 8px activationConstraint (click vs drag distinction) - GripVertical drag handle on each group header - Disabled during search (order irrelevant while filtering) - Persist group order to DB via new app_settings table (migration v27) - GET/PUT /api/sidebar/group-order API route - Optimistic update with server revert on error - Order applies across browsers and devices - Update unit tests for new color values and schema version 27 Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 56 ++++ package.json | 3 + src/app/api/sidebar/group-order/route.ts | 56 ++++ src/components/layout/Sidebar.tsx | 290 +++++++++++++++--- src/components/sidebar/BranchListItem.tsx | 4 +- src/components/sidebar/SortSelectorBase.tsx | 4 +- src/lib/db/app-settings-db.ts | 63 ++++ src/lib/db/migrations/index.ts | 2 + src/lib/db/migrations/runner.ts | 4 +- src/lib/db/migrations/v27-app-settings.ts | 23 ++ tests/unit/components/layout/Sidebar.test.tsx | 2 +- .../sidebar/BranchListItem.test.tsx | 5 +- tests/unit/lib/db-migrations.test.ts | 8 +- 13 files changed, 462 insertions(+), 58 deletions(-) create mode 100644 src/app/api/sidebar/group-order/route.ts create mode 100644 src/lib/db/app-settings-db.ts create mode 100644 src/lib/db/migrations/v27-app-settings.ts diff --git a/package-lock.json b/package-lock.json index 4c41055d..05d874b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "commandmate", "version": "0.5.4", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@marp-team/marp-core": "^4.3.0", "ansi-to-html": "^0.7.2", "autoprefixer": "^10.4.22", @@ -715,6 +718,59 @@ "postcss-selector-parser": "^7.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", diff --git a/package.json b/package.json index 9ede978e..3a360421 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "db:reset": "rm -f db.sqlite && npm run db:init" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@marp-team/marp-core": "^4.3.0", "ansi-to-html": "^0.7.2", "autoprefixer": "^10.4.22", diff --git a/src/app/api/sidebar/group-order/route.ts b/src/app/api/sidebar/group-order/route.ts new file mode 100644 index 00000000..43097603 --- /dev/null +++ b/src/app/api/sidebar/group-order/route.ts @@ -0,0 +1,56 @@ +/** + * GET /api/sidebar/group-order — retrieve saved repository group order + * PUT /api/sidebar/group-order — save repository group order + */ + +import { NextResponse } from 'next/server'; +import { getDbInstance } from '@/lib/db/db-instance'; +import { getSidebarGroupOrder, setSidebarGroupOrder } from '@/lib/db/app-settings-db'; + +export async function GET(): Promise { + try { + const db = getDbInstance(); + const order = getSidebarGroupOrder(db); + return NextResponse.json({ success: true, order }); + } catch (error) { + console.error('GET /api/sidebar/group-order error:', error); + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT(request: Request): Promise { + try { + const body: unknown = await request.json(); + + if ( + !body || + typeof body !== 'object' || + !('order' in body) || + !Array.isArray((body as { order: unknown }).order) || + !(body as { order: unknown[] }).order.every((v) => typeof v === 'string') + ) { + return NextResponse.json( + { success: false, error: 'Invalid request body: order must be an array of strings' }, + { status: 400 } + ); + } + + const order = (body as { order: string[] }).order; + + // Limit to prevent abuse + if (order.length > 500) { + return NextResponse.json( + { success: false, error: 'Too many items in order array' }, + { status: 400 } + ); + } + + const db = getDbInstance(); + setSidebarGroupOrder(db, order); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('PUT /api/sidebar/group-order error:', error); + return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index cba27246..2fddebfe 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,6 +4,9 @@ * Main sidebar component containing the branch list. * Includes search/filter functionality, branch status display, * and repository-based grouping (Issue #449). + * + * Issue #651: Compact w-56 sidebar + tooltips. + * DnD group reordering (drag-and-drop) with DB persistence via /api/sidebar/group-order. */ 'use client'; @@ -11,6 +14,21 @@ import React, { memo, useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { useWorktreeSelection } from '@/contexts/WorktreeSelectionContext'; import { useSidebarContext } from '@/contexts/SidebarContext'; import { BranchListItem } from '@/components/sidebar/BranchListItem'; @@ -24,6 +42,7 @@ import { toBranchItem } from '@/types/sidebar'; import { generateRepositoryColor } from '@/lib/sidebar-utils'; import { useWorktreeList } from '@/hooks/useWorktreeList'; import type { ViewMode } from '@/lib/sidebar-utils'; +import type { BranchGroup } from '@/lib/sidebar-utils'; // ============================================================================ // Constants @@ -61,6 +80,27 @@ export const Sidebar = memo(function Sidebar() { } }); + // Repository group display order (DB-backed, fetched on mount) + const [repositoryOrder, setRepositoryOrder] = useState([]); + const orderLoadedRef = useRef(false); + + // Fetch saved group order from server on mount + useEffect(() => { + if (orderLoadedRef.current) return; + orderLoadedRef.current = true; + + fetch('/api/sidebar/group-order') + .then((res) => res.json()) + .then((data: { success: boolean; order: string[] | null }) => { + if (data.success && Array.isArray(data.order)) { + setRepositoryOrder(data.order); + } + }) + .catch(() => { + // Non-fatal: fall back to alphabetical order + }); + }, []); + // Convert worktrees to sidebar items const branchItems = useMemo(() => worktrees.map(toBranchItem), [worktrees]); @@ -73,8 +113,27 @@ export const Sidebar = memo(function Sidebar() { filterText: searchQuery, }); + // Apply saved repository order to groupedItems (only when not searching) + const orderedGroups: BranchGroup[] | null = useMemo(() => { + if (viewMode !== 'grouped' || !groupedItems) return null; + + if (searchQuery.trim() || repositoryOrder.length === 0) { + // No custom order: use default (alphabetical from groupBranches) + return groupedItems; + } + + // Place known repos first in saved order, then append any new repos at end + const orderMap = new Map(repositoryOrder.map((name, idx) => [name, idx])); + return [...groupedItems].sort((a, b) => { + const ia = orderMap.has(a.repositoryName) ? orderMap.get(a.repositoryName)! : Infinity; + const ib = orderMap.has(b.repositoryName) ? orderMap.get(b.repositoryName)! : Infinity; + if (ia === ib) return a.repositoryName.localeCompare(b.repositoryName); + return ia - ib; + }); + }, [viewMode, groupedItems, repositoryOrder, searchQuery]); + // Adapt groupedItems to match previous interface (null when flat mode) - const groupedBranches = viewMode === 'grouped' ? groupedItems : null; + const groupedBranches = viewMode === 'grouped' ? orderedGroups : null; // Persist groupCollapsed to localStorage useEffect(() => { @@ -120,6 +179,40 @@ export const Sidebar = memo(function Sidebar() { }; }, [selectWorktree, router, closeMobileDrawer]); + // DnD sensors: require 8px move before activating (distinguishes click from drag) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) + ); + + // Handle group reorder via DnD + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id || !groupedBranches) return; + + const currentOrder = groupedBranches.map((g) => g.repositoryName); + const oldIndex = currentOrder.indexOf(String(active.id)); + const newIndex = currentOrder.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + + const newOrder = arrayMove(currentOrder, oldIndex, newIndex); + + // Optimistic update + setRepositoryOrder(newOrder); + + // Persist to server + fetch('/api/sidebar/group-order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order: newOrder }), + }).catch(() => { + // Revert on error + setRepositoryOrder(currentOrder); + }); + }, + [groupedBranches] + ); + // Check if list is empty (for both modes) const isEmpty = viewMode === 'flat' ? flatBranches.length === 0 @@ -129,7 +222,7 @@ export const Sidebar = memo(function Sidebar() {