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 = {}; } } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy