Skip to content

Commit 36c363a

Browse files
Dangerdan9631S2thend
authored andcommitted
Update session listing and workspace handling
Changes: - Enhanced `listSessions()` to clarify deduplication behavior when filtering by workspace, ensuring no cross-workspace deduplication occurs when the `--workspace` option is set. - Updated documentation in `CLAUDE.md` to reflect changes in session deduplication and workspace path resolution. - Improved error handling in `workspaceUriToPath()` to decode percent-encoded characters in workspace URIs. - Incremented version in `package.json` to 0.12.1. Why: These updates improve user clarity on session listing behavior and enhance workspace path handling, ensuring a more robust experience.
1 parent eed97fc commit 36c363a

7 files changed

Lines changed: 36 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ So when someone uses `import { listSessions } from 'cursor-history'`, they're ca
114114

115115
### Storage Layer (`src/core/storage.ts`)
116116

117-
- `listSessions()` - Uses workspace storage for listing (correct paths); when listing all (no workspace filter), deduplicates by session ID and attributes to first workspace in deterministic order (.code-workspace paths before folder paths)
117+
- `listSessions()` - Uses workspace storage for listing (correct paths); when listing all sessions (no `--workspace` filter), deduplicates by session ID and attributes to first workspace in deterministic order (.code-workspace paths before folder paths)
118118
- `getSession(identifier, ...)` - Get session by 1-based index (number) or composer ID (string). Tries global storage first (full AI responses), falls back to workspace. Returns null when index or composer ID not found.
119119
- `findWorkspaceForSession(sessionId)` - Finds which workspace contains a session by ID
120120
- `findWorkspaceByPath(path)` - Finds workspace by its project path (folder or .code-workspace file path)
121-
- `readWorkspaceJson(workspaceDir)` - Reads workspace path from `workspace.json`: supports `folder` (single-folder workspace, file URI) and `configuration` (.code-workspace file URI); prefers `folder` when both exist; same logic used when reading from backup zip
121+
- `readWorkspaceJson(workspaceDir)` - Reads workspace path from `workspace.json`: supports `folder` (single-folder workspace, file URI) and `workspace` (.code-workspace file URI); prefers `workspace` when both exist; same logic used when reading from backup zip
122122
- `getComposerData(db)` - Reads composer array, handles both `allComposers` and legacy formats
123123
- `updateComposerData(db, composers)` - Writes composer array, preserves original format
124124
- `resolveSessionIdentifiers(input)` - Converts index/ID/comma-separated to session ID array
@@ -372,7 +372,7 @@ Edit `extractBubbleText()` in `src/core/storage.ts`. Priority matters:
372372
- Workspace file path resolution: Workspaces opened via a .code-workspace file are now discovered and matchable
373373
- `readWorkspaceJson()` and `readWorkspaceJsonFromBackup()` now support the `workspace` key (workspace file URI) in addition to `folder` (single-folder URI); prefer `workspace` when both exist;
374374
- Listing, `--workspace` filter, and `findWorkspaceByPath()` work when the workspace path is the .code-workspace file path
375-
- Session deduplication: When Cursor has two workspaceStorage entries (folder and .code-workspace), both may receive the same chats. When listing all sessions (no `--workspace` filter), the tool deduplicates by session ID so each chat appears once. Attribution is deterministic: workspaces are sorted by path with .code-workspace paths before others, so the session is attributed to the .code-workspace workspace when the same session exists in both. Filtering or exporting by `--workspace` is unchanged (only that workspace's DB is read).
375+
- Session deduplication: When Cursor has two workspaceStorage entries (folder and .code-workspace), both may receive the same chats. When listing all sessions (no `--workspace` filter), the tool deduplicates by session ID so each chat appears once. Attribution is deterministic: workspaces are sorted by path with .code-workspace paths before others, so the session is attributed to the .code-workspace workspace when the same session exists in both. Deduplication is not applied when `--workspace` is used; each workspace's DB is listed as-is. Filtering or exporting by `--workspace` is unchanged (only that workspace's DB is read).
376376
- 010-fix-timestamp-fallback: Fixed incorrect timestamps on pre-2025-09 sessions (Issue #13)
377377
- Extended `RawBubbleData.timingInfo` with `clientRpcSendTime` and `clientSettleTime` fields
378378
- New `extractTimestamp()` function in `src/core/storage.ts`: priority chain `createdAt` > `clientRpcSendTime` > `clientSettleTime` > `clientEndTime` > `null`

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cursor-history",
3-
"version": "0.12.0",
3+
"version": "0.12.1",
44
"description": "The ultimate CLI tool and library to browse, search, export, migrate, and backup your Cursor AI chat history",
55
"type": "module",
66
"main": "dist/lib/index.js",

src/cli/formatters/table.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ export function formatSessionsTable(sessions: ChatSessionSummary[], showIds = fa
123123
}
124124

125125
lines.push('');
126-
lines.push(pc.dim(`Showing ${sessions.length} session(s). Use "show <#>" or "show <composer-id>" to view details.`));
126+
lines.push(
127+
pc.dim(
128+
`Showing ${sessions.length} session(s). Use "show <#>" or "show <composer-id>" to view details.`
129+
)
130+
);
127131
if (showIds) {
128132
lines.push(pc.dim(`Composer IDs can be used with external tools and show/export commands.`));
129133
}

src/cli/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ program
2525
.version(packageJson.version, '-v, --version', 'Show version number')
2626
.option('--json', 'Output in JSON format')
2727
.option('--data-path <path>', 'Custom Cursor data directory')
28-
.option('-w, --workspace <path>', 'Filter by workspace path');
28+
.option(
29+
'-w, --workspace <path>',
30+
'Filter by workspace path (no session deduplication across workspaces when set)'
31+
);
2932

3033
// Lazy-load commands to avoid circular dependencies
3134
async function loadCommands() {

src/core/storage.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,23 +87,27 @@ export async function openDatabaseReadWrite(dbPath: string): Promise<Database> {
8787
// ============================================================================
8888

8989
/**
90-
* Workspace.json shape: folder (single-folder) or workspace (.code-workspace path)
90+
* Workspace.json shape: folder (single-folder) or workspace (.code-workspace path)
9191
*/
9292
interface WorkspaceJsonShape {
9393
folder?: string;
9494
workspace?: string;
9595
}
9696

9797
/**
98-
* Convert file:// URI from workspace.json to filesystem path
98+
* Convert file:// URI from workspace.json to filesystem path
9999
*/
100100
function workspaceUriToPath(uri: string): string {
101-
return uri.replace(/^file:\/\//, '').replace(/%20/g, ' ');
101+
try {
102+
return decodeURIComponent(uri.replace(/^file:\/\//, ''));
103+
} catch {
104+
return uri.replace(/^file:\/\//, '');
105+
}
102106
}
103107

104108
/**
105109
* Read workspace path from parsed workspace.json (folder or configuration).
106-
* Prefers folder; falls back to configuration for .code-workspace workspaces.
110+
* Prefers workspace (.code-workspace path); falls back to folder for single-folder workspaces.
107111
*/
108112
function getWorkspacePathFromJson(data: WorkspaceJsonShape): string | null {
109113
if (data.workspace) {
@@ -341,6 +345,8 @@ function getChatDataFromDb(db: Database): { data: string; bundle: CursorChatBund
341345
/**
342346
* List chat sessions with optional filtering
343347
* Uses workspace storage for listing (has correct paths and complete list)
348+
* When `options.workspacePath` is unset, deduplicates by session ID across workspaces (deterministic order).
349+
* When `workspacePath` is set, lists that workspace's DB only with no cross-workspace deduplication.
344350
* @param options - List options (limit, all, workspacePath)
345351
* @param customDataPath - Custom Cursor data path (for live data)
346352
* @param backupPath - Path to backup zip file (if reading from backup)
@@ -355,11 +361,12 @@ export async function listSessions(
355361

356362
// Filter by workspace if specified
357363
// Deterministic order: .code-workspace paths before others, then by path (for stable attribution when deduping)
358-
const filteredWorkspaces = (options.workspacePath
359-
? workspaces.filter(
360-
(w) => w.path === options.workspacePath || w.path.endsWith(options.workspacePath ?? '')
361-
)
362-
: workspaces
364+
const filteredWorkspaces = (
365+
options.workspacePath
366+
? workspaces.filter(
367+
(w) => w.path === options.workspacePath || w.path.endsWith(options.workspacePath ?? '')
368+
)
369+
: workspaces
363370
).sort((a, b) => {
364371
const normA = normalizePath(a.path);
365372
const normB = normalizePath(b.path);

src/lib/platform.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,5 @@ export function pathsEqual(path1: string, path2: string): boolean {
116116
const normalize = (p: string) => normalizePath(p).replace(/\\/g, '/');
117117
const n1 = normalize(path1);
118118
const n2 = normalize(path2);
119-
return process.platform === 'win32'
120-
? n1.toLowerCase() === n2.toLowerCase()
121-
: n1 === n2;
119+
return process.platform === 'win32' ? n1.toLowerCase() === n2.toLowerCase() : n1 === n2;
122120
}

tests/unit/storage.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,15 @@ describe('readWorkspaceJson', () => {
199199
expect(result).toBe('/my/workspace.code-workspace');
200200
});
201201

202-
it('decodes %20 in workspace', () => {
202+
it('decodes percent-encoded characters in workspace URI (e.g. %20, %23, %28, %29)', () => {
203203
vi.mocked(existsSync).mockReturnValue(true);
204204
vi.mocked(readFileSync).mockReturnValue(
205-
JSON.stringify({ workspace: 'file:///my%20project/ws.code-workspace' })
205+
JSON.stringify({
206+
workspace: 'file:///path%23with%28hash%29/my%20ws.code-workspace',
207+
})
206208
);
207209
const result = readWorkspaceJson('/workspace/dir');
208-
expect(result).toBe('/my project/ws.code-workspace');
210+
expect(result).toBe('/path#with(hash)/my ws.code-workspace');
209211
});
210212

211213
it('prefers workspace when both folder and workspace exist', () => {
@@ -837,7 +839,7 @@ describe('searchSessions', () => {
837839
it('returns empty when no matches', async () => {
838840
vi.mocked(existsSync).mockReturnValue(true);
839841
vi.mocked(readdirSync).mockReturnValue([]);
840-
const result = await searchSessions('xyz', { limit: 10, contextChars: 50 }, '/data');
842+
const result = await searchSessions('xyz', { limit: 10 }, '/data');
841843
expect(result).toEqual([]);
842844
});
843845
});

0 commit comments

Comments
 (0)