Skip to content

feat: Add search filter to sidebar #562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,18 @@
"title": "Coder: Open App Status",
"icon": "$(robot)",
"when": "coder.authenticated"
},
{
"command": "coder.searchWorkspaces",
"title": "Coder: Search Workspaces",
"icon": "$(search)",
"when": "coder.authenticated && coder.isOwner"
},
{
"command": "coder.clearWorkspaceSearch",
"title": "Coder: Clear Workspace Search",
"icon": "$(clear-all)",
"when": "coder.authenticated && coder.isOwner"
}
],
"menus": {
Expand Down Expand Up @@ -239,6 +251,21 @@
"command": "coder.refreshWorkspaces",
"when": "coder.authenticated && view == myWorkspaces",
"group": "navigation"
},
{
"command": "coder.searchWorkspaces",
"when": "coder.authenticated && coder.isOwner && view == allWorkspaces",
"group": "navigation"
},
{
"command": "coder.refreshWorkspaces",
"when": "coder.authenticated && coder.isOwner && view == allWorkspaces",
"group": "navigation"
},
Comment on lines +265 to +269
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels a little weird to put this in the middle

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where do you suggest we move it to? or should we remove it entirely?

{
"command": "coder.clearWorkspaceSearch",
"when": "coder.authenticated && coder.isOwner && view == allWorkspaces",
"group": "navigation"
}
],
"view/item/context": [
Expand Down
47 changes: 47 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,53 @@ export class Commands {
});
}

/**
* Search/filter workspaces in the All Workspaces view.
* This method will be called from the view title menu.
*/
public async searchWorkspaces(): Promise<void> {
const quickPick = vscode.window.createQuickPick();
quickPick.placeholder =
"Type to search workspaces by name, owner, template, status, or agent";
quickPick.title = "Search All Workspaces";
quickPick.value = "";

// Get current search filter to show in the input
const currentFilter = (await vscode.commands.executeCommand(
"coder.getWorkspaceSearchFilter",
)) as string;
if (currentFilter) {
quickPick.value = currentFilter;
}

quickPick.ignoreFocusOut = true; // Keep open when clicking elsewhere
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like an odd thing to set. can you explain your rationale?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, here was my thought process on user behavior -

When a user clicks outside the Quickpick (like on the workspace list or elsewhere in VS Code), the quickpick automatically closes

  1. They type "docker", see the filtered results, but accidentally click outside -> Quickpick closes
  2. User clicks on a workspace to see details, but that closes the search input
  3. User has to reopen the search every time they want to redefine their search

With the current approach it solves the above scenarios. The context is still preserved in state (private searchFilter) so the filtered results will not change if the input closes - its more so just a means for the user to not have to click on the search button again if they are not satisfied or have not completed their search

quickPick.canSelectMany = false; // Don't show selection list

quickPick.onDidChangeValue((value) => {
// Update the search filter in real-time as user types
vscode.commands.executeCommand("coder.setWorkspaceSearchFilter", value);
});

quickPick.onDidAccept(() => {
// When user presses Enter, close the search
quickPick.hide();
});

quickPick.onDidHide(() => {
// Don't clear the search when closed - keep the filter active
quickPick.dispose();
});

quickPick.show();
}

/**
* Clear the workspace search filter.
*/
public clearWorkspaceSearch(): void {
vscode.commands.executeCommand("coder.setWorkspaceSearchFilter", "");
}

/**
* Return agents from the workspace.
*
Expand Down
17 changes: 17 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
"coder.viewLogs",
commands.viewLogs.bind(commands),
);
vscode.commands.registerCommand(
"coder.searchWorkspaces",
commands.searchWorkspaces.bind(commands),
);
vscode.commands.registerCommand(
"coder.setWorkspaceSearchFilter",
(searchTerm: string) => {
allWorkspacesProvider.setSearchFilter(searchTerm);
},
);
vscode.commands.registerCommand("coder.getWorkspaceSearchFilter", () => {
return allWorkspacesProvider.getSearchFilter();
});
vscode.commands.registerCommand(
"coder.clearWorkspaceSearch",
commands.clearWorkspaceSearch.bind(commands),
);

// Since the "onResolveRemoteAuthority:ssh-remote" activation event exists
// in package.json we're able to perform actions before the authority is
Expand Down
95 changes: 94 additions & 1 deletion src/workspacesProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class WorkspaceProvider
private timeout: NodeJS.Timeout | undefined;
private fetching = false;
private visible = false;
private searchFilter = "";

constructor(
private readonly getWorkspacesQuery: WorkspaceQuery,
Expand All @@ -52,6 +53,15 @@ export class WorkspaceProvider
// No initialization.
}

setSearchFilter(filter: string) {
this.searchFilter = filter;
this.refresh(undefined);
}

getSearchFilter(): string {
return this.searchFilter;
}

// fetchAndRefresh fetches new workspaces, re-renders the entire tree, then
// keeps refreshing (if a timer length was provided) as long as the user is
// still logged in and no errors were encountered fetching workspaces.
Expand Down Expand Up @@ -300,7 +310,90 @@ export class WorkspaceProvider

return Promise.resolve([]);
}
return Promise.resolve(this.workspaces || []);

// Filter workspaces based on search term
let filteredWorkspaces = this.workspaces || [];
const trimmedFilter = this.searchFilter.trim();
if (trimmedFilter) {
const searchTerm = trimmedFilter.toLowerCase();
filteredWorkspaces = filteredWorkspaces.filter((workspace) =>
this.matchesSearchTerm(workspace, searchTerm),
);
}

return Promise.resolve(filteredWorkspaces);
}

/**
* Check if a workspace matches the given search term using smart search logic.
* Prioritizes exact word matches over substring matches.
*/
private matchesSearchTerm(
workspace: WorkspaceTreeItem,
searchTerm: string,
): boolean {
const workspaceName = workspace.workspace.name.toLowerCase();
const ownerName = workspace.workspace.owner_name.toLowerCase();
const templateName = (
workspace.workspace.template_display_name ||
workspace.workspace.template_name ||
""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this first || does actually do something, because template_display_name can be empty, and this will fall back to template_name, but if both are empty then it'll be "" anyway so the second || is redundant

).toLowerCase();
const status = workspace.workspace.latest_build.status.toLowerCase();

// Check if any agent names match the search term
const agents = extractAgents(workspace.workspace.latest_build.resources);
const agentNames = agents.map((agent) => agent.name.toLowerCase());
const hasMatchingAgent = agentNames.some((agentName) =>
agentName.includes(searchTerm),
);

// Check if any agent metadata contains the search term
const hasMatchingMetadata = agents.some((agent) => {
const watcher = this.agentWatchers[agent.id];
if (watcher?.metadata) {
return watcher.metadata.some((metadata) => {
const metadataStr = JSON.stringify(metadata).toLowerCase();
return metadataStr.includes(searchTerm);
});
}
return false;
});

// Smart search: Try exact word match first, then fall back to substring
const searchWords = searchTerm
.split(/\s+/)
.filter((word) => word.length > 0);
const allText = [
workspaceName,
ownerName,
templateName,
status,
...agentNames,
].join(" ");

// Check for exact word matches (higher priority)
const hasExactWordMatch =
searchWords.length > 0 &&
searchWords.some((word) => {
// Escape special regex characters to prevent injection
const escapedWord = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const wordBoundaryRegex = new RegExp(`\\b${escapedWord}\\b`, "i");
return wordBoundaryRegex.test(allText);
});

// Check for substring matches (lower priority) - only if no exact word match
const hasSubstringMatch =
!hasExactWordMatch &&
(workspaceName.includes(searchTerm) ||
ownerName.includes(searchTerm) ||
templateName.includes(searchTerm) ||
status.includes(searchTerm) ||
hasMatchingAgent ||
hasMatchingMetadata);

// Return true if either exact word match or substring match
return hasExactWordMatch || hasSubstringMatch;
}
}

Expand Down