cursor-history 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/migrate.d.ts +3 -1
- package/dist/core/migrate.d.ts.map +1 -1
- package/dist/core/migrate.js +206 -53
- package/dist/core/migrate.js.map +1 -1
- package/dist/core/parser.d.ts.map +1 -1
- package/dist/core/parser.js +18 -2
- package/dist/core/parser.js.map +1 -1
- package/dist/core/storage.d.ts +9 -2
- package/dist/core/storage.d.ts.map +1 -1
- package/dist/core/storage.js +730 -62
- package/dist/core/storage.js.map +1 -1
- package/dist/lib/platform.d.ts +4 -0
- package/dist/lib/platform.d.ts.map +1 -1
- package/dist/lib/platform.js +7 -1
- package/dist/lib/platform.js.map +1 -1
- package/package.json +1 -1
package/dist/core/storage.js
CHANGED
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { readFile } from 'node:fs/promises';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { homedir } from 'node:os';
|
|
8
7
|
import JSZip from 'jszip';
|
|
9
8
|
import { openDatabase as openDatabaseAsync, openDatabaseReadWrite as openDatabaseReadWriteAsync, ensureDriver, } from './database/index.js';
|
|
10
|
-
import { getCursorDataPath, contractPath, normalizePath, pathsEqual } from '../lib/platform.js';
|
|
9
|
+
import { getCursorDataPath, getGlobalStoragePath, contractPath, normalizePath, pathsEqual, } from '../lib/platform.js';
|
|
11
10
|
import { SessionNotFoundError } from '../lib/errors.js';
|
|
12
11
|
import { parseChatData, getSearchSnippets } from './parser.js';
|
|
13
12
|
import { openBackupDatabase, readBackupManifest } from './backup.js';
|
|
@@ -25,22 +24,6 @@ const CHAT_DATA_KEYS = [
|
|
|
25
24
|
*/
|
|
26
25
|
const PROMPTS_KEY = 'aiService.prompts';
|
|
27
26
|
const GENERATIONS_KEY = 'aiService.generations';
|
|
28
|
-
/**
|
|
29
|
-
* Get the global Cursor storage path
|
|
30
|
-
*/
|
|
31
|
-
function getGlobalStoragePath() {
|
|
32
|
-
const platform = process.platform;
|
|
33
|
-
const home = homedir();
|
|
34
|
-
if (platform === 'win32') {
|
|
35
|
-
return join(process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming'), 'Cursor', 'User', 'globalStorage');
|
|
36
|
-
}
|
|
37
|
-
else if (platform === 'darwin') {
|
|
38
|
-
return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage');
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
return join(home, '.config', 'Cursor', 'User', 'globalStorage');
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
27
|
/**
|
|
45
28
|
* Open a SQLite database file (read-only)
|
|
46
29
|
* @deprecated Use openDatabaseAsync for new code
|
|
@@ -76,6 +59,162 @@ function closeDatabase(db) {
|
|
|
76
59
|
function getBubbleRowId(rowKey) {
|
|
77
60
|
return rowKey.split(':').pop() ?? null;
|
|
78
61
|
}
|
|
62
|
+
function isRecord(value) {
|
|
63
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A composer entry carries real metadata when it has any field beyond `composerId`.
|
|
67
|
+
* Synthetic `{ composerId }` stubs (fabricated from `selectedComposerIds` by
|
|
68
|
+
* getComposerData) must never be persisted back into `allComposers`, or they would
|
|
69
|
+
* parse to phantom sessions with a null title and a "now" timestamp.
|
|
70
|
+
*/
|
|
71
|
+
function isHydratedComposer(composer) {
|
|
72
|
+
return Object.keys(composer).some((key) => key !== 'composerId');
|
|
73
|
+
}
|
|
74
|
+
function parseDateValue(value) {
|
|
75
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
// Epoch-ms timestamps can arrive as numeric strings ("1778672423842"); new Date()
|
|
79
|
+
// treats those as invalid, so coerce all-digit strings to a number first.
|
|
80
|
+
const normalized = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value.trim()) : value;
|
|
81
|
+
const date = new Date(normalized);
|
|
82
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
83
|
+
}
|
|
84
|
+
function uriToPath(uri) {
|
|
85
|
+
try {
|
|
86
|
+
return decodeURIComponent(uri.replace(/^file:\/\//, ''));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return uri.replace(/^file:\/\//, '');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function workspacePathMatches(candidatePath, workspacePath) {
|
|
93
|
+
return typeof candidatePath === 'string' && pathsEqual(uriToPath(candidatePath), workspacePath);
|
|
94
|
+
}
|
|
95
|
+
function composerBelongsToWorkspace(composerData, workspace) {
|
|
96
|
+
const workspaceIdentifier = composerData['workspaceIdentifier'];
|
|
97
|
+
if (isRecord(workspaceIdentifier)) {
|
|
98
|
+
if (workspaceIdentifier['id'] === workspace.id) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const uri = workspaceIdentifier['uri'];
|
|
102
|
+
if (isRecord(uri)) {
|
|
103
|
+
if (workspacePathMatches(uri['fsPath'], workspace.path) ||
|
|
104
|
+
workspacePathMatches(uri['path'], workspace.path) ||
|
|
105
|
+
workspacePathMatches(uri['external'], workspace.path)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return workspacePathMatches(composerData['workspaceUri'], workspace.path);
|
|
111
|
+
}
|
|
112
|
+
/** Whether a global composer record carries any explicit workspace attribution. */
|
|
113
|
+
function composerHasWorkspaceStamp(composerData) {
|
|
114
|
+
if (isRecord(composerData['workspaceIdentifier'])) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
const workspaceUri = composerData['workspaceUri'];
|
|
118
|
+
return typeof workspaceUri === 'string' && workspaceUri.length > 0;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Best-effort workspace path for a global composer that is not attributed to any
|
|
122
|
+
* discovered workspace, derived from whatever workspace metadata the record does
|
|
123
|
+
* carry. Returns null when the record has no usable workspace hint.
|
|
124
|
+
*/
|
|
125
|
+
function workspacePathFromComposer(composerData) {
|
|
126
|
+
const workspaceIdentifier = composerData['workspaceIdentifier'];
|
|
127
|
+
if (isRecord(workspaceIdentifier) && isRecord(workspaceIdentifier['uri'])) {
|
|
128
|
+
const uri = workspaceIdentifier['uri'];
|
|
129
|
+
for (const field of ['fsPath', 'path', 'external']) {
|
|
130
|
+
const value = uri[field];
|
|
131
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
132
|
+
return uriToPath(value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const workspaceUri = composerData['workspaceUri'];
|
|
137
|
+
if (typeof workspaceUri === 'string' && workspaceUri.length > 0) {
|
|
138
|
+
return uriToPath(workspaceUri);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
function extractComposerIdsFromData(dataText) {
|
|
143
|
+
if (!dataText) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(dataText);
|
|
148
|
+
const ids = new Map();
|
|
149
|
+
if (Array.isArray(parsed)) {
|
|
150
|
+
for (const entry of parsed) {
|
|
151
|
+
if (!entry || typeof entry !== 'object')
|
|
152
|
+
continue;
|
|
153
|
+
const composerId = entry.composerId;
|
|
154
|
+
if (typeof composerId === 'string' && composerId.trim().length > 0) {
|
|
155
|
+
ids.set(composerId, 'allComposers');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else if (parsed && typeof parsed === 'object') {
|
|
160
|
+
const allComposers = parsed.allComposers;
|
|
161
|
+
if (Array.isArray(allComposers)) {
|
|
162
|
+
for (const entry of allComposers) {
|
|
163
|
+
if (!entry || typeof entry !== 'object')
|
|
164
|
+
continue;
|
|
165
|
+
const composerId = entry.composerId;
|
|
166
|
+
if (typeof composerId === 'string' && composerId.trim().length > 0) {
|
|
167
|
+
ids.set(composerId, 'allComposers');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const selected = parsed.selectedComposerIds;
|
|
172
|
+
if (Array.isArray(selected)) {
|
|
173
|
+
for (const composerId of selected) {
|
|
174
|
+
if (typeof composerId === 'string' && composerId.trim().length > 0 && !ids.has(composerId)) {
|
|
175
|
+
ids.set(composerId, 'selectedComposerIds');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return [...ids.entries()].map(([composerId, source]) => ({ composerId, source }));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const COMPOSER_GUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g;
|
|
187
|
+
/**
|
|
188
|
+
* Modern Cursor links a workspace to its agent/composer sessions through
|
|
189
|
+
* per-workspace UI-state pointer keys (e.g. `workbench.panel.composerChatViewPane.<guid>`)
|
|
190
|
+
* rather than stamping the workspace into the global composer record. Extract the
|
|
191
|
+
* composer GUIDs referenced by those pointers so they can be resolved from global
|
|
192
|
+
* storage. GUIDs that are not real composers simply resolve to nothing downstream.
|
|
193
|
+
*/
|
|
194
|
+
function getWorkspaceComposerPointerIds(db) {
|
|
195
|
+
try {
|
|
196
|
+
const rows = db
|
|
197
|
+
.prepare("SELECT key, value FROM ItemTable WHERE key LIKE '%composerChatViewPane%'")
|
|
198
|
+
.all();
|
|
199
|
+
const ids = new Set();
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
for (const source of [row.key, row.value]) {
|
|
202
|
+
if (typeof source !== 'string')
|
|
203
|
+
continue;
|
|
204
|
+
const matches = source.match(COMPOSER_GUID_RE);
|
|
205
|
+
if (matches) {
|
|
206
|
+
for (const match of matches) {
|
|
207
|
+
ids.add(match.toLowerCase());
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return [...ids];
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
79
218
|
function parseToolParams(paramsText, rawArgsText) {
|
|
80
219
|
const rawText = paramsText ?? rawArgsText;
|
|
81
220
|
if (typeof rawText !== 'string' || rawText.trim().length === 0) {
|
|
@@ -386,6 +525,14 @@ export async function findWorkspaces(customDataPath, backupPath) {
|
|
|
386
525
|
return [];
|
|
387
526
|
}
|
|
388
527
|
const workspaces = [];
|
|
528
|
+
let globalDb = null;
|
|
529
|
+
let globalDbChecked = false;
|
|
530
|
+
let globalDbAvailable = false;
|
|
531
|
+
let globalComposerRecords = [];
|
|
532
|
+
let globalBubbleCounts = new Map();
|
|
533
|
+
// Run-level set so a global composer is counted for at most one workspace,
|
|
534
|
+
// keeping `list --workspaces` counts consistent with the deduped `list` output.
|
|
535
|
+
const attributedComposerIds = new Set();
|
|
389
536
|
try {
|
|
390
537
|
const entries = readdirSync(basePath, { withFileTypes: true });
|
|
391
538
|
for (const entry of entries) {
|
|
@@ -395,24 +542,102 @@ export async function findWorkspaces(customDataPath, backupPath) {
|
|
|
395
542
|
const dbPath = join(workspaceDir, 'state.vscdb');
|
|
396
543
|
if (!existsSync(dbPath))
|
|
397
544
|
continue;
|
|
398
|
-
const workspacePath = readWorkspaceJson(workspaceDir)
|
|
399
|
-
if (
|
|
400
|
-
|
|
545
|
+
const workspacePath = readWorkspaceJson(workspaceDir) ?? `(workspace: ${entry.name})`;
|
|
546
|
+
if (workspacePath.startsWith('(workspace:')) {
|
|
547
|
+
debugLogStorage(`Using workspace ID fallback path for ${entry.name} (workspace.json missing/unknown)`);
|
|
548
|
+
}
|
|
401
549
|
// Count sessions in this workspace
|
|
402
550
|
let sessionCount = 0;
|
|
551
|
+
const seenComposerIds = new Set();
|
|
552
|
+
const selectedIds = [];
|
|
553
|
+
const pointerIds = [];
|
|
403
554
|
try {
|
|
404
555
|
const db = await openDatabase(dbPath);
|
|
405
556
|
const result = getChatDataFromDb(db);
|
|
406
557
|
if (result) {
|
|
407
558
|
const parsed = parseChatData(result.data, result.bundle);
|
|
408
559
|
sessionCount = parsed.length;
|
|
560
|
+
for (const session of parsed) {
|
|
561
|
+
seenComposerIds.add(session.id);
|
|
562
|
+
attributedComposerIds.add(session.id);
|
|
563
|
+
}
|
|
564
|
+
const rawComposerData = result.bundle.composerData;
|
|
565
|
+
const composerRefs = extractComposerIdsFromData(rawComposerData);
|
|
566
|
+
selectedIds.push(...composerRefs
|
|
567
|
+
.filter((ref) => ref.source === 'selectedComposerIds')
|
|
568
|
+
.map((ref) => ref.composerId));
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
debugLogStorage(`No chat data keys found in workspace DB ${dbPath}`);
|
|
409
572
|
}
|
|
573
|
+
// Pointer keys live in ItemTable independently of composer.composerData.
|
|
574
|
+
pointerIds.push(...getWorkspaceComposerPointerIds(db));
|
|
410
575
|
db.close();
|
|
411
576
|
}
|
|
412
|
-
catch {
|
|
577
|
+
catch (error) {
|
|
578
|
+
debugLogStorage(`Skipping unreadable workspace DB ${dbPath}: ${getErrorMessage(error)}`);
|
|
413
579
|
// Skip workspaces with unreadable databases
|
|
414
580
|
continue;
|
|
415
581
|
}
|
|
582
|
+
if (!globalDbChecked) {
|
|
583
|
+
globalDbChecked = true;
|
|
584
|
+
const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
|
|
585
|
+
if (existsSync(globalDbPath)) {
|
|
586
|
+
try {
|
|
587
|
+
globalDb = await openDatabase(globalDbPath);
|
|
588
|
+
const tableCheck = globalDb
|
|
589
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
|
|
590
|
+
.get();
|
|
591
|
+
globalDbAvailable = Boolean(tableCheck);
|
|
592
|
+
if (globalDbAvailable) {
|
|
593
|
+
globalComposerRecords = loadGlobalComposerRecords(globalDb);
|
|
594
|
+
globalBubbleCounts = loadGlobalBubbleCounts(globalDb);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
closeDatabase(globalDb);
|
|
599
|
+
globalDb = null;
|
|
600
|
+
globalDbAvailable = false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (globalDb && globalDbAvailable) {
|
|
605
|
+
// Selected + pointer IDs are only counted when they resolve to a real
|
|
606
|
+
// global composer with bubbles, so non-composer GUIDs never inflate counts.
|
|
607
|
+
for (const composerId of [...selectedIds, ...pointerIds]) {
|
|
608
|
+
if (seenComposerIds.has(composerId) || attributedComposerIds.has(composerId))
|
|
609
|
+
continue;
|
|
610
|
+
if ((globalBubbleCounts.get(composerId) ?? 0) > 0) {
|
|
611
|
+
seenComposerIds.add(composerId);
|
|
612
|
+
attributedComposerIds.add(composerId);
|
|
613
|
+
sessionCount++;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const workspaceForGlobalMatch = {
|
|
617
|
+
id: entry.name,
|
|
618
|
+
path: workspacePath,
|
|
619
|
+
dbPath,
|
|
620
|
+
sessionCount,
|
|
621
|
+
};
|
|
622
|
+
for (const summary of getGlobalComposerSummariesForWorkspace(globalDb, workspaceForGlobalMatch, globalComposerRecords, globalBubbleCounts)) {
|
|
623
|
+
if (seenComposerIds.has(summary.id) || attributedComposerIds.has(summary.id))
|
|
624
|
+
continue;
|
|
625
|
+
seenComposerIds.add(summary.id);
|
|
626
|
+
attributedComposerIds.add(summary.id);
|
|
627
|
+
sessionCount++;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
// No global storage: pointer GUIDs cannot be confirmed as composers, so
|
|
632
|
+
// only count real selectedComposerIds (avoid phantom session counts).
|
|
633
|
+
for (const composerId of selectedIds) {
|
|
634
|
+
if (seenComposerIds.has(composerId) || attributedComposerIds.has(composerId))
|
|
635
|
+
continue;
|
|
636
|
+
seenComposerIds.add(composerId);
|
|
637
|
+
attributedComposerIds.add(composerId);
|
|
638
|
+
sessionCount++;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
416
641
|
if (sessionCount > 0) {
|
|
417
642
|
workspaces.push({
|
|
418
643
|
id: entry.name,
|
|
@@ -426,6 +651,9 @@ export async function findWorkspaces(customDataPath, backupPath) {
|
|
|
426
651
|
catch {
|
|
427
652
|
return [];
|
|
428
653
|
}
|
|
654
|
+
finally {
|
|
655
|
+
closeDatabase(globalDb);
|
|
656
|
+
}
|
|
429
657
|
return workspaces;
|
|
430
658
|
}
|
|
431
659
|
/**
|
|
@@ -433,27 +661,40 @@ export async function findWorkspaces(customDataPath, backupPath) {
|
|
|
433
661
|
* Returns both the main chat data and the bundle for new format
|
|
434
662
|
*/
|
|
435
663
|
function getChatDataFromDb(db) {
|
|
436
|
-
|
|
437
|
-
const bundle = {};
|
|
438
|
-
// Try to get the main chat data
|
|
664
|
+
const candidates = [];
|
|
439
665
|
for (const key of CHAT_DATA_KEYS) {
|
|
440
666
|
try {
|
|
441
667
|
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key);
|
|
442
668
|
if (row?.value) {
|
|
443
|
-
|
|
444
|
-
if (key === 'composer.composerData') {
|
|
445
|
-
bundle.composerData = row.value;
|
|
446
|
-
}
|
|
447
|
-
break;
|
|
669
|
+
candidates.push({ key, value: row.value });
|
|
448
670
|
}
|
|
449
671
|
}
|
|
450
672
|
catch {
|
|
451
673
|
continue;
|
|
452
674
|
}
|
|
453
675
|
}
|
|
454
|
-
if (
|
|
676
|
+
if (candidates.length === 0) {
|
|
455
677
|
return null;
|
|
456
678
|
}
|
|
679
|
+
let selected = candidates[0];
|
|
680
|
+
let bestSessionCount = -1;
|
|
681
|
+
for (const candidate of candidates) {
|
|
682
|
+
const candidateBundle = {};
|
|
683
|
+
if (candidate.key === 'composer.composerData') {
|
|
684
|
+
candidateBundle.composerData = candidate.value;
|
|
685
|
+
}
|
|
686
|
+
const parsedCount = parseChatData(candidate.value, candidateBundle).length;
|
|
687
|
+
if (parsedCount > bestSessionCount) {
|
|
688
|
+
selected = candidate;
|
|
689
|
+
bestSessionCount = parsedCount;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const mainData = selected.value;
|
|
693
|
+
const bundle = {};
|
|
694
|
+
const composerCandidate = candidates.find((candidate) => candidate.key === 'composer.composerData');
|
|
695
|
+
if (composerCandidate) {
|
|
696
|
+
bundle.composerData = composerCandidate.value;
|
|
697
|
+
}
|
|
457
698
|
// For new format, also get prompts and generations
|
|
458
699
|
try {
|
|
459
700
|
const promptsRow = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(PROMPTS_KEY);
|
|
@@ -475,6 +716,193 @@ function getChatDataFromDb(db) {
|
|
|
475
716
|
}
|
|
476
717
|
return { data: mainData, bundle };
|
|
477
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Count bubbles per composer in a single pass over global storage, instead of one
|
|
721
|
+
* `COUNT(*) ... LIKE 'bubbleId:<id>:%'` full scan per composer. Reused across the
|
|
722
|
+
* recovery passes so listing stays roughly one bubble-table scan rather than O(C).
|
|
723
|
+
*/
|
|
724
|
+
function loadGlobalBubbleCounts(db) {
|
|
725
|
+
const counts = new Map();
|
|
726
|
+
try {
|
|
727
|
+
const rows = db
|
|
728
|
+
.prepare("SELECT key FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'")
|
|
729
|
+
.all();
|
|
730
|
+
for (const row of rows) {
|
|
731
|
+
// key form: bubbleId:<composerId>:<bubbleId>
|
|
732
|
+
const composerId = row.key.split(':')[1];
|
|
733
|
+
if (composerId) {
|
|
734
|
+
counts.set(composerId, (counts.get(composerId) ?? 0) + 1);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Fall back to per-composer counting when the scan fails.
|
|
740
|
+
}
|
|
741
|
+
return counts;
|
|
742
|
+
}
|
|
743
|
+
function buildGlobalComposerSummary(db, composerId, composerData, options) {
|
|
744
|
+
const messageCount = options?.bubbleCount ??
|
|
745
|
+
(db
|
|
746
|
+
.prepare('SELECT COUNT(*) as count FROM cursorDiskKV WHERE key LIKE ?')
|
|
747
|
+
.get(`bubbleId:${composerId}:%`).count);
|
|
748
|
+
if (messageCount <= 0) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
let preview = '';
|
|
752
|
+
if (options?.includePreview !== false) {
|
|
753
|
+
const firstBubble = db
|
|
754
|
+
.prepare('SELECT value FROM cursorDiskKV WHERE key LIKE ? ORDER BY rowid ASC LIMIT 1')
|
|
755
|
+
.get(`bubbleId:${composerId}:%`);
|
|
756
|
+
if (firstBubble?.value) {
|
|
757
|
+
try {
|
|
758
|
+
const bubbleData = JSON.parse(firstBubble.value);
|
|
759
|
+
preview = extractBubbleText(bubbleData).slice(0, 100);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
preview = '';
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const createdAt = parseDateValue(composerData['createdAt']) ?? new Date();
|
|
767
|
+
const lastUpdatedAt = parseDateValue(composerData['lastUpdatedAt']) ??
|
|
768
|
+
parseDateValue(composerData['updatedAt']) ??
|
|
769
|
+
createdAt;
|
|
770
|
+
return {
|
|
771
|
+
id: composerId,
|
|
772
|
+
title: typeof composerData['name'] === 'string'
|
|
773
|
+
? composerData['name']
|
|
774
|
+
: typeof composerData['title'] === 'string'
|
|
775
|
+
? composerData['title']
|
|
776
|
+
: null,
|
|
777
|
+
createdAt,
|
|
778
|
+
lastUpdatedAt,
|
|
779
|
+
messageCount,
|
|
780
|
+
preview,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function getGlobalComposerSummary(db, composerId, bubbleCounts) {
|
|
784
|
+
try {
|
|
785
|
+
const composerRow = db.prepare('SELECT value FROM cursorDiskKV WHERE key = ?').get(`composerData:${composerId}`);
|
|
786
|
+
if (!composerRow?.value) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
const composerData = JSON.parse(composerRow.value);
|
|
790
|
+
if (!isRecord(composerData)) {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
return buildGlobalComposerSummary(db, composerId, composerData, {
|
|
794
|
+
bubbleCount: bubbleCounts?.get(composerId),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Load and parse every `composerData:%` row from global storage exactly once.
|
|
803
|
+
* Callers reuse the result across all workspaces instead of re-scanning and
|
|
804
|
+
* re-parsing the full composer table per workspace (which made `list` O(W×C)).
|
|
805
|
+
*/
|
|
806
|
+
function loadGlobalComposerRecords(db) {
|
|
807
|
+
try {
|
|
808
|
+
const rows = db
|
|
809
|
+
.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'")
|
|
810
|
+
.all();
|
|
811
|
+
const records = [];
|
|
812
|
+
for (const row of rows) {
|
|
813
|
+
try {
|
|
814
|
+
const data = JSON.parse(row.value);
|
|
815
|
+
if (isRecord(data)) {
|
|
816
|
+
records.push({ id: row.key.replace('composerData:', ''), data });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return records;
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function getGlobalComposerSummariesForWorkspace(db, workspace, records, bubbleCounts) {
|
|
830
|
+
const summaries = [];
|
|
831
|
+
for (const record of records) {
|
|
832
|
+
if (!composerBelongsToWorkspace(record.data, workspace)) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const summary = buildGlobalComposerSummary(db, record.id, record.data, {
|
|
836
|
+
bubbleCount: bubbleCounts?.get(record.id),
|
|
837
|
+
});
|
|
838
|
+
if (summary) {
|
|
839
|
+
summaries.push(summary);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return summaries;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Return the composer IDs whose global-storage record is linked to `workspace`
|
|
846
|
+
* (via workspaceIdentifier or workspaceUri). Used by workspace migration so that
|
|
847
|
+
* sessions discoverable only through global storage are migrated too, matching
|
|
848
|
+
* what `list` surfaces for the workspace.
|
|
849
|
+
*/
|
|
850
|
+
export async function getWorkspaceLinkedComposerIds(workspace, customDataPath) {
|
|
851
|
+
const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
|
|
852
|
+
if (!existsSync(globalDbPath)) {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
let db = null;
|
|
856
|
+
try {
|
|
857
|
+
db = await openDatabase(globalDbPath);
|
|
858
|
+
const tableCheck = db
|
|
859
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
|
|
860
|
+
.get();
|
|
861
|
+
if (!tableCheck) {
|
|
862
|
+
return [];
|
|
863
|
+
}
|
|
864
|
+
const ids = new Set();
|
|
865
|
+
const records = loadGlobalComposerRecords(db);
|
|
866
|
+
const recordById = new Map(records.map((record) => [record.id, record.data]));
|
|
867
|
+
for (const record of records) {
|
|
868
|
+
if (composerBelongsToWorkspace(record.data, workspace)) {
|
|
869
|
+
ids.add(record.id);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// Also include composers referenced by this workspace's pointer keys (the
|
|
873
|
+
// modern linkage). Because migration is destructive, only include a pointer ID
|
|
874
|
+
// when its record is unstamped or already belongs to this workspace — never one
|
|
875
|
+
// explicitly stamped for a different workspace (which the user merely viewed).
|
|
876
|
+
if (existsSync(workspace.dbPath)) {
|
|
877
|
+
let wsDb = null;
|
|
878
|
+
try {
|
|
879
|
+
wsDb = await openDatabase(workspace.dbPath);
|
|
880
|
+
for (const pointerId of getWorkspaceComposerPointerIds(wsDb)) {
|
|
881
|
+
const data = recordById.get(pointerId);
|
|
882
|
+
if (!data)
|
|
883
|
+
continue;
|
|
884
|
+
if (!composerHasWorkspaceStamp(data) || composerBelongsToWorkspace(data, workspace)) {
|
|
885
|
+
ids.add(pointerId);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
// Ignore unreadable workspace DB; global-linked IDs are still returned.
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
closeDatabase(wsDb);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return [...ids];
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
debugLogStorage(`Failed to load workspace-linked composer IDs: ${getErrorMessage(error)}`);
|
|
900
|
+
return [];
|
|
901
|
+
}
|
|
902
|
+
finally {
|
|
903
|
+
closeDatabase(db);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
478
906
|
/**
|
|
479
907
|
* List chat sessions with optional filtering
|
|
480
908
|
* Uses workspace storage for listing (has correct paths and complete list)
|
|
@@ -503,6 +931,7 @@ export async function listSessions(options, customDataPath, backupPath) {
|
|
|
503
931
|
const allSessions = [];
|
|
504
932
|
// When listing all workspaces (no filter), dedupe by session id; keep first occurrence (workspace order is already deterministic)
|
|
505
933
|
const seenIds = options.workspacePath ? null : new Set();
|
|
934
|
+
const globalFallbackCandidates = [];
|
|
506
935
|
for (const workspace of filteredWorkspaces) {
|
|
507
936
|
try {
|
|
508
937
|
// Open database from live or backup source
|
|
@@ -510,11 +939,26 @@ export async function listSessions(options, customDataPath, backupPath) {
|
|
|
510
939
|
? await openBackupDatabase(backupPath, workspace.dbPath)
|
|
511
940
|
: await openDatabase(workspace.dbPath);
|
|
512
941
|
const result = getChatDataFromDb(db);
|
|
942
|
+
// Pointer keys (e.g. composerChatViewPane.<guid>) live in ItemTable and link
|
|
943
|
+
// this workspace to its global composers even when no workspace stamp exists.
|
|
944
|
+
const pointerIds = backupPath ? [] : getWorkspaceComposerPointerIds(db);
|
|
513
945
|
db.close();
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
946
|
+
const sessions = result ? parseChatData(result.data, result.bundle) : [];
|
|
947
|
+
const workspaceSeenIds = new Set();
|
|
948
|
+
const selectedIds = [];
|
|
949
|
+
if (result) {
|
|
950
|
+
const rawComposerData = result.bundle.composerData;
|
|
951
|
+
const composerRefs = extractComposerIdsFromData(rawComposerData);
|
|
952
|
+
selectedIds.push(...composerRefs
|
|
953
|
+
.filter((ref) => ref.source === 'selectedComposerIds')
|
|
954
|
+
.map((ref) => ref.composerId));
|
|
955
|
+
}
|
|
956
|
+
selectedIds.push(...pointerIds);
|
|
957
|
+
if (selectedIds.length > 0) {
|
|
958
|
+
debugLogStorage(`Workspace ${workspace.id} has ${selectedIds.length} global recovery candidate(s)`);
|
|
959
|
+
}
|
|
517
960
|
for (const session of sessions) {
|
|
961
|
+
workspaceSeenIds.add(session.id);
|
|
518
962
|
if (seenIds?.has(session.id))
|
|
519
963
|
continue;
|
|
520
964
|
seenIds?.add(session.id);
|
|
@@ -530,11 +974,136 @@ export async function listSessions(options, customDataPath, backupPath) {
|
|
|
530
974
|
preview: session.messages[0]?.content.slice(0, 100) ?? '(Empty session)',
|
|
531
975
|
});
|
|
532
976
|
}
|
|
977
|
+
if (!backupPath) {
|
|
978
|
+
globalFallbackCandidates.push({
|
|
979
|
+
workspace,
|
|
980
|
+
composerIds: selectedIds,
|
|
981
|
+
existingIds: workspaceSeenIds,
|
|
982
|
+
includeWorkspaceLinked: true,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
533
985
|
}
|
|
534
|
-
catch {
|
|
986
|
+
catch (error) {
|
|
987
|
+
debugLogStorage(`Skipping workspace ${workspace.id} while listing sessions: ${getErrorMessage(error)}`);
|
|
535
988
|
continue;
|
|
536
989
|
}
|
|
537
990
|
}
|
|
991
|
+
if (!backupPath && (globalFallbackCandidates.length > 0 || !options.workspacePath)) {
|
|
992
|
+
const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
|
|
993
|
+
if (existsSync(globalDbPath)) {
|
|
994
|
+
let globalDb = null;
|
|
995
|
+
try {
|
|
996
|
+
globalDb = await openDatabase(globalDbPath);
|
|
997
|
+
const tableCheck = globalDb
|
|
998
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
|
|
999
|
+
.get();
|
|
1000
|
+
if (tableCheck) {
|
|
1001
|
+
const globalComposerRecords = loadGlobalComposerRecords(globalDb);
|
|
1002
|
+
// One pass over the bubble table instead of two LIKE scans per composer.
|
|
1003
|
+
const globalBubbleCounts = loadGlobalBubbleCounts(globalDb);
|
|
1004
|
+
const summaryCache = new Map();
|
|
1005
|
+
// Dedup recovered sessions across candidates even under `--workspace`
|
|
1006
|
+
// (where the shared `seenIds` is intentionally null), so a single global
|
|
1007
|
+
// composer matching multiple workspace dirs is listed at most once.
|
|
1008
|
+
const recoveredIds = new Set();
|
|
1009
|
+
for (const candidate of globalFallbackCandidates) {
|
|
1010
|
+
for (const composerId of candidate.composerIds) {
|
|
1011
|
+
if (candidate.existingIds.has(composerId))
|
|
1012
|
+
continue;
|
|
1013
|
+
if (seenIds?.has(composerId))
|
|
1014
|
+
continue;
|
|
1015
|
+
if (recoveredIds.has(composerId))
|
|
1016
|
+
continue;
|
|
1017
|
+
if (!summaryCache.has(composerId)) {
|
|
1018
|
+
summaryCache.set(composerId, getGlobalComposerSummary(globalDb, composerId, globalBubbleCounts));
|
|
1019
|
+
}
|
|
1020
|
+
const summary = summaryCache.get(composerId);
|
|
1021
|
+
if (!summary) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
candidate.existingIds.add(summary.id);
|
|
1025
|
+
seenIds?.add(summary.id);
|
|
1026
|
+
recoveredIds.add(summary.id);
|
|
1027
|
+
allSessions.push({
|
|
1028
|
+
id: summary.id,
|
|
1029
|
+
index: 0,
|
|
1030
|
+
title: summary.title,
|
|
1031
|
+
createdAt: summary.createdAt,
|
|
1032
|
+
lastUpdatedAt: summary.lastUpdatedAt,
|
|
1033
|
+
messageCount: summary.messageCount,
|
|
1034
|
+
workspaceId: candidate.workspace.id,
|
|
1035
|
+
workspacePath: contractPath(candidate.workspace.path),
|
|
1036
|
+
preview: summary.preview || '(Empty session)',
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
if (candidate.includeWorkspaceLinked) {
|
|
1040
|
+
for (const summary of getGlobalComposerSummariesForWorkspace(globalDb, candidate.workspace, globalComposerRecords, globalBubbleCounts)) {
|
|
1041
|
+
if (candidate.existingIds.has(summary.id))
|
|
1042
|
+
continue;
|
|
1043
|
+
if (seenIds?.has(summary.id))
|
|
1044
|
+
continue;
|
|
1045
|
+
if (recoveredIds.has(summary.id))
|
|
1046
|
+
continue;
|
|
1047
|
+
candidate.existingIds.add(summary.id);
|
|
1048
|
+
seenIds?.add(summary.id);
|
|
1049
|
+
recoveredIds.add(summary.id);
|
|
1050
|
+
allSessions.push({
|
|
1051
|
+
id: summary.id,
|
|
1052
|
+
index: 0,
|
|
1053
|
+
title: summary.title,
|
|
1054
|
+
createdAt: summary.createdAt,
|
|
1055
|
+
lastUpdatedAt: summary.lastUpdatedAt,
|
|
1056
|
+
messageCount: summary.messageCount,
|
|
1057
|
+
workspaceId: candidate.workspace.id,
|
|
1058
|
+
workspacePath: contractPath(candidate.workspace.path),
|
|
1059
|
+
preview: summary.preview || '(Empty session)',
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
// Catch-all: surface global composers that could not be attributed to any
|
|
1065
|
+
// workspace (modern Cursor frequently stores no workspace stamp on the
|
|
1066
|
+
// global record). Only on the unfiltered listing, where `seenIds` tracks
|
|
1067
|
+
// everything already shown.
|
|
1068
|
+
if (seenIds && !options.workspacePath) {
|
|
1069
|
+
for (const record of globalComposerRecords) {
|
|
1070
|
+
if (seenIds.has(record.id) || recoveredIds.has(record.id))
|
|
1071
|
+
continue;
|
|
1072
|
+
// Use the precomputed bubble counts and skip the per-composer
|
|
1073
|
+
// first-bubble preview query so the catch-all stays ~one scan total
|
|
1074
|
+
// even when hundreds of composers are unattributed.
|
|
1075
|
+
const summary = buildGlobalComposerSummary(globalDb, record.id, record.data, {
|
|
1076
|
+
bubbleCount: globalBubbleCounts.get(record.id) ?? 0,
|
|
1077
|
+
includePreview: false,
|
|
1078
|
+
});
|
|
1079
|
+
if (!summary)
|
|
1080
|
+
continue;
|
|
1081
|
+
seenIds.add(summary.id);
|
|
1082
|
+
recoveredIds.add(summary.id);
|
|
1083
|
+
const derivedPath = workspacePathFromComposer(record.data);
|
|
1084
|
+
allSessions.push({
|
|
1085
|
+
id: summary.id,
|
|
1086
|
+
index: 0,
|
|
1087
|
+
title: summary.title,
|
|
1088
|
+
createdAt: summary.createdAt,
|
|
1089
|
+
lastUpdatedAt: summary.lastUpdatedAt,
|
|
1090
|
+
messageCount: summary.messageCount,
|
|
1091
|
+
workspaceId: 'global',
|
|
1092
|
+
workspacePath: derivedPath ? contractPath(derivedPath) : '(global)',
|
|
1093
|
+
preview: summary.preview || '(Empty session)',
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
debugLogStorage(`Failed to load global fallback sessions: ${getErrorMessage(error)}`);
|
|
1101
|
+
}
|
|
1102
|
+
finally {
|
|
1103
|
+
closeDatabase(globalDb);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
538
1107
|
// Sort by most recent first
|
|
539
1108
|
allSessions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
540
1109
|
// Assign indexes
|
|
@@ -583,7 +1152,7 @@ export async function getSession(identifier, customDataPath, backupPath) {
|
|
|
583
1152
|
// This works for both live data and backup (if backup includes globalStorage)
|
|
584
1153
|
let globalDb = null;
|
|
585
1154
|
let globalLoadFailed = false;
|
|
586
|
-
const globalDbPath = join(getGlobalStoragePath(), 'state.vscdb');
|
|
1155
|
+
const globalDbPath = join(getGlobalStoragePath(customDataPath), 'state.vscdb');
|
|
587
1156
|
try {
|
|
588
1157
|
if (backupPath) {
|
|
589
1158
|
try {
|
|
@@ -741,8 +1310,8 @@ export async function searchSessions(query, options, customDataPath, backupPath)
|
|
|
741
1310
|
* List sessions from global Cursor storage (cursorDiskKV table)
|
|
742
1311
|
* This is where Cursor stores full conversation data including AI responses
|
|
743
1312
|
*/
|
|
744
|
-
export async function listGlobalSessions() {
|
|
745
|
-
const globalPath = getGlobalStoragePath();
|
|
1313
|
+
export async function listGlobalSessions(customDataPath) {
|
|
1314
|
+
const globalPath = getGlobalStoragePath(customDataPath);
|
|
746
1315
|
const dbPath = join(globalPath, 'state.vscdb');
|
|
747
1316
|
if (!existsSync(dbPath)) {
|
|
748
1317
|
return [];
|
|
@@ -786,16 +1355,20 @@ export async function listGlobalSessions() {
|
|
|
786
1355
|
// Ignore
|
|
787
1356
|
}
|
|
788
1357
|
}
|
|
789
|
-
const createdAt =
|
|
790
|
-
const workspacePath = data.
|
|
791
|
-
|
|
792
|
-
|
|
1358
|
+
const createdAt = parseDateValue(data.createdAt) ?? new Date();
|
|
1359
|
+
const workspacePath = data.workspaceIdentifier?.uri?.fsPath ??
|
|
1360
|
+
data.workspaceIdentifier?.uri?.path ??
|
|
1361
|
+
(data.workspaceIdentifier?.uri?.external
|
|
1362
|
+
? uriToPath(data.workspaceIdentifier.uri.external)
|
|
1363
|
+
: data.workspaceUri
|
|
1364
|
+
? uriToPath(data.workspaceUri)
|
|
1365
|
+
: 'Global');
|
|
793
1366
|
sessions.push({
|
|
794
1367
|
id: composerId,
|
|
795
1368
|
index: 0,
|
|
796
1369
|
title: data.name ?? data.title ?? null,
|
|
797
1370
|
createdAt,
|
|
798
|
-
lastUpdatedAt: data.
|
|
1371
|
+
lastUpdatedAt: parseDateValue(data.lastUpdatedAt) ?? parseDateValue(data.updatedAt) ?? createdAt,
|
|
799
1372
|
messageCount: bubbleCount.count,
|
|
800
1373
|
workspaceId: 'global',
|
|
801
1374
|
workspacePath: contractPath(workspacePath),
|
|
@@ -822,13 +1395,13 @@ export async function listGlobalSessions() {
|
|
|
822
1395
|
/**
|
|
823
1396
|
* Get a session from global storage by index
|
|
824
1397
|
*/
|
|
825
|
-
export async function getGlobalSession(index) {
|
|
826
|
-
const summaries = await listGlobalSessions();
|
|
1398
|
+
export async function getGlobalSession(index, customDataPath) {
|
|
1399
|
+
const summaries = await listGlobalSessions(customDataPath);
|
|
827
1400
|
const summary = summaries.find((s) => s.index === index);
|
|
828
1401
|
if (!summary) {
|
|
829
1402
|
return null;
|
|
830
1403
|
}
|
|
831
|
-
const globalPath = getGlobalStoragePath();
|
|
1404
|
+
const globalPath = getGlobalStoragePath(customDataPath);
|
|
832
1405
|
const dbPath = join(globalPath, 'state.vscdb');
|
|
833
1406
|
let db = null;
|
|
834
1407
|
try {
|
|
@@ -1605,19 +2178,8 @@ export async function findWorkspaceForSession(sessionId, customDataPath) {
|
|
|
1605
2178
|
db.close();
|
|
1606
2179
|
if (!result)
|
|
1607
2180
|
continue;
|
|
1608
|
-
|
|
1609
|
-
const
|
|
1610
|
-
// Handle new format with allComposers array
|
|
1611
|
-
let composers;
|
|
1612
|
-
if ('allComposers' in parsed && Array.isArray(parsed.allComposers)) {
|
|
1613
|
-
composers = parsed.allComposers;
|
|
1614
|
-
}
|
|
1615
|
-
else if (Array.isArray(parsed)) {
|
|
1616
|
-
composers = parsed;
|
|
1617
|
-
}
|
|
1618
|
-
else {
|
|
1619
|
-
continue;
|
|
1620
|
-
}
|
|
2181
|
+
const composerRefs = extractComposerIdsFromData(result.bundle.composerData ?? result.data);
|
|
2182
|
+
const composers = composerRefs.map((ref) => ({ composerId: ref.composerId }));
|
|
1621
2183
|
const found = composers.some((session) => session.composerId === sessionId);
|
|
1622
2184
|
if (found) {
|
|
1623
2185
|
return { workspace, dbPath: workspace.dbPath };
|
|
@@ -1642,6 +2204,39 @@ export async function findWorkspaceByPath(workspacePath, customDataPath) {
|
|
|
1642
2204
|
return { workspace, dbPath: workspace.dbPath };
|
|
1643
2205
|
}
|
|
1644
2206
|
}
|
|
2207
|
+
// Fallback: include workspaces with zero sessions so migrations can target empty destinations.
|
|
2208
|
+
const basePath = getCursorDataPath(customDataPath);
|
|
2209
|
+
if (!existsSync(basePath)) {
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
try {
|
|
2213
|
+
const entries = readdirSync(basePath, { withFileTypes: true });
|
|
2214
|
+
for (const entry of entries) {
|
|
2215
|
+
if (!entry.isDirectory())
|
|
2216
|
+
continue;
|
|
2217
|
+
const workspaceDir = join(basePath, entry.name);
|
|
2218
|
+
const dbPath = join(workspaceDir, 'state.vscdb');
|
|
2219
|
+
if (!existsSync(dbPath))
|
|
2220
|
+
continue;
|
|
2221
|
+
const workspacePathFromJson = readWorkspaceJson(workspaceDir);
|
|
2222
|
+
if (!workspacePathFromJson)
|
|
2223
|
+
continue;
|
|
2224
|
+
if (pathsEqual(workspacePathFromJson, normalizedPath)) {
|
|
2225
|
+
return {
|
|
2226
|
+
workspace: {
|
|
2227
|
+
id: entry.name,
|
|
2228
|
+
path: workspacePathFromJson,
|
|
2229
|
+
dbPath,
|
|
2230
|
+
sessionCount: 0,
|
|
2231
|
+
},
|
|
2232
|
+
dbPath,
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
catch {
|
|
2238
|
+
return null;
|
|
2239
|
+
}
|
|
1645
2240
|
return null;
|
|
1646
2241
|
}
|
|
1647
2242
|
/**
|
|
@@ -1660,8 +2255,38 @@ export function getComposerData(db) {
|
|
|
1660
2255
|
// Check if new format with allComposers
|
|
1661
2256
|
if (rawData && typeof rawData === 'object' && 'allComposers' in rawData) {
|
|
1662
2257
|
const data = rawData;
|
|
2258
|
+
const selectedComposers = Array.isArray(data.selectedComposerIds)
|
|
2259
|
+
? data.selectedComposerIds
|
|
2260
|
+
.filter((id) => typeof id === 'string' && id.trim().length > 0)
|
|
2261
|
+
.map((id) => ({ composerId: id }))
|
|
2262
|
+
: [];
|
|
2263
|
+
const allComposers = Array.isArray(data.allComposers) ? data.allComposers : [];
|
|
2264
|
+
const seenIds = new Set(allComposers
|
|
2265
|
+
.map((composer) => composer.composerId)
|
|
2266
|
+
.filter((id) => typeof id === 'string' && id.trim().length > 0));
|
|
2267
|
+
const missingSelectedComposers = selectedComposers.filter((composer) => {
|
|
2268
|
+
if (!composer.composerId || seenIds.has(composer.composerId)) {
|
|
2269
|
+
return false;
|
|
2270
|
+
}
|
|
2271
|
+
seenIds.add(composer.composerId);
|
|
2272
|
+
return true;
|
|
2273
|
+
});
|
|
1663
2274
|
return {
|
|
1664
|
-
composers:
|
|
2275
|
+
composers: [...allComposers, ...missingSelectedComposers],
|
|
2276
|
+
rawData,
|
|
2277
|
+
isNewFormat: true,
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
// Newer workspace shape: selectedComposerIds only (no allComposers list)
|
|
2281
|
+
if (rawData &&
|
|
2282
|
+
typeof rawData === 'object' &&
|
|
2283
|
+
'selectedComposerIds' in rawData &&
|
|
2284
|
+
Array.isArray(rawData.selectedComposerIds)) {
|
|
2285
|
+
const selectedComposers = rawData.selectedComposerIds
|
|
2286
|
+
.filter((id) => typeof id === 'string' && id.trim().length > 0)
|
|
2287
|
+
.map((id) => ({ composerId: id }));
|
|
2288
|
+
return {
|
|
2289
|
+
composers: selectedComposers,
|
|
1665
2290
|
rawData,
|
|
1666
2291
|
isNewFormat: true,
|
|
1667
2292
|
};
|
|
@@ -1689,10 +2314,53 @@ export function updateComposerData(db, composers, isNewFormat, originalRawData)
|
|
|
1689
2314
|
if (isNewFormat) {
|
|
1690
2315
|
// Preserve the original structure, just update allComposers
|
|
1691
2316
|
if (originalRawData && typeof originalRawData === 'object') {
|
|
1692
|
-
|
|
2317
|
+
const original = originalRawData;
|
|
2318
|
+
const selectedOnly = Array.isArray(original['selectedComposerIds']) &&
|
|
2319
|
+
(!Array.isArray(original['allComposers']) || original['allComposers'].length === 0);
|
|
2320
|
+
// Only persist composers that carry real metadata. Synthetic `{ composerId }`
|
|
2321
|
+
// stubs (surfaced by getComposerData's union of selectedComposerIds) would
|
|
2322
|
+
// otherwise be written back and parse to phantom sessions on the next list.
|
|
2323
|
+
const nextData = {
|
|
2324
|
+
...original,
|
|
2325
|
+
allComposers: composers.filter(isHydratedComposer),
|
|
2326
|
+
};
|
|
2327
|
+
if (Array.isArray(original['selectedComposerIds'])) {
|
|
2328
|
+
const composerIds = composers
|
|
2329
|
+
.map((composer) => composer.composerId)
|
|
2330
|
+
.filter((id) => typeof id === 'string' && id.trim().length > 0);
|
|
2331
|
+
const composerIdSet = new Set(composerIds);
|
|
2332
|
+
const originalSelected = original['selectedComposerIds'].filter((id) => typeof id === 'string' && id.trim().length > 0);
|
|
2333
|
+
const selectedComposerIds = selectedOnly
|
|
2334
|
+
? composerIds
|
|
2335
|
+
: originalSelected.filter((id) => composerIdSet.has(id));
|
|
2336
|
+
nextData['selectedComposerIds'] = selectedComposerIds;
|
|
2337
|
+
if (Array.isArray(original['lastFocusedComposerIds'])) {
|
|
2338
|
+
const originalFocused = original['lastFocusedComposerIds'].filter((id) => typeof id === 'string' && id.trim().length > 0);
|
|
2339
|
+
// Keep the previously focused tab whenever it survives the change;
|
|
2340
|
+
// only fall back to the first surviving composer when it does not.
|
|
2341
|
+
const survivingFocused = originalFocused.filter((id) => composerIdSet.has(id));
|
|
2342
|
+
const focusedComposerIds = survivingFocused.length > 0
|
|
2343
|
+
? survivingFocused
|
|
2344
|
+
: selectedOnly
|
|
2345
|
+
? selectedComposerIds.slice(0, 1)
|
|
2346
|
+
: [];
|
|
2347
|
+
nextData['lastFocusedComposerIds'] = focusedComposerIds;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
dataToWrite = nextData;
|
|
1693
2351
|
}
|
|
1694
2352
|
else {
|
|
1695
|
-
|
|
2353
|
+
// No original shape to preserve: write only real composers, but keep any
|
|
2354
|
+
// id-only stubs referenced via selectedComposerIds so they remain listable.
|
|
2355
|
+
const hydrated = composers.filter(isHydratedComposer);
|
|
2356
|
+
const stubIds = composers
|
|
2357
|
+
.filter((composer) => !isHydratedComposer(composer))
|
|
2358
|
+
.map((composer) => composer.composerId)
|
|
2359
|
+
.filter((id) => typeof id === 'string' && id.trim().length > 0);
|
|
2360
|
+
dataToWrite =
|
|
2361
|
+
stubIds.length > 0
|
|
2362
|
+
? { allComposers: hydrated, selectedComposerIds: stubIds }
|
|
2363
|
+
: { allComposers: hydrated };
|
|
1696
2364
|
}
|
|
1697
2365
|
}
|
|
1698
2366
|
else {
|