Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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. */
Expand Down
4 changes: 4 additions & 0 deletions test/activate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
34 changes: 34 additions & 0 deletions test/bookmarkStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
53 changes: 53 additions & 0 deletions test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
renameBookmark,
removeBrokenBookmarks,
renameFolder,
resolveBookmarkTargets,
type Bookmark,
type BookmarkFolder,
} from '../extension';
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 35 additions & 0 deletions test/e2e/extension.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BookmarkTreeItem | FolderGroupItem>).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);
});
});
Loading