diff --git a/extension.ts b/extension.ts index e9e2344..8e15bca 100644 --- a/extension.ts +++ b/extension.ts @@ -31,8 +31,42 @@ export interface BookmarkFolder { createdAt: number; order?: number; parentId?: string | null; + /** A codicon id (e.g. `server`), shown instead of the default folder icon. */ + icon?: string; + /** A theme color id (e.g. `charts.red`) tinting the folder's icon. */ + color?: string; } +/** Codicons offered when customizing a folder's icon. */ +export const FOLDER_ICON_CHOICES: ReadonlyArray<{ id: string; label: string }> = [ + { id: 'folder', label: 'Folder' }, + { id: 'server', label: 'Server' }, + { id: 'database', label: 'Database' }, + { id: 'beaker', label: 'Beaker' }, + { id: 'bug', label: 'Bug' }, + { id: 'checklist', label: 'Checklist' }, + { id: 'eye', label: 'In Review' }, + { id: 'flame', label: 'Flame' }, + { id: 'gear', label: 'Gear' }, + { id: 'globe', label: 'Globe' }, + { id: 'heart', label: 'Heart' }, + { id: 'lock', label: 'Lock' }, + { id: 'rocket', label: 'Rocket' }, + { id: 'star-full', label: 'Star' }, + { id: 'tag', label: 'Tag' }, + { id: 'tools', label: 'Tools' }, +]; + +/** Theme colors offered when customizing a folder's icon color. */ +export const FOLDER_COLOR_CHOICES: ReadonlyArray<{ id: string; label: string }> = [ + { id: 'charts.red', label: 'Red' }, + { id: 'charts.orange', label: 'Orange' }, + { id: 'charts.yellow', label: 'Yellow' }, + { id: 'charts.green', label: 'Green' }, + { id: 'charts.blue', label: 'Blue' }, + { id: 'charts.purple', label: 'Purple' }, +]; + /** Folders may nest up to this many levels deep (root-level folders are depth 1). */ export const MAX_FOLDER_DEPTH = 3; @@ -287,6 +321,10 @@ export function activate(context: vscode.ExtensionContext): ExtensionApi { 'workspace-file-bookmarks.renameFolder', (item: FolderGroupItem) => renameFolder(store, item), ), + vscode.commands.registerCommand( + 'workspace-file-bookmarks.setFolderAppearance', + (item: FolderGroupItem) => setFolderAppearance(store, item), + ), vscode.commands.registerCommand( 'workspace-file-bookmarks.deleteFolder', (item: FolderGroupItem) => deleteFolder(store, item), @@ -476,6 +514,27 @@ export class BookmarkStore { this.setFolders(this.getAllFolders().map((f) => (f.id === id ? { ...f, name } : f))); } + /** Sets a folder's icon and/or color; `undefined` for either clears it back to the default. */ + setFolderAppearance(id: string, appearance: { icon?: string; color?: string }) { + this.setFolders( + this.getAllFolders().map((f) => { + if (f.id !== id) { + return f; + } + const updated: BookmarkFolder = { ...f }; + delete updated.icon; + delete updated.color; + if (appearance.icon) { + updated.icon = appearance.icon; + } + if (appearance.color) { + updated.color = appearance.color; + } + return updated; + }), + ); + } + moveFolderToParent(id: string, parentId: string | null) { this.setFolders(this.getAllFolders().map((f) => (f.id === id ? { ...f, parentId } : f))); } @@ -554,7 +613,10 @@ export class FolderGroupItem extends vscode.TreeItem { ) { super(folder.name, vscode.TreeItemCollapsibleState.Expanded); this.contextValue = 'bookmarkFolder'; - this.iconPath = new vscode.ThemeIcon('folder'); + this.iconPath = new vscode.ThemeIcon( + folder.icon ?? 'folder', + folder.color ? new vscode.ThemeColor(folder.color) : undefined, + ); this.description = childFolders.length > 0 ? `${bookmarks.length} • ${childFolders.length} subfolder${childFolders.length === 1 ? '' : 's'}` @@ -1005,6 +1067,38 @@ export async function renameFolder(store: BookmarkStore, item: FolderGroupItem) } } +const DEFAULT_APPEARANCE_PICK = '$(discard) Default'; + +/** Two-step quick-pick (icon, then color) that stores the chosen look on the folder. Cancelling either step leaves the folder unchanged. */ +export async function setFolderAppearance(store: BookmarkStore, item: FolderGroupItem) { + const iconItems = [ + { label: DEFAULT_APPEARANCE_PICK, iconId: undefined as string | undefined }, + ...FOLDER_ICON_CHOICES.map((c) => ({ label: `$(${c.id}) ${c.label}`, iconId: c.id })), + ]; + const pickedIcon = await vscode.window.showQuickPick(iconItems, { + placeHolder: `Icon for "${item.folder.name}"`, + }); + if (!pickedIcon) { + return; + } + + const colorItems = [ + { label: DEFAULT_APPEARANCE_PICK, colorId: undefined as string | undefined }, + ...FOLDER_COLOR_CHOICES.map((c) => ({ label: c.label, colorId: c.id })), + ]; + const pickedColor = await vscode.window.showQuickPick(colorItems, { + placeHolder: `Color for "${item.folder.name}"`, + }); + if (!pickedColor) { + return; + } + + store.setFolderAppearance(item.folder.id, { + icon: pickedIcon.iconId, + color: pickedColor.colorId, + }); +} + export async function renameBookmark(store: BookmarkStore, item: BookmarkTreeItem) { const label = await vscode.window.showInputBox({ prompt: 'Rename bookmark', diff --git a/package.json b/package.json index fa402bc..b8be975 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,11 @@ "title": "Rename Folder", "category": "Workspace File Bookmarks" }, + { + "command": "workspace-file-bookmarks.setFolderAppearance", + "title": "Set Folder Icon and Color...", + "category": "Workspace File Bookmarks" + }, { "command": "workspace-file-bookmarks.deleteFolder", "title": "Delete Folder", @@ -350,6 +355,11 @@ "command": "workspace-file-bookmarks.moveFolderToParent", "when": "view == workspace-file-bookmarks-view && viewItem == bookmarkFolder", "group": "1_modification" + }, + { + "command": "workspace-file-bookmarks.setFolderAppearance", + "when": "view == workspace-file-bookmarks-view && viewItem == bookmarkFolder", + "group": "1_modification" } ], "editor/title": [ diff --git a/test/activate.test.ts b/test/activate.test.ts index 142b970..473758a 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -15,8 +15,8 @@ describe('activate', () => { 'workspace-file-bookmarks-view', expect.objectContaining({ canSelectMany: true }), ); - expect(commands.registerCommand).toHaveBeenCalledTimes(29); - expect((context as any).subscriptions).toHaveLength(30); // tree view + 29 commands + expect(commands.registerCommand).toHaveBeenCalledTimes(30); + expect((context as any).subscriptions).toHaveLength(31); // tree view + 30 commands }); it('exercises every registered command handler at least once', async () => { @@ -61,6 +61,8 @@ describe('activate', () => { await handlers.get('workspace-file-bookmarks.createSubfolder')?.({ folder }); await handlers.get('workspace-file-bookmarks.moveFolderToParent')?.({ folder }); await handlers.get('workspace-file-bookmarks.renameFolder')?.({ folder }); + window.showQuickPick.mockResolvedValue(undefined); + await handlers.get('workspace-file-bookmarks.setFolderAppearance')?.({ folder }); await handlers.get('workspace-file-bookmarks.deleteFolder')?.({ folder, bookmarks: [], @@ -83,8 +85,8 @@ describe('activate', () => { await handlers.get('workspace-file-bookmarks.revealBookmarkInExplorer')?.({ bookmark }); await handlers.get('workspace-file-bookmarks.revealBookmarkInFileManager')?.({ bookmark }); - expect(commands.registerCommand).toHaveBeenCalledTimes(29); - expect((context as any).subscriptions).toHaveLength(30); // tree view + 29 commands + expect(commands.registerCommand).toHaveBeenCalledTimes(30); + expect((context as any).subscriptions).toHaveLength(31); // tree view + 30 commands }); }); diff --git a/test/bookmarkStore.test.ts b/test/bookmarkStore.test.ts index 4ea2bfc..6cf73ff 100644 --- a/test/bookmarkStore.test.ts +++ b/test/bookmarkStore.test.ts @@ -544,3 +544,45 @@ describe('bulk bookmark operations', () => { expect(changes).toBe(1); }); }); + +describe('setFolderAppearance', () => { + it('sets icon and color on only the targeted folder', () => { + const store = newStore(); + const a = store.createFolder('A'); + const b = store.createFolder('B'); + + store.setFolderAppearance(a.id, { icon: 'bug', color: 'charts.green' }); + + const folders = store.getAllFolders(); + expect(folders.find((f) => f.id === a.id)).toMatchObject({ + icon: 'bug', + color: 'charts.green', + }); + expect(folders.find((f) => f.id === b.id)).not.toHaveProperty('icon'); + }); + + it('clears icon and color when called with no values', () => { + const store = newStore(); + const a = store.createFolder('A'); + store.setFolderAppearance(a.id, { icon: 'bug', color: 'charts.green' }); + + store.setFolderAppearance(a.id, {}); + + expect(store.getAllFolders()[0]).toEqual( + expect.not.objectContaining({ icon: expect.anything(), color: expect.anything() }), + ); + expect(store.getAllFolders()[0]).not.toHaveProperty('icon'); + expect(store.getAllFolders()[0]).not.toHaveProperty('color'); + }); + + it('preserves icon and color through mergeImport', () => { + const store = newStore(); + + store.mergeImport( + [], + [{ id: 'x', name: 'Imported', createdAt: 0, icon: 'rocket', color: 'charts.blue' }], + ); + + expect(store.getAllFolders()[0]).toMatchObject({ icon: 'rocket', color: 'charts.blue' }); + }); +}); diff --git a/test/commands.test.ts b/test/commands.test.ts index 99065d4..0cdef40 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -28,6 +28,8 @@ import { resolveBookmarkTargets, revealBookmarkInExplorer, revealBookmarkInFileManager, + setFolderAppearance, + FolderGroupItem, type Bookmark, type BookmarkFolder, } from '../extension'; @@ -1133,3 +1135,55 @@ describe('copy path / reveal actions', () => { ); }); }); + +describe('setFolderAppearance', () => { + it('stores the chosen icon and color on the folder', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showQuickPick + .mockResolvedValueOnce({ label: '$(server) Server', iconId: 'server' }) + .mockResolvedValueOnce({ label: 'Red', colorId: 'charts.red' }); + + await setFolderAppearance(store, new FolderGroupItem(folder, [])); + + expect(store.getAllFolders()[0]).toMatchObject({ icon: 'server', color: 'charts.red' }); + }); + + it('clears the appearance when Default is chosen for both steps', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + store.setFolderAppearance(folder.id, { icon: 'server', color: 'charts.red' }); + window.showQuickPick + .mockResolvedValueOnce({ label: 'Default', iconId: undefined }) + .mockResolvedValueOnce({ label: 'Default', colorId: undefined }); + + await setFolderAppearance(store, new FolderGroupItem(store.getAllFolders()[0], [])); + + const stored = store.getAllFolders()[0]; + expect(stored).not.toHaveProperty('icon'); + expect(stored).not.toHaveProperty('color'); + }); + + it('leaves the folder untouched when the icon pick is cancelled', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showQuickPick.mockResolvedValueOnce(undefined); + + await setFolderAppearance(store, new FolderGroupItem(folder, [])); + + expect(window.showQuickPick).toHaveBeenCalledOnce(); + expect(store.getAllFolders()[0]).not.toHaveProperty('icon'); + }); + + it('leaves the folder untouched when the color pick is cancelled', async () => { + const store = newStore(); + const folder = store.createFolder('Backend'); + window.showQuickPick + .mockResolvedValueOnce({ label: '$(server) Server', iconId: 'server' }) + .mockResolvedValueOnce(undefined); + + await setFolderAppearance(store, new FolderGroupItem(folder, [])); + + expect(store.getAllFolders()[0]).not.toHaveProperty('icon'); + }); +}); diff --git a/test/e2e/extension.e2e.test.ts b/test/e2e/extension.e2e.test.ts index dcab61d..ec101ba 100644 --- a/test/e2e/extension.e2e.test.ts +++ b/test/e2e/extension.e2e.test.ts @@ -60,6 +60,7 @@ describe('Workspace File Bookmarks (e2e)', () => { 'workspace-file-bookmarks.createSubfolder', 'workspace-file-bookmarks.moveFolderToParent', 'workspace-file-bookmarks.renameFolder', + 'workspace-file-bookmarks.setFolderAppearance', 'workspace-file-bookmarks.deleteFolder', 'workspace-file-bookmarks.moveToFolder', 'workspace-file-bookmarks.openAllInFolder', @@ -401,4 +402,33 @@ describe('Workspace File Bookmarks (e2e)', () => { await vscode.commands.executeCommand('workspace-file-bookmarks.revealBookmarkInExplorer', item); }); + + it('applies a custom icon and color to a folder via setFolderAppearance and the real tree', async () => { + const { store, provider } = await getApi(); + const folder = store.createFolder('Backend'); + const [node] = provider.getChildren() as FolderGroupItem[]; + + const originalShowQuickPick = vscode.window.showQuickPick; + try { + let call = 0; + (vscode.window as any).showQuickPick = async (items: any[]) => + call++ === 0 + ? items.find((i) => i.iconId === 'server') + : items.find((i) => i.colorId === 'charts.red'); + + await vscode.commands.executeCommand('workspace-file-bookmarks.setFolderAppearance', node); + } finally { + vscode.window.showQuickPick = originalShowQuickPick; + } + + assert.deepEqual( + { icon: store.getAllFolders()[0].icon, color: store.getAllFolders()[0].color }, + { icon: 'server', color: 'charts.red' }, + ); + const [updated] = provider.getChildren() as FolderGroupItem[]; + const icon = updated.iconPath as vscode.ThemeIcon; + assert.equal(icon.id, 'server'); + assert.equal((icon.color as vscode.ThemeColor).id, 'charts.red'); + assert.equal(updated.folder.id, folder.id); + }); }); diff --git a/test/treeProvider.test.ts b/test/treeProvider.test.ts index 7a2a5f7..cd18e9a 100644 --- a/test/treeProvider.test.ts +++ b/test/treeProvider.test.ts @@ -333,6 +333,21 @@ describe('BookmarksTreeProvider', () => { } }); + it('uses the default folder icon when no icon or color is set', () => { + const node = new FolderGroupItem({ id: 'f', name: 'Backend', createdAt: 0 }, []); + + expect(node.iconPath).toMatchObject({ id: 'folder', color: undefined }); + }); + + it('applies a custom icon and theme color to the folder', () => { + const node = new FolderGroupItem( + { id: 'f', name: 'Backend', createdAt: 0, icon: 'server', color: 'charts.red' }, + [], + ); + + expect(node.iconPath).toMatchObject({ id: 'server', color: { id: 'charts.red' } }); + }); + it('uses the plural "subfolders" when there is more than one', () => { const parent = { id: 'p', name: 'Backend', createdAt: 0, parentId: null }; const node = new FolderGroupItem(