Skip to content

Commit 5cb9d6c

Browse files
Feature/tree view search (#253)
* Add tree filtering logic * Add filter commands for containers, images, networks, registries, and volumes * Implement logic to match filters * Add filter commands to package.json / package.nls.json * Fix codicon syntax error * Remove filter support for networks,volumes,contexts and registries * Add $(search-stop) icon when active filter * Implement fuzzy search fallback * Delete FILTER_FEATURE_GUIDE.md * Delete FILTER_FEATURE_IMPLEMENTATION.md * Revert package.json to upstream state * Revert package.nls.json * Add package.json commands back * Revert registerCommands to original state * Apply suggestions from code review Co-authored-by: Brandon Waterloo [MSFT] <36966225+bwateratmsft@users.noreply.github.com> * Use vscode.l10n.t() * Format to use 4 spaces per indent * Fix: Update treeFilters declaration for lint rules --------- Co-authored-by: Brandon Waterloo [MSFT] <36966225+bwateratmsft@users.noreply.github.com>
1 parent c8ab7ec commit 5cb9d6c

File tree

5 files changed

+334
-0
lines changed

5 files changed

+334
-0
lines changed

package.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@
6363
"command": "vscode-containers.containers.select",
6464
"when": "never"
6565
},
66+
{
67+
"command": "vscode-containers.containers.clearFilter",
68+
"when": "never"
69+
},
70+
{
71+
"command": "vscode-containers.images.clearFilter",
72+
"when": "never"
73+
},
6674
{
6775
"command": "vscode-containers.registries.reconnectRegistry",
6876
"when": "never"
@@ -236,6 +244,16 @@
236244
"when": "view == vscode-containers.views.containers",
237245
"group": "navigation@1"
238246
},
247+
{
248+
"command": "vscode-containers.containers.filter",
249+
"when": "view == vscode-containers.views.containers && !vscode-containers:containersFiltered",
250+
"group": "navigation@2"
251+
},
252+
{
253+
"command": "vscode-containers.containers.clearFilter",
254+
"when": "view == vscode-containers.views.containers && vscode-containers:containersFiltered",
255+
"group": "navigation@2"
256+
},
239257
{
240258
"command": "vscode-containers.chooseContainerRuntime",
241259
"when": "view == vscode-containers.views.containers",
@@ -276,6 +294,16 @@
276294
"when": "view == vscode-containers.views.images",
277295
"group": "navigation@2"
278296
},
297+
{
298+
"command": "vscode-containers.images.filter",
299+
"when": "view == vscode-containers.views.images && !vscode-containers:imagesFiltered",
300+
"group": "navigation@3"
301+
},
302+
{
303+
"command": "vscode-containers.images.clearFilter",
304+
"when": "view == vscode-containers.views.images && vscode-containers:imagesFiltered",
305+
"group": "navigation@3"
306+
},
279307
{
280308
"command": "vscode-containers.images.showDangling",
281309
"when": "view == vscode-containers.views.images && !vscode-containers:danglingShown",
@@ -2552,6 +2580,18 @@
25522580
"title": "%vscode-containers.commands.containers.composeGroup.down%",
25532581
"category": "%vscode-containers.commands.category.containers%"
25542582
},
2583+
{
2584+
"command": "vscode-containers.containers.filter",
2585+
"title": "%vscode-containers.commands.containers.filter%",
2586+
"category": "%vscode-containers.commands.category.containers%",
2587+
"icon": "$(search)"
2588+
},
2589+
{
2590+
"command": "vscode-containers.containers.clearFilter",
2591+
"title": "%vscode-containers.commands.containers.clearFilter%",
2592+
"category": "%vscode-containers.commands.category.containers%",
2593+
"icon": "$(search-stop)"
2594+
},
25552595
{
25562596
"command": "vscode-containers.debugging.initializeForDebugging",
25572597
"title": "%vscode-containers.commands.debugging.initializeForDebugging%",
@@ -2642,6 +2682,18 @@
26422682
"title": "%vscode-containers.commands.images.copyFullTag%",
26432683
"category": "%vscode-containers.commands.category.images%"
26442684
},
2685+
{
2686+
"command": "vscode-containers.images.filter",
2687+
"title": "%vscode-containers.commands.images.filter%",
2688+
"category": "%vscode-containers.commands.category.images%",
2689+
"icon": "$(search)"
2690+
},
2691+
{
2692+
"command": "vscode-containers.images.clearFilter",
2693+
"title": "%vscode-containers.commands.images.clearFilter%",
2694+
"category": "%vscode-containers.commands.category.images%",
2695+
"icon": "$(search-stop)"
2696+
},
26452697
{
26462698
"command": "vscode-containers.networks.configureExplorer",
26472699
"title": "%vscode-containers.commands.networks.configureExplorer%",

package.nls.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@
219219
"vscode-containers.commands.containers.attachShell": "Attach Shell",
220220
"vscode-containers.commands.containers.browse": "Open in Browser",
221221
"vscode-containers.commands.containers.configureExplorer": "Configure Explorer...",
222+
"vscode-containers.commands.containers.filter": "Filter...",
223+
"vscode-containers.commands.containers.clearFilter": "Clear Filter",
222224
"vscode-containers.commands.containers.downloadFile": "Download...",
223225
"vscode-containers.commands.containers.inspect": "Inspect",
224226
"vscode-containers.commands.containers.openFile": "Open",
@@ -245,6 +247,8 @@
245247
"vscode-containers.commands.askCopilot": "Ask Copilot",
246248
"vscode-containers.commands.images.build": "Build Image...",
247249
"vscode-containers.commands.images.configureExplorer": "Configure Explorer...",
250+
"vscode-containers.commands.images.filter": "Filter...",
251+
"vscode-containers.commands.images.clearFilter": "Clear Filter",
248252
"vscode-containers.commands.images.inspect": "Inspect",
249253
"vscode-containers.commands.images.showDangling": "Show dangling images",
250254
"vscode-containers.commands.images.hideDangling": "Hide dangling images",

src/commands/filterTree.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See LICENSE.md in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IActionContext } from "@microsoft/vscode-azext-utils";
7+
import * as vscode from "vscode";
8+
import { ext } from "../extensionVariables";
9+
import { TreePrefix } from "../tree/TreePrefix";
10+
11+
interface TreeFilterState {
12+
filterText: string;
13+
isActive: boolean;
14+
}
15+
16+
const treeFilters = new Map<TreePrefix, TreeFilterState>();
17+
18+
// Only support filtering for containers and images
19+
const contextKeys: Partial<Record<TreePrefix, string>> = {
20+
containers: "vscode-containers:containersFiltered",
21+
images: "vscode-containers:imagesFiltered",
22+
};
23+
24+
export function getTreeFilter(treePrefix: TreePrefix): TreeFilterState {
25+
return treeFilters.get(treePrefix) || { filterText: "", isActive: false };
26+
}
27+
28+
function setTreeFilter(treePrefix: TreePrefix, filterText: string): void {
29+
treeFilters.set(treePrefix, {
30+
filterText: filterText.toLowerCase(),
31+
isActive: filterText.length > 0,
32+
});
33+
setFilterContextValue(treePrefix, filterText.length > 0);
34+
}
35+
36+
function clearTreeFilter(treePrefix: TreePrefix): void {
37+
treeFilters.set(treePrefix, { filterText: "", isActive: false });
38+
setFilterContextValue(treePrefix, false);
39+
}
40+
41+
function setFilterContextValue(treePrefix: TreePrefix, value: boolean): void {
42+
const contextKey = contextKeys[treePrefix];
43+
if (contextKey) {
44+
void vscode.commands.executeCommand("setContext", contextKey, value);
45+
}
46+
}
47+
48+
export function setInitialFilterContextValues(): void {
49+
for (const treePrefix of Object.keys(contextKeys) as TreePrefix[]) {
50+
const filter = getTreeFilter(treePrefix);
51+
setFilterContextValue(treePrefix, filter.isActive);
52+
}
53+
}
54+
55+
/**
56+
* @param filterText The filter pattern (already lowercase)
57+
* @param searchableText The text to search in (already lowercase)
58+
*/
59+
function fuzzyMatch(filterText: string, searchableText: string): boolean {
60+
let filterIndex = 0;
61+
let searchIndex = 0;
62+
63+
while (
64+
filterIndex < filterText.length &&
65+
searchIndex < searchableText.length
66+
) {
67+
if (filterText[filterIndex] === searchableText[searchIndex]) {
68+
filterIndex++;
69+
}
70+
searchIndex++;
71+
}
72+
73+
return filterIndex === filterText.length;
74+
}
75+
76+
export function shouldShowItem(
77+
treePrefix: TreePrefix,
78+
searchableText: string
79+
): boolean {
80+
const filter = getTreeFilter(treePrefix);
81+
if (!filter.isActive) {
82+
return true;
83+
}
84+
85+
const lowerSearchableText = searchableText.toLowerCase();
86+
87+
if (lowerSearchableText.includes(filter.filterText)) {
88+
return true;
89+
}
90+
91+
return fuzzyMatch(filter.filterText, lowerSearchableText);
92+
}
93+
94+
/**
95+
* Command to filter a tree view
96+
*/
97+
async function filterTreeView(
98+
context: IActionContext,
99+
treePrefix: TreePrefix
100+
): Promise<void> {
101+
const currentFilter = getTreeFilter(treePrefix);
102+
const clearFilterLabel = vscode.l10n.t("$(clear-all) Clear Filter");
103+
104+
const quickPick = vscode.window.createQuickPick();
105+
quickPick.placeholder = vscode.l10n.t(
106+
"Filter {0}... (Press Enter to apply, Esc to cancel)",
107+
treePrefix
108+
);
109+
quickPick.value = currentFilter.filterText;
110+
quickPick.title = vscode.l10n.t("Filter {0}", capitalize(treePrefix));
111+
112+
if (currentFilter.isActive) {
113+
quickPick.items = [
114+
{
115+
label: clearFilterLabel,
116+
description: vscode.l10n.t(
117+
'Currently filtering by: "{0}"',
118+
currentFilter.filterText
119+
),
120+
},
121+
];
122+
}
123+
124+
quickPick.onDidAccept(() => {
125+
const value = quickPick.value.trim();
126+
const selectedItem = quickPick.selectedItems[0];
127+
128+
// Check if "Clear Filter" was selected
129+
if (selectedItem?.label === clearFilterLabel) {
130+
clearTreeFilter(treePrefix);
131+
context.telemetry.properties.action = "clearFilter";
132+
} else if (value) {
133+
setTreeFilter(treePrefix, value);
134+
context.telemetry.properties.action = "applyFilter";
135+
context.telemetry.properties.filterLength = value.length.toString();
136+
} else {
137+
clearTreeFilter(treePrefix);
138+
context.telemetry.properties.action = "clearFilter";
139+
}
140+
141+
quickPick.hide();
142+
void refreshTreeView(treePrefix);
143+
});
144+
145+
quickPick.onDidHide(() => {
146+
quickPick.dispose();
147+
});
148+
149+
quickPick.show();
150+
}
151+
152+
/**
153+
* Update the tree view title to show filter status
154+
*/
155+
function updateTreeViewTitle(treePrefix: TreePrefix): void {
156+
const filter = getTreeFilter(treePrefix);
157+
const treeView = getTreeViewForPrefix(treePrefix);
158+
159+
if (!treeView) {
160+
return;
161+
}
162+
163+
if (filter.isActive) {
164+
treeView.description = vscode.l10n.t(
165+
'Filtered: "{0}"',
166+
filter.filterText
167+
);
168+
} else {
169+
treeView.description = undefined;
170+
}
171+
}
172+
173+
function getTreeViewForPrefix(
174+
treePrefix: TreePrefix
175+
): vscode.TreeView<unknown> | undefined {
176+
switch (treePrefix) {
177+
case "containers":
178+
return ext.containersTreeView;
179+
case "images":
180+
return ext.imagesTreeView;
181+
default:
182+
return undefined;
183+
}
184+
}
185+
186+
async function refreshTreeView(treePrefix: TreePrefix): Promise<void> {
187+
updateTreeViewTitle(treePrefix);
188+
189+
// Get the root and refresh it
190+
const root = getTreeRootForPrefix(treePrefix);
191+
if (root) {
192+
await root.refresh(undefined);
193+
}
194+
}
195+
196+
function getTreeRootForPrefix(
197+
treePrefix: TreePrefix
198+
): { refresh(context: IActionContext): Promise<void> } | undefined {
199+
switch (treePrefix) {
200+
case "containers":
201+
return ext.containersRoot;
202+
case "images":
203+
return ext.imagesRoot;
204+
default:
205+
return undefined;
206+
}
207+
}
208+
209+
function capitalize(str: string): string {
210+
return str.charAt(0).toUpperCase() + str.slice(1);
211+
}
212+
213+
export async function filterContainersTree(
214+
context: IActionContext
215+
): Promise<void> {
216+
await filterTreeView(context, "containers");
217+
}
218+
219+
export async function filterImagesTree(context: IActionContext): Promise<void> {
220+
await filterTreeView(context, "images");
221+
}
222+
223+
export async function clearContainersFilter(
224+
context: IActionContext
225+
): Promise<void> {
226+
clearTreeFilter("containers");
227+
context.telemetry.properties.action = "clearFilter";
228+
void refreshTreeView("containers");
229+
}
230+
231+
export async function clearImagesFilter(
232+
context: IActionContext
233+
): Promise<void> {
234+
clearTreeFilter("images");
235+
context.telemetry.properties.action = "clearFilter";
236+
void refreshTreeView("images");
237+
}

src/commands/registerCommands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { configureDockerContextsExplorer, dockerContextsHelp } from "./context/D
3232
import { inspectDockerContext } from "./context/inspectDockerContext";
3333
import { removeDockerContext } from "./context/removeDockerContext";
3434
import { useDockerContext } from "./context/useDockerContext";
35+
import { clearContainersFilter, clearImagesFilter, filterContainersTree, filterImagesTree, setInitialFilterContextValues } from "./filterTree";
3536
import { help, reportIssue } from "./help";
3637
import { buildImage } from "./images/buildImage";
3738
import { configureImagesExplorer } from "./images/configureImagesExplorer";
@@ -130,6 +131,8 @@ export function registerCommands(): void {
130131
registerCommand('vscode-containers.containers.downloadFile', downloadContainerFile);
131132
registerCommand('vscode-containers.containers.inspect', inspectContainer);
132133
registerCommand('vscode-containers.containers.configureExplorer', configureContainersExplorer);
134+
registerCommand('vscode-containers.containers.filter', filterContainersTree);
135+
registerCommand('vscode-containers.containers.clearFilter', clearContainersFilter);
133136
registerCommand('vscode-containers.containers.openFile', openContainerFile);
134137
registerCommand('vscode-containers.containers.prune', pruneContainers);
135138
registerCommand('vscode-containers.containers.remove', removeContainer);
@@ -148,11 +151,14 @@ export function registerCommands(): void {
148151

149152
registerWorkspaceCommand('vscode-containers.images.build', buildImage);
150153
registerCommand('vscode-containers.images.configureExplorer', configureImagesExplorer);
154+
registerCommand('vscode-containers.images.filter', filterImagesTree);
155+
registerCommand('vscode-containers.images.clearFilter', clearImagesFilter);
151156
registerCommand('vscode-containers.images.inspect', inspectImage);
152157
registerCommand('vscode-containers.images.prune', pruneImages);
153158
registerCommand('vscode-containers.images.showDangling', showDanglingImages);
154159
registerCommand('vscode-containers.images.hideDangling', hideDanglingImages);
155160
setInitialDanglingContextValue();
161+
setInitialFilterContextValues();
156162
registerWorkspaceCommand('vscode-containers.images.pull', pullImage);
157163
registerWorkspaceCommand('vscode-containers.images.push', pushImage);
158164
registerCommand('vscode-containers.images.remove', removeImage);

0 commit comments

Comments
 (0)