diff --git a/package.json b/package.json index 5a725ad5..1a7d8507 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,14 @@ "command": "vscode-containers.containers.select", "when": "never" }, + { + "command": "vscode-containers.containers.clearFilter", + "when": "never" + }, + { + "command": "vscode-containers.images.clearFilter", + "when": "never" + }, { "command": "vscode-containers.registries.reconnectRegistry", "when": "never" @@ -232,6 +240,16 @@ "when": "view == vscode-containers.views.containers", "group": "navigation@1" }, + { + "command": "vscode-containers.containers.filter", + "when": "view == vscode-containers.views.containers && !vscode-containers:containersFiltered", + "group": "navigation@2" + }, + { + "command": "vscode-containers.containers.clearFilter", + "when": "view == vscode-containers.views.containers && vscode-containers:containersFiltered", + "group": "navigation@2" + }, { "command": "vscode-containers.chooseContainerRuntime", "when": "view == vscode-containers.views.containers", @@ -272,6 +290,16 @@ "when": "view == vscode-containers.views.images", "group": "navigation@2" }, + { + "command": "vscode-containers.images.filter", + "when": "view == vscode-containers.views.images && !vscode-containers:imagesFiltered", + "group": "navigation@3" + }, + { + "command": "vscode-containers.images.clearFilter", + "when": "view == vscode-containers.views.images && vscode-containers:imagesFiltered", + "group": "navigation@3" + }, { "command": "vscode-containers.images.showDangling", "when": "view == vscode-containers.views.images && !vscode-containers:danglingShown", @@ -2538,6 +2566,18 @@ "title": "%vscode-containers.commands.containers.composeGroup.down%", "category": "%vscode-containers.commands.category.containers%" }, + { + "command": "vscode-containers.containers.filter", + "title": "%vscode-containers.commands.containers.filter%", + "category": "%vscode-containers.commands.category.containers%", + "icon": "$(search)" + }, + { + "command": "vscode-containers.containers.clearFilter", + "title": "%vscode-containers.commands.containers.clearFilter%", + "category": "%vscode-containers.commands.category.containers%", + "icon": "$(search-stop)" + }, { "command": "vscode-containers.debugging.initializeForDebugging", "title": "%vscode-containers.commands.debugging.initializeForDebugging%", @@ -2628,6 +2668,18 @@ "title": "%vscode-containers.commands.images.copyFullTag%", "category": "%vscode-containers.commands.category.images%" }, + { + "command": "vscode-containers.images.filter", + "title": "%vscode-containers.commands.images.filter%", + "category": "%vscode-containers.commands.category.images%", + "icon": "$(search)" + }, + { + "command": "vscode-containers.images.clearFilter", + "title": "%vscode-containers.commands.images.clearFilter%", + "category": "%vscode-containers.commands.category.images%", + "icon": "$(search-stop)" + }, { "command": "vscode-containers.networks.configureExplorer", "title": "%vscode-containers.commands.networks.configureExplorer%", diff --git a/package.nls.json b/package.nls.json index 75d60cb5..8fff368e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -219,6 +219,8 @@ "vscode-containers.commands.containers.attachShell": "Attach Shell", "vscode-containers.commands.containers.browse": "Open in Browser", "vscode-containers.commands.containers.configureExplorer": "Configure Explorer...", + "vscode-containers.commands.containers.filter": "Filter...", + "vscode-containers.commands.containers.clearFilter": "Clear Filter", "vscode-containers.commands.containers.downloadFile": "Download...", "vscode-containers.commands.containers.inspect": "Inspect", "vscode-containers.commands.containers.openFile": "Open", @@ -244,6 +246,8 @@ "vscode-containers.commands.chooseContainerRuntime": "Choose container runtime...", "vscode-containers.commands.images.build": "Build Image...", "vscode-containers.commands.images.configureExplorer": "Configure Explorer...", + "vscode-containers.commands.images.filter": "Filter...", + "vscode-containers.commands.images.clearFilter": "Clear Filter", "vscode-containers.commands.images.inspect": "Inspect", "vscode-containers.commands.images.showDangling": "Show dangling images", "vscode-containers.commands.images.hideDangling": "Hide dangling images", diff --git a/src/commands/filterTree.ts b/src/commands/filterTree.ts new file mode 100644 index 00000000..e5f9846f --- /dev/null +++ b/src/commands/filterTree.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IActionContext } from "@microsoft/vscode-azext-utils"; +import * as vscode from "vscode"; +import { ext } from "../extensionVariables"; +import { TreePrefix } from "../tree/TreePrefix"; + +interface TreeFilterState { + filterText: string; + isActive: boolean; +} + +const treeFilters: Map = new Map(); + +// Only support filtering for containers and images +const contextKeys: Partial> = { + containers: "vscode-containers:containersFiltered", + images: "vscode-containers:imagesFiltered", +}; + +export function getTreeFilter(treePrefix: TreePrefix): TreeFilterState { + return treeFilters.get(treePrefix) || { filterText: "", isActive: false }; +} + +export function setTreeFilter( + treePrefix: TreePrefix, + filterText: string +): void { + treeFilters.set(treePrefix, { + filterText: filterText.toLowerCase(), + isActive: filterText.length > 0, + }); + setFilterContextValue(treePrefix, filterText.length > 0); +} + +export function clearTreeFilter(treePrefix: TreePrefix): void { + treeFilters.set(treePrefix, { filterText: "", isActive: false }); + setFilterContextValue(treePrefix, false); +} + +function setFilterContextValue(treePrefix: TreePrefix, value: boolean): void { + const contextKey = contextKeys[treePrefix]; + if (contextKey) { + void vscode.commands.executeCommand("setContext", contextKey, value); + } +} + +export function setInitialFilterContextValues(): void { + for (const treePrefix of Object.keys(contextKeys) as TreePrefix[]) { + const filter = getTreeFilter(treePrefix); + setFilterContextValue(treePrefix, filter.isActive); + } +} + +/** + * @param filterText The filter pattern (already lowercase) + * @param searchableText The text to search in (already lowercase) + */ +function fuzzyMatch(filterText: string, searchableText: string): boolean { + let filterIndex = 0; + let searchIndex = 0; + + while ( + filterIndex < filterText.length && + searchIndex < searchableText.length + ) { + if (filterText[filterIndex] === searchableText[searchIndex]) { + filterIndex++; + } + searchIndex++; + } + + return filterIndex === filterText.length; +} + +export function shouldShowItem( + treePrefix: TreePrefix, + searchableText: string +): boolean { + const filter = getTreeFilter(treePrefix); + if (!filter.isActive) { + return true; + } + + const lowerSearchableText = searchableText.toLowerCase(); + + if (lowerSearchableText.includes(filter.filterText)) { + return true; + } + + return fuzzyMatch(filter.filterText, lowerSearchableText); +} + +/** + * Command to filter a tree view + */ +export async function filterTreeView( + context: IActionContext, + treePrefix: TreePrefix +): Promise { + const currentFilter = getTreeFilter(treePrefix); + + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = `Filter ${treePrefix}... (Press Enter to apply, Esc to cancel)`; + quickPick.value = currentFilter.filterText; + quickPick.title = `Filter ${capitalize(treePrefix)}`; + + if (currentFilter.isActive) { + quickPick.items = [ + { + label: "$(clear-all) Clear Filter", + description: `Currently filtering by: "${currentFilter.filterText}"`, + }, + ]; + } + + quickPick.onDidAccept(() => { + const value = quickPick.value.trim(); + const selectedItem = quickPick.selectedItems[0]; + + // Check if "Clear Filter" was selected + if (selectedItem?.label === "$(clear-all) Clear Filter") { + clearTreeFilter(treePrefix); + context.telemetry.properties.action = "clearFilter"; + } else if (value) { + setTreeFilter(treePrefix, value); + context.telemetry.properties.action = "applyFilter"; + context.telemetry.properties.filterLength = value.length.toString(); + } else { + clearTreeFilter(treePrefix); + context.telemetry.properties.action = "clearFilter"; + } + + quickPick.hide(); + void refreshTreeView(treePrefix); + }); + + quickPick.onDidHide(() => { + quickPick.dispose(); + }); + + quickPick.show(); +} + +/** + * Update the tree view title to show filter status + */ +export function updateTreeViewTitle(treePrefix: TreePrefix): void { + const filter = getTreeFilter(treePrefix); + const treeView = getTreeViewForPrefix(treePrefix); + + if (!treeView) { + return; + } + + if (filter.isActive) { + treeView.description = `Filtered: "${filter.filterText}"`; + } else { + treeView.description = undefined; + } +} + +function getTreeViewForPrefix( + treePrefix: TreePrefix +): vscode.TreeView | undefined { + switch (treePrefix) { + case "containers": + return ext.containersTreeView; + case "images": + return ext.imagesTreeView; + default: + return undefined; + } +} + +async function refreshTreeView(treePrefix: TreePrefix): Promise { + updateTreeViewTitle(treePrefix); + + // Get the root and refresh it + const root = getTreeRootForPrefix(treePrefix); + if (root) { + await root.refresh(undefined); + } +} + +function getTreeRootForPrefix( + treePrefix: TreePrefix +): { refresh(context: IActionContext): Promise } | undefined { + switch (treePrefix) { + case "containers": + return ext.containersRoot; + case "images": + return ext.imagesRoot; + default: + return undefined; + } +} + +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export async function filterContainersTree( + context: IActionContext +): Promise { + await filterTreeView(context, "containers"); +} + +export async function filterImagesTree(context: IActionContext): Promise { + await filterTreeView(context, "images"); +} + +export async function clearContainersFilter( + context: IActionContext +): Promise { + clearTreeFilter("containers"); + context.telemetry.properties.action = "clearFilter"; + void refreshTreeView("containers"); +} + +export async function clearImagesFilter( + context: IActionContext +): Promise { + clearTreeFilter("images"); + context.telemetry.properties.action = "clearFilter"; + void refreshTreeView("images"); +} diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 045d1a76..878b3f0d 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -31,6 +31,7 @@ import { configureDockerContextsExplorer, dockerContextsHelp } from "./context/D import { inspectDockerContext } from "./context/inspectDockerContext"; import { removeDockerContext } from "./context/removeDockerContext"; import { useDockerContext } from "./context/useDockerContext"; +import { clearContainersFilter, clearImagesFilter, filterContainersTree, filterImagesTree, setInitialFilterContextValues } from "./filterTree"; import { help, reportIssue } from "./help"; import { buildImage } from "./images/buildImage"; import { configureImagesExplorer } from "./images/configureImagesExplorer"; @@ -129,6 +130,8 @@ export function registerCommands(): void { registerCommand('vscode-containers.containers.downloadFile', downloadContainerFile); registerCommand('vscode-containers.containers.inspect', inspectContainer); registerCommand('vscode-containers.containers.configureExplorer', configureContainersExplorer); + registerCommand('vscode-containers.containers.filter', filterContainersTree); + registerCommand('vscode-containers.containers.clearFilter', clearContainersFilter); registerCommand('vscode-containers.containers.openFile', openContainerFile); registerCommand('vscode-containers.containers.prune', pruneContainers); registerCommand('vscode-containers.containers.remove', removeContainer); @@ -147,11 +150,14 @@ export function registerCommands(): void { registerWorkspaceCommand('vscode-containers.images.build', buildImage); registerCommand('vscode-containers.images.configureExplorer', configureImagesExplorer); + registerCommand('vscode-containers.images.filter', filterImagesTree); + registerCommand('vscode-containers.images.clearFilter', clearImagesFilter); registerCommand('vscode-containers.images.inspect', inspectImage); registerCommand('vscode-containers.images.prune', pruneImages); registerCommand('vscode-containers.images.showDangling', showDanglingImages); registerCommand('vscode-containers.images.hideDangling', hideDanglingImages); setInitialDanglingContextValue(); + setInitialFilterContextValues(); registerWorkspaceCommand('vscode-containers.images.pull', pullImage); registerWorkspaceCommand('vscode-containers.images.push', pushImage); registerCommand('vscode-containers.images.remove', removeImage); diff --git a/src/tree/LocalRootTreeItemBase.ts b/src/tree/LocalRootTreeItemBase.ts index 489eaa97..9ee6cdbb 100644 --- a/src/tree/LocalRootTreeItemBase.ts +++ b/src/tree/LocalRootTreeItemBase.ts @@ -6,6 +6,7 @@ import { AzExtParentTreeItem, AzExtTreeItem, AzureWizard, GenericTreeItem, IActionContext, parseError } from "@microsoft/vscode-azext-utils"; import { ListContainersItem, ListContextItem, ListImagesItem, ListNetworkItem, ListVolumeItem, isCommandNotSupportedError } from "@microsoft/vscode-container-client"; import { ConfigurationTarget, ThemeColor, ThemeIcon, WorkspaceConfiguration, l10n, workspace } from "vscode"; +import { getTreeFilter, shouldShowItem } from "../commands/filterTree"; import { configPrefix } from "../constants"; import { ext } from "../extensionVariables"; import { runtimeInstallStatusProvider } from "../utils/RuntimeInstallStatusProvider"; @@ -91,6 +92,14 @@ export abstract class LocalRootTreeItemBase this.matchesFilter(item)); + context.telemetry.properties.filtered = 'true'; + context.telemetry.measurements.filteredItemCount = this._currentItems.length; + } + this.failedToConnect = false; this._currentDockerStatus = 'Running'; } catch (error) { @@ -317,6 +326,32 @@ export abstract class LocalRootTreeItemBase