From 2587b5266654142f471057d0ee7bb2e60d403492 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 11:47:58 +0800 Subject: [PATCH 01/16] feat(persistence): add sort_order column to workspaces table --- src/platform/persistence/sqlite/migrate.ts | 26 +++++- src/platform/persistence/sqlite/schema.ts | 1 + .../platform/persistenceStore.spec.ts | 86 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/platform/persistence/sqlite/migrate.ts b/src/platform/persistence/sqlite/migrate.ts index 97ea2e9c..01ccdc10 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 ( @@ -146,6 +147,21 @@ function ensureTableColumn( ) } +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 { createTables(db) @@ -161,6 +177,14 @@ function ensureCurrentSchema(db: Database.Database): void { definitionSql: `TEXT NOT NULL DEFAULT '[]'`, }) + ensureTableColumn(db, { + tableName: 'workspaces', + columnName: 'sort_order', + definitionSql: 'INTEGER NOT NULL DEFAULT 0', + }) + + backfillWorkspaceSortOrder(db) + ensureTableColumn(db, { tableName: 'nodes', columnName: 'label_color_override', 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/tests/contract/platform/persistenceStore.spec.ts b/tests/contract/platform/persistenceStore.spec.ts index 37bd0575..499c462b 100644 --- a/tests/contract/platform/persistenceStore.spec.ts +++ b/tests/contract/platform/persistenceStore.spec.ts @@ -8,6 +8,10 @@ const PERSISTENCE_STORE_TEST_TIMEOUT_MS = 20_000 type MockDbState = { userVersion: number tables: Map + workspaceRows: Array<{ + id: string + sortOrder: number | null + }> openAttempts: number failOnFirstOpen?: boolean } @@ -27,6 +31,7 @@ const CURRENT_SCHEMA_COLUMNS = { 'viewport_zoom', 'is_minimap_visible', 'active_space_id', + 'sort_order', ], nodes: [ 'id', @@ -128,11 +133,19 @@ function createMockDbState( userVersion?: number version2Schema?: boolean failOnFirstOpen?: boolean + workspaceRows?: Array<{ + id: string + sortOrder?: number + }> } = {}, ): MockDbState { return { userVersion: options.userVersion ?? 0, tables: options.version2Schema ? createVersion2Tables() : new Map(), + workspaceRows: (options.workspaceRows ?? []).map(row => ({ + id: row.id, + sortOrder: row.sortOrder ?? null, + })), openAttempts: 0, ...(options.failOnFirstOpen ? { failOnFirstOpen: true } : {}), } @@ -215,6 +228,44 @@ function createMockDatabaseModule(mockDbByPath: Map) { } } + if (sql === 'SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') { + return { + all: () => [], + get: () => ({ + cnt: this.state.workspaceRows.filter(row => (row.sortOrder ?? 0) !== 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 + }, + } + } + const insertMatch = sql.match( /INSERT INTO\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*?)\)\s*VALUES/i, ) @@ -279,6 +330,41 @@ describe('PersistenceStore', () => { tempDir = '' }) + it( + 'repairs the workspaces schema and backfills sort_order in legacy rowid order', + async () => { + tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-')) + const dbPath = join(tempDir, 'opencove.db') + const mockDbByPath = new Map([ + [ + dbPath, + createMockDbState({ + userVersion: 5, + version2Schema: true, + workspaceRows: [{ id: 'ws-2' }, { id: 'ws-4' }, { id: 'ws-1' }], + }), + ], + ]) + 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() + + const repairedState = mockDbByPath.get(dbPath) + expect(repairedState?.tables.get('workspaces')).toContain('sort_order') + expect(repairedState?.workspaceRows).toEqual([ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-4', sortOrder: 1 }, + { id: 'ws-1', sortOrder: 2 }, + ]) + }, + PERSISTENCE_STORE_TEST_TIMEOUT_MS, + ) + it( 'creates a backup when migrating an existing db file', async () => { From c9a042d5707b0fee2c91d62ebe2f272d043edd31 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 11:57:41 +0800 Subject: [PATCH 02/16] fix(persistence): gate workspace sort order backfill --- src/platform/persistence/sqlite/migrate.ts | 12 +- ...persistenceStoreSortOrderMigration.spec.ts | 247 ++++++++++++++++++ 2 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts diff --git a/src/platform/persistence/sqlite/migrate.ts b/src/platform/persistence/sqlite/migrate.ts index 01ccdc10..9398a7ac 100644 --- a/src/platform/persistence/sqlite/migrate.ts +++ b/src/platform/persistence/sqlite/migrate.ts @@ -135,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( @@ -145,6 +145,8 @@ function ensureTableColumn( options.columnName, )} ${options.definitionSql}`, ) + + return true } function backfillWorkspaceSortOrder(db: Database.Database): void { @@ -177,13 +179,15 @@ function ensureCurrentSchema(db: Database.Database): void { definitionSql: `TEXT NOT NULL DEFAULT '[]'`, }) - ensureTableColumn(db, { + const addedWorkspaceSortOrder = ensureTableColumn(db, { tableName: 'workspaces', columnName: 'sort_order', definitionSql: 'INTEGER NOT NULL DEFAULT 0', }) - backfillWorkspaceSortOrder(db) + if (addedWorkspaceSortOrder) { + backfillWorkspaceSortOrder(db) + } ensureTableColumn(db, { tableName: 'nodes', diff --git a/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts new file mode 100644 index 00000000..6add5471 --- /dev/null +++ b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts @@ -0,0 +1,247 @@ +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 + }> + 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 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 + }, + } + } + + 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( + '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, + { + userVersion: 5, + tables: new Map([['workspaces', [...CURRENT_SCHEMA_COLUMNS.workspaces]]]), + workspaceRows: [ + { id: 'ws-2', sortOrder: 0 }, + { id: 'ws-4', sortOrder: 0 }, + { id: 'ws-1', sortOrder: 0 }, + ], + openAttempts: 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, + ) +}) From 480814d99b7286bb4943845f5538667c394f631c Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 12:07:55 +0800 Subject: [PATCH 03/16] fix(persistence): backfill sort_order in v1 migration path and trim test file Add backfillWorkspaceSortOrder call to the legacy v1 migration branch so migrated workspaces get proper sort_order values. Move sort_order migration tests to dedicated spec file to keep persistenceStore.spec.ts under the 500-line limit. --- src/platform/persistence/sqlite/migrate.ts | 1 + .../platform/persistenceStore.spec.ts | 97 +------ ...persistenceStoreSortOrderMigration.spec.ts | 268 +++++++++++++++++- 3 files changed, 263 insertions(+), 103 deletions(-) diff --git a/src/platform/persistence/sqlite/migrate.ts b/src/platform/persistence/sqlite/migrate.ts index 9398a7ac..ce5d8aba 100644 --- a/src/platform/persistence/sqlite/migrate.ts +++ b/src/platform/persistence/sqlite/migrate.ts @@ -220,6 +220,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/tests/contract/platform/persistenceStore.spec.ts b/tests/contract/platform/persistenceStore.spec.ts index 499c462b..31f35c23 100644 --- a/tests/contract/platform/persistenceStore.spec.ts +++ b/tests/contract/platform/persistenceStore.spec.ts @@ -6,14 +6,7 @@ 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 | null - }> - openAttempts: number - failOnFirstOpen?: boolean + userVersion: number; tables: Map; openAttempts: number; failOnFirstOpen?: boolean } const CURRENT_SCHEMA_COLUMNS = { @@ -133,19 +126,11 @@ function createMockDbState( userVersion?: number version2Schema?: boolean failOnFirstOpen?: boolean - workspaceRows?: Array<{ - id: string - sortOrder?: number - }> } = {}, ): MockDbState { return { userVersion: options.userVersion ?? 0, tables: options.version2Schema ? createVersion2Tables() : new Map(), - workspaceRows: (options.workspaceRows ?? []).map(row => ({ - id: row.id, - sortOrder: row.sortOrder ?? null, - })), openAttempts: 0, ...(options.failOnFirstOpen ? { failOnFirstOpen: true } : {}), } @@ -229,41 +214,7 @@ function createMockDatabaseModule(mockDbByPath: Map) { } if (sql === 'SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') { - return { - all: () => [], - get: () => ({ - cnt: this.state.workspaceRows.filter(row => (row.sortOrder ?? 0) !== 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 - }, - } + return { all: () => [], get: () => ({ cnt: 1 }), run: () => undefined } } const insertMatch = sql.match( @@ -288,15 +239,6 @@ function createMockDatabaseModule(mockDbByPath: Map) { }, } } - - if (sql.includes('SELECT value FROM kv WHERE key = ?')) { - return { - all: () => [], - get: () => undefined, - run: () => undefined, - } - } - return { all: () => [], get: () => undefined, @@ -330,41 +272,6 @@ describe('PersistenceStore', () => { tempDir = '' }) - it( - 'repairs the workspaces schema and backfills sort_order in legacy rowid order', - async () => { - tempDir = await mkdtemp(join(tmpdir(), 'cove-persist-')) - const dbPath = join(tempDir, 'opencove.db') - const mockDbByPath = new Map([ - [ - dbPath, - createMockDbState({ - userVersion: 5, - version2Schema: true, - workspaceRows: [{ id: 'ws-2' }, { id: 'ws-4' }, { id: 'ws-1' }], - }), - ], - ]) - 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() - - const repairedState = mockDbByPath.get(dbPath) - expect(repairedState?.tables.get('workspaces')).toContain('sort_order') - expect(repairedState?.workspaceRows).toEqual([ - { id: 'ws-2', sortOrder: 0 }, - { id: 'ws-4', sortOrder: 1 }, - { id: 'ws-1', sortOrder: 2 }, - ]) - }, - 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 index 6add5471..138118a9 100644 --- a/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts +++ b/tests/contract/platform/persistenceStoreSortOrderMigration.spec.ts @@ -8,10 +8,8 @@ const PERSISTENCE_STORE_TEST_TIMEOUT_MS = 20_000 type MockDbState = { userVersion: number tables: Map - workspaceRows: Array<{ - id: string - sortOrder: number - }> + workspaceRows: Array<{ id: string; sortOrder: number }> + legacyWorkspaceStateRaw?: string openAttempts: number } @@ -68,6 +66,88 @@ const CURRENT_SCHEMA_COLUMNS = { 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 @@ -174,6 +254,62 @@ function createMockDatabaseModule(mockDbByPath: Map) { } } + 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, @@ -206,6 +342,45 @@ describe('PersistenceStore sort order migration', () => { 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 () => { @@ -214,16 +389,14 @@ describe('PersistenceStore sort order migration', () => { const mockDbByPath = new Map([ [ dbPath, - { + createMockDbState({ userVersion: 5, - tables: new Map([['workspaces', [...CURRENT_SCHEMA_COLUMNS.workspaces]]]), workspaceRows: [ { id: 'ws-2', sortOrder: 0 }, { id: 'ws-4', sortOrder: 0 }, { id: 'ws-1', sortOrder: 0 }, ], - openAttempts: 0, - }, + }), ], ]) @@ -244,4 +417,83 @@ describe('PersistenceStore sort order migration', () => { }, 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, + ) }) From c9e29c4a1cd25907072ca21cde898857f9b1c4d6 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 12:11:33 +0800 Subject: [PATCH 04/16] feat(persistence): order workspace reads by sort_order --- src/platform/persistence/sqlite/read.ts | 2 +- .../contract/platform/persistenceRead.spec.ts | 130 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/contract/platform/persistenceRead.spec.ts 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/tests/contract/platform/persistenceRead.spec.ts b/tests/contract/platform/persistenceRead.spec.ts new file mode 100644 index 00000000..9d06517e --- /dev/null +++ b/tests/contract/platform/persistenceRead.spec.ts @@ -0,0 +1,130 @@ +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', + ]) + }) +}) From a3231b63238db8063b384ecdaf12c9a769007685 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 12:15:31 +0800 Subject: [PATCH 05/16] feat(persistence): include sort_order in workspace writes --- src/platform/persistence/sqlite/write.ts | 8 +- .../platform/persistenceStore.spec.ts | 89 ++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/platform/persistence/sqlite/write.ts b/src/platform/persistence/sqlite/write.ts index c072e583..5a16b9c3 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 index = 0; index < state.workspaces.length; index += 1) { + const workspace = state.workspaces[index] insertWorkspace.run( workspace.id, workspace.name, @@ -90,6 +91,7 @@ export function writeNormalizedAppState( workspace.viewport.zoom, workspace.isMinimapVisible ? 1 : 0, workspace.activeSpaceId, + index, ) for (const node of workspace.nodes) { diff --git a/tests/contract/platform/persistenceStore.spec.ts b/tests/contract/platform/persistenceStore.spec.ts index 31f35c23..1b1a9463 100644 --- a/tests/contract/platform/persistenceStore.spec.ts +++ b/tests/contract/platform/persistenceStore.spec.ts @@ -6,7 +6,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const PERSISTENCE_STORE_TEST_TIMEOUT_MS = 20_000 type MockDbState = { - userVersion: number; tables: Map; openAttempts: number; failOnFirstOpen?: boolean + userVersion: number + tables: Map + openAttempts: number + workspaceRows: Array<{ id: string; sortOrder: number }> + failOnFirstOpen?: boolean } const CURRENT_SCHEMA_COLUMNS = { @@ -132,6 +136,7 @@ function createMockDbState( userVersion: options.userVersion ?? 0, tables: options.version2Schema ? createVersion2Tables() : new Map(), openAttempts: 0, + workspaceRows: [], ...(options.failOnFirstOpen ? { failOnFirstOpen: true } : {}), } } @@ -229,13 +234,35 @@ 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 (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 }) }, } } @@ -272,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 () => { From 15a71c8200071b2b3341f45f14091e06c99d753d Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 12:20:57 +0800 Subject: [PATCH 06/16] feat(store): add reorderWorkspaces action --- .../renderer/shell/store/useAppStore.spec.ts | 59 +++++++++++++++++++ src/app/renderer/shell/store/useAppStore.ts | 16 +++++ 2 files changed, 75 insertions(+) create mode 100644 src/app/renderer/shell/store/useAppStore.spec.ts 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..6b5a41ae --- /dev/null +++ b/src/app/renderer/shell/store/useAppStore.spec.ts @@ -0,0 +1,59 @@ +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', + ]) + }) +}) diff --git a/src/app/renderer/shell/store/useAppStore.ts b/src/app/renderer/shell/store/useAppStore.ts index ea8d5201..03747713 100644 --- a/src/app/renderer/shell/store/useAppStore.ts +++ b/src/app/renderer/shell/store/useAppStore.ts @@ -36,6 +36,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 +70,19 @@ 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 + } + + const reordered = [...state.workspaces] + const [moved] = reordered.splice(oldIndex, 1) + reordered.splice(newIndex, 0, moved) + + return { workspaces: reordered } + }), })) From d751b184773e1da8fc43c848865ccec89b7e0a84 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 12:33:26 +0800 Subject: [PATCH 07/16] feat(sidebar): add drag-to-reorder with dnd-kit --- src/app/renderer/shell/AppShell.tsx | 122 ++--- .../shell/components/Sidebar.spec.tsx | 209 +++++++++ src/app/renderer/shell/components/Sidebar.tsx | 417 ++++++++++++------ src/app/renderer/styles/workspace-sidebar.css | 13 + 4 files changed, 553 insertions(+), 208 deletions(-) create mode 100644 src/app/renderer/shell/components/Sidebar.spec.tsx 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..4be0cfe3 --- /dev/null +++ b/src/app/renderer/shell/components/Sidebar.spec.tsx @@ -0,0 +1,209 @@ +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: class PointerSensor {}, + 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' } }) + }) + + expect(container.querySelector('.workspace-item--drag-overlay')).toHaveTextContent( + 'workspace-b', + ) + expect(screen.getByTitle('/tmp/workspace-b').closest('.workspace-item-group')).toHaveStyle({ + opacity: '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).toHaveAttribute('data-drag-listener', 'true') + expect(agentButton).not.toHaveAttribute('data-drag-listener') + + 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..d0d90e14 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -1,5 +1,18 @@ -import React from 'react' -import { useTranslation } from '@app/renderer/i18n' +import React, { useCallback, useState } from 'react' +import { + DndContext, + DragOverlay, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragCancelEvent, + 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' @@ -12,6 +25,27 @@ 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') { return 'working' @@ -20,6 +54,191 @@ 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 { + const terminalCount = workspace.nodes.filter(node => node.data.kind === 'terminal').length + const agentCount = workspace.nodes.filter(node => node.data.kind === 'agent').length + const taskCount = workspace.nodes.filter(node => node.data.kind === 'task').length + + 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: SidebarStatusTone = + sidebarAgentStatus === 'working' ? 'working' : 'standby' + 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 +249,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((_event: DragCancelEvent): 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/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; From eb9dc281970e1a3767eb8305df11e211d023587e Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 12:39:34 +0800 Subject: [PATCH 08/16] test: add edge case tests for workspace reorder --- .../renderer/shell/store/useAppStore.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/app/renderer/shell/store/useAppStore.spec.ts b/src/app/renderer/shell/store/useAppStore.spec.ts index 6b5a41ae..3d5312ee 100644 --- a/src/app/renderer/shell/store/useAppStore.spec.ts +++ b/src/app/renderer/shell/store/useAppStore.spec.ts @@ -56,4 +56,55 @@ describe('useAppStore', () => { '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', + ]) + }) }) From 8406861f951e26c6cb04e4f50c573fcd9374ba53 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 12:41:18 +0800 Subject: [PATCH 09/16] test: cover appending new workspaces to sidebar order --- .../hooks/useAddWorkspaceAction.spec.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/app/renderer/shell/hooks/useAddWorkspaceAction.spec.tsx 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') + }) +}) From cc5c95ef041fc06f54d1c8049500ccb5ef1f3e1b Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 13:08:49 +0800 Subject: [PATCH 10/16] chore: include uncommitted changes for handoff --- .claude/ship-coding.local.md | 16 ++++++++++++++ .gitignore | 1 + .../shell/components/Sidebar.spec.tsx | 2 +- src/app/renderer/shell/components/Sidebar.tsx | 22 ++++++++++--------- src/app/renderer/shell/store/useAppStore.ts | 7 ++---- src/platform/persistence/sqlite/write.ts | 6 ++--- 6 files changed, 35 insertions(+), 19 deletions(-) create mode 100644 .claude/ship-coding.local.md diff --git a/.claude/ship-coding.local.md b/.claude/ship-coding.local.md new file mode 100644 index 00000000..a133fdf9 --- /dev/null +++ b/.claude/ship-coding.local.md @@ -0,0 +1,16 @@ +--- +active: true +session_id: +task_id: feat-sidebar-drag-to-reorder-projects-87 +task_dir: .ship/tasks/feat-sidebar-drag-to-reorder-projects-87 +required_artifacts: + - plan/spec.md + - plan/plan.md + - review.md + - verify.md + - qa.md + - simplify.md +started_at: "2026-03-30T03:24:31Z" +--- + +feat: sidebar drag-to-reorder projects (#87) diff --git a/.gitignore b/.gitignore index c4a043c0..8de86887 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,4 @@ tmp/ .cove/ .opencove docs/Microsoft/ +.ship/ diff --git a/src/app/renderer/shell/components/Sidebar.spec.tsx b/src/app/renderer/shell/components/Sidebar.spec.tsx index 4be0cfe3..36c53a5b 100644 --- a/src/app/renderer/shell/components/Sidebar.spec.tsx +++ b/src/app/renderer/shell/components/Sidebar.spec.tsx @@ -30,7 +30,7 @@ vi.mock('@dnd-kit/core', () => ({ return <>{children} }, DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}, - PointerSensor: class PointerSensor {}, + PointerSensor: vi.fn(), closestCenter: vi.fn(), useSensor: vi.fn((_sensor: unknown, options?: unknown) => ({ options })), useSensors: vi.fn((...sensors: unknown[]) => sensors), diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx index d0d90e14..341925b0 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -6,7 +6,6 @@ import { closestCenter, useSensor, useSensors, - type DragCancelEvent, type DragEndEvent, type DragStartEvent, } from '@dnd-kit/core' @@ -23,8 +22,6 @@ import type { type SidebarAgentStatus = 'working' | 'standby' -type SidebarStatusTone = 'working' | 'standby' - type SidebarProps = { workspaces: WorkspaceState[] activeWorkspaceId: string | null @@ -65,9 +62,15 @@ function getWorkspaceAgents(workspace: WorkspaceState) { } function getWorkspaceMetaText(workspace: WorkspaceState, t: TranslateFn): string { - const terminalCount = workspace.nodes.filter(node => node.data.kind === 'terminal').length - const agentCount = workspace.nodes.filter(node => node.data.kind === 'agent').length - const taskCount = workspace.nodes.filter(node => node.data.kind === 'task').length + 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 }), @@ -131,8 +134,7 @@ function WorkspaceAgentItems({ ? AGENT_PROVIDER_LABEL[provider] : t('sidebar.fallbackAgentLabel') const sidebarAgentStatus = resolveSidebarAgentStatus(node.data.status) - const sidebarAgentStatusTone: SidebarStatusTone = - sidebarAgentStatus === 'working' ? 'working' : 'standby' + const sidebarAgentStatusTone = sidebarAgentStatus const startedText = toRelativeTime(node.data.startedAt) const sidebarAgentStatusText = sidebarAgentStatus === 'working' @@ -263,7 +265,7 @@ export function Sidebar({ setActiveId(String(event.active.id)) }, []) - const handleDragCancel = useCallback((_event: DragCancelEvent): void => { + const handleDragCancel = useCallback((): void => { setActiveId(null) }, []) @@ -274,7 +276,7 @@ export function Sidebar({ setActiveId(null) - if (nextOverId === null || nextOverId === undefined) { + if (nextOverId == null) { return } diff --git a/src/app/renderer/shell/store/useAppStore.ts b/src/app/renderer/shell/store/useAppStore.ts index 03747713..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 { @@ -79,10 +80,6 @@ export const useAppStore = create(set => ({ return state } - const reordered = [...state.workspaces] - const [moved] = reordered.splice(oldIndex, 1) - reordered.splice(newIndex, 0, moved) - - return { workspaces: reordered } + return { workspaces: arrayMove(state.workspaces, oldIndex, newIndex) } }), })) diff --git a/src/platform/persistence/sqlite/write.ts b/src/platform/persistence/sqlite/write.ts index 5a16b9c3..1bcfecff 100644 --- a/src/platform/persistence/sqlite/write.ts +++ b/src/platform/persistence/sqlite/write.ts @@ -77,8 +77,8 @@ export function writeNormalizedAppState( upsertSettings.run(safeJsonStringify(state.settings ?? {})) - for (let index = 0; index < state.workspaces.length; index += 1) { - const workspace = state.workspaces[index] + for (let sortOrder = 0; sortOrder < state.workspaces.length; sortOrder += 1) { + const workspace = state.workspaces[sortOrder] insertWorkspace.run( workspace.id, workspace.name, @@ -91,7 +91,7 @@ export function writeNormalizedAppState( workspace.viewport.zoom, workspace.isMinimapVisible ? 1 : 0, workspace.activeSpaceId, - index, + sortOrder, ) for (const node of workspace.nodes) { From 3148def5449f2be447b6fe9f0fa07197e67d3ca2 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 13:11:18 +0800 Subject: [PATCH 11/16] fix: resolve lint errors in Sidebar.tsx (curly braces, strict equality) --- src/app/renderer/shell/components/Sidebar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx index 341925b0..653dd196 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -67,9 +67,9 @@ function getWorkspaceMetaText(workspace: WorkspaceState, t: TranslateFn): string 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 + if (node.data.kind === 'terminal') { terminalCount += 1 } + else if (node.data.kind === 'agent') { agentCount += 1 } + else if (node.data.kind === 'task') { taskCount += 1 } } return [ @@ -276,7 +276,7 @@ export function Sidebar({ setActiveId(null) - if (nextOverId == null) { + if (nextOverId === null || nextOverId === undefined) { return } From 7e7ba7c33524aba8694c6f5c4aac0b96f7144218 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 13:12:19 +0800 Subject: [PATCH 12/16] docs: update CHANGELOG for sidebar drag-to-reorder (#87) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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) From 57eac1b95321b189b6a7ee6f11b5ca3489bda22d Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 13:51:05 +0800 Subject: [PATCH 13/16] test(sidebar): add E2E drag-reorder tests and fix unit test type errors - Add 3 E2E tests: drag reorder + persistence, click selection, context menu - Fix Sidebar.spec.tsx TS errors: replace jest-dom matchers with standard assertions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../shell/components/Sidebar.spec.tsx | 17 +- ...kspace-canvas.sidebar-drag-reorder.spec.ts | 165 ++++++++++++++++++ 2 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/workspace-canvas.sidebar-drag-reorder.spec.ts diff --git a/src/app/renderer/shell/components/Sidebar.spec.tsx b/src/app/renderer/shell/components/Sidebar.spec.tsx index 36c53a5b..d90639d1 100644 --- a/src/app/renderer/shell/components/Sidebar.spec.tsx +++ b/src/app/renderer/shell/components/Sidebar.spec.tsx @@ -148,12 +148,13 @@ describe('Sidebar', () => { dndState.onDragStart?.({ active: { id: 'workspace-b' } }) }) - expect(container.querySelector('.workspace-item--drag-overlay')).toHaveTextContent( - 'workspace-b', - ) - expect(screen.getByTitle('/tmp/workspace-b').closest('.workspace-item-group')).toHaveStyle({ - opacity: '0.4', - }) + 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 @@ -190,8 +191,8 @@ describe('Sidebar', () => { const workspaceButton = screen.getByTitle('/tmp/workspace-a') const agentButton = screen.getByTestId('workspace-agent-item-workspace-a-workspace-a-agent') - expect(workspaceButton).toHaveAttribute('data-drag-listener', 'true') - expect(agentButton).not.toHaveAttribute('data-drag-listener') + expect(workspaceButton.getAttribute('data-drag-listener')).toBe('true') + expect(agentButton.getAttribute('data-drag-listener')).toBeNull() fireEvent.click(workspaceButton) expect(onSelectWorkspace).toHaveBeenCalledWith('workspace-a') 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() + } + }) +}) From 52f500f4c27b4e5c6104a13538270e49f1d8fd41 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 13:59:04 +0800 Subject: [PATCH 14/16] chore: remove ship-coding.local.md from tracking --- .claude/ship-coding.local.md | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .claude/ship-coding.local.md diff --git a/.claude/ship-coding.local.md b/.claude/ship-coding.local.md deleted file mode 100644 index a133fdf9..00000000 --- a/.claude/ship-coding.local.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -active: true -session_id: -task_id: feat-sidebar-drag-to-reorder-projects-87 -task_dir: .ship/tasks/feat-sidebar-drag-to-reorder-projects-87 -required_artifacts: - - plan/spec.md - - plan/plan.md - - review.md - - verify.md - - qa.md - - simplify.md -started_at: "2026-03-30T03:24:31Z" ---- - -feat: sidebar drag-to-reorder projects (#87) From 26257d325e5c6e27855e582688d06f4424a614f1 Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 14:02:37 +0800 Subject: [PATCH 15/16] chore: remove .ship/ from .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8de86887..c4a043c0 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,3 @@ tmp/ .cove/ .opencove docs/Microsoft/ -.ship/ From 23bafed4842be33da2004b72c88ac47d1d1b492a Mon Sep 17 00:00:00 2001 From: Travis Xie Date: Mon, 30 Mar 2026 14:42:56 +0800 Subject: [PATCH 16/16] style: format Sidebar, migrate, and persistenceRead with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/renderer/shell/components/Sidebar.tsx | 10 +++++++--- src/platform/persistence/sqlite/migrate.ts | 4 +++- tests/contract/platform/persistenceRead.spec.ts | 9 ++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx index 653dd196..6d35f9bd 100644 --- a/src/app/renderer/shell/components/Sidebar.tsx +++ b/src/app/renderer/shell/components/Sidebar.tsx @@ -67,9 +67,13 @@ function getWorkspaceMetaText(workspace: WorkspaceState, t: TranslateFn): string 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 } + if (node.data.kind === 'terminal') { + terminalCount += 1 + } else if (node.data.kind === 'agent') { + agentCount += 1 + } else if (node.data.kind === 'task') { + taskCount += 1 + } } return [ diff --git a/src/platform/persistence/sqlite/migrate.ts b/src/platform/persistence/sqlite/migrate.ts index ce5d8aba..02b7fb0e 100644 --- a/src/platform/persistence/sqlite/migrate.ts +++ b/src/platform/persistence/sqlite/migrate.ts @@ -150,7 +150,9 @@ function ensureTableColumn( } function backfillWorkspaceSortOrder(db: Database.Database): void { - const allZero = db.prepare('SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0').get() as { + const allZero = db + .prepare('SELECT COUNT(*) as cnt FROM workspaces WHERE sort_order != 0') + .get() as { cnt: number } if (allZero.cnt > 0) { diff --git a/tests/contract/platform/persistenceRead.spec.ts b/tests/contract/platform/persistenceRead.spec.ts index 9d06517e..ac2cccc2 100644 --- a/tests/contract/platform/persistenceRead.spec.ts +++ b/tests/contract/platform/persistenceRead.spec.ts @@ -1,7 +1,14 @@ 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' +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 }