Skip to content

Commit bab6c21

Browse files
committed
feat: Add search filter to sidebar
1 parent a75342a commit bab6c21

File tree

4 files changed

+185
-1
lines changed

4 files changed

+185
-1
lines changed

package.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,18 @@
212212
"title": "Coder: Open App Status",
213213
"icon": "$(robot)",
214214
"when": "coder.authenticated"
215+
},
216+
{
217+
"command": "coder.searchWorkspaces",
218+
"title": "Coder: Search Workspaces",
219+
"icon": "$(search)",
220+
"when": "coder.authenticated && coder.isOwner"
221+
},
222+
{
223+
"command": "coder.clearWorkspaceSearch",
224+
"title": "Coder: Clear Workspace Search",
225+
"icon": "$(clear-all)",
226+
"when": "coder.authenticated && coder.isOwner"
215227
}
216228
],
217229
"menus": {
@@ -239,6 +251,21 @@
239251
"command": "coder.refreshWorkspaces",
240252
"when": "coder.authenticated && view == myWorkspaces",
241253
"group": "navigation"
254+
},
255+
{
256+
"command": "coder.searchWorkspaces",
257+
"when": "coder.authenticated && coder.isOwner && view == allWorkspaces",
258+
"group": "navigation"
259+
},
260+
{
261+
"command": "coder.refreshWorkspaces",
262+
"when": "coder.authenticated && coder.isOwner && view == allWorkspaces",
263+
"group": "navigation"
264+
},
265+
{
266+
"command": "coder.clearWorkspaceSearch",
267+
"when": "coder.authenticated && coder.isOwner && view == allWorkspaces",
268+
"group": "navigation"
242269
}
243270
],
244271
"view/item/context": [

src/commands.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,53 @@ export class Commands {
684684
});
685685
}
686686

687+
/**
688+
* Search/filter workspaces in the All Workspaces view.
689+
* This method will be called from the view title menu.
690+
*/
691+
public async searchWorkspaces(): Promise<void> {
692+
const quickPick = vscode.window.createQuickPick();
693+
quickPick.placeholder =
694+
"Type to search workspaces by name, owner, template, status, or agent";
695+
quickPick.title = "Search All Workspaces";
696+
quickPick.value = "";
697+
698+
// Get current search filter to show in the input
699+
const currentFilter = (await vscode.commands.executeCommand(
700+
"coder.getWorkspaceSearchFilter",
701+
)) as string;
702+
if (currentFilter) {
703+
quickPick.value = currentFilter;
704+
}
705+
706+
quickPick.ignoreFocusOut = true; // Keep open when clicking elsewhere
707+
quickPick.canSelectMany = false; // Don't show selection list
708+
709+
quickPick.onDidChangeValue((value) => {
710+
// Update the search filter in real-time as user types
711+
vscode.commands.executeCommand("coder.setWorkspaceSearchFilter", value);
712+
});
713+
714+
quickPick.onDidAccept(() => {
715+
// When user presses Enter, close the search
716+
quickPick.hide();
717+
});
718+
719+
quickPick.onDidHide(() => {
720+
// Don't clear the search when closed - keep the filter active
721+
quickPick.dispose();
722+
});
723+
724+
quickPick.show();
725+
}
726+
727+
/**
728+
* Clear the workspace search filter.
729+
*/
730+
public clearWorkspaceSearch(): void {
731+
vscode.commands.executeCommand("coder.setWorkspaceSearchFilter", "");
732+
}
733+
687734
/**
688735
* Return agents from the workspace.
689736
*

src/extension.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
290290
"coder.viewLogs",
291291
commands.viewLogs.bind(commands),
292292
);
293+
vscode.commands.registerCommand(
294+
"coder.searchWorkspaces",
295+
commands.searchWorkspaces.bind(commands),
296+
);
297+
vscode.commands.registerCommand(
298+
"coder.setWorkspaceSearchFilter",
299+
(searchTerm: string) => {
300+
allWorkspacesProvider.setSearchFilter(searchTerm);
301+
},
302+
);
303+
vscode.commands.registerCommand("coder.getWorkspaceSearchFilter", () => {
304+
return allWorkspacesProvider.getSearchFilter();
305+
});
306+
vscode.commands.registerCommand(
307+
"coder.clearWorkspaceSearch",
308+
commands.clearWorkspaceSearch.bind(commands),
309+
);
293310

294311
// Since the "onResolveRemoteAuthority:ssh-remote" activation event exists
295312
// in package.json we're able to perform actions before the authority is

src/workspacesProvider.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export class WorkspaceProvider
4242
private timeout: NodeJS.Timeout | undefined;
4343
private fetching = false;
4444
private visible = false;
45+
private searchFilter = "";
4546

4647
constructor(
4748
private readonly getWorkspacesQuery: WorkspaceQuery,
@@ -52,6 +53,15 @@ export class WorkspaceProvider
5253
// No initialization.
5354
}
5455

56+
setSearchFilter(filter: string) {
57+
this.searchFilter = filter;
58+
this.refresh(undefined);
59+
}
60+
61+
getSearchFilter(): string {
62+
return this.searchFilter;
63+
}
64+
5565
// fetchAndRefresh fetches new workspaces, re-renders the entire tree, then
5666
// keeps refreshing (if a timer length was provided) as long as the user is
5767
// still logged in and no errors were encountered fetching workspaces.
@@ -300,7 +310,90 @@ export class WorkspaceProvider
300310

301311
return Promise.resolve([]);
302312
}
303-
return Promise.resolve(this.workspaces || []);
313+
314+
// Filter workspaces based on search term
315+
let filteredWorkspaces = this.workspaces || [];
316+
const trimmedFilter = this.searchFilter.trim();
317+
if (trimmedFilter) {
318+
const searchTerm = trimmedFilter.toLowerCase();
319+
filteredWorkspaces = filteredWorkspaces.filter((workspace) =>
320+
this.matchesSearchTerm(workspace, searchTerm),
321+
);
322+
}
323+
324+
return Promise.resolve(filteredWorkspaces);
325+
}
326+
327+
/**
328+
* Check if a workspace matches the given search term using smart search logic.
329+
* Prioritizes exact word matches over substring matches.
330+
*/
331+
private matchesSearchTerm(
332+
workspace: WorkspaceTreeItem,
333+
searchTerm: string,
334+
): boolean {
335+
const workspaceName = workspace.workspace.name.toLowerCase();
336+
const ownerName = workspace.workspace.owner_name.toLowerCase();
337+
const templateName = (
338+
workspace.workspace.template_display_name ||
339+
workspace.workspace.template_name ||
340+
""
341+
).toLowerCase();
342+
const status = workspace.workspace.latest_build.status.toLowerCase();
343+
344+
// Check if any agent names match the search term
345+
const agents = extractAgents(workspace.workspace.latest_build.resources);
346+
const agentNames = agents.map((agent) => agent.name.toLowerCase());
347+
const hasMatchingAgent = agentNames.some((agentName) =>
348+
agentName.includes(searchTerm),
349+
);
350+
351+
// Check if any agent metadata contains the search term
352+
const hasMatchingMetadata = agents.some((agent) => {
353+
const watcher = this.agentWatchers[agent.id];
354+
if (watcher?.metadata) {
355+
return watcher.metadata.some((metadata) => {
356+
const metadataStr = JSON.stringify(metadata).toLowerCase();
357+
return metadataStr.includes(searchTerm);
358+
});
359+
}
360+
return false;
361+
});
362+
363+
// Smart search: Try exact word match first, then fall back to substring
364+
const searchWords = searchTerm
365+
.split(/\s+/)
366+
.filter((word) => word.length > 0);
367+
const allText = [
368+
workspaceName,
369+
ownerName,
370+
templateName,
371+
status,
372+
...agentNames,
373+
].join(" ");
374+
375+
// Check for exact word matches (higher priority)
376+
const hasExactWordMatch =
377+
searchWords.length > 0 &&
378+
searchWords.some((word) => {
379+
// Escape special regex characters to prevent injection
380+
const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
381+
const wordBoundaryRegex = new RegExp(`\\b${escapedWord}\\b`, "i");
382+
return wordBoundaryRegex.test(allText);
383+
});
384+
385+
// Check for substring matches (lower priority) - only if no exact word match
386+
const hasSubstringMatch =
387+
!hasExactWordMatch &&
388+
(workspaceName.includes(searchTerm) ||
389+
ownerName.includes(searchTerm) ||
390+
templateName.includes(searchTerm) ||
391+
status.includes(searchTerm) ||
392+
hasMatchingAgent ||
393+
hasMatchingMetadata);
394+
395+
// Return true if either exact word match or substring match
396+
return hasExactWordMatch || hasSubstringMatch;
304397
}
305398
}
306399

0 commit comments

Comments
 (0)