From e5ca75e6f3893df8cbb5a86418dd8db327004cf2 Mon Sep 17 00:00:00 2001 From: Taha Basri Date: Sun, 16 Mar 2025 23:07:59 +0000 Subject: [PATCH 1/2] Draft of folders coloring --- src/extension.ts | 7 ++++-- src/provider/decorationProvider.ts | 35 ++++++++++++++++++++++++++++++ src/provider/snippetsProvider.ts | 6 ++++- 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/provider/decorationProvider.ts diff --git a/src/extension.ts b/src/extension.ts index c0651c5..ef1905b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,6 +13,7 @@ import { StringUtility } from './utility/stringUtility'; import { Labels } from './config/labels'; import { FileDataAccess } from './data/fileDataAccess'; import { LoggingUtility } from './utility/loggingUtility'; +import { DecorationProvider } from './provider/decorationProvider'; /** * Activate extension by initializing views for snippets and feature commands. @@ -61,7 +62,9 @@ export function activate(context: vscode.ExtensionContext) { // initialize global snippets const dataAccess = new MementoDataAccess(context.globalState); const snippetService = new SnippetService(dataAccess); - const snippetsProvider = new SnippetsProvider(snippetService, allLanguages); + const decorationProvider = new DecorationProvider(); + context.subscriptions.push(decorationProvider); + const snippetsProvider = new SnippetsProvider(snippetService, allLanguages, decorationProvider); let cipDisposable: { dispose(): any } = { dispose: function () { } @@ -254,7 +257,7 @@ export function activate(context: vscode.ExtensionContext) { if (!wsSnippetsExplorer) { const wsDataAccess = new FileDataAccess(snippetsPath); wsSnippetService = new SnippetService(wsDataAccess); - wsSnippetsProvider = new SnippetsProvider(wsSnippetService, allLanguages); + wsSnippetsProvider = new SnippetsProvider(wsSnippetService, allLanguages, decorationProvider); wsSnippetsExplorer = vscode.window.createTreeView('wsSnippetsExplorer', { treeDataProvider: wsSnippetsProvider, diff --git a/src/provider/decorationProvider.ts b/src/provider/decorationProvider.ts new file mode 100644 index 0000000..cbfa969 --- /dev/null +++ b/src/provider/decorationProvider.ts @@ -0,0 +1,35 @@ +import * as vscode from 'vscode'; + +export class DecorationProvider implements vscode.FileDecorationProvider { + private _disposables: vscode.Disposable[] = []; + + private _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter(); + onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; + + constructor() { + this._disposables.push(vscode.window.registerFileDecorationProvider(this)); + } + + async decorateSnippet(resourceUri: vscode.Uri): Promise { + // if (snippet.contextValue?.startsWith('snippet')) { + this._onDidChangeFileDecorations.fire(resourceUri); + // } + } + + provideFileDecoration(uri: vscode.Uri, _token: vscode.CancellationToken): vscode.ProviderResult { + if (!uri.scheme.startsWith('snippets')) { + return; + } + return { + propagate: false, + // badge: "⇐", + color: new vscode.ThemeColor("charts.red"), + tooltip: 'unread notification' + }; + } + + + dispose() { + this._disposables.forEach(dispose => dispose.dispose()); + } +} \ No newline at end of file diff --git a/src/provider/snippetsProvider.ts b/src/provider/snippetsProvider.ts index 2b723dc..55cfa41 100644 --- a/src/provider/snippetsProvider.ts +++ b/src/provider/snippetsProvider.ts @@ -4,9 +4,10 @@ import { CommandsConsts } from '../config/commands'; import { SnippetService } from '../service/snippetService'; import { Labels } from '../config/labels'; import { LoggingUtility } from '../utility/loggingUtility'; +import { DecorationProvider } from './decorationProvider'; export class SnippetsProvider implements vscode.TreeDataProvider, vscode.TreeDragAndDropController { - constructor(private _snippetService: SnippetService, private _languagesConfig: any[]) { } + constructor(private _snippetService: SnippetService, private _languagesConfig: any[], private _decorationProvider: DecorationProvider) { } dropMimeTypes: readonly string[] = ['application/vnd.code.tree.snippetsProvider']; dragMimeTypes: readonly string[] = ['text/uri-list']; @@ -194,6 +195,9 @@ export class SnippetsProvider implements vscode.TreeDataProvider, vscod } else { treeItem.iconPath = vscode.ThemeIcon.Folder; } + treeItem.resourceUri = vscode.Uri.parse(`snippets:${snippet.id}`); + // decorate item + this._decorationProvider.decorateSnippet(treeItem.resourceUri); } else { treeItem.tooltip = snippet.description ? `(${snippet.description})\n${snippet.value}` : `${snippet.value}`; treeItem.contextValue = 'snippet'; From afea0db70780945913ede801e6d7e05cf7e4564e Mon Sep 17 00:00:00 2001 From: Taha Basri Date: Sun, 28 Jun 2026 17:52:57 +0400 Subject: [PATCH 2/2] [#99] Add folder coloring with optional cascade Let folders be assigned a color from the Edit Folder screen. The color tints both the item icon (via a directly-rendered codicon - file-directory /symbol-file - so the glyph is preserved) and the label (via a FileDecorationProvider reading the color from the resourceUri query). By default a folder's color cascades to nested snippets and subfolders; the new snippets.disableFolderColorCascade setting limits it to the folder and the items directly inside it. Edit Folder is redesigned into the same two-panel layout as Edit Snippet. --- README.md | 4 ++ package.json | 5 +++ src/extension.ts | 11 ++++- src/interface/snippet.ts | 1 + src/provider/decorationProvider.ts | 44 ++++++++++++-------- src/provider/snippetsProvider.ts | 66 +++++++++++++++++++++++++----- src/views/editSnippetFolder.ts | 2 + views/css/vscode-custom.css | 5 +++ views/editSnippet.html | 2 +- views/editSnippetFolder.html | 42 ++++++++++++++----- views/js/editSnippetFolder.js | 10 ++++- 11 files changed, 151 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index eaf105c..78ba9ae 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ alt="Action Buttons"> Set Folder Icon +### Color your folders + +> Give a folder a color from the **Edit Folder** screen to make it stand out in the tree. By default the color cascades to the snippets and subfolders nested inside it. Set `snippets.disableFolderColorCascade` to limit the color to the folder and the items directly inside it instead. + ### Add a description to your Snippet > Descriptions show when hovering on top of a Snippet and in IntelliSense. diff --git a/package.json b/package.json index 334d710..881e274 100644 --- a/package.json +++ b/package.json @@ -839,6 +839,11 @@ "type": "boolean", "default": false, "markdownDescription": "Open folders collapsed. Expanding a folder then reveals only its immediate children, one level at a time, instead of expanding the whole subtree. This change requires a window restart." + }, + "snippets.disableFolderColorCascade": { + "type": "boolean", + "default": false, + "markdownDescription": "By default, a folder's color cascades to all snippets and subfolders nested inside it. Enable this to limit the color to the folder and the items directly inside it (deeper descendants keep their default color)." } } } diff --git a/src/extension.ts b/src/extension.ts index 550d448..4d00f4e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,7 @@ export function activate(context: vscode.ExtensionContext) { const useWorkspaceFolderKey = "useWorkspaceFolder"; const openButtonKey = "openButton"; const collapseFoldersKey = "collapseFolders"; + const disableFolderColorCascadeKey = "disableFolderColorCascade"; const workspaceFileName = ".vscode/snippets.json"; let workspaceSnippetsAvailable = false; let wsSnippetService: SnippetService; @@ -64,9 +65,10 @@ export function activate(context: vscode.ExtensionContext) { // initialize global snippets const dataAccess = new MementoDataAccess(context.globalState); const snippetService = new SnippetService(dataAccess); + // tints snippet/folder labels (icons are tinted via ThemeIcon in the provider) const decorationProvider = new DecorationProvider(); context.subscriptions.push(decorationProvider); - const snippetsProvider = new SnippetsProvider(snippetService, allLanguages, decorationProvider); + const snippetsProvider = new SnippetsProvider(snippetService, allLanguages); let cipDisposable: { dispose(): any } = { dispose: function () { } @@ -189,6 +191,9 @@ export function activate(context: vscode.ExtensionContext) { if (event.affectsConfiguration(`${snippetsConfigKey}.${collapseFoldersKey}`)) { refreshUI(); } + if (event.affectsConfiguration(`${snippetsConfigKey}.${disableFolderColorCascadeKey}`)) { + refreshUI(); + } }); let snippetsExplorer = vscode.window.createTreeView('snippetsExplorer', { @@ -211,6 +216,8 @@ export function activate(context: vscode.ExtensionContext) { LoggingUtility.getInstance().debug('Refreshing UI'); // dispose CIP snippets cipDisposable?.dispose(); + // invalidate cached label decorations so colors (incl. cascade) re-resolve + decorationProvider.refresh(); snippetsProvider.refresh(); // re-check if .vscode/snippets.json is always available (use case when deleting file after enabling workspace in settings) requestWSConfigSetup(false); @@ -275,7 +282,7 @@ export function activate(context: vscode.ExtensionContext) { if (!wsSnippetsExplorer) { const wsDataAccess = new FileDataAccess(snippetsPath); wsSnippetService = new SnippetService(wsDataAccess); - wsSnippetsProvider = new SnippetsProvider(wsSnippetService, allLanguages, decorationProvider); + wsSnippetsProvider = new SnippetsProvider(wsSnippetService, allLanguages); wsSnippetsExplorer = vscode.window.createTreeView('wsSnippetsExplorer', { treeDataProvider: wsSnippetsProvider, diff --git a/src/interface/snippet.ts b/src/interface/snippet.ts index 026aba8..ef11dd4 100644 --- a/src/interface/snippet.ts +++ b/src/interface/snippet.ts @@ -13,6 +13,7 @@ export class Snippet { prefix?: string; language?: string; icon?: string; + color?: string; completionDescription?: string; constructor( diff --git a/src/provider/decorationProvider.ts b/src/provider/decorationProvider.ts index cbfa969..21a9b17 100644 --- a/src/provider/decorationProvider.ts +++ b/src/provider/decorationProvider.ts @@ -1,35 +1,47 @@ import * as vscode from 'vscode'; +/** + * Tints snippet/folder labels in the tree view. + * + * VS Code's only hook for coloring a tree item's label is `FileDecorationProvider`. + * The chosen color rides in the item's `resourceUri` query (`...?color=`), + * so this provider stays stateless: it simply reads the color back from the uri. + * + * The icon itself is tinted separately via the item's `ThemeIcon` color (see + * SnippetsProvider) so that the original icon glyph is always preserved - a resourceUri + * alone would override icon resolution and blank out folder icons. + * + * VS Code caches decorations per resourceUri, so when colors change (e.g. toggling the + * cascade setting) `refresh()` must be called to invalidate the cache and re-query. + */ export class DecorationProvider implements vscode.FileDecorationProvider { private _disposables: vscode.Disposable[] = []; - private _onDidChangeFileDecorations: vscode.EventEmitter = new vscode.EventEmitter(); - onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; + private _onDidChangeFileDecorations = new vscode.EventEmitter(); + readonly onDidChangeFileDecorations: vscode.Event = this._onDidChangeFileDecorations.event; constructor() { this._disposables.push(vscode.window.registerFileDecorationProvider(this)); } - async decorateSnippet(resourceUri: vscode.Uri): Promise { - // if (snippet.contextValue?.startsWith('snippet')) { - this._onDidChangeFileDecorations.fire(resourceUri); - // } - } + // invalidate all decorations so VS Code re-queries them (colors may have changed) + refresh(): void { + this._onDidChangeFileDecorations.fire(undefined); + } provideFileDecoration(uri: vscode.Uri, _token: vscode.CancellationToken): vscode.ProviderResult { - if (!uri.scheme.startsWith('snippets')) { + const color = new URLSearchParams(uri.query).get('color'); + if (!color) { return; - } - return { - propagate: false, - // badge: "⇐", - color: new vscode.ThemeColor("charts.red"), - tooltip: 'unread notification' + } + return { + color: new vscode.ThemeColor(color), + propagate: false }; } - dispose() { + this._onDidChangeFileDecorations.dispose(); this._disposables.forEach(dispose => dispose.dispose()); } -} \ No newline at end of file +} diff --git a/src/provider/snippetsProvider.ts b/src/provider/snippetsProvider.ts index a482ed2..6c5fdb8 100644 --- a/src/provider/snippetsProvider.ts +++ b/src/provider/snippetsProvider.ts @@ -4,10 +4,9 @@ import { CommandsConsts } from '../config/commands'; import { SnippetService } from '../service/snippetService'; import { Labels } from '../config/labels'; import { LoggingUtility } from '../utility/loggingUtility'; -import { DecorationProvider } from './decorationProvider'; export class SnippetsProvider implements vscode.TreeDataProvider, vscode.TreeDragAndDropController { - constructor(private _snippetService: SnippetService, private _languagesConfig: any[], private _decorationProvider: DecorationProvider) { } + constructor(private _snippetService: SnippetService, private _languagesConfig: any[]) { } dropMimeTypes: readonly string[] = ['application/vnd.code.tree.snippetsProvider']; dragMimeTypes: readonly string[] = ['text/uri-list']; @@ -179,6 +178,32 @@ export class SnippetsProvider implements vscode.TreeDataProvider, vscod this.sync(); } + // resolve the color an item should display: its own color, otherwise the nearest + // colored ancestor folder. When the cascade is disabled the search stops at the + // direct parent, so a folder's color still reaches the items immediately inside it + // but not deeper descendants. + private getEffectiveColor(snippet: Snippet): string | undefined { + if (snippet.color) { + return snippet.color; + } + const cascadeDisabled = vscode.workspace.getConfiguration('snippets').get('disableFolderColorCascade'); + let current = this._snippetService.getParent(snippet.parentId); + while (current) { + if (current.color) { + return current.color; + } + // direct parent had no color; with the cascade off we don't look further up + if (cascadeDisabled) { + return undefined; + } + if (current.parentId === undefined || current.parentId < 0) { + break; + } + current = this._snippetService.getParent(current.parentId); + } + return undefined; + } + private snippetToTreeItem(snippet: Snippet): vscode.TreeItem { // when 'collapseFolders' is enabled, folders open collapsed so that // expanding one only reveals its immediate children (one level at a time) @@ -191,26 +216,45 @@ export class SnippetsProvider implements vscode.TreeDataProvider, vscod ? folderCollapsibleState : vscode.TreeItemCollapsibleState.None ); + // effective color is the folder's own color, or the nearest colored ancestor's + // (so a folder's color cascades down to everything inside it). When set, the + // color tints both the icon and the label: + // - icon: a directly-rendered codicon (file-directory == folder glyph, symbol-file + // == file glyph) tinted via ThemeIcon. The 'file'/'folder' ThemeIcon ids can't + // be used here because VS Code derives them from the file-icon theme using the + // resourceUri, which blanks the icon for our synthetic uris. + // - label: the color rides in the resourceUri query read by DecorationProvider. + // Uncolored items keep the themed ThemeIcon.Folder/File exactly as before. + const color = this.getEffectiveColor(snippet); + const themeColor = color ? new vscode.ThemeColor(color) : undefined; + const colorUri = color + ? vscode.Uri.from({ scheme: 'snippets', path: `/${snippet.id}`, query: `color=${color}` }) + : undefined; // dynamic context value depending on item type (snippet or snippet folder) // context value is used in view/item/context in 'when' condition if (snippet.folder && snippet.folder === true) { treeItem.contextValue = 'snippetFolder'; - if (snippet.icon) { - treeItem.iconPath = new vscode.ThemeIcon(snippet.icon); + if (color) { + treeItem.iconPath = new vscode.ThemeIcon(snippet.icon || 'file-directory', themeColor); + treeItem.resourceUri = colorUri; } else { - treeItem.iconPath = vscode.ThemeIcon.Folder; + treeItem.iconPath = snippet.icon ? new vscode.ThemeIcon(snippet.icon) : vscode.ThemeIcon.Folder; } - treeItem.resourceUri = vscode.Uri.parse(`snippets:${snippet.id}`); - // decorate item - this._decorationProvider.decorateSnippet(treeItem.resourceUri); } else { treeItem.tooltip = snippet.description ? `(${snippet.description})\n${snippet.value}` : `${snippet.value}`; treeItem.contextValue = 'snippet'; - treeItem.iconPath = vscode.ThemeIcon.File; - treeItem.description = snippet.prefix; if (snippet.language) { - treeItem.resourceUri = vscode.Uri.parse(`_${snippet.language}`); + // keep the themed language icon (resolved from the resourceUri path); a + // themed icon can't be tinted, so only the label is colored via the query + treeItem.iconPath = vscode.ThemeIcon.File; + treeItem.resourceUri = vscode.Uri.parse(`_${snippet.language}${color ? `?color=${color}` : ''}`); + } else if (color) { + treeItem.iconPath = new vscode.ThemeIcon('symbol-file', themeColor); + treeItem.resourceUri = colorUri; + } else { + treeItem.iconPath = vscode.ThemeIcon.File; } + treeItem.description = snippet.prefix; // conditional in configuration treeItem.command = { command: CommandsConsts.commonOpenSnippet, diff --git a/src/views/editSnippetFolder.ts b/src/views/editSnippetFolder.ts index 9dd8310..7ac5e7a 100644 --- a/src/views/editSnippetFolder.ts +++ b/src/views/editSnippetFolder.ts @@ -21,11 +21,13 @@ export class EditSnippetFolder extends EditView { case 'edit-folder': const label = message.data.label; const icon = message.data.icon; + const color = message.data.color; // call provider only if there is data change if (label) { this._snippet.label = label; } this._snippet.icon = icon; + this._snippet.color = color || undefined; this._snippetsProvider.editSnippetFolder(this._snippet); this._panel.dispose(); return; diff --git a/views/css/vscode-custom.css b/views/css/vscode-custom.css index 6750055..d929e3a 100644 --- a/views/css/vscode-custom.css +++ b/views/css/vscode-custom.css @@ -76,6 +76,7 @@ button.secondary:hover { } input:not([type="checkbox"]), +select, textarea { display: block; width: 100%; @@ -98,6 +99,10 @@ textarea::placeholder { margin: 0; } +.upper-p { + padding-top: 10px; +} + div { margin: 15px auto; } diff --git a/views/editSnippet.html b/views/editSnippet.html index acd492b..33f40c4 100644 --- a/views/editSnippet.html +++ b/views/editSnippet.html @@ -15,7 +15,7 @@
-
+

Optional description of the snippet displayed by IntelliSense.

diff --git a/views/editSnippetFolder.html b/views/editSnippetFolder.html index 18ddd8a..2b256e7 100644 --- a/views/editSnippetFolder.html +++ b/views/editSnippetFolder.html @@ -13,19 +13,41 @@ -
-
-
- -