diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index abcba0b..5db8e78 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,11 +31,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd #v4.38.1 + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: javascript-typescript queries: security-and-quality - - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd #v4.38.1 + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: /language:javascript-typescript diff --git a/extension.ts b/extension.ts index 86d5950..08a9742 100644 --- a/extension.ts +++ b/extension.ts @@ -174,6 +174,27 @@ export function folderBreadcrumb(folders: BookmarkFolder[], folderId: string): s return parts.join(' › '); } +/** Shape written by `exportBookmarks` and read back by `importBookmarks`. */ +export interface BookmarkExportData { + version: 1; + exportedAt: string; + bookmarks: Bookmark[]; + folders: BookmarkFolder[]; +} + +function isBookmarkExportData(data: unknown): data is BookmarkExportData { + return ( + typeof data === 'object' && + data !== null && + Array.isArray((data as BookmarkExportData).bookmarks) && + Array.isArray((data as BookmarkExportData).folders) + ); +} + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + /** The extension's public API, returned from `activate()` — used by e2e tests to inspect state that isn't reachable through the tree view UI alone. */ export interface ExtensionApi { store: BookmarkStore; @@ -279,6 +300,12 @@ export function activate(context: vscode.ExtensionContext): ExtensionApi { vscode.commands.registerCommand('workspace-file-bookmarks.setViewModeTree', () => provider.setViewMode('tree'), ), + vscode.commands.registerCommand('workspace-file-bookmarks.exportBookmarks', () => + exportBookmarks(store), + ), + vscode.commands.registerCommand('workspace-file-bookmarks.importBookmarks', () => + importBookmarks(store), + ), ); return { store, provider }; @@ -365,7 +392,7 @@ export class BookmarkStore { createFolder(name: string, parentId: string | null = null): BookmarkFolder { const folder: BookmarkFolder = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: generateId(), name, createdAt: Date.now(), parentId, @@ -394,6 +421,49 @@ export class BookmarkStore { ); } + /** Replaces all bookmarks and folders wholesale, e.g. importing over the existing set. */ + replaceAll(bookmarks: Bookmark[], folders: BookmarkFolder[]) { + this.setFolders(folders); + this.setBookmarks(bookmarks); + } + + /** + * Merges an imported bookmark/folder set into the existing store, appending rather than + * replacing. Every imported folder and bookmark gets a freshly generated id (and folder ids + * on both folders and bookmarks are remapped to match) so imported ids never collide with + * local ones; the imported folder hierarchy itself is preserved as-is. Bookmarks that already + * exist at the same uri/line range are skipped, mirroring `addBookmark`'s de-dupe. + */ + mergeImport( + importedBookmarks: Bookmark[], + importedFolders: BookmarkFolder[], + ): { addedBookmarks: number; addedFolders: number } { + const folderIdMap = new Map(importedFolders.map((f) => [f.id, generateId()])); + const remapFolderId = (id: string | null | undefined): string | null => + (id && folderIdMap.get(id)) ?? null; + + const newFolders = importedFolders.map((folder) => ({ + ...folder, + id: folderIdMap.get(folder.id) as string, + parentId: remapFolderId(folder.parentId), + })); + + const existingBookmarks = this.getAllBookmarks(); + const newBookmarks = importedBookmarks + .filter( + (b) => + !existingBookmarks.some( + (e) => e.uri === b.uri && e.lineStart === b.lineStart && e.lineEnd === b.lineEnd, + ), + ) + .map((b) => ({ ...b, id: generateId(), folderId: remapFolderId(b.folderId) })); + + this.setFolders([...this.getAllFolders(), ...newFolders]); + this.setBookmarks([...newBookmarks, ...existingBookmarks]); + + return { addedBookmarks: newBookmarks.length, addedFolders: newFolders.length }; + } + private setBookmarks(bookmarks: Bookmark[]) { this.context.workspaceState.update(STORAGE_KEY_BOOKMARKS, bookmarks); this._onDidChange.fire(); @@ -651,7 +721,7 @@ export function toBookmark(uri: vscode.Uri, selection?: LineSelection): Bookmark const label = selection ? `${fileName}:${formatLineRange(selection)}` : fileName; return { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: generateId(), uri: uri.toString(), label, relativePath, @@ -1071,6 +1141,95 @@ export async function moveToFolder(store: BookmarkStore, item: BookmarkTreeItem) store.moveBookmarkToFolder(item.bookmark.id, folderId); } +/** Writes all bookmarks and folders to a JSON file the user picks via a save dialog. */ +export async function exportBookmarks(store: BookmarkStore): Promise { + const data: BookmarkExportData = { + version: 1, + exportedAt: new Date().toISOString(), + bookmarks: store.getAllBookmarks(), + folders: store.getAllFolders(), + }; + + const uri = await vscode.window.showSaveDialog({ + filters: { JSON: ['json'] }, + saveLabel: 'Export Bookmarks', + defaultUri: vscode.Uri.file('workspace-file-bookmarks.json'), + }); + if (!uri) { + return; + } + + try { + await vscode.workspace.fs.writeFile(uri, Buffer.from(JSON.stringify(data, null, 2), 'utf8')); + vscode.window.showInformationMessage( + `Exported ${data.bookmarks.length} bookmark(s) and ${data.folders.length} folder(s).`, + ); + } catch { + vscode.window.showErrorMessage('Could not write the export file.'); + } +} + +const IMPORT_MERGE_PICK = 'Merge with existing bookmarks'; +const IMPORT_REPLACE_PICK = 'Replace existing bookmarks'; + +/** + * Reads bookmarks and folders from a JSON file the user picks via an open dialog, then either + * merges them into the current store or (after a confirmation, since it's destructive) replaces + * the current store outright. + */ +export async function importBookmarks(store: BookmarkStore): Promise { + const uris = await vscode.window.showOpenDialog({ + filters: { JSON: ['json'] }, + canSelectMany: false, + openLabel: 'Import Bookmarks', + }); + const uri = uris?.[0]; + if (!uri) { + return; + } + + let data: unknown; + try { + const bytes = await vscode.workspace.fs.readFile(uri); + data = JSON.parse(Buffer.from(bytes).toString('utf8')); + } catch { + vscode.window.showErrorMessage('Could not read or parse the import file.'); + return; + } + if (!isBookmarkExportData(data)) { + vscode.window.showErrorMessage('That file is not a valid Workspace File Bookmarks export.'); + return; + } + + const mode = await vscode.window.showQuickPick([IMPORT_MERGE_PICK, IMPORT_REPLACE_PICK], { + placeHolder: `Import ${data.bookmarks.length} bookmark(s) and ${data.folders.length} folder(s)`, + }); + if (!mode) { + return; + } + + if (mode === IMPORT_REPLACE_PICK) { + const confirm = await vscode.window.showWarningMessage( + 'Replace all existing bookmarks and folders with the imported set? This cannot be undone.', + { modal: true }, + 'Replace', + ); + if (confirm !== 'Replace') { + return; + } + store.replaceAll(data.bookmarks, data.folders); + vscode.window.showInformationMessage( + `Imported ${data.bookmarks.length} bookmark(s) and ${data.folders.length} folder(s).`, + ); + return; + } + + const { addedBookmarks, addedFolders } = store.mergeImport(data.bookmarks, data.folders); + vscode.window.showInformationMessage( + `Merged ${addedBookmarks} bookmark(s) and ${addedFolders} folder(s); duplicates were skipped.`, + ); +} + export async function addBookmarksToFolder( store: BookmarkStore, uri: vscode.Uri | undefined, diff --git a/package-lock.json b/package-lock.json index b4c1092..06632cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,9 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/mocha": "^10.0.10", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@types/vscode": "^1.138.0", - "@vitest/coverage-v8": "^5.0.1", + "@vitest/coverage-v8": "^5.0.2", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^4.0.0", @@ -21,11 +21,11 @@ "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "lefthook": "^2.1.14", - "prettier": "^3.9.8", + "prettier": "^3.9.9", "typescript": "~6.0.3", - "typescript-eslint": "^8.70.0", - "vite": "^8.3.0", - "vitest": "^5.0.1" + "typescript-eslint": "^8.71.0", + "vite": "^8.3.1", + "vitest": "^5.0.2" }, "engines": { "vscode": "^1.138.0" @@ -174,9 +174,9 @@ } }, "node_modules/@azure/msal-browser": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.22.0.tgz", - "integrity": "sha512-5kgu9xeEKgGc2JeidxAtU15NJTqiH/CMCRRQAJ4Rac56kB7KVg91vbNmn+z3RO1vNomPN69UvjKG9h1Pghx6dQ==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.23.0.tgz", + "integrity": "sha512-IRCwkRCK47hBXK+7bu67UZWY7HS0Jx1dZgi4C1HVbjlBum8Jy8fb2l87xFr6HxdQJWy951zRUHtwwDITHFAtaA==", "dev": true, "license": "MIT", "dependencies": { @@ -1314,9 +1314,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.150.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", - "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", "dev": true, "license": "MIT", "funding": { @@ -1335,9 +1335,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", - "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", "cpu": [ "arm" ], @@ -1352,9 +1352,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", - "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ "arm64" ], @@ -1369,9 +1369,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", - "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -1386,9 +1386,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", - "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -1403,9 +1403,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", - "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ "x64" ], @@ -1420,9 +1420,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", - "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ "arm" ], @@ -1437,9 +1437,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", - "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ "arm64" ], @@ -1457,9 +1457,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", - "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -1477,9 +1477,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", - "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ "ppc64" ], @@ -1497,9 +1497,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", - "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ "s390x" ], @@ -1517,9 +1517,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", - "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ "x64" ], @@ -1537,9 +1537,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", - "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ "x64" ], @@ -1557,9 +1557,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", - "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ "arm64" ], @@ -1574,9 +1574,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", - "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ "arm64" ], @@ -1591,9 +1591,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", - "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -1738,9 +1738,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -1755,17 +1755,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1778,15 +1778,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, "license": "MIT", "engines": { @@ -1794,16 +1794,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "engines": { @@ -1819,14 +1819,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "engines": { @@ -1841,14 +1841,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1859,9 +1859,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { @@ -1876,15 +1876,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1901,9 +1901,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { @@ -1915,16 +1915,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1943,16 +1943,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1967,13 +1967,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2000,9 +2000,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.1.tgz", - "integrity": "sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.2.tgz", + "integrity": "sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==", "dev": true, "license": "MIT", "dependencies": { @@ -2019,8 +2019,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "5.0.1", - "vitest": "5.0.1" + "@vitest/browser": "5.0.2", + "vitest": "5.0.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2029,9 +2029,9 @@ } }, "node_modules/@vitest/istanbul-lib-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", - "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", "dev": true, "license": "MIT", "engines": { @@ -2039,27 +2039,27 @@ } }, "node_modules/@vitest/istanbul-lib-report": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", - "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/istanbul-lib-coverage": "1.0.1" + "@vitest/istanbul-lib-coverage": "1.0.2" }, "engines": { "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", - "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.1", + "@vitest/spy": "5.0.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, @@ -2080,9 +2080,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", - "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", "dev": true, "license": "MIT", "funding": { @@ -2367,9 +2367,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -2413,9 +2413,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", - "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -2506,9 +2506,9 @@ "license": "BSD-3-Clause" }, "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", "dev": true, "license": "MIT", "dependencies": { @@ -4663,9 +4663,9 @@ } }, "node_modules/magic-string": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", - "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { @@ -4701,9 +4701,9 @@ } }, "node_modules/marked": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", - "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", + "version": "18.0.14", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.14.tgz", + "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==", "dev": true, "license": "MIT", "bin": { @@ -5407,9 +5407,9 @@ } }, "node_modules/prettier": { - "version": "3.9.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", - "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -5576,13 +5576,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", - "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.150.0", + "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5592,21 +5592,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.9", - "@rolldown/binding-android-arm64": "1.2.9", - "@rolldown/binding-darwin-arm64": "1.2.9", - "@rolldown/binding-darwin-x64": "1.2.9", - "@rolldown/binding-freebsd-x64": "1.2.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", - "@rolldown/binding-linux-arm64-gnu": "1.2.9", - "@rolldown/binding-linux-arm64-musl": "1.2.9", - "@rolldown/binding-linux-ppc64-gnu": "1.2.9", - "@rolldown/binding-linux-s390x-gnu": "1.2.9", - "@rolldown/binding-linux-x64-gnu": "1.2.9", - "@rolldown/binding-linux-x64-musl": "1.2.9", - "@rolldown/binding-openharmony-arm64": "1.2.9", - "@rolldown/binding-win32-arm64-msvc": "1.2.9", - "@rolldown/binding-win32-x64-msvc": "1.2.9" + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/run-applescript": { @@ -5653,9 +5653,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", - "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5768,13 +5768,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5798,13 +5791,6 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -6021,9 +6007,9 @@ } }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", "dev": true, "license": "MIT", "engines": { @@ -6031,9 +6017,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -6137,16 +6123,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", - "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0" + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6227,16 +6213,16 @@ } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "bin": { @@ -6305,14 +6291,14 @@ } }, "node_modules/vitest": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", - "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.1", + "@vitest/mocker": "5.0.2", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -6320,10 +6306,10 @@ "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" + "why-is-node-running": "^3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -6338,12 +6324,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.1", - "@vitest/browser-preview": "5.0.1", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.1", - "@vitest/coverage-v8": "5.0.1", - "@vitest/ui": "5.0.1", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -6404,20 +6390,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { @@ -6590,9 +6572,9 @@ "license": "ISC" }, "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "dev": true, "license": "MIT", "dependencies": { @@ -6634,9 +6616,9 @@ } }, "node_modules/yargs/node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2d0b520..4afd1ee 100644 --- a/package.json +++ b/package.json @@ -176,6 +176,18 @@ "title": "View as Tree", "category": "Workspace File Bookmarks", "icon": "$(list-tree)" + }, + { + "command": "workspace-file-bookmarks.exportBookmarks", + "title": "Export Bookmarks...", + "category": "Workspace File Bookmarks", + "icon": "$(export)" + }, + { + "command": "workspace-file-bookmarks.importBookmarks", + "title": "Import Bookmarks...", + "category": "Workspace File Bookmarks", + "icon": "$(desktop-download)" } ], "menus": { @@ -219,6 +231,16 @@ "command": "workspace-file-bookmarks.setViewModeTree", "when": "view == workspace-file-bookmarks-view && workspace-file-bookmarks.viewMode == list", "group": "navigation@5" + }, + { + "command": "workspace-file-bookmarks.exportBookmarks", + "when": "view == workspace-file-bookmarks-view", + "group": "9_data@1" + }, + { + "command": "workspace-file-bookmarks.importBookmarks", + "when": "view == workspace-file-bookmarks-view", + "group": "9_data@2" } ], "view/item/context": [ @@ -320,14 +342,14 @@ }, "overrides": { "diff": "^9.0.0", - "serialize-javascript": "^7.1.1" + "serialize-javascript": "^7.1.2" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/mocha": "^10.0.10", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@types/vscode": "^1.138.0", - "@vitest/coverage-v8": "^5.0.1", + "@vitest/coverage-v8": "^5.0.2", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^4.0.0", @@ -335,10 +357,10 @@ "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "lefthook": "^2.1.14", - "prettier": "^3.9.8", + "prettier": "^3.9.9", "typescript": "~6.0.3", - "typescript-eslint": "^8.70.0", - "vite": "^8.3.0", - "vitest": "^5.0.1" + "typescript-eslint": "^8.71.0", + "vite": "^8.3.1", + "vitest": "^5.0.2" } } diff --git a/scripts/dep_update/serialize-javascript.sh b/scripts/dep_update/serialize-javascript.sh new file mode 100644 index 0000000..f4e44b8 --- /dev/null +++ b/scripts/dep_update/serialize-javascript.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +NPM_MODULES=( + "$REPO_ROOT" + ) + +for MODULE in "${NPM_MODULES[@]}"; do + echo "============================================================================" + echo "Updating: $MODULE" + echo "============================================================================" + + cd "$MODULE" || exit 1 + + if [ -n "$(npm pkg get overrides.serialize-javascript)" ]; then + LATEST="$(npm view serialize-javascript version)" + npm pkg set "overrides.serialize-javascript=^${LATEST}" + npm install + else + npm update serialize-javascript + fi +done diff --git a/test/activate.test.ts b/test/activate.test.ts index beff3b0..d1a9481 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -11,8 +11,8 @@ describe('activate', () => { activate(context as any); expect(window.createTreeView).toHaveBeenCalledOnce(); - expect(commands.registerCommand).toHaveBeenCalledTimes(21); - expect((context as any).subscriptions).toHaveLength(22); // tree view + 21 commands + expect(commands.registerCommand).toHaveBeenCalledTimes(23); + expect((context as any).subscriptions).toHaveLength(24); // tree view + 23 commands }); it('exercises every registered command handler at least once', async () => { @@ -66,8 +66,12 @@ describe('activate', () => { await handlers.get('workspace-file-bookmarks.openAllInFolder')?.({ folder, bookmarks: [] }); await handlers.get('workspace-file-bookmarks.setViewModeList')?.(); await handlers.get('workspace-file-bookmarks.setViewModeTree')?.(); + window.showSaveDialog.mockResolvedValue(undefined); + await handlers.get('workspace-file-bookmarks.exportBookmarks')?.(); + window.showOpenDialog.mockResolvedValue(undefined); + await handlers.get('workspace-file-bookmarks.importBookmarks')?.(); - expect(handlers.size).toBe(21); + expect(handlers.size).toBe(23); }); }); diff --git a/test/bookmarkStore.test.ts b/test/bookmarkStore.test.ts index bd5759d..08f1e56 100644 --- a/test/bookmarkStore.test.ts +++ b/test/bookmarkStore.test.ts @@ -416,3 +416,68 @@ describe('folderBreadcrumb', () => { expect(folderBreadcrumb([], 'missing')).toBe(''); }); }); + +describe('replaceAll', () => { + it('discards the existing bookmarks and folders and stores the given ones', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'old' })); + store.createFolder('Old Folder'); + + store.replaceAll( + [makeBookmark({ id: 'new', uri: 'file:///repo/src/b.ts' })], + [{ id: 'f1', name: 'New Folder', createdAt: 1 }], + ); + + expect(store.getAllBookmarks().map((b) => b.id)).toEqual(['new']); + expect(store.getAllFolders().map((f) => f.id)).toEqual(['f1']); + }); +}); + +describe('mergeImport', () => { + it('appends imported bookmarks and folders under freshly generated ids', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'existing' })); + + const result = store.mergeImport( + [makeBookmark({ id: 'imported', uri: 'file:///repo/src/b.ts', folderId: 'imported-folder' })], + [{ id: 'imported-folder', name: 'Imported', createdAt: 1 }], + ); + + expect(result).toEqual({ addedBookmarks: 1, addedFolders: 1 }); + const bookmarks = store.getAllBookmarks(); + expect(bookmarks).toHaveLength(2); + const imported = bookmarks.find((b) => b.uri === 'file:///repo/src/b.ts')!; + expect(imported.id).not.toBe('imported'); + const folders = store.getAllFolders(); + expect(folders).toHaveLength(1); + expect(folders[0].id).not.toBe('imported-folder'); + expect(imported.folderId).toBe(folders[0].id); + }); + + it('remaps parentId references within the imported folder hierarchy', () => { + const store = newStore(); + + store.mergeImport( + [], + [ + { id: 'parent', name: 'Parent', createdAt: 1 }, + { id: 'child', name: 'Child', createdAt: 2, parentId: 'parent' }, + ], + ); + + const folders = store.getAllFolders(); + const parent = folders.find((f) => f.name === 'Parent')!; + const child = folders.find((f) => f.name === 'Child')!; + expect(child.parentId).toBe(parent.id); + }); + + it('skips bookmarks that already exist at the same uri and line range', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'existing', lineStart: 5, lineEnd: 5 })); + + const result = store.mergeImport([makeBookmark({ id: 'dup', lineStart: 5, lineEnd: 5 })], []); + + expect(result).toEqual({ addedBookmarks: 0, addedFolders: 0 }); + expect(store.getAllBookmarks()).toHaveLength(1); + }); +}); diff --git a/test/commands.test.ts b/test/commands.test.ts index cddd9df..5cf44f7 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -9,8 +9,10 @@ import { createSubfolder, deleteFolder, editBookmarkTags, + exportBookmarks, filterBookmarks, filterByTag, + importBookmarks, moveFolderToParent, moveToFolder, openAllInFolder, @@ -769,3 +771,148 @@ describe('addBookmarksToFolder', () => { expect(store.getAllBookmarks()).toHaveLength(0); }); }); + +describe('exportBookmarks', () => { + it('writes bookmarks and folders to the picked file', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.createFolder('Backend'); + const uri = Uri.file('/tmp/export.json'); + window.showSaveDialog.mockResolvedValue(uri); + + await exportBookmarks(store); + + expect(workspace.fs.writeFile).toHaveBeenCalledOnce(); + const [writtenUri, bytes] = workspace.fs.writeFile.mock.calls[0]; + expect(writtenUri).toBe(uri); + const data = JSON.parse(Buffer.from(bytes).toString('utf8')); + expect(data.bookmarks).toHaveLength(1); + expect(data.folders).toHaveLength(1); + expect(window.showInformationMessage).toHaveBeenCalledOnce(); + }); + + it('does nothing when the save dialog is cancelled', async () => { + const store = newStore(); + window.showSaveDialog.mockResolvedValue(undefined); + + await exportBookmarks(store); + + expect(workspace.fs.writeFile).not.toHaveBeenCalled(); + }); + + it('shows an error if writing fails', async () => { + const store = newStore(); + window.showSaveDialog.mockResolvedValue(Uri.file('/tmp/export.json')); + workspace.fs.writeFile.mockRejectedValue(new Error('disk full')); + + await exportBookmarks(store); + + expect(window.showErrorMessage).toHaveBeenCalledOnce(); + }); +}); + +describe('importBookmarks', () => { + function mockFileContents(data: unknown) { + workspace.fs.readFile.mockResolvedValue(Buffer.from(JSON.stringify(data), 'utf8')); + } + + it('does nothing when the open dialog is cancelled', async () => { + const store = newStore(); + window.showOpenDialog.mockResolvedValue(undefined); + + await importBookmarks(store); + + expect(workspace.fs.readFile).not.toHaveBeenCalled(); + }); + + it('shows an error when the file cannot be read or parsed', async () => { + const store = newStore(); + window.showOpenDialog.mockResolvedValue([Uri.file('/tmp/import.json')]); + workspace.fs.readFile.mockRejectedValue(new Error('not found')); + + await importBookmarks(store); + + expect(window.showErrorMessage).toHaveBeenCalledOnce(); + }); + + it('shows an error when the file is not a valid export', async () => { + const store = newStore(); + window.showOpenDialog.mockResolvedValue([Uri.file('/tmp/import.json')]); + mockFileContents({ foo: 'bar' }); + + await importBookmarks(store); + + expect(window.showErrorMessage).toHaveBeenCalledOnce(); + expect(store.getAllBookmarks()).toHaveLength(0); + }); + + it('does nothing when the merge/replace picker is cancelled', async () => { + const store = newStore(); + window.showOpenDialog.mockResolvedValue([Uri.file('/tmp/import.json')]); + mockFileContents({ version: 1, exportedAt: '', bookmarks: [makeBookmark()], folders: [] }); + window.showQuickPick.mockResolvedValue(undefined); + + await importBookmarks(store); + + expect(store.getAllBookmarks()).toHaveLength(0); + }); + + it('merges imported bookmarks and folders into the existing store', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'existing' })); + window.showOpenDialog.mockResolvedValue([Uri.file('/tmp/import.json')]); + mockFileContents({ + version: 1, + exportedAt: '', + bookmarks: [makeBookmark({ id: 'imported', uri: 'file:///repo/src/b.ts' })], + folders: [makeFolder({ id: 'f1' })], + }); + window.showQuickPick.mockResolvedValue('Merge with existing bookmarks'); + + await importBookmarks(store); + + expect(store.getAllBookmarks()).toHaveLength(2); + expect(store.getAllFolders()).toHaveLength(1); + expect(window.showInformationMessage).toHaveBeenCalledOnce(); + }); + + it('replaces the store after confirmation', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'existing' })); + window.showOpenDialog.mockResolvedValue([Uri.file('/tmp/import.json')]); + mockFileContents({ + version: 1, + exportedAt: '', + bookmarks: [makeBookmark({ id: 'imported' })], + folders: [], + }); + window.showQuickPick.mockResolvedValue('Replace existing bookmarks'); + window.showWarningMessage.mockResolvedValue('Replace'); + + await importBookmarks(store); + + const bookmarks = store.getAllBookmarks(); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0].id).toBe('imported'); + }); + + it('does not replace when the confirmation is declined', async () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'existing' })); + window.showOpenDialog.mockResolvedValue([Uri.file('/tmp/import.json')]); + mockFileContents({ + version: 1, + exportedAt: '', + bookmarks: [makeBookmark({ id: 'imported' })], + folders: [], + }); + window.showQuickPick.mockResolvedValue('Replace existing bookmarks'); + window.showWarningMessage.mockResolvedValue(undefined); + + await importBookmarks(store); + + const bookmarks = store.getAllBookmarks(); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0].id).toBe('existing'); + }); +}); diff --git a/test/e2e/extension.e2e.test.ts b/test/e2e/extension.e2e.test.ts index 720390b..561407a 100644 --- a/test/e2e/extension.e2e.test.ts +++ b/test/e2e/extension.e2e.test.ts @@ -1,4 +1,5 @@ import * as assert from 'node:assert/strict'; +import * as os from 'node:os'; import * as path from 'node:path'; import * as vscode from 'vscode'; import type { BookmarkTreeItem, ExtensionApi, FolderGroupItem } from '../../extension'; @@ -64,6 +65,8 @@ describe('Workspace File Bookmarks (e2e)', () => { 'workspace-file-bookmarks.openAllInFolder', 'workspace-file-bookmarks.setViewModeList', 'workspace-file-bookmarks.setViewModeTree', + 'workspace-file-bookmarks.exportBookmarks', + 'workspace-file-bookmarks.importBookmarks', ]) { assert.ok(commands.includes(id), `expected command "${id}" to be registered`); } @@ -229,4 +232,49 @@ describe('Workspace File Bookmarks (e2e)', () => { await provider.handleDrop(undefined, dataTransfer); assert.equal(store.getAllFolders().find((f) => f.id === child.id)?.parentId, null); }); + + it('round-trips bookmarks and folders through exportBookmarks and importBookmarks', async () => { + const { store } = await getApi(); + const folder = store.createFolder('Backend'); + const document = await vscode.workspace.openTextDocument(fixtureUri('src/sample-a.ts')); + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + store.moveBookmarkToFolder(store.getAllBookmarks()[0].id, folder.id); + const exportUri = vscode.Uri.file(path.join(os.tmpdir(), `wfb-e2e-export-${Date.now()}.json`)); + + const originalShowSaveDialog = vscode.window.showSaveDialog; + const originalShowOpenDialog = vscode.window.showOpenDialog; + const originalShowQuickPick = vscode.window.showQuickPick; + try { + (vscode.window as any).showSaveDialog = async () => exportUri; + await vscode.commands.executeCommand('workspace-file-bookmarks.exportBookmarks'); + + const written = JSON.parse( + Buffer.from(await vscode.workspace.fs.readFile(exportUri)).toString('utf8'), + ); + assert.equal(written.bookmarks.length, 1); + assert.equal(written.folders.length, 1); + + for (const bookmark of store.getAllBookmarks()) { + store.removeBookmark(bookmark.id); + } + for (const existingFolder of store.getAllFolders()) { + store.deleteFolder(existingFolder.id); + } + + (vscode.window as any).showOpenDialog = async () => [exportUri]; + (vscode.window as any).showQuickPick = async () => 'Merge with existing bookmarks'; + await vscode.commands.executeCommand('workspace-file-bookmarks.importBookmarks'); + + assert.equal(store.getAllBookmarks().length, 1); + assert.equal(store.getAllFolders().length, 1); + assert.equal(store.getAllBookmarks()[0].relativePath, 'src/sample-a.ts'); + assert.equal(store.getAllFolders()[0].name, 'Backend'); + } finally { + vscode.window.showSaveDialog = originalShowSaveDialog; + vscode.window.showOpenDialog = originalShowOpenDialog; + vscode.window.showQuickPick = originalShowQuickPick; + await vscode.workspace.fs.delete(exportUri); + } + }); }); diff --git a/test/vscode-mock.ts b/test/vscode-mock.ts index 3f1b357..110ab5d 100644 --- a/test/vscode-mock.ts +++ b/test/vscode-mock.ts @@ -147,6 +147,8 @@ export const window = { showErrorMessage: vi.fn(), showInputBox: vi.fn(), showQuickPick: vi.fn(), + showSaveDialog: vi.fn(), + showOpenDialog: vi.fn(), showTextDocument: vi.fn(() => ({ selection: undefined, revealRange: vi.fn(), @@ -160,6 +162,10 @@ export const workspace = { getWorkspaceFolder: vi.fn(), asRelativePath: vi.fn((uri: Uri) => uri.fsPath), openTextDocument: vi.fn(), + fs: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, }; export const commands = {