diff --git a/CHANGELOG.md b/CHANGELOG.md index 3049789a..c27fa170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### 🚀 Added +- Sidebar: drag-to-reorder workspace projects with dnd-kit, persisted sort order, and migration backfill for existing databases. (#87) - Workspace canvas: arrange all / arrange canvas / arrange in space actions. (#42) - Workspace canvas: Arrange By menu (scope, ordering, space sizing, magnetic snapping). (#42) - Workspace canvas: live magnetic snap guides for node dragging, aligned to the 24px canvas rhythm and enabled by default. (#42) diff --git a/src/app/renderer/shell/AppShell.tsx b/src/app/renderer/shell/AppShell.tsx index 01801ff4..2be5cfd3 100644 --- a/src/app/renderer/shell/AppShell.tsx +++ b/src/app/renderer/shell/AppShell.tsx @@ -27,7 +27,6 @@ import { useFloatingMessage } from './hooks/useFloatingMessage' import { useWorkspaceStateHandlers } from './hooks/useWorkspaceStateHandlers' import { useAppUpdates } from './hooks/useAppUpdates' import { useWhatsNew } from './hooks/useWhatsNew' -import type { ProjectContextMenuState } from './types' import { useAppStore } from './store/useAppStore' import { removeWorkspace } from './utils/removeWorkspace' import { WhatsNewDialog } from './components/WhatsNewDialog' @@ -285,6 +284,21 @@ export default function App(): React.JSX.Element { store.setProjectContextMenu(null) }, []) + const handleReorderWorkspaces = useCallback( + (activeId: string, overId: string): void => { + const store = useAppStore.getState() + store.reorderWorkspaces(activeId, overId) + requestPersistFlush() + }, + [requestPersistFlush], + ) + + const handleOpenSettings = useCallback((): void => { + setIsFocusNodeTargetZoomPreviewing(false) + closeControlCenter() + setIsSettingsOpen(true) + }, [closeControlCenter, setIsSettingsOpen]) + return ( <>
{ - toggleControlCenter() - }} - onToggleCommandCenter={() => { - toggleCommandCenter() - }} - onOpenSettings={() => { - setIsFocusNodeTargetZoomPreviewing(false) - closeControlCenter() - setIsSettingsOpen(true) - }} - onCheckForUpdates={() => { - void checkForUpdates() - }} - onDownloadUpdate={() => { - void downloadUpdate() - }} - onInstallUpdate={() => { - void installUpdate() - }} + onToggleControlCenter={toggleControlCenter} + onToggleCommandCenter={toggleCommandCenter} + onOpenSettings={handleOpenSettings} + onCheckForUpdates={checkForUpdates} + onDownloadUpdate={downloadUpdate} + onInstallUpdate={installUpdate} /> {isPrimarySidebarCollapsed ? null : ( @@ -333,18 +333,11 @@ export default function App(): React.JSX.Element { activeProviderLabel={activeProviderLabel} activeProviderModel={activeProviderModel} persistNotice={persistNotice} - onAddWorkspace={() => { - void handleAddWorkspace() - }} - onSelectWorkspace={workspaceId => { - handleSelectWorkspace(workspaceId) - }} - onOpenProjectContextMenu={(state: ProjectContextMenuState) => { - setProjectContextMenu(state) - }} - onSelectAgentNode={(workspaceId, nodeId) => { - handleSelectAgentNode(workspaceId, nodeId) - }} + onAddWorkspace={handleAddWorkspace} + onSelectWorkspace={handleSelectWorkspace} + onOpenProjectContextMenu={setProjectContextMenu} + onSelectAgentNode={handleSelectAgentNode} + onReorderWorkspaces={handleReorderWorkspaces} /> )} @@ -361,9 +354,7 @@ export default function App(): React.JSX.Element { !isSpaceArchivesOpen && projectDeleteConfirmation === null } - onAddWorkspace={() => { - void handleAddWorkspace() - }} + onAddWorkspace={handleAddWorkspace} onShowMessage={handleShowMessage} onRequestPersistFlush={requestPersistFlush} onAppendSpaceArchiveRecord={handleWorkspaceSpaceArchiveRecordAppend} @@ -378,9 +369,7 @@ export default function App(): React.JSX.Element { isOpen={isWorkspaceSearchOpen} activeWorkspace={activeWorkspace} onClose={closeWorkspaceSearch} - onSelectSpace={spaceId => { - handleWorkspaceActiveSpaceChange(spaceId) - }} + onSelectSpace={handleWorkspaceActiveSpaceChange} panelWidth={agentSettings.workspaceSearchPanelWidth} onPanelWidthChange={nextWidth => { setAgentSettings(prev => ({ @@ -403,11 +392,7 @@ export default function App(): React.JSX.Element { isControlCenterOpen={isControlCenterOpen} onCloseControlCenter={closeControlCenter} onMinimapVisibilityChange={handleWorkspaceMinimapVisibilityChange} - onOpenSettings={() => { - setIsFocusNodeTargetZoomPreviewing(false) - closeControlCenter() - setIsSettingsOpen(true) - }} + onOpenSettings={handleOpenSettings} /> { - closeCommandCenter() - }} - onOpenSettings={() => { - setIsFocusNodeTargetZoomPreviewing(false) - setIsSettingsOpen(true) - }} - onOpenSpaceArchives={() => { - openSpaceArchives() - }} + onClose={closeCommandCenter} + onOpenSettings={handleOpenSettings} + onOpenSpaceArchives={openSpaceArchives} onTogglePrimarySidebar={() => { setAgentSettings(prev => ({ ...prev, isPrimarySidebarCollapsed: !prev.isPrimarySidebarCollapsed, })) }} - onAddWorkspace={() => { - void handleAddWorkspace() - }} - onSelectWorkspace={workspaceId => { - handleSelectWorkspace(workspaceId) - }} - onSelectSpace={spaceId => { - handleWorkspaceActiveSpaceChange(spaceId) - }} + onAddWorkspace={handleAddWorkspace} + onSelectWorkspace={handleSelectWorkspace} + onSelectSpace={handleWorkspaceActiveSpaceChange} /> { - handleRequestRemoveProject(workspaceId) - }} + onRequestRemove={handleRequestRemoveProject} /> ) : null} @@ -480,23 +450,15 @@ export default function App(): React.JSX.Element { updateState={updateState} modelCatalogByProvider={providerModelCatalog} workspaces={workspaces} - onWorkspaceWorktreesRootChange={(id, root) => { - handleAnyWorkspaceWorktreesRootChange(id, root) - }} + onWorkspaceWorktreesRootChange={handleAnyWorkspaceWorktreesRootChange} isFocusNodeTargetZoomPreviewing={isFocusNodeTargetZoomPreviewing} onFocusNodeTargetZoomPreviewChange={setIsFocusNodeTargetZoomPreviewing} onChange={next => { setAgentSettings(next) }} - onCheckForUpdates={() => { - void checkForUpdates() - }} - onDownloadUpdate={() => { - void downloadUpdate() - }} - onInstallUpdate={() => { - void installUpdate() - }} + onCheckForUpdates={checkForUpdates} + onDownloadUpdate={downloadUpdate} + onInstallUpdate={installUpdate} onClose={() => { flushPersistNow() setIsFocusNodeTargetZoomPreviewing(false) @@ -512,9 +474,7 @@ export default function App(): React.JSX.Element { isLoading={whatsNew.isLoading} error={whatsNew.error} compareUrl={whatsNew.compareUrl} - onClose={() => { - whatsNew.close() - }} + onClose={whatsNew.close} /> ) diff --git a/src/app/renderer/shell/components/Sidebar.spec.tsx b/src/app/renderer/shell/components/Sidebar.spec.tsx new file mode 100644 index 00000000..d90639d1 --- /dev/null +++ b/src/app/renderer/shell/components/Sidebar.spec.tsx @@ -0,0 +1,210 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + DEFAULT_WORKSPACE_VIEWPORT, + type WorkspaceState, +} from '@contexts/workspace/presentation/renderer/types' +import { Sidebar } from './Sidebar' + +const dndState = vi.hoisted(() => ({ + draggingId: null as string | null, + onDragStart: null as ((event: { active: { id: string } }) => void) | null, + onDragEnd: null as + | ((event: { active: { id: string }; over: { id: string } | null }) => void) + | null, +})) + +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ + children, + onDragStart, + onDragEnd, + }: { + children: React.ReactNode + onDragStart?: (event: { active: { id: string } }) => void + onDragEnd?: (event: { active: { id: string }; over: { id: string } | null }) => void + }) => { + dndState.onDragStart = onDragStart ?? null + dndState.onDragEnd = onDragEnd ?? null + return <>{children} + }, + DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}, + PointerSensor: vi.fn(), + closestCenter: vi.fn(), + useSensor: vi.fn((_sensor: unknown, options?: unknown) => ({ options })), + useSensors: vi.fn((...sensors: unknown[]) => sensors), +})) + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: ({ id }: { id: string }) => ({ + attributes: { 'data-sortable-id': id }, + listeners: { 'data-drag-listener': 'true' }, + setNodeRef: () => undefined, + transform: null, + transition: undefined, + isDragging: dndState.draggingId === id, + }), + verticalListSortingStrategy: vi.fn(), +})) + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: () => undefined, + }, + }, +})) + +function createWorkspace(id: string, options?: { hasAgent?: boolean }): WorkspaceState { + return { + id, + name: id, + path: `/tmp/${id}`, + worktreesRoot: '', + nodes: options?.hasAgent + ? [ + { + id: `${id}-agent`, + position: { x: 0, y: 0 }, + width: 320, + height: 240, + data: { + sessionId: `${id}-session`, + title: `${id} agent`, + width: 320, + height: 240, + kind: 'agent', + status: 'running', + startedAt: '2026-03-29T10:00:00.000Z', + endedAt: null, + exitCode: null, + lastError: null, + scrollback: null, + executionDirectory: `/tmp/${id}`, + expectedDirectory: `/tmp/${id}`, + agent: { + provider: 'codex', + prompt: 'ship it', + model: 'gpt-5.2-codex', + effectiveModel: 'gpt-5.2-codex', + launchMode: 'new', + resumeSessionId: null, + executionDirectory: `/tmp/${id}`, + expectedDirectory: `/tmp/${id}`, + directoryMode: 'workspace', + customDirectory: null, + shouldCreateDirectory: false, + taskId: null, + }, + task: null, + note: null, + image: null, + document: null, + }, + type: 'default', + measured: { width: 320, height: 240 }, + selected: false, + dragging: false, + deletable: true, + }, + ] + : [], + viewport: DEFAULT_WORKSPACE_VIEWPORT, + isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + spaces: [], + activeSpaceId: null, + spaceArchiveRecords: [], + } +} + +describe('Sidebar', () => { + beforeEach(() => { + dndState.draggingId = null + dndState.onDragStart = null + dndState.onDragEnd = null + }) + + it('renders a drag overlay, dims the dragged item, and reorders on drag end', () => { + const onReorderWorkspaces = vi.fn() + const { container } = render( + undefined} + onSelectWorkspace={() => undefined} + onOpenProjectContextMenu={() => undefined} + onSelectAgentNode={() => undefined} + onReorderWorkspaces={onReorderWorkspaces} + />, + ) + + act(() => { + dndState.draggingId = 'workspace-b' + dndState.onDragStart?.({ active: { id: 'workspace-b' } }) + }) + + const overlayElement = container.querySelector('.workspace-item--drag-overlay') + expect(overlayElement).not.toBeNull() + expect(overlayElement?.textContent).toContain('workspace-b') + + const draggedGroup = screen.getByTitle('/tmp/workspace-b').closest('.workspace-item-group') + expect(draggedGroup).not.toBeNull() + expect((draggedGroup as HTMLElement).style.opacity).toBe('0.4') + + act(() => { + dndState.draggingId = null + dndState.onDragEnd?.({ + active: { id: 'workspace-b' }, + over: { id: 'workspace-a' }, + }) + }) + + expect(onReorderWorkspaces).toHaveBeenCalledWith('workspace-b', 'workspace-a') + expect(container.querySelector('.workspace-item--drag-overlay')).toBeNull() + }) + + it('keeps workspace clicks, context menus, and nested agent clicks working', () => { + const onSelectWorkspace = vi.fn() + const onOpenProjectContextMenu = vi.fn() + const onSelectAgentNode = vi.fn() + + render( + undefined} + onSelectWorkspace={onSelectWorkspace} + onOpenProjectContextMenu={onOpenProjectContextMenu} + onSelectAgentNode={onSelectAgentNode} + onReorderWorkspaces={() => undefined} + />, + ) + + const workspaceButton = screen.getByTitle('/tmp/workspace-a') + const agentButton = screen.getByTestId('workspace-agent-item-workspace-a-workspace-a-agent') + + expect(workspaceButton.getAttribute('data-drag-listener')).toBe('true') + expect(agentButton.getAttribute('data-drag-listener')).toBeNull() + + fireEvent.click(workspaceButton) + expect(onSelectWorkspace).toHaveBeenCalledWith('workspace-a') + + fireEvent.contextMenu(workspaceButton, { clientX: 120, clientY: 220 }) + expect(onOpenProjectContextMenu).toHaveBeenCalledWith({ + workspaceId: 'workspace-a', + x: 120, + y: 220, + }) + + fireEvent.click(agentButton) + expect(onSelectAgentNode).toHaveBeenCalledWith('workspace-a', 'workspace-a-agent') + }) +}) diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx index dd0a9117..6d35f9bd 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -1,5 +1,17 @@ -import React from 'react' -import { useTranslation } from '@app/renderer/i18n' +import React, { useCallback, useState } from 'react' +import { + DndContext, + DragOverlay, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core' +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { useTranslation, type TranslateFn } from '@app/renderer/i18n' import { AGENT_PROVIDER_LABEL } from '@contexts/settings/domain/agentSettings' import type { PersistNotice, ProjectContextMenuState } from '../types' import { toRelativeTime } from '../utils/format' @@ -10,7 +22,26 @@ import type { type SidebarAgentStatus = 'working' | 'standby' -type SidebarStatusTone = 'working' | 'standby' +type SidebarProps = { + workspaces: WorkspaceState[] + activeWorkspaceId: string | null + activeProviderLabel: string + activeProviderModel: string + persistNotice: PersistNotice | null + onAddWorkspace: () => void + onSelectWorkspace: (workspaceId: string) => void + onOpenProjectContextMenu: (state: ProjectContextMenuState) => void + onSelectAgentNode: (workspaceId: string, nodeId: string) => void + onReorderWorkspaces: (activeId: string, overId: string) => void +} + +type SortableWorkspaceItemProps = { + workspace: WorkspaceState + isActive: boolean + onSelectWorkspace: (workspaceId: string) => void + onOpenProjectContextMenu: (state: ProjectContextMenuState) => void + onSelectAgentNode: (workspaceId: string, nodeId: string) => void +} function resolveSidebarAgentStatus(runtimeStatus: TerminalNodeData['status']): SidebarAgentStatus { if (runtimeStatus === 'running' || runtimeStatus === 'restoring') { @@ -20,6 +51,200 @@ function resolveSidebarAgentStatus(runtimeStatus: TerminalNodeData['status']): S return 'standby' } +function getWorkspaceAgents(workspace: WorkspaceState) { + return workspace.nodes + .filter(node => node.data.kind === 'agent') + .sort((left, right) => { + const leftTime = left.data.startedAt ? Date.parse(left.data.startedAt) : 0 + const rightTime = right.data.startedAt ? Date.parse(right.data.startedAt) : 0 + return rightTime - leftTime + }) +} + +function getWorkspaceMetaText(workspace: WorkspaceState, t: TranslateFn): string { + let terminalCount = 0 + let agentCount = 0 + let taskCount = 0 + + for (const node of workspace.nodes) { + if (node.data.kind === 'terminal') { + terminalCount += 1 + } else if (node.data.kind === 'agent') { + agentCount += 1 + } else if (node.data.kind === 'task') { + taskCount += 1 + } + } + + return [ + t('sidebar.terminals', { count: terminalCount }), + t('sidebar.agents', { count: agentCount }), + t('sidebar.tasks', { count: taskCount }), + ].join(' · ') +} + +function resolveLinkedTaskTitle(workspace: WorkspaceState, nodeId: string, taskId: string | null) { + const linkedTaskNode = + (taskId + ? (workspace.nodes.find( + candidate => + candidate.id === taskId && candidate.data.kind === 'task' && candidate.data.task, + ) ?? null) + : null) ?? + workspace.nodes.find( + candidate => + candidate.data.kind === 'task' && candidate.data.task?.linkedAgentNodeId === nodeId, + ) ?? + null + + return linkedTaskNode && linkedTaskNode.data.kind === 'task' ? linkedTaskNode.data.title : null +} + +function WorkspaceItemContent({ + workspace, + metaText, +}: { + workspace: WorkspaceState + metaText: string +}): React.JSX.Element { + return ( + <> + {workspace.name} + {workspace.path} + {metaText} + + ) +} + +function WorkspaceAgentItems({ + workspace, + onSelectAgentNode, +}: { + workspace: WorkspaceState + onSelectAgentNode: (workspaceId: string, nodeId: string) => void +}): React.JSX.Element | null { + const { t } = useTranslation() + const workspaceAgents = getWorkspaceAgents(workspace) + + if (workspaceAgents.length === 0) { + return null + } + + return ( +
+ {workspaceAgents.map(node => { + const provider = node.data.agent?.provider + const providerText = provider + ? AGENT_PROVIDER_LABEL[provider] + : t('sidebar.fallbackAgentLabel') + const sidebarAgentStatus = resolveSidebarAgentStatus(node.data.status) + const sidebarAgentStatusTone = sidebarAgentStatus + const startedText = toRelativeTime(node.data.startedAt) + const sidebarAgentStatusText = + sidebarAgentStatus === 'working' + ? t('sidebar.status.working') + : t('sidebar.status.standby') + const taskTitle = resolveLinkedTaskTitle( + workspace, + node.id, + node.data.agent?.taskId ?? null, + ) + + return ( + + ) + })} +
+ ) +} + +function SortableWorkspaceItem({ + workspace, + isActive, + onSelectWorkspace, + onOpenProjectContextMenu, + onSelectAgentNode, +}: SortableWorkspaceItemProps): React.JSX.Element { + const { t } = useTranslation() + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: workspace.id, + }) + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + } + const metaText = getWorkspaceMetaText(workspace, t) + + return ( +
+ + + +
+ ) +} + +function WorkspaceItemOverlay({ workspace }: { workspace: WorkspaceState }): React.JSX.Element { + const { t } = useTranslation() + + return ( +
+
+ +
+
+ ) +} + export function Sidebar({ workspaces, activeWorkspaceId, @@ -30,18 +255,47 @@ export function Sidebar({ onSelectWorkspace, onOpenProjectContextMenu, onSelectAgentNode, -}: { - workspaces: WorkspaceState[] - activeWorkspaceId: string | null - activeProviderLabel: string - activeProviderModel: string - persistNotice: PersistNotice | null - onAddWorkspace: () => void - onSelectWorkspace: (workspaceId: string) => void - onOpenProjectContextMenu: (state: ProjectContextMenuState) => void - onSelectAgentNode: (workspaceId: string, nodeId: string) => void -}): React.JSX.Element { + onReorderWorkspaces, +}: SidebarProps): React.JSX.Element { const { t } = useTranslation() + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ) + const [activeId, setActiveId] = useState(null) + + const handleDragStart = useCallback((event: DragStartEvent): void => { + setActiveId(String(event.active.id)) + }, []) + + const handleDragCancel = useCallback((): void => { + setActiveId(null) + }, []) + + const handleDragEnd = useCallback( + (event: DragEndEvent): void => { + const nextActiveId = String(event.active.id) + const nextOverId = event.over?.id + + setActiveId(null) + + if (nextOverId === null || nextOverId === undefined) { + return + } + + const overId = String(nextOverId) + if (overId === nextActiveId) { + return + } + + onReorderWorkspaces(nextActiveId, overId) + }, + [onReorderWorkspaces], + ) + + const activeWorkspace = + activeId === null ? null : (workspaces.find(workspace => workspace.id === activeId) ?? null) return ( ) diff --git a/src/app/renderer/shell/hooks/useAddWorkspaceAction.spec.tsx b/src/app/renderer/shell/hooks/useAddWorkspaceAction.spec.tsx new file mode 100644 index 00000000..b329f8b6 --- /dev/null +++ b/src/app/renderer/shell/hooks/useAddWorkspaceAction.spec.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + DEFAULT_WORKSPACE_VIEWPORT, + type WorkspaceState, +} from '@contexts/workspace/presentation/renderer/types' +import { useAppStore } from '../store/useAppStore' +import { useAddWorkspaceAction } from './useAddWorkspaceAction' + +const initialState = useAppStore.getInitialState() + +function createWorkspace(id: string): WorkspaceState { + return { + id, + name: id, + path: `/tmp/${id}`, + worktreesRoot: '', + nodes: [], + viewport: DEFAULT_WORKSPACE_VIEWPORT, + isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + spaces: [], + activeSpaceId: null, + spaceArchiveRecords: [], + } +} + +function HookHost(): React.JSX.Element { + const addWorkspace = useAddWorkspaceAction() + + return ( + + ) +} + +afterEach(() => { + useAppStore.setState(initialState, true) + vi.restoreAllMocks() +}) + +describe('useAddWorkspaceAction', () => { + it('appends a newly selected workspace to the end of the list', async () => { + const selectedWorkspace = { + id: 'workspace-3', + name: 'workspace-3', + path: '/tmp/workspace-3', + } + + useAppStore.setState( + { + workspaces: [createWorkspace('workspace-1'), createWorkspace('workspace-2')], + activeWorkspaceId: 'workspace-1', + }, + false, + ) + + Object.defineProperty(window, 'opencoveApi', { + configurable: true, + value: { + workspace: { + selectDirectory: vi.fn(async () => selectedWorkspace), + }, + }, + }) + + render() + fireEvent.click(screen.getByRole('button', { name: 'Add workspace' })) + + await waitFor(() => { + expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([ + 'workspace-1', + 'workspace-2', + 'workspace-3', + ]) + }) + + expect(useAppStore.getState().activeWorkspaceId).toBe('workspace-3') + }) +}) diff --git a/src/app/renderer/shell/store/useAppStore.spec.ts b/src/app/renderer/shell/store/useAppStore.spec.ts new file mode 100644 index 00000000..3d5312ee --- /dev/null +++ b/src/app/renderer/shell/store/useAppStore.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + DEFAULT_WORKSPACE_VIEWPORT, + type WorkspaceState, +} from '@contexts/workspace/presentation/renderer/types' +import { useAppStore, type AppStoreState } from './useAppStore' + +type ReorderWorkspacesAction = (activeId: string, overId: string) => void + +const initialState = useAppStore.getInitialState() + +function createWorkspace(id: string): WorkspaceState { + return { + id, + name: id, + path: `/tmp/${id}`, + worktreesRoot: '', + nodes: [], + viewport: DEFAULT_WORKSPACE_VIEWPORT, + isMinimapVisible: DEFAULT_WORKSPACE_MINIMAP_VISIBLE, + spaces: [], + activeSpaceId: null, + spaceArchiveRecords: [], + } +} + +afterEach(() => { + useAppStore.setState(initialState, true) +}) + +describe('useAppStore', () => { + it('reorders workspaces when dragging one item over another', () => { + useAppStore.setState( + { + workspaces: [ + createWorkspace('workspace-1'), + createWorkspace('workspace-2'), + createWorkspace('workspace-3'), + ], + }, + false, + ) + + const state = useAppStore.getState() as AppStoreState & { + reorderWorkspaces?: ReorderWorkspacesAction + } + + expect(state.reorderWorkspaces).toBeTypeOf('function') + + state.reorderWorkspaces?.('workspace-3', 'workspace-1') + + expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([ + 'workspace-3', + 'workspace-1', + 'workspace-2', + ]) + }) + + it('ignores reorder requests when either workspace id is missing', () => { + useAppStore.setState( + { + workspaces: [ + createWorkspace('workspace-1'), + createWorkspace('workspace-2'), + createWorkspace('workspace-3'), + ], + }, + false, + ) + + const state = useAppStore.getState() as AppStoreState & { + reorderWorkspaces?: ReorderWorkspacesAction + } + + state.reorderWorkspaces?.('workspace-missing', 'workspace-1') + state.reorderWorkspaces?.('workspace-1', 'workspace-missing') + + expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([ + 'workspace-1', + 'workspace-2', + 'workspace-3', + ]) + }) + + it('treats dragging a workspace onto itself as a no-op', () => { + useAppStore.setState( + { + workspaces: [ + createWorkspace('workspace-1'), + createWorkspace('workspace-2'), + createWorkspace('workspace-3'), + ], + }, + false, + ) + + const state = useAppStore.getState() as AppStoreState & { + reorderWorkspaces?: ReorderWorkspacesAction + } + + state.reorderWorkspaces?.('workspace-2', 'workspace-2') + + expect(useAppStore.getState().workspaces.map(workspace => workspace.id)).toEqual([ + 'workspace-1', + 'workspace-2', + 'workspace-3', + ]) + }) +}) diff --git a/src/app/renderer/shell/store/useAppStore.ts b/src/app/renderer/shell/store/useAppStore.ts index ea8d5201..885e4fcb 100644 --- a/src/app/renderer/shell/store/useAppStore.ts +++ b/src/app/renderer/shell/store/useAppStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { arrayMove } from '@dnd-kit/sortable' import { DEFAULT_AGENT_SETTINGS, type AgentSettings } from '@contexts/settings/domain/agentSettings' import type { WorkspaceState } from '@contexts/workspace/presentation/renderer/types' import type { @@ -36,6 +37,7 @@ export interface AppStoreState { setIsSettingsOpen: (action: SetStateAction) => void setFocusRequest: (action: SetStateAction) => void setPersistNotice: (action: SetStateAction) => void + reorderWorkspaces: (activeId: string, overId: string) => void } export const useAppStore = create(set => ({ @@ -69,4 +71,15 @@ export const useAppStore = create(set => ({ set(state => ({ focusRequest: applySetStateAction(state.focusRequest, action) })), setPersistNotice: action => set(state => ({ persistNotice: applySetStateAction(state.persistNotice, action) })), + reorderWorkspaces: (activeId, overId) => + set(state => { + const oldIndex = state.workspaces.findIndex(workspace => workspace.id === activeId) + const newIndex = state.workspaces.findIndex(workspace => workspace.id === overId) + + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return state + } + + return { workspaces: arrayMove(state.workspaces, oldIndex, newIndex) } + }), })) diff --git a/src/app/renderer/styles/workspace-sidebar.css b/src/app/renderer/styles/workspace-sidebar.css index 494a9420..1efa030b 100644 --- a/src/app/renderer/styles/workspace-sidebar.css +++ b/src/app/renderer/styles/workspace-sidebar.css @@ -154,6 +154,10 @@ gap: 6px; } +.workspace-item-group--drag-overlay { + pointer-events: none; +} + .workspace-sidebar__empty { font-size: calc(13px * var(--cove-ui-font-scale)); color: var(--cove-text-faint); @@ -191,6 +195,15 @@ box-shadow: 0 0 0 1px rgba(94, 156, 255, 0.2); } +.workspace-item--drag-overlay { + background: color-mix(in srgb, var(--cove-surface) 78%, transparent); + border-color: rgba(94, 156, 255, 0.28); + box-shadow: + 0 12px 24px rgba(15, 23, 42, 0.12), + 0 0 0 1px rgba(94, 156, 255, 0.14); + opacity: 0.86; +} + .workspace-item__name { font-size: calc(14px * var(--cove-ui-font-scale)); font-weight: 500; diff --git a/src/platform/persistence/sqlite/migrate.ts b/src/platform/persistence/sqlite/migrate.ts index 97ea2e9c..02b7fb0e 100644 --- a/src/platform/persistence/sqlite/migrate.ts +++ b/src/platform/persistence/sqlite/migrate.ts @@ -39,7 +39,8 @@ function createTables(db: Database.Database): void { viewport_y REAL NOT NULL, viewport_zoom REAL NOT NULL, is_minimap_visible INTEGER NOT NULL, - active_space_id TEXT + active_space_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS nodes ( @@ -134,9 +135,9 @@ function hasTableColumn(db: Database.Database, tableName: string, columnName: st function ensureTableColumn( db: Database.Database, options: { tableName: string; columnName: string; definitionSql: string }, -): void { +): boolean { if (hasTableColumn(db, options.tableName, options.columnName)) { - return + return false } db.exec( @@ -144,6 +145,25 @@ function ensureTableColumn( options.columnName, )} ${options.definitionSql}`, ) + + return true +} + +function backfillWorkspaceSortOrder(db: Database.Database): void { + const allZero = db + .prepare('SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') + .get() as { + cnt: number + } + if (allZero.cnt > 0) { + return + } + + const rows = db.prepare('SELECT id FROM workspaces ORDER BY rowid').all() as { id: string }[] + const update = db.prepare('UPDATE workspaces SET sort_order = ? WHERE id = ?') + rows.forEach((row, index) => { + update.run(index, row.id) + }) } function ensureCurrentSchema(db: Database.Database): void { @@ -161,6 +181,16 @@ function ensureCurrentSchema(db: Database.Database): void { definitionSql: `TEXT NOT NULL DEFAULT '[]'`, }) + const addedWorkspaceSortOrder = ensureTableColumn(db, { + tableName: 'workspaces', + columnName: 'sort_order', + definitionSql: 'INTEGER NOT NULL DEFAULT 0', + }) + + if (addedWorkspaceSortOrder) { + backfillWorkspaceSortOrder(db) + } + ensureTableColumn(db, { tableName: 'nodes', columnName: 'label_color_override', @@ -192,6 +222,7 @@ export function migrate(db: Database.Database): void { const normalized = normalizePersistedAppState(parsed) if (normalized) { writeNormalizedAppState(db, normalized) + backfillWorkspaceSortOrder(db) writeNormalizedScrollbacks(db, normalized) } } diff --git a/src/platform/persistence/sqlite/read.ts b/src/platform/persistence/sqlite/read.ts index 56e7811d..f521d52c 100644 --- a/src/platform/persistence/sqlite/read.ts +++ b/src/platform/persistence/sqlite/read.ts @@ -47,7 +47,7 @@ export function readAppStateFromDb(db: BetterSQLite3Database): NormalizedPersist const settingsValue = typeof settingsRow?.value === 'string' ? safeJsonParse(settingsRow.value) : {} - const workspaceRows = db.select().from(workspaces).all() + const workspaceRows = db.select().from(workspaces).orderBy(workspaces.sortOrder).all() const nodeRows = db.select().from(nodes).all() const spaceRows = db.select().from(spaces).all() const spaceNodeRows = db.select().from(spaceNodes).all() diff --git a/src/platform/persistence/sqlite/schema.ts b/src/platform/persistence/sqlite/schema.ts index 90a13caa..115c0978 100644 --- a/src/platform/persistence/sqlite/schema.ts +++ b/src/platform/persistence/sqlite/schema.ts @@ -24,6 +24,7 @@ export const workspaces = sqliteTable('workspaces', { viewportZoom: real('viewport_zoom').notNull(), isMinimapVisible: integer('is_minimap_visible', { mode: 'boolean' }).notNull(), activeSpaceId: text('active_space_id'), + sortOrder: integer('sort_order').notNull().default(0), }) export const nodes = sqliteTable('nodes', { diff --git a/src/platform/persistence/sqlite/write.ts b/src/platform/persistence/sqlite/write.ts index c072e583..1bcfecff 100644 --- a/src/platform/persistence/sqlite/write.ts +++ b/src/platform/persistence/sqlite/write.ts @@ -28,9 +28,9 @@ export function writeNormalizedAppState( INSERT INTO workspaces ( id, name, path, worktrees_root, pull_request_base_branch_options_json, space_archive_records_json, viewport_x, viewport_y, viewport_zoom, - is_minimap_visible, active_space_id + is_minimap_visible, active_space_id, sort_order ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) @@ -77,7 +77,8 @@ export function writeNormalizedAppState( upsertSettings.run(safeJsonStringify(state.settings ?? {})) - for (const workspace of state.workspaces) { + for (let sortOrder = 0; sortOrder < state.workspaces.length; sortOrder += 1) { + const workspace = state.workspaces[sortOrder] insertWorkspace.run( workspace.id, workspace.name, @@ -90,6 +91,7 @@ export function writeNormalizedAppState( workspace.viewport.zoom, workspace.isMinimapVisible ? 1 : 0, workspace.activeSpaceId, + sortOrder, ) for (const node of workspace.nodes) { diff --git a/tests/contract/platform/persistenceRead.spec.ts b/tests/contract/platform/persistenceRead.spec.ts new file mode 100644 index 00000000..ac2cccc2 --- /dev/null +++ b/tests/contract/platform/persistenceRead.spec.ts @@ -0,0 +1,137 @@ +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' +import { describe, expect, it } from 'vitest' +import { readAppStateFromDb } from '../../../src/platform/persistence/sqlite/read' +import { + appMeta, + appSettings, + nodes, + spaceNodes, + spaces, + workspaces, +} from '../../../src/platform/persistence/sqlite/schema' + +type MetaRow = { key: 'format_version' | 'active_workspace_id'; value: string } +type SettingsRow = { id: number; value: string } +type WorkspaceRow = { + id: string + name: string + path: string + worktreesRoot: string + pullRequestBaseBranchOptionsJson: string + spaceArchiveRecordsJson: string + viewportX: number + viewportY: number + viewportZoom: number + isMinimapVisible: boolean + activeSpaceId: string | null + sortOrder: number +} + +function createReadDb(options: { + metaRows: MetaRow[] + settingsRow: SettingsRow | undefined + workspaceRows: WorkspaceRow[] +}): BetterSQLite3Database { + const db = { + select(selection?: unknown) { + return { + from(table: unknown) { + if (table === appMeta) { + return { + all: () => options.metaRows, + } + } + + if (table === appSettings) { + return { + where: (_predicate: unknown) => ({ + get: () => { + if (!options.settingsRow) { + return undefined + } + + return selection + ? { + value: options.settingsRow.value, + } + : options.settingsRow + }, + }), + } + } + + if (table === workspaces) { + return { + all: () => options.workspaceRows, + orderBy: (_column: unknown) => ({ + all: () => + [...options.workspaceRows].sort( + (left, right) => left.sortOrder - right.sortOrder, + ), + }), + } + } + + if (table === nodes || table === spaces || table === spaceNodes) { + return { + all: () => [], + } + } + + throw new Error('Unexpected table') + }, + } + }, + } + + return db as BetterSQLite3Database +} + +describe('sqlite persistence read', () => { + it('loads workspaces in ascending sort_order', () => { + const db = createReadDb({ + metaRows: [ + { key: 'format_version', value: '1' }, + { key: 'active_workspace_id', value: 'workspace-1' }, + ], + settingsRow: { id: 1, value: '{}' }, + workspaceRows: [ + { + id: 'workspace-2', + name: 'Workspace 2', + path: '/tmp/workspace-2', + worktreesRoot: '/tmp/worktrees', + pullRequestBaseBranchOptionsJson: '[]', + spaceArchiveRecordsJson: '[]', + viewportX: 0, + viewportY: 0, + viewportZoom: 1, + isMinimapVisible: true, + activeSpaceId: null, + sortOrder: 2, + }, + { + id: 'workspace-1', + name: 'Workspace 1', + path: '/tmp/workspace-1', + worktreesRoot: '/tmp/worktrees', + pullRequestBaseBranchOptionsJson: '[]', + spaceArchiveRecordsJson: '[]', + viewportX: 0, + viewportY: 0, + viewportZoom: 1, + isMinimapVisible: true, + activeSpaceId: null, + sortOrder: 1, + }, + ], + }) + + const appState = readAppStateFromDb(db) + + expect(appState?.workspaces.map(workspace => workspace.id)).toEqual([ + 'workspace-1', + 'workspace-2', + ]) + }) +}) diff --git a/tests/contract/platform/persistenceStore.spec.ts b/tests/contract/platform/persistenceStore.spec.ts index 37bd0575..1b1a9463 100644 --- a/tests/contract/platform/persistenceStore.spec.ts +++ b/tests/contract/platform/persistenceStore.spec.ts @@ -9,6 +9,7 @@ type MockDbState = { userVersion: number tables: Map openAttempts: number + workspaceRows: Array<{ id: string; sortOrder: number }> failOnFirstOpen?: boolean } @@ -27,6 +28,7 @@ const CURRENT_SCHEMA_COLUMNS = { 'viewport_zoom', 'is_minimap_visible', 'active_space_id', + 'sort_order', ], nodes: [ 'id', @@ -134,6 +136,7 @@ function createMockDbState( userVersion: options.userVersion ?? 0, tables: options.version2Schema ? createVersion2Tables() : new Map(), openAttempts: 0, + workspaceRows: [], ...(options.failOnFirstOpen ? { failOnFirstOpen: true } : {}), } } @@ -215,6 +218,10 @@ function createMockDatabaseModule(mockDbByPath: Map) { } } + if (sql === 'SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') { + return { all: () => [], get: () => ({ cnt: 1 }), run: () => undefined } + } + const insertMatch = sql.match( /INSERT INTO\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*VALUES/i, ) @@ -227,25 +234,38 @@ function createMockDatabaseModule(mockDbByPath: Map) { return { all: () => [], get: () => undefined, - run: () => { + run: (...params: unknown[]) => { const tableColumns = this.state.tables.get(tableName) ?? [] for (const column of columns) { if (!tableColumns.includes(column)) { throw new Error(`table ${tableName} has no column named ${column}`) } } - }, - } - } - if (sql.includes('SELECT value FROM kv WHERE key = ?')) { - return { - all: () => [], - get: () => undefined, - run: () => undefined, + if (tableName !== 'workspaces') { + return + } + + const idIndex = columns.indexOf('id') + if (idIndex < 0) { + throw new Error('workspace insert missing id column') + } + + const id = params[idIndex] + if (typeof id !== 'string') { + throw new Error('workspace insert missing id value') + } + + const sortOrderIndex = columns.indexOf('sort_order') + const sortOrderParam = sortOrderIndex >= 0 ? params[sortOrderIndex] : 0 + if (typeof sortOrderParam !== 'number') { + throw new Error('workspace insert sort_order must be numeric') + } + + this.state.workspaceRows.push({ id, sortOrder: sortOrderParam }) + }, } } - return { all: () => [], get: () => undefined, @@ -279,6 +299,64 @@ describe('PersistenceStore', () => { tempDir = '' }) + it( + 'writes workspace sort_order from the in-memory array order', + async () => { + tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-')) + const dbPath = join(tempDir, 'opencove.db') + const mockDbByPath = new Map() + vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) })) + + const { createPersistenceStore } = + await import('../../../src/platform/persistence/sqlite/PersistenceStore') + + const store = await createPersistenceStore({ dbPath }) + + const result = await store.writeAppState({ + formatVersion: 1, + activeWorkspaceId: 'ws-2', + workspaces: [ + { + id: 'ws-2', + name: 'Workspace 2', + path: '/tmp/ws-2', + worktreesRoot: '/tmp', + pullRequestBaseBranchOptions: [], + spaceArchiveRecords: [], + viewport: { x: 0, y: 0, zoom: 1 }, + isMinimapVisible: false, + activeSpaceId: null, + nodes: [], + spaces: [], + }, + { + id: 'ws-1', + name: 'Workspace 1', + path: '/tmp/ws-1', + worktreesRoot: '/tmp', + pullRequestBaseBranchOptions: [], + spaceArchiveRecords: [], + viewport: { x: 0, y: 0, zoom: 1 }, + isMinimapVisible: false, + activeSpaceId: null, + nodes: [], + spaces: [], + }, + ], + settings: {}, + }) + + expect(result).toMatchObject({ ok: true, level: 'full' }) + expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-1', sortOrder: 1 }, + ]) + + store.dispose() + }, + PERSISTENCE_STORE_TEST_TIMEOUT_MS, + ) + it( 'creates a backup when migrating an existing db file', async () => { diff --git a/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts new file mode 100644 index 00000000..138118a9 --- /dev/null +++ b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts @@ -0,0 +1,499 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const PERSISTENCE_STORE_TEST_TIMEOUT_MS = 20_000 + +type MockDbState = { + userVersion: number + tables: Map + workspaceRows: Array<{ id: string; sortOrder: number }> + legacyWorkspaceStateRaw?: string + openAttempts: number +} + +const CURRENT_SCHEMA_COLUMNS = { + app_meta: ['key', 'value'], + app_settings: ['id', 'value'], + workspaces: [ + 'id', + 'name', + 'path', + 'worktrees_root', + 'pull_request_base_branch_options_json', + 'space_archive_records_json', + 'viewport_x', + 'viewport_y', + 'viewport_zoom', + 'is_minimap_visible', + 'active_space_id', + 'sort_order', + ], + nodes: [ + 'id', + 'workspace_id', + 'title', + 'title_pinned_by_user', + 'position_x', + 'position_y', + 'width', + 'height', + 'kind', + 'label_color_override', + 'status', + 'started_at', + 'ended_at', + 'exit_code', + 'last_error', + 'execution_directory', + 'expected_directory', + 'agent_json', + 'task_json', + ], + workspace_spaces: [ + 'id', + 'workspace_id', + 'name', + 'directory_path', + 'label_color', + 'rect_x', + 'rect_y', + 'rect_width', + 'rect_height', + ], + workspace_space_nodes: ['space_id', 'node_id', 'sort_order'], + node_scrollback: ['node_id', 'scrollback', 'updated_at'], +} as const + +function createMockDbState( + options: { + userVersion?: number + version2Schema?: boolean + workspaceRows?: Array<{ + id: string + sortOrder?: number + }> + legacyWorkspaceStateRaw?: string + } = {}, +): MockDbState { + return { + userVersion: options.userVersion ?? 0, + tables: options.version2Schema + ? new Map([ + ['app_meta', [...CURRENT_SCHEMA_COLUMNS.app_meta]], + ['app_settings', [...CURRENT_SCHEMA_COLUMNS.app_settings]], + [ + 'workspaces', + [ + 'id', + 'name', + 'path', + 'worktrees_root', + 'viewport_x', + 'viewport_y', + 'viewport_zoom', + 'is_minimap_visible', + 'active_space_id', + ], + ], + [ + 'nodes', + [ + 'id', + 'workspace_id', + 'title', + 'title_pinned_by_user', + 'position_x', + 'position_y', + 'width', + 'height', + 'kind', + 'status', + 'started_at', + 'ended_at', + 'exit_code', + 'last_error', + 'execution_directory', + 'expected_directory', + 'agent_json', + 'task_json', + ], + ], + [ + 'workspace_spaces', + [ + 'id', + 'workspace_id', + 'name', + 'directory_path', + 'rect_x', + 'rect_y', + 'rect_width', + 'rect_height', + ], + ], + ['workspace_space_nodes', [...CURRENT_SCHEMA_COLUMNS.workspace_space_nodes]], + ['node_scrollback', [...CURRENT_SCHEMA_COLUMNS.node_scrollback]], + ]) + : new Map(), + workspaceRows: (options.workspaceRows ?? []).map(row => ({ + id: row.id, + sortOrder: row.sortOrder ?? 0, + })), + ...(typeof options.legacyWorkspaceStateRaw === 'string' + ? { legacyWorkspaceStateRaw: options.legacyWorkspaceStateRaw } + : {}), + openAttempts: 0, + } +} + +function createMockDatabaseModule(mockDbByPath: Map) { + return class MockDatabase { + private readonly state: MockDbState + + public constructor(private readonly path: string) { + const existing = mockDbByPath.get(path) + if (!existing) { + throw new Error(`Missing mock database state for ${path}`) + } + + existing.openAttempts += 1 + this.state = existing + } + + public pragma(query: string, options?: { simple?: boolean }): unknown { + if (query === 'user_version' && options?.simple === true) { + return this.state.userVersion + } + + const match = query.match(/^user_version\s*=\s*(\d+)$/) + if (match) { + this.state.userVersion = Number(match[1]) + } + + return undefined + } + + public exec(sql: string): void { + for (const [tableName, columns] of Object.entries(CURRENT_SCHEMA_COLUMNS)) { + if ( + sql.includes(`CREATE TABLE IF NOT EXISTS ${tableName}`) && + !this.state.tables.has(tableName) + ) { + this.state.tables.set(tableName, [...columns]) + } + } + + const alterRegex = + /ALTER TABLE\s+("?)([A-Za-z_][A-Za-z0-9_]*)\1\s+ADD COLUMN\s+("?)([A-Za-z_][A-Za-z0-9_]*)\3/gi + for (const match of sql.matchAll(alterRegex)) { + const tableName = match[2] + const columnName = match[4] + const existingColumns = this.state.tables.get(tableName) ?? [] + if (!existingColumns.includes(columnName)) { + existingColumns.push(columnName) + this.state.tables.set(tableName, existingColumns) + } + } + } + + public prepare(sql: string): { + all: () => unknown[] + get: (...params: unknown[]) => unknown + run: (...params: unknown[]) => void + } { + const tableInfoMatch = sql.match(/PRAGMA table_info\("?([A-Za-z_][A-Za-z0-9_]*)"?\)/i) + if (tableInfoMatch) { + const tableName = tableInfoMatch[1] + return { + all: () => + (this.state.tables.get(tableName) ?? []).map(name => ({ + name, + })), + get: () => undefined, + run: () => undefined, + } + } + + if (sql === 'SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') { + return { + all: () => [], + get: () => ({ + cnt: this.state.workspaceRows.filter(row => row.sortOrder !== 0).length, + }), + run: () => undefined, + } + } + + if (sql === 'SELECT id FROM workspaces ORDER BY rowid') { + return { + all: () => this.state.workspaceRows.map(row => ({ id: row.id })), + get: () => undefined, + run: () => undefined, + } + } + + if (sql === 'UPDATE workspaces SET sort_order = ? WHERE id = ?') { + return { + all: () => [], + get: () => undefined, + run: (...params: unknown[]) => { + const [sortOrder, id] = params + if (typeof sortOrder !== 'number' || typeof id !== 'string') { + throw new Error('Invalid workspace sort_order backfill parameters') + } + + const row = this.state.workspaceRows.find(workspaceRow => workspaceRow.id === id) + if (!row) { + throw new Error(`Unknown workspace row: ${id}`) + } + + row.sortOrder = sortOrder + }, + } + } + + if (sql.includes('SELECT value FROM kv WHERE key = ?')) { + return { + all: () => [], + get: () => + typeof this.state.legacyWorkspaceStateRaw === 'string' + ? { value: this.state.legacyWorkspaceStateRaw } + : undefined, + run: () => undefined, + } + } + + const insertMatch = sql.match( + /INSERT INTO\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*VALUES/i, + ) + if (insertMatch) { + const tableName = insertMatch[1] + const columns = insertMatch[2] + .split(',') + .map(column => column.replace(/\s+/g, ' ').trim()) + .filter(column => column.length > 0) + return { + all: () => [], + get: () => undefined, + run: (...params: unknown[]) => { + const tableColumns = this.state.tables.get(tableName) ?? [] + for (const column of columns) { + if (!tableColumns.includes(column)) { + throw new Error(`table ${tableName} has no column named ${column}`) + } + } + + if (tableName !== 'workspaces') { + return + } + + const idIndex = columns.indexOf('id') + if (idIndex < 0) { + throw new Error('workspace insert missing id column') + } + + const id = params[idIndex] + if (typeof id !== 'string') { + throw new Error('workspace insert missing id value') + } + + const sortOrderIndex = columns.indexOf('sort_order') + const sortOrderParam = sortOrderIndex >= 0 ? params[sortOrderIndex] : 0 + if (typeof sortOrderParam !== 'number') { + throw new Error('workspace insert sort_order must be numeric') + } + + this.state.workspaceRows.push({ id, sortOrder: sortOrderParam }) + }, + } + } + + return { + all: () => [], + get: () => undefined, + run: () => undefined, + } + } + + public transaction( + fn: (...args: TArgs) => TResult, + ): (...args: TArgs) => TResult { + return (...args: TArgs) => fn(...args) + } + + public close(): void {} + } +} + +describe('PersistenceStore sort order migration', () => { + let tempDir = '' + + afterEach(async () => { + vi.resetModules() + vi.clearAllMocks() + + if (!tempDir) { + return + } + + await rm(tempDir, { recursive: true, force: true }) + tempDir = '' + }) + + it( + 'repairs the workspaces schema and backfills sort_order in legacy rowid order', + async () => { + tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-sort-order-')) + const dbPath = join(tempDir, 'opencove.db') + const mockDbByPath = new Map([ + [ + dbPath, + createMockDbState({ + userVersion: 5, + version2Schema: true, + workspaceRows: [ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-4', sortOrder: 0 }, + { id: 'ws-1', sortOrder: 0 }, + ], + }), + ], + ]) + + vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) })) + + const { createPersistenceStore } = + await import('../../../src/platform/persistence/sqlite/PersistenceStore') + + const store = await createPersistenceStore({ dbPath }) + expect(store.consumeRecovery()).toBeNull() + store.dispose() + + expect(mockDbByPath.get(dbPath)?.tables.get('workspaces')).toContain('sort_order') + expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-4', sortOrder: 1 }, + { id: 'ws-1', sortOrder: 2 }, + ]) + }, + PERSISTENCE_STORE_TEST_TIMEOUT_MS, + ) + + it( + 'does not backfill workspace sort_order when the column already exists', + async () => { + tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-sort-order-')) + const dbPath = join(tempDir, 'opencove.db') + const mockDbByPath = new Map([ + [ + dbPath, + createMockDbState({ + userVersion: 5, + workspaceRows: [ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-4', sortOrder: 0 }, + { id: 'ws-1', sortOrder: 0 }, + ], + }), + ], + ]) + + vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) })) + + const { createPersistenceStore } = + await import('../../../src/platform/persistence/sqlite/PersistenceStore') + + const store = await createPersistenceStore({ dbPath }) + expect(store.consumeRecovery()).toBeNull() + store.dispose() + + expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-4', sortOrder: 0 }, + { id: 'ws-1', sortOrder: 0 }, + ]) + }, + PERSISTENCE_STORE_TEST_TIMEOUT_MS, + ) + + it( + 'backfills workspace sort_order after migrating legacy v1 kv state', + async () => { + tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-sort-order-')) + const dbPath = join(tempDir, 'opencove.db') + const mockDbByPath = new Map([ + [ + dbPath, + createMockDbState({ + userVersion: 1, + legacyWorkspaceStateRaw: JSON.stringify({ + formatVersion: 1, + activeWorkspaceId: 'ws-2', + workspaces: [ + { + id: 'ws-2', + name: 'Workspace 2', + path: '/tmp/ws-2', + worktreesRoot: '/tmp', + pullRequestBaseBranchOptions: [], + spaceArchiveRecords: [], + viewport: { x: 0, y: 0, zoom: 1 }, + isMinimapVisible: false, + activeSpaceId: null, + nodes: [], + spaces: [], + }, + { + id: 'ws-4', + name: 'Workspace 4', + path: '/tmp/ws-4', + worktreesRoot: '/tmp', + pullRequestBaseBranchOptions: [], + spaceArchiveRecords: [], + viewport: { x: 0, y: 0, zoom: 1 }, + isMinimapVisible: false, + activeSpaceId: null, + nodes: [], + spaces: [], + }, + { + id: 'ws-1', + name: 'Workspace 1', + path: '/tmp/ws-1', + worktreesRoot: '/tmp', + pullRequestBaseBranchOptions: [], + spaceArchiveRecords: [], + viewport: { x: 0, y: 0, zoom: 1 }, + isMinimapVisible: false, + activeSpaceId: null, + nodes: [], + spaces: [], + }, + ], + settings: {}, + }), + }), + ], + ]) + + vi.doMock('better-sqlite3', () => ({ default: createMockDatabaseModule(mockDbByPath) })) + + const { createPersistenceStore } = + await import('../../../src/platform/persistence/sqlite/PersistenceStore') + + const store = await createPersistenceStore({ dbPath }) + expect(store.consumeRecovery()).toBeNull() + store.dispose() + + expect(mockDbByPath.get(dbPath)?.userVersion).toBe(5) + expect(mockDbByPath.get(dbPath)?.workspaceRows).toEqual([ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-4', sortOrder: 1 }, + { id: 'ws-1', sortOrder: 2 }, + ]) + }, + PERSISTENCE_STORE_TEST_TIMEOUT_MS, + ) +}) diff --git a/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts b/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts new file mode 100644 index 00000000..ca998339 --- /dev/null +++ b/tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts @@ -0,0 +1,165 @@ +import { expect, test } from '@playwright/test' +import { + dragLocatorTo, + launchApp, + seedWorkspaceState, + testWorkspacePath, +} from './workspace-canvas.helpers' + +test.describe('Workspace Canvas - Sidebar Drag Reorder', () => { + test('reorders workspaces by dragging and persists the new order', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-drag-a', + workspaces: [ + { + id: 'workspace-drag-a', + name: 'Project Alpha', + path: testWorkspacePath, + nodes: [], + }, + { + id: 'workspace-drag-b', + name: 'Project Beta', + path: `${testWorkspacePath}-b`, + nodes: [], + }, + { + id: 'workspace-drag-c', + name: 'Project Gamma', + path: `${testWorkspacePath}-c`, + nodes: [], + }, + ], + }) + + const workspaceNames = window.locator('.workspace-item__name') + await expect(workspaceNames).toHaveCount(3) + await expect(workspaceNames.nth(0)).toHaveText('Project Alpha') + await expect(workspaceNames.nth(1)).toHaveText('Project Beta') + await expect(workspaceNames.nth(2)).toHaveText('Project Gamma') + + // Drag "Project Alpha" (first) down to "Project Gamma" (third) position + const firstItem = window + .locator('.workspace-item') + .filter({ hasText: 'Project Alpha' }) + .first() + const thirdItem = window + .locator('.workspace-item') + .filter({ hasText: 'Project Gamma' }) + .first() + + await dragLocatorTo(window, firstItem, thirdItem) + + // Verify new order in DOM — Alpha should have moved down + await expect + .poll( + async () => { + const names = await workspaceNames.allTextContents() + return names + }, + { timeout: 5_000 }, + ) + .toEqual(['Project Beta', 'Project Gamma', 'Project Alpha']) + + // Verify persistence — reload and check order is maintained + await expect + .poll( + async () => { + const raw = await window.evaluate(async () => { + return await window.opencoveApi.persistence.readWorkspaceStateRaw() + }) + if (!raw) { + return null + } + + const parsed = JSON.parse(raw) as { + workspaces?: Array<{ name?: string }> + } + + return (parsed.workspaces ?? []).map(workspace => workspace.name) + }, + { timeout: 10_000 }, + ) + .toEqual(['Project Beta', 'Project Gamma', 'Project Alpha']) + } finally { + await electronApp.close() + } + }) + + test('click still selects workspace after drag setup', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-click-a', + workspaces: [ + { + id: 'workspace-click-a', + name: 'Click Alpha', + path: testWorkspacePath, + nodes: [], + }, + { + id: 'workspace-click-b', + name: 'Click Beta', + path: `${testWorkspacePath}-b`, + nodes: [], + }, + ], + }) + + const activeItem = window.locator('.workspace-item.workspace-item--active') + await expect(activeItem).toContainText('Click Alpha') + + // Click the second workspace — should select it, not start a drag + const secondItem = window.locator('.workspace-item').filter({ hasText: 'Click Beta' }).first() + await secondItem.click() + + await expect(activeItem).toContainText('Click Beta') + } finally { + await electronApp.close() + } + }) + + test('right-click context menu still works on sortable items', async () => { + const { electronApp, window } = await launchApp() + + try { + await seedWorkspaceState(window, { + activeWorkspaceId: 'workspace-ctx-a', + workspaces: [ + { + id: 'workspace-ctx-a', + name: 'Context Alpha', + path: testWorkspacePath, + nodes: [], + }, + { + id: 'workspace-ctx-b', + name: 'Context Beta', + path: `${testWorkspacePath}-b`, + nodes: [], + }, + ], + }) + + const secondItem = window + .locator('.workspace-item') + .filter({ hasText: 'Context Beta' }) + .first() + await expect(secondItem).toBeVisible() + + await secondItem.click({ button: 'right' }) + + const removeButton = window.locator( + '[data-testid="workspace-project-remove-workspace-ctx-b"]', + ) + await expect(removeButton).toBeVisible() + } finally { + await electronApp.close() + } + }) +})