From b79b7595a0ac17d9ef653b4b9ec75297316e3f5e Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Fri, 21 Aug 2026 16:41:41 -0400 Subject: [PATCH] feat: reorder bookmarks and folders via drag-and-drop Adds an explicit `order` field to Bookmark/BookmarkFolder and wires BookmarksTreeProvider up as a vscode.TreeDragAndDropController so users can drag to reorder bookmarks within a folder/list, reorder folders, and drag a bookmark onto a folder to move it. Items sort by `order` when set, falling back to the existing createdAt/name ordering for anything not yet touched by a drag. Closes #38 --- extension.ts | 170 ++++++++++++++++++--- test/bookmarkStore.test.ts | 26 ++++ test/dragAndDrop.test.ts | 293 +++++++++++++++++++++++++++++++++++++ test/vscode-mock.ts | 20 +++ 4 files changed, 491 insertions(+), 18 deletions(-) create mode 100644 test/dragAndDrop.test.ts diff --git a/extension.ts b/extension.ts index 9596e0c..2cb0149 100644 --- a/extension.ts +++ b/extension.ts @@ -15,21 +15,64 @@ export interface Bookmark { workspaceFolderName: string; folderId: string | null; createdAt: number; + order?: number; } export interface BookmarkFolder { id: string; name: string; createdAt: number; + order?: number; } +export const BOOKMARK_DND_MIME_TYPE = 'application/vnd.code.tree.workspace-file-bookmarks-view'; + +/** Sorts by explicit `order` when set (ascending), falling back for items without one. Items with an order always sort before items without. */ +export function sortByOrder(items: T[], fallbackCompare: (a: T, b: T) => number): T[] { + return items.slice().sort((a, b) => { + if (a.order !== undefined && b.order !== undefined) { + return a.order - b.order; + } + if (a.order !== undefined) { + return -1; + } + if (b.order !== undefined) { + return 1; + } + return fallbackCompare(a, b); + }); +} + +/** Computes the ordered id list for a drag-and-drop scope: `draggedId` removed then reinserted before `beforeId` (or appended when `beforeId` is null). */ +export function computeReorderedIds( + scopeItems: T[], + draggedId: string, + beforeId: string | null, + fallbackCompare: (a: T, b: T) => number +): string[] { + const ids = sortByOrder(scopeItems, fallbackCompare) + .filter(item => item.id !== draggedId) + .map(item => item.id); + const insertAt = beforeId ? ids.indexOf(beforeId) : -1; + if (insertAt === -1) { + ids.push(draggedId); + } else { + ids.splice(insertAt, 0, draggedId); + } + return ids; +} + +const bookmarkFallbackCompare = (a: Bookmark, b: Bookmark) => b.createdAt - a.createdAt; +const folderFallbackCompare = (a: BookmarkFolder, b: BookmarkFolder) => a.name.localeCompare(b.name); + export function activate(context: vscode.ExtensionContext) { const store = new BookmarkStore(context); const provider = new BookmarksTreeProvider(store, context); const treeView = vscode.window.createTreeView('workspace-file-bookmarks-view', { treeDataProvider: provider, - showCollapseAll: true + showCollapseAll: true, + dragAndDropController: provider }); vscode.commands.executeCommand('setContext', VIEW_MODE_CONTEXT_KEY, provider.getViewMode()); @@ -89,6 +132,18 @@ export class BookmarkStore { this.setBookmarks(this.getAllBookmarks().map(b => (b.id === id ? { ...b, label } : b))); } + /** Assigns sequential `order` values to the bookmarks named in `orderedIds`; bookmarks outside that set are untouched. */ + reorderBookmarks(orderedIds: string[]) { + const orderById = new Map(orderedIds.map((id, index) => [id, index])); + this.setBookmarks(this.getAllBookmarks().map(b => (orderById.has(b.id) ? { ...b, order: orderById.get(b.id) } : b))); + } + + /** Assigns sequential `order` values to the folders named in `orderedIds`; folders outside that set are untouched. */ + reorderFolders(orderedIds: string[]) { + const orderById = new Map(orderedIds.map((id, index) => [id, index])); + this.setFolders(this.getAllFolders().map(f => (orderById.has(f.id) ? { ...f, order: orderById.get(f.id) } : f))); + } + createFolder(name: string): BookmarkFolder { const folder: BookmarkFolder = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -145,7 +200,7 @@ export class BookmarkTreeItem extends vscode.TreeItem { type TreeNode = FolderGroupItem | BookmarkTreeItem; -export class BookmarksTreeProvider implements vscode.TreeDataProvider { +export class BookmarksTreeProvider implements vscode.TreeDataProvider, vscode.TreeDragAndDropController { private readonly _onDidChangeTreeData = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; @@ -181,35 +236,64 @@ export class BookmarksTreeProvider implements vscode.TreeDataProvider return []; } const folderById = new Map(folders.map(f => [f.id, f.name])); - return bookmarks - .slice() - .sort((a, b) => b.createdAt - a.createdAt) - .map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, folderById.get(b.folderId ?? '') ?? null))); + return sortByOrder(bookmarks, bookmarkFallbackCompare).map( + b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, folderById.get(b.folderId ?? '') ?? null)) + ); } if (element instanceof FolderGroupItem) { - return element.bookmarks - .slice() - .sort((a, b) => b.createdAt - a.createdAt) - .map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null))); + return sortByOrder(element.bookmarks, bookmarkFallbackCompare).map( + b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null)) + ); } if (element) { return []; } - const folderNodes = folders - .slice() - .sort((a, b) => a.name.localeCompare(b.name)) - .map(folder => new FolderGroupItem(folder, bookmarks.filter(b => b.folderId === folder.id))); + const folderNodes = sortByOrder(folders, folderFallbackCompare).map( + folder => new FolderGroupItem(folder, bookmarks.filter(b => b.folderId === folder.id)) + ); - const ungrouped = bookmarks - .filter(b => !b.folderId) - .sort((a, b) => b.createdAt - a.createdAt) - .map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null))); + const ungrouped = sortByOrder( + bookmarks.filter(b => !b.folderId), + bookmarkFallbackCompare + ).map(b => new BookmarkTreeItem(b, describeBookmark(b, isMultiRoot, null))); return [...folderNodes, ...ungrouped]; } + + get dropMimeTypes(): string[] { + return [BOOKMARK_DND_MIME_TYPE]; + } + + get dragMimeTypes(): string[] { + return [BOOKMARK_DND_MIME_TYPE]; + } + + handleDrag(source: readonly TreeNode[], dataTransfer: vscode.DataTransfer): void { + const item = source[0]; + if (!item) { + return; + } + const payload = item instanceof BookmarkTreeItem + ? { kind: 'bookmark' as const, id: item.bookmark.id } + : { kind: 'folder' as const, id: item.folder.id }; + dataTransfer.set(BOOKMARK_DND_MIME_TYPE, new vscode.DataTransferItem(JSON.stringify(payload))); + } + + async handleDrop(target: TreeNode | undefined, dataTransfer: vscode.DataTransfer): Promise { + const transferItem = dataTransfer.get(BOOKMARK_DND_MIME_TYPE); + if (!transferItem) { + return; + } + const payload = JSON.parse(await transferItem.asString()) as { kind: 'bookmark' | 'folder'; id: string }; + if (payload.kind === 'folder') { + dropFolder(this.store, payload.id, target); + } else { + dropBookmark(this.store, payload.id, target, this.viewMode); + } + } } export function describeBookmark(bookmark: Bookmark, isMultiRoot: boolean, folderName: string | null): string[] { @@ -356,6 +440,56 @@ export async function pickFolder(store: BookmarkStore, placeHolder: string): Pro return folders.find(f => f.name === picked)?.id ?? undefined; } +/** Reorders folders by moving `draggedId` to just before the folder `target` represents (or to the end if dropped elsewhere). */ +export function dropFolder(store: BookmarkStore, draggedId: string, target: TreeNode | undefined) { + if (target instanceof BookmarkTreeItem) { + return; + } + const beforeId = target instanceof FolderGroupItem ? target.folder.id : null; + if (beforeId === draggedId) { + return; + } + store.reorderFolders(computeReorderedIds(store.getAllFolders(), draggedId, beforeId, folderFallbackCompare)); +} + +/** + * Handles a bookmark dropped onto `target`: dropping onto a folder moves the bookmark into it + * (appended at the end); dropping onto another bookmark reorders within that bookmark's scope, + * moving folders too in tree view (list view reorders in place without touching folderId); + * dropping on empty space appends to the end of the root/list scope. + */ +export function dropBookmark(store: BookmarkStore, draggedId: string, target: TreeNode | undefined, viewMode: ViewMode) { + const dragged = store.getAllBookmarks().find(b => b.id === draggedId); + if (!dragged) { + return; + } + + if (target instanceof FolderGroupItem) { + if (dragged.folderId !== target.folder.id) { + store.moveBookmarkToFolder(draggedId, target.folder.id); + } + const scope = store.getAllBookmarks().filter(b => b.folderId === target.folder.id); + store.reorderBookmarks(computeReorderedIds(scope, draggedId, null, bookmarkFallbackCompare)); + return; + } + + if (target instanceof BookmarkTreeItem && target.bookmark.id === draggedId) { + return; + } + + const beforeId = target instanceof BookmarkTreeItem ? target.bookmark.id : null; + // List view is a flat scope: reorder in place without ever reassigning folderId. + const destinationFolderId = + viewMode === 'list' ? dragged.folderId : target instanceof BookmarkTreeItem ? target.bookmark.folderId : null; + + if (destinationFolderId !== dragged.folderId) { + store.moveBookmarkToFolder(draggedId, destinationFolderId); + } + + const scope = store.getAllBookmarks().filter(b => (viewMode === 'list' ? true : b.folderId === destinationFolderId)); + 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...`); if (folderId === undefined) { diff --git a/test/bookmarkStore.test.ts b/test/bookmarkStore.test.ts index 4a81a63..15f86b1 100644 --- a/test/bookmarkStore.test.ts +++ b/test/bookmarkStore.test.ts @@ -63,6 +63,32 @@ describe('BookmarkStore', () => { expect(store.getAllBookmarks()[0].label).toBe('My File'); }); + it('assigns sequential order to the named bookmarks and leaves others untouched', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a', uri: 'file:///repo/src/a.ts' })); + store.addBookmark(makeBookmark({ id: 'b', uri: 'file:///repo/src/b.ts' })); + store.addBookmark(makeBookmark({ id: 'c', uri: 'file:///repo/src/c.ts' })); + + store.reorderBookmarks(['c', 'a']); + + const byId = new Map(store.getAllBookmarks().map(b => [b.id, b])); + expect(byId.get('c')?.order).toBe(0); + expect(byId.get('a')?.order).toBe(1); + expect(byId.get('b')?.order).toBeUndefined(); + }); + + it('assigns sequential order to the named folders and leaves others untouched', () => { + const store = newStore(); + const first = store.createFolder('First'); + const second = store.createFolder('Second'); + + store.reorderFolders([second.id, first.id]); + + const byId = new Map(store.getAllFolders().map(f => [f.id, f])); + expect(byId.get(second.id)?.order).toBe(0); + expect(byId.get(first.id)?.order).toBe(1); + }); + it('creates, renames, and deletes a folder', () => { const store = newStore(); const folder = store.createFolder('Backend'); diff --git a/test/dragAndDrop.test.ts b/test/dragAndDrop.test.ts new file mode 100644 index 0000000..0914d5a --- /dev/null +++ b/test/dragAndDrop.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { + BOOKMARK_DND_MIME_TYPE, + BookmarkStore, + BookmarksTreeProvider, + BookmarkTreeItem, + FolderGroupItem, + computeReorderedIds, + dropBookmark, + dropFolder, + sortByOrder, + type Bookmark +} from '../extension'; +import { createFakeContext } from './fakeContext'; +import { DataTransfer, DataTransferItem } from './vscode-mock'; + +function makeBookmark(overrides: Partial = {}): Bookmark { + const id = overrides.id ?? 'id-1'; + return { + id, + uri: `file:///repo/src/${id}.ts`, + label: `${id}.ts`, + relativePath: `src/${id}.ts`, + workspaceFolderName: 'repo', + folderId: null, + createdAt: 0, + ...overrides + }; +} + +function newStore() { + return new BookmarkStore(createFakeContext() as any); +} + +function newProvider() { + const context = createFakeContext(); + const store = new BookmarkStore(context as any); + const provider = new BookmarksTreeProvider(store, context as any); + return { store, provider }; +} + +describe('sortByOrder', () => { + it('sorts by order ascending when every item has one', () => { + const items = [{ id: 'b', order: 1 }, { id: 'a', order: 0 }]; + expect(sortByOrder(items, () => 0).map(i => i.id)).toEqual(['a', 'b']); + }); + + it('puts ordered items before unordered ones', () => { + const items = [{ id: 'unordered' }, { id: 'ordered', order: 0 }]; + expect(sortByOrder(items, () => 0).map(i => i.id)).toEqual(['ordered', 'unordered']); + }); + + it('puts ordered items before unordered ones regardless of comparison direction', () => { + const items = [{ id: 'ordered', order: 0 }, { id: 'unordered' }]; + expect(sortByOrder(items, () => 0).map(i => i.id)).toEqual(['ordered', 'unordered']); + }); + + it('falls back to the comparator when neither item has an order', () => { + const items: Array<{ id: string; createdAt: number; order?: number }> = [ + { id: 'a', createdAt: 1 }, + { id: 'b', createdAt: 2 } + ]; + expect(sortByOrder(items, (a, b) => b.createdAt - a.createdAt).map(i => i.id)).toEqual(['b', 'a']); + }); +}); + +describe('computeReorderedIds', () => { + it('appends the dragged id at the end when beforeId is null', () => { + const items = [{ id: 'a', order: 0 }, { id: 'b', order: 1 }]; + expect(computeReorderedIds(items, 'a', null, () => 0)).toEqual(['b', 'a']); + }); + + it('inserts the dragged id before beforeId', () => { + const items = [{ id: 'a', order: 0 }, { id: 'b', order: 1 }, { id: 'c', order: 2 }]; + expect(computeReorderedIds(items, 'c', 'a', () => 0)).toEqual(['c', 'a', 'b']); + }); +}); + +describe('dropFolder', () => { + it('reorders a folder to just before the target folder', () => { + const store = newStore(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); + const c = store.createFolder('C'); + + dropFolder(store, c.id, new FolderGroupItem(a, [])); + + const byId = new Map(store.getAllFolders().map(f => [f.id, f])); + expect(byId.get(c.id)?.order).toBe(0); + expect(byId.get(a.id)?.order).toBe(1); + expect(byId.get(b.id)?.order).toBe(2); + }); + + it('moves a folder to the end when dropped on empty space', () => { + const store = newStore(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); + + dropFolder(store, a.id, undefined); + + const byId = new Map(store.getAllFolders().map(f => [f.id, f])); + expect(byId.get(b.id)?.order).toBe(0); + expect(byId.get(a.id)?.order).toBe(1); + }); + + it('does nothing when dropped onto itself', () => { + const store = newStore(); + const a = store.createFolder('A'); + + dropFolder(store, a.id, new FolderGroupItem(a, [])); + + expect(store.getAllFolders()[0].order).toBeUndefined(); + }); + + it('ignores a drop onto a bookmark item', () => { + const store = newStore(); + const a = store.createFolder('A'); + store.addBookmark(makeBookmark({ id: 'bm' })); + + dropFolder(store, a.id, new BookmarkTreeItem(makeBookmark({ id: 'bm' }), [])); + + expect(store.getAllFolders()[0].order).toBeUndefined(); + }); +}); + +describe('dropBookmark', () => { + it('moves a bookmark into the target folder and appends it', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'existing', folderId: folder.id })); + store.addBookmark(makeBookmark({ id: 'dragged', folderId: null })); + + dropBookmark(store, 'dragged', new FolderGroupItem(folder, []), 'tree'); + + const byId = new Map(store.getAllBookmarks().map(b => [b.id, b])); + expect(byId.get('dragged')?.folderId).toBe(folder.id); + expect(byId.get('existing')?.order).toBe(0); + expect(byId.get('dragged')?.order).toBe(1); + }); + + it('reorders within the same folder scope in tree view without changing folderId', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'a', folderId: folder.id })); + store.addBookmark(makeBookmark({ id: 'b', folderId: folder.id })); + + dropBookmark(store, 'b', new BookmarkTreeItem(makeBookmark({ id: 'a', folderId: folder.id }), []), 'tree'); + + const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); + expect(byId.get('b')?.folderId).toBe(folder.id); + expect(byId.get('b')?.order).toBe(0); + expect(byId.get('a')?.order).toBe(1); + }); + + it('moves folders when dropped onto a bookmark in a different folder in tree view', () => { + const store = newStore(); + const source = store.createFolder('Source'); + const dest = store.createFolder('Dest'); + store.addBookmark(makeBookmark({ id: 'dragged', folderId: source.id })); + store.addBookmark(makeBookmark({ id: 'target', folderId: dest.id })); + + dropBookmark(store, 'dragged', new BookmarkTreeItem(makeBookmark({ id: 'target', folderId: dest.id }), []), 'tree'); + + const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); + expect(byId.get('dragged')?.folderId).toBe(dest.id); + expect(byId.get('dragged')?.order).toBe(0); + expect(byId.get('target')?.order).toBe(1); + }); + + it('reorders in place in list view without reassigning folderId', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'grouped', folderId: folder.id })); + store.addBookmark(makeBookmark({ id: 'root', folderId: null })); + + dropBookmark(store, 'root', new BookmarkTreeItem(makeBookmark({ id: 'grouped', folderId: folder.id }), []), 'list'); + + const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); + expect(byId.get('root')?.folderId).toBeNull(); + expect(byId.get('root')?.order).toBe(0); + expect(byId.get('grouped')?.order).toBe(1); + }); + + it('moves a bookmark to the root and appends it when dropped on empty space in tree view', () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.addBookmark(makeBookmark({ id: 'root-existing', folderId: null })); + store.addBookmark(makeBookmark({ id: 'dragged', folderId: folder.id })); + + dropBookmark(store, 'dragged', undefined, 'tree'); + + const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); + expect(byId.get('dragged')?.folderId).toBeNull(); + expect(byId.get('root-existing')?.order).toBe(0); + expect(byId.get('dragged')?.order).toBe(1); + }); + + it('appends to the end of the flat list when dropped on empty space in list view', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.addBookmark(makeBookmark({ id: 'dragged' })); + + dropBookmark(store, 'dragged', undefined, 'list'); + + const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); + expect(byId.get('a')?.order).toBe(0); + expect(byId.get('dragged')?.order).toBe(1); + }); + + it('does nothing when the dragged bookmark no longer exists', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + + dropBookmark(store, 'missing', undefined, 'tree'); + + expect(store.getAllBookmarks()[0].order).toBeUndefined(); + }); + + it('does nothing when dropped onto itself', () => { + const store = newStore(); + store.addBookmark(makeBookmark({ id: 'a' })); + + dropBookmark(store, 'a', new BookmarkTreeItem(makeBookmark({ id: 'a' }), []), 'tree'); + + expect(store.getAllBookmarks()[0].order).toBeUndefined(); + }); +}); + +describe('BookmarksTreeProvider drag and drop', () => { + it('exposes the same mime type for drag and drop', () => { + const { provider } = newProvider(); + expect(provider.dragMimeTypes).toEqual([BOOKMARK_DND_MIME_TYPE]); + expect(provider.dropMimeTypes).toEqual([BOOKMARK_DND_MIME_TYPE]); + }); + + it('handleDrag stores the bookmark id under the mime type', async () => { + const { provider } = newProvider(); + const item = new BookmarkTreeItem(makeBookmark({ id: 'a' }), []); + const dataTransfer = new DataTransfer(); + + provider.handleDrag([item], dataTransfer as any); + + const stored = await dataTransfer.get(BOOKMARK_DND_MIME_TYPE)?.asString(); + expect(JSON.parse(stored ?? '{}')).toEqual({ kind: 'bookmark', id: 'a' }); + }); + + it('handleDrag does nothing when the source list is empty', () => { + const { provider } = newProvider(); + const dataTransfer = new DataTransfer(); + + provider.handleDrag([], dataTransfer as any); + + expect(dataTransfer.get(BOOKMARK_DND_MIME_TYPE)).toBeUndefined(); + }); + + it('handleDrop reorders folders end-to-end via the data transfer payload', async () => { + const { store, provider } = newProvider(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); + const dataTransfer = new DataTransfer(); + dataTransfer.set(BOOKMARK_DND_MIME_TYPE, new DataTransferItem(JSON.stringify({ kind: 'folder', id: b.id }))); + + await provider.handleDrop(new FolderGroupItem(a, []), dataTransfer as any); + + const byId = new Map(store.getAllFolders().map(f => [f.id, f])); + expect(byId.get(b.id)?.order).toBe(0); + expect(byId.get(a.id)?.order).toBe(1); + }); + + it('handleDrop reorders bookmarks end-to-end via the data transfer payload', async () => { + const { store, provider } = newProvider(); + store.addBookmark(makeBookmark({ id: 'a' })); + store.addBookmark(makeBookmark({ id: 'b' })); + const dataTransfer = new DataTransfer(); + dataTransfer.set(BOOKMARK_DND_MIME_TYPE, new DataTransferItem(JSON.stringify({ kind: 'bookmark', id: 'b' }))); + + await provider.handleDrop(new BookmarkTreeItem(makeBookmark({ id: 'a' }), []), dataTransfer as any); + + const byId = new Map(store.getAllBookmarks().map(bm => [bm.id, bm])); + expect(byId.get('b')?.order).toBe(0); + expect(byId.get('a')?.order).toBe(1); + }); + + it('handleDrop does nothing when the data transfer has no matching payload', async () => { + const { store, provider } = newProvider(); + store.addBookmark(makeBookmark({ id: 'a' })); + const dataTransfer = new DataTransfer(); + + await provider.handleDrop(undefined, dataTransfer as any); + + expect(store.getAllBookmarks()[0].order).toBeUndefined(); + }); +}); diff --git a/test/vscode-mock.ts b/test/vscode-mock.ts index e4489b5..f22a211 100644 --- a/test/vscode-mock.ts +++ b/test/vscode-mock.ts @@ -50,6 +50,26 @@ export class ThemeIcon { constructor(public id: string) {} } +export class DataTransferItem { + constructor(private readonly value: string) {} + + asString(): Promise { + return Promise.resolve(this.value); + } +} + +export class DataTransfer { + private readonly items = new Map(); + + set(mimeType: string, value: DataTransferItem) { + this.items.set(mimeType, value); + } + + get(mimeType: string): DataTransferItem | undefined { + return this.items.get(mimeType); + } +} + export const window = { activeTextEditor: undefined as { document: { uri: Uri } } | undefined, showInformationMessage: vi.fn(),