From de97a26825fd81fdc4df1a9103c8cc427de3f24f Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Wed, 11 Nov 2026 09:12:52 -0500 Subject: [PATCH] feat: apply remove and move-to-folder to every selected bookmark Enables multi-select on the bookmarks tree so bulk actions work on the whole selection. Closes #48 --- extension.ts | 58 ++++++++++++++++++++++++++++++---- test/activate.test.ts | 4 +++ test/bookmarkStore.test.ts | 34 ++++++++++++++++++++ test/commands.test.ts | 53 +++++++++++++++++++++++++++++++ test/e2e/extension.e2e.test.ts | 35 ++++++++++++++++++++ 5 files changed, 178 insertions(+), 6 deletions(-) diff --git a/extension.ts b/extension.ts index f80ba8b..efd7f41 100644 --- a/extension.ts +++ b/extension.ts @@ -208,6 +208,7 @@ export function activate(context: vscode.ExtensionContext): ExtensionApi { const treeView = vscode.window.createTreeView('workspace-file-bookmarks-view', { treeDataProvider: provider, showCollapseAll: true, + canSelectMany: true, dragAndDropController: provider, }); @@ -243,7 +244,8 @@ export function activate(context: vscode.ExtensionContext): ExtensionApi { ), vscode.commands.registerCommand( 'workspace-file-bookmarks.removeBookmark', - (item: BookmarkTreeItem) => store.removeBookmark(item.bookmark.id), + (item: BookmarkTreeItem, selection?: readonly TreeNode[]) => + store.removeBookmarks(resolveBookmarkTargets(item, selection).map((b) => b.id)), ), vscode.commands.registerCommand( 'workspace-file-bookmarks.renameBookmark', @@ -291,7 +293,8 @@ export function activate(context: vscode.ExtensionContext): ExtensionApi { ), vscode.commands.registerCommand( 'workspace-file-bookmarks.moveToFolder', - (item: BookmarkTreeItem) => moveToFolder(store, item), + (item: BookmarkTreeItem, selection?: readonly TreeNode[]) => + moveToFolder(store, item, selection), ), vscode.commands.registerCommand( 'workspace-file-bookmarks.openAllInFolder', @@ -385,8 +388,22 @@ export class BookmarkStore { this.setBookmarks(this.getAllBookmarks().filter((b) => b.id !== id)); } + /** Removes several bookmarks in one write, so the tree refreshes once. */ + removeBookmarks(ids: string[]) { + const idSet = new Set(ids); + this.setBookmarks(this.getAllBookmarks().filter((b) => !idSet.has(b.id))); + } + moveBookmarkToFolder(id: string, folderId: string | null) { - this.setBookmarks(this.getAllBookmarks().map((b) => (b.id === id ? { ...b, folderId } : b))); + this.moveBookmarksToFolder([id], folderId); + } + + /** Moves several bookmarks to a folder (or the root) in one write. */ + moveBookmarksToFolder(ids: string[], folderId: string | null) { + const idSet = new Set(ids); + this.setBookmarks( + this.getAllBookmarks().map((b) => (idSet.has(b.id) ? { ...b, folderId } : b)), + ); } renameBookmark(id: string, label: string) { @@ -1232,12 +1249,41 @@ export function dropBookmark( store.reorderBookmarks(computeReorderedIds(scope, draggedId, beforeId, bookmarkFallbackCompare)); } -export async function moveToFolder(store: BookmarkStore, item: BookmarkTreeItem) { - const folderId = await pickFolder(store, `Move "${item.bookmark.label}" to...`); +/** + * The bookmarks a tree command should act on. When the command is invoked from a multi-selected + * context menu, VS Code passes the clicked item plus the whole selection; if the clicked item is + * part of that selection the action applies to every selected bookmark (folders in the selection + * are ignored), otherwise just to the clicked item. + */ +export function resolveBookmarkTargets( + item: BookmarkTreeItem, + selection?: readonly TreeNode[], +): Bookmark[] { + const selected = (selection ?? []).filter((node): node is BookmarkTreeItem => 'bookmark' in node); + if (selected.length > 1 && selected.some((n) => n.bookmark.id === item.bookmark.id)) { + return selected.map((n) => n.bookmark); + } + return [item.bookmark]; +} + +export async function moveToFolder( + store: BookmarkStore, + item: BookmarkTreeItem, + selection?: readonly TreeNode[], +) { + const targets = resolveBookmarkTargets(item, selection); + const placeHolder = + targets.length > 1 + ? `Move ${targets.length} bookmarks to...` + : `Move "${item.bookmark.label}" to...`; + const folderId = await pickFolder(store, placeHolder); if (folderId === undefined) { return; } - store.moveBookmarkToFolder(item.bookmark.id, folderId); + store.moveBookmarksToFolder( + targets.map((b) => b.id), + folderId, + ); } /** Writes all bookmarks and folders to a JSON file the user picks via a save dialog. */ diff --git a/test/activate.test.ts b/test/activate.test.ts index 42acbd3..24a4d11 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -11,6 +11,10 @@ describe('activate', () => { activate(context as any); expect(window.createTreeView).toHaveBeenCalledOnce(); + expect(window.createTreeView).toHaveBeenCalledWith( + 'workspace-file-bookmarks-view', + expect.objectContaining({ canSelectMany: true }), + ); expect(commands.registerCommand).toHaveBeenCalledTimes(25); expect((context as any).subscriptions).toHaveLength(26); // tree view + 25 commands }); diff --git a/test/bookmarkStore.test.ts b/test/bookmarkStore.test.ts index 39dad3f..4ea2bfc 100644 --- a/test/bookmarkStore.test.ts +++ b/test/bookmarkStore.test.ts @@ -510,3 +510,37 @@ describe('stale bookmark tracking', () => { expect(store.isStale('a')).toBe(false); }); }); + +describe('bulk bookmark operations', () => { + it('removeBookmarks removes only the named bookmarks in a single change event', () => { + const store = newStore(); + for (const id of ['a', 'b', 'c']) { + store.addBookmark(makeBookmark({ id, uri: `file:///repo/${id}.ts` })); + } + let changes = 0; + store.onDidChange(() => changes++); + + store.removeBookmarks(['a', 'c']); + + expect(store.getAllBookmarks().map((b) => b.id)).toEqual(['b']); + expect(changes).toBe(1); + }); + + it('moveBookmarksToFolder moves only the named bookmarks in a single change event', () => { + const store = newStore(); + for (const id of ['a', 'b', 'c']) { + store.addBookmark(makeBookmark({ id, uri: `file:///repo/${id}.ts` })); + } + const folder = store.createFolder('Backend'); + let changes = 0; + store.onDidChange(() => changes++); + + store.moveBookmarksToFolder(['a', 'b'], folder.id); + + const byId = new Map(store.getAllBookmarks().map((b) => [b.id, b.folderId])); + expect(byId.get('a')).toBe(folder.id); + expect(byId.get('b')).toBe(folder.id); + expect(byId.get('c')).toBeNull(); + expect(changes).toBe(1); + }); +}); diff --git a/test/commands.test.ts b/test/commands.test.ts index 236a8ab..bb089b5 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -23,6 +23,7 @@ import { renameBookmark, removeBrokenBookmarks, renameFolder, + resolveBookmarkTargets, type Bookmark, type BookmarkFolder, } from '../extension'; @@ -776,6 +777,58 @@ describe('moveToFolder', () => { }); }); +describe('resolveBookmarkTargets', () => { + const nodeFor = (id: string) => ({ bookmark: makeBookmark({ id }) }) as any; + + it('returns just the clicked item when there is no selection', () => { + expect(resolveBookmarkTargets(nodeFor('a')).map((b) => b.id)).toEqual(['a']); + }); + + it('returns just the clicked item when the selection is only that item', () => { + const a = nodeFor('a'); + expect(resolveBookmarkTargets(a, [a]).map((b) => b.id)).toEqual(['a']); + }); + + it('returns every selected bookmark when the clicked item is part of a multi-selection', () => { + const [a, b, c] = [nodeFor('a'), nodeFor('b'), nodeFor('c')]; + expect(resolveBookmarkTargets(b, [a, b, c]).map((x) => x.id)).toEqual(['a', 'b', 'c']); + }); + + it('ignores folders in the selection', () => { + const [a, b] = [nodeFor('a'), nodeFor('b')]; + const folderNode = { folder: makeFolder() } as any; + expect(resolveBookmarkTargets(a, [a, folderNode, b]).map((x) => x.id)).toEqual(['a', 'b']); + }); + + it('acts only on the clicked item when it is outside the selection', () => { + const [a, b, c] = [nodeFor('a'), nodeFor('b'), nodeFor('c')]; + expect(resolveBookmarkTargets(c, [a, b]).map((x) => x.id)).toEqual(['c']); + }); +}); + +describe('moveToFolder with a multi-selection', () => { + it('moves every selected bookmark and words the prompt for the count', async () => { + const store = newStore(); + for (const id of ['a', 'b', 'c']) { + store.addBookmark(makeBookmark({ id, uri: `file:///repo/${id}.ts` })); + } + const folder = store.createFolder('Backend'); + window.showQuickPick.mockResolvedValue('Backend'); + const nodeFor = (id: string) => ({ bookmark: makeBookmark({ id }) }) as any; + const [a, b] = [nodeFor('a'), nodeFor('b')]; + + await moveToFolder(store, a, [a, b]); + + const byId = new Map(store.getAllBookmarks().map((x) => [x.id, x.folderId])); + expect(byId.get('a')).toBe(folder.id); + expect(byId.get('b')).toBe(folder.id); + expect(byId.get('c')).toBeNull(); + expect(window.showQuickPick.mock.calls[0][1]).toMatchObject({ + placeHolder: 'Move 2 bookmarks to...', + }); + }); +}); + describe('addBookmarksToFolder', () => { it('adds every target uri into the picked folder', async () => { const store = newStore(); diff --git a/test/e2e/extension.e2e.test.ts b/test/e2e/extension.e2e.test.ts index 665c440..5764275 100644 --- a/test/e2e/extension.e2e.test.ts +++ b/test/e2e/extension.e2e.test.ts @@ -338,4 +338,39 @@ describe('Workspace File Bookmarks (e2e)', () => { ['src/sample-a.ts'], ); }); + + it('removes and moves every bookmark in a multi-selection via the tree commands', async () => { + const { store, provider } = await getApi(); + for (const file of ['src/sample-a.ts', 'src/sample-b.ts']) { + const document = await vscode.workspace.openTextDocument(fixtureUri(file)); + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand('workspace-file-bookmarks.addBookmark'); + } + assert.equal(store.getAllBookmarks().length, 2); + const folder = store.createFolder('Batch'); + const items = (provider.getChildren() as Array).filter( + isBookmarkNode, + ); + assert.equal(items.length, 2); + + const originalShowQuickPick = vscode.window.showQuickPick; + try { + (vscode.window as any).showQuickPick = async () => 'Batch'; + await vscode.commands.executeCommand( + 'workspace-file-bookmarks.moveToFolder', + items[0], + items, + ); + } finally { + vscode.window.showQuickPick = originalShowQuickPick; + } + assert.ok(store.getAllBookmarks().every((b) => b.folderId === folder.id)); + + await vscode.commands.executeCommand( + 'workspace-file-bookmarks.removeBookmark', + items[0], + items, + ); + assert.equal(store.getAllBookmarks().length, 0); + }); });