diff --git a/CHANGELOG.md b/CHANGELOG.md index 68495b2d..e1f66607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Add binary signature verification. This can be disabled with `coder.disableSignatureVerification` if you purposefully run a binary that is not signed by Coder (for example a binary you built yourself). +- Add search functionality to the "All Workspaces" view. ## [v1.9.2](https://github.com/coder/vscode-coder/releases/tag/v1.9.2) 2025-06-25 diff --git a/package.json b/package.json index 23ba62ef..810a09ec 100644 --- a/package.json +++ b/package.json @@ -217,6 +217,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": { @@ -244,6 +256,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" + }, + { + "command": "coder.clearWorkspaceSearch", + "when": "coder.authenticated && coder.isOwner && view == allWorkspaces", + "group": "navigation" } ], "view/item/context": [ diff --git a/src/commands.ts b/src/commands.ts index b40ea56e..8fdfc1a0 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -684,6 +684,46 @@ 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 { + 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 + 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(); + } + /** * Return agents from the workspace. * diff --git a/src/extension.ts b/src/extension.ts index e765ee1b..04d47d58 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -291,6 +291,22 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { "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", () => { + allWorkspacesProvider.clearSearchFilter(); + }); // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index 278ee492..7f7237ed 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -42,6 +42,9 @@ export class WorkspaceProvider private timeout: NodeJS.Timeout | undefined; private fetching = false; private visible = false; + private searchFilter = ""; + private metadataCache: Record = {}; + private searchDebounceTimer: NodeJS.Timeout | undefined; constructor( private readonly getWorkspacesQuery: WorkspaceQuery, @@ -52,6 +55,43 @@ export class WorkspaceProvider // No initialization. } + setSearchFilter(filter: string) { + // Validate search term length to prevent performance issues + if (filter.length > 200) { + filter = filter.substring(0, 200); + } + + // Clear any existing debounce timer + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + + // Debounce the search operation to improve performance + this.searchDebounceTimer = setTimeout(() => { + this.searchFilter = filter; + this.refresh(undefined); + this.searchDebounceTimer = undefined; + }, 150); // 150ms debounce delay - good balance between responsiveness and performance + } + + getSearchFilter(): string { + return this.searchFilter; + } + + /** + * Clear the search filter immediately without debouncing. + * Use this when the user explicitly clears the search. + */ + clearSearchFilter(): void { + // Clear any pending debounce timer + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = undefined; + } + this.searchFilter = ""; + this.refresh(undefined); + } + // 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. @@ -63,6 +103,9 @@ export class WorkspaceProvider } this.fetching = true; + // Clear metadata cache when refreshing to ensure data consistency + this.clearMetadataCache(); + // It is possible we called fetchAndRefresh() manually (through the button // for example), in which case we might still have a pending refresh that // needs to be cleared. @@ -201,6 +244,11 @@ export class WorkspaceProvider clearTimeout(this.timeout); this.timeout = undefined; } + // clear search debounce timer + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + this.searchDebounceTimer = undefined; + } } /** @@ -300,7 +348,157 @@ 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); + } + + /** + * Extract and normalize searchable text fields from a workspace. + * This helper method reduces code duplication between exact word and substring matching. + */ + private extractSearchableFields(workspace: WorkspaceTreeItem): { + workspaceName: string; + ownerName: string; + templateName: string; + status: string; + agentNames: string[]; + agentMetadataText: string; + } { + // Handle null/undefined workspace data safely + const workspaceName = workspace.workspace.name.toLowerCase(); + const ownerName = workspace.workspace.owner_name.toLowerCase(); + const templateName = ( + workspace.workspace.template_display_name || + workspace.workspace.template_name + ).toLowerCase(); + const status = ( + workspace.workspace.latest_build.status || "" + ).toLowerCase(); + + // Extract agent names with null safety + const agents = extractAgents(workspace.workspace.latest_build.resources); + const agentNames = agents + .map((agent) => agent.name.toLowerCase()) + .filter((name) => name.length > 0); + + // Extract and cache agent metadata with error handling + let agentMetadataText = ""; + const metadataCacheKey = agents.map((agent) => agent.id).join(","); + + if (this.metadataCache[metadataCacheKey]) { + agentMetadataText = this.metadataCache[metadataCacheKey]; + } else { + const metadataStrings: string[] = []; + let hasSerializationErrors = false; + + agents.forEach((agent) => { + const watcher = this.agentWatchers[agent.id]; + if (watcher?.metadata) { + watcher.metadata.forEach((metadata) => { + try { + metadataStrings.push(JSON.stringify(metadata).toLowerCase()); + } catch (error) { + hasSerializationErrors = true; + // Handle JSON serialization errors gracefully + this.storage.output.warn( + `Failed to serialize metadata for agent ${agent.id}: ${error}`, + ); + } + }); + } + }); + + agentMetadataText = metadataStrings.join(" "); + + // Only cache if all metadata serialized successfully + if (!hasSerializationErrors) { + this.metadataCache[metadataCacheKey] = agentMetadataText; + } + } + + return { + workspaceName, + ownerName, + templateName, + status, + agentNames, + agentMetadataText, + }; + } + + /** + * 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 { + // Early return for empty search terms + if (!searchTerm || searchTerm.trim().length === 0) { + return true; + } + + // Extract all searchable fields once + const fields = this.extractSearchableFields(workspace); + + // Pre-compile regex patterns for exact word matching + const searchWords = searchTerm + .split(/\s+/) + .filter((word) => word.length > 0); + + const regexPatterns: RegExp[] = []; + for (const word of searchWords) { + // Simple word boundary search + regexPatterns.push(new RegExp(`\\b${word}\\b`, "i")); + } + + // Combine all text for exact word matching + const allText = [ + fields.workspaceName, + fields.ownerName, + fields.templateName, + fields.status, + ...fields.agentNames, + fields.agentMetadataText, + ].join(" "); + + // Check for exact word matches (higher priority) + const hasExactWordMatch = + regexPatterns.length > 0 && + regexPatterns.some((pattern) => pattern.test(allText)); + + // Check for substring matches (lower priority) - only if no exact word match + const hasSubstringMatch = + !hasExactWordMatch && allText.includes(searchTerm); + + // Return true if either exact word match or substring match + return hasExactWordMatch || hasSubstringMatch; + } + + /** + * Clear the metadata cache when workspaces are refreshed to ensure data consistency. + * Also clears cache if it grows too large to prevent memory issues. + */ + private clearMetadataCache(): void { + // Clear cache if it grows too large (prevent memory issues) + const cacheSize = Object.keys(this.metadataCache).length; + if (cacheSize > 1000) { + this.storage.output.info( + `Clearing metadata cache due to size (${cacheSize} entries)`, + ); + } + this.metadataCache = {}; } }