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
96 changes: 95 additions & 1 deletion extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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'}`
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
10 changes: 6 additions & 4 deletions test/activate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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: [],
Expand All @@ -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
});
});

Expand Down
42 changes: 42 additions & 0 deletions test/bookmarkStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
54 changes: 54 additions & 0 deletions test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
resolveBookmarkTargets,
revealBookmarkInExplorer,
revealBookmarkInFileManager,
setFolderAppearance,
FolderGroupItem,
type Bookmark,
type BookmarkFolder,
} from '../extension';
Expand Down Expand Up @@ -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');
});
});
30 changes: 30 additions & 0 deletions test/e2e/extension.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});
});
15 changes: 15 additions & 0 deletions test/treeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading