Skip to main content
Glama

Code MCP Server

by block
contextTracker.ts29.9 kB
import * as vscode from 'vscode' import * as path from 'path' import { EditorUtils } from './editorUtils' import * as fs from 'fs' import { LineRange } from './types' /** * Tree item for file and line range entries in the context explorer */ class FileTreeItem extends vscode.TreeItem { constructor( public readonly uri: vscode.Uri, public readonly collapsibleState: vscode.TreeItemCollapsibleState, public readonly type: 'file' | 'lineRange' = 'file', public readonly lineRange?: { startLine: number; endLine: number } ) { super( type === 'file' ? path.basename(uri.fsPath) : `Lines ${lineRange!.startLine}-${lineRange!.endLine}`, collapsibleState ); if (type === 'file') { this.contextValue = 'contextFile'; this.tooltip = uri.fsPath; this.description = path.dirname(uri.fsPath); this.iconPath = new vscode.ThemeIcon('file'); // Open the file when clicked this.command = { command: 'vscode.open', title: 'Open File', arguments: [uri] }; } else if (type === 'lineRange') { this.contextValue = 'lineRange'; this.tooltip = `Lines ${lineRange!.startLine}-${lineRange!.endLine}`; this.description = ''; this.iconPath = new vscode.ThemeIcon('list-selection'); // Add command to reveal this range when clicked this.command = { command: 'mcp-companion.revealLineRange', title: 'Reveal Line Range', arguments: [uri, lineRange!.startLine - 1, lineRange!.endLine - 1] // Convert to 0-based for internal use }; } } } /** * Tree data provider for showing context-included files */ class ContextFilesProvider implements vscode.TreeDataProvider<FileTreeItem | vscode.Uri> { private _onDidChangeTreeData = new vscode.EventEmitter<FileTreeItem | vscode.Uri | undefined | null>(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; constructor(private contextTracker: ContextTracker) {} refresh(): void { this._onDidChangeTreeData.fire(null); } getTreeItem(element: FileTreeItem | vscode.Uri): vscode.TreeItem { if (element instanceof FileTreeItem) { return element; } // For URI elements, create a file tree item const uri = element; const hasLineRanges = this.contextTracker.getLineRanges(uri.fsPath); return new FileTreeItem( uri, hasLineRanges && hasLineRanges.length > 0 ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None ); } getChildren(element?: FileTreeItem | vscode.Uri): Thenable<(FileTreeItem | vscode.Uri)[]> { if (!element) { // Root level - return all included files as URIs const fileUris = this.contextTracker.getIncludedFiles().map(file => vscode.Uri.file(file)); return Promise.resolve(fileUris); } // If we have a FileTreeItem type element that's not a file if (element instanceof FileTreeItem && element.type !== 'file') { return Promise.resolve([]); } // If we have a file URI or a file type FileTreeItem, return line ranges if any const filePath = element instanceof FileTreeItem ? element.uri.fsPath : element.fsPath; const lineRanges = this.contextTracker.getLineRanges(filePath); if (lineRanges && lineRanges.length > 0) { const uri = element instanceof FileTreeItem ? element.uri : element; return Promise.resolve( lineRanges.map(range => new FileTreeItem( uri, vscode.TreeItemCollapsibleState.None, 'lineRange', range ) ) ); } return Promise.resolve([]); } } /** * CodeLens provider for showing "Add to Goose" button on text selections */ class ContextCodeLensProvider implements vscode.CodeLensProvider { private _onDidChangeCodeLenses: vscode.EventEmitter<void> = new vscode.EventEmitter<void>(); public readonly onDidChangeCodeLenses: vscode.Event<void> = this._onDidChangeCodeLenses.event; constructor() { // Refresh CodeLenses when the editor selection changes vscode.window.onDidChangeTextEditorSelection(() => { this._onDidChangeCodeLenses.fire(); }); // Also refresh when configuration changes vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration('mcp-companion.enableInlineButtons')) { this._onDidChangeCodeLenses.fire(); } }); } public provideCodeLenses(document: vscode.TextDocument): vscode.ProviderResult<vscode.CodeLens[]> { // Check if the feature is enabled in settings const config = vscode.workspace.getConfiguration('mcp-companion'); if (config.get<boolean>('enableInlineButtons') === false) { return []; } const editor = vscode.window.activeTextEditor; if (!editor || editor.document !== document || editor.selections.length === 0) { return []; } // Only provide CodeLenses for non-empty selections const codeLenses: vscode.CodeLens[] = []; for (const selection of editor.selections) { if (selection.isEmpty) { continue; } // Create a range for the entire selection const range = new vscode.Range( selection.start.line, selection.start.character, selection.end.line, selection.end.character ); // Add a CodeLens at the start of the selection const lens = new vscode.CodeLens(range); lens.command = { title: '$(add) Add to Goose', command: 'mcp-companion.includeSelectedLines', tooltip: 'Add selected lines to AI context' }; codeLenses.push(lens); } return codeLenses; } } /** * Manages file tabs that are marked for inclusion in AI context */ export class ContextTracker { private context: vscode.ExtensionContext private includedFiles: Set<string> = new Set() private fileLineRanges: Map<string, LineRange[]> = new Map() private decorationType: vscode.TextEditorDecorationType private treeDataProvider: ContextFilesProvider private statusBarItem: vscode.StatusBarItem private _disposables: vscode.Disposable[] = [] private readonly storageFilePath: string constructor(context: vscode.ExtensionContext, storagePath: string) { this.context = context this.storageFilePath = path.join(storagePath, 'included-files.json') // Log startup for debugging console.log('ContextTracker initializing...'); // Create decoration type for included files this.decorationType = vscode.window.createTextEditorDecorationType({ overviewRulerColor: new vscode.ThemeColor('terminal.ansiBlue'), overviewRulerLane: vscode.OverviewRulerLane.Right, isWholeLine: true }) // Create tree view for context files this.treeDataProvider = new ContextFilesProvider(this); context.subscriptions.push( vscode.window.createTreeView('contextFilesExplorer', { treeDataProvider: this.treeDataProvider, showCollapseAll: false, canSelectMany: false }) ); // Create status bar item this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); this.statusBarItem.command = 'mcp-companion.showContextFiles'; context.subscriptions.push(this.statusBarItem); this.updateStatusBar(); this.statusBarItem.show(); // Register commands context.subscriptions.push( vscode.commands.registerCommand('mcp-companion.toggleFileContext', (uri?: vscode.Uri) => { console.log('Toggle file context command triggered'); if (!uri) { const activeEditor = EditorUtils.getWorkspaceActiveEditor(); if (activeEditor) { uri = activeEditor.document.uri; } } if (uri) { // Simply toggle the context state this.toggleFileContext(uri); } }), vscode.commands.registerCommand('mcp-companion.includeSelectedLines', () => { console.log('Include selected lines command triggered'); const activeEditor = vscode.window.activeTextEditor; if (!activeEditor || activeEditor.selections.length === 0) { vscode.window.showWarningMessage('No active selection to include in AI context'); return; } const filePath = activeEditor.document.uri.fsPath; // Include the file first if it's not already included if (!this.isFileIncluded(filePath)) { this.toggleFileInclusion(filePath); } // Get the line ranges from the selections const newRanges = activeEditor.selections.map(selection => { return { startLine: selection.start.line + 1, // Convert to 1-based endLine: selection.end.line + 1 // Convert to 1-based }; }); // Get existing ranges and append new ones const existingRanges = this.getLineRanges(filePath) || []; // Merge existing and new ranges, avoiding duplicates const combinedRanges = [...existingRanges]; for (const newRange of newRanges) { // Check if this range already exists const isDuplicate = combinedRanges.some( range => range.startLine === newRange.startLine && range.endLine === newRange.endLine ); if (!isDuplicate) { combinedRanges.push(newRange); } } // Store the combined line ranges for this file this.setLineRanges(filePath, combinedRanges); // Update UI to reflect the changes this.updateStatusBarForFile(filePath); this.updateAllEditorDecorations(); this.treeDataProvider.refresh(); const totalNewLines = newRanges.reduce((sum, range) => sum + (range.endLine - range.startLine + 1), 0); vscode.window.showInformationMessage(`Added ${totalNewLines} selected lines from ${path.basename(filePath)} to AI context`); }), vscode.commands.registerCommand('mcp-companion.clearLineSelections', () => { console.log('Clear line selections command triggered'); const activeEditor = vscode.window.activeTextEditor; if (!activeEditor) { vscode.window.showWarningMessage('No active file to clear line selections from'); return; } const filePath = activeEditor.document.uri.fsPath; // Clear any line range selections for this file this.clearLineRanges(filePath); vscode.window.showInformationMessage(`Cleared line selections for ${path.basename(filePath)}`); }), vscode.commands.registerCommand('mcp-companion.revealLineRange', async (uri: vscode.Uri, startLine: number, endLine: number) => { try { console.log(`Revealing lines ${startLine}-${endLine} in ${uri.fsPath}`); // Open the document const document = await vscode.workspace.openTextDocument(uri); const editor = await vscode.window.showTextDocument(document); // Create a selection from start to end line const start = new vscode.Position(startLine - 1, 0); // Convert to 0-based const end = new vscode.Position(endLine - 1, document.lineAt(endLine - 1).text.length); // End of line // Select the range editor.selection = new vscode.Selection(start, end); // Reveal the range in the editor editor.revealRange( new vscode.Range(start, end), vscode.TextEditorRevealType.InCenter ); } catch (error) { console.error('Error revealing line range:', error); vscode.window.showErrorMessage(`Failed to reveal line range: ${error}`); } }), vscode.commands.registerCommand('mcp-companion.debugContextInfo', () => { console.log('Debug context info command triggered'); const activeEditor = EditorUtils.getWorkspaceActiveEditor(); if (activeEditor) { const filePath = activeEditor.document.uri.fsPath; const isIncluded = this.includedFiles.has(filePath); // Show detailed debug info vscode.window.showInformationMessage( `Debug Info:\n` + `File: ${filePath}\n` + `Is included: ${isIncluded}\n` + `Total files in context: ${this.includedFiles.size}` ); // Force update the context value this.updateContextForEditor(activeEditor); } else { vscode.window.showInformationMessage('No active editor to debug'); } }), vscode.commands.registerCommand('mcp-companion.showContextFiles', () => { vscode.commands.executeCommand('mcp-sidebar.focus'); }), vscode.commands.registerCommand('mcp-companion.removeFromContext', (uri: vscode.Uri) => { this.removeFromContext(uri); }), vscode.commands.registerCommand('mcp-companion.clearAllContext', () => { this.clearAllContext(); }), vscode.commands.registerCommand('mcp-companion.removeLineRange', this.removeLineRange.bind(this)) ) // Register the CodeLens provider for "Add to Goose" button const codeLensProvider = new ContextCodeLensProvider(); context.subscriptions.push( vscode.languages.registerCodeLensProvider('*', codeLensProvider) ); // Load previously included files from storage this.loadIncludedFiles(); // Set up editor decorations this.updateAllEditorDecorations(); // Update context variables for all editors when starting up this.updateContextForAllEditors(); // Listen for editor changes this.registerEditorListeners(); // Register a basic file system watcher to handle deleted files const watcher = vscode.workspace.createFileSystemWatcher('**/*', false, true, false); context.subscriptions.push( watcher.onDidDelete(uri => { const filePath = uri.fsPath; if (this.includedFiles.has(filePath)) { console.log(`File was deleted, removing from context: ${filePath}`); this.includedFiles.delete(filePath); this.saveIncludedFiles(); this.treeDataProvider.refresh(); this.updateStatusBar(); } }) ); // Initial update of context for all editors setTimeout(() => this.updateContextForAllEditors(), 1000); } /** * Listen for changes in visible editors */ private registerEditorListeners(): void { // Register editor change listener this._disposables.push( vscode.window.onDidChangeVisibleTextEditors(() => { this.updateAllEditorDecorations() this.updateContextForAllEditors() }), vscode.window.onDidChangeActiveTextEditor((editor) => { this.updateAllEditorDecorations() this.updateStatusBar() if (editor) { this.updateContextForEditor(editor) } }) ) } /** * Updates context variable for all currently visible editors */ private updateContextForAllEditors(): void { console.log('Updating context for all editors...'); EditorUtils.getWorkspaceEditors().forEach(editor => { this.updateContextForEditor(editor); }); // Also update for active editor specifically (may not be in visible editors) const activeEditor = EditorUtils.getWorkspaceActiveEditor(); if (activeEditor) { this.updateContextForEditor(activeEditor); } } /** * Updates the VS Code context variable for a specific editor */ private updateContextForEditor(editor: vscode.TextEditor): void { const filePath = editor.document.uri.fsPath; const isIncluded = this.includedFiles.has(filePath); console.log(`Setting context for ${path.basename(filePath)}: mcp-companion:fileInContext = ${isIncluded}`); // This is the critical line that controls button visibility vscode.commands.executeCommand('setContext', 'mcp-companion:fileInContext', isIncluded); } /** * Adds a file to the AI context */ private addToContext(uri: vscode.Uri): void { const filePath = uri.fsPath; if (!this.includedFiles.has(filePath)) { console.log(`Adding file to context: ${filePath}`); this.includedFiles.add(filePath); this.saveIncludedFiles(); this.updateUIAfterContextChange(filePath, true); } } /** * Removes a file from the AI context */ private removeFromContext(uri: vscode.Uri): void { const filePath = uri.fsPath; if (this.includedFiles.has(filePath)) { console.log(`Removing file from context: ${filePath}`); this.includedFiles.delete(filePath); // Also clear any line ranges associated with this file this.fileLineRanges.delete(filePath); this.saveIncludedFiles(); this.updateUIAfterContextChange(filePath, false); } } /** * Updates all UI elements after a context change */ private updateUIAfterContextChange(filePath: string, added: boolean): void { console.log(`Updating UI after context change for ${filePath}, added: ${added}`); // Update decorations this.updateAllEditorDecorations(); // Update status bar indicator this.updateStatusBarForFile(filePath); // Refresh the tree view this.treeDataProvider.refresh(); // Update status bar counter this.updateStatusBar(); // Update context for all editors to catch any that might be showing this file this.updateContextForAllEditors(); // Show feedback to the user const fileName = path.basename(filePath); const action = added ? 'included in' : 'excluded from'; vscode.window.showInformationMessage(`${fileName} ${action} AI context`); } /** * Saves context files to persistent storage */ private saveIncludedFiles(): void { try { // Ensure the directory exists const dir = path.dirname(this.storageFilePath) if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } // Convert map to object for JSON serialization const lineRangesObj: Record<string, LineRange[]> = {} this.fileLineRanges.forEach((ranges, filePath) => { lineRangesObj[filePath] = ranges }) // Save both the included files and line ranges fs.writeFileSync( this.storageFilePath, JSON.stringify({ files: Array.from(this.includedFiles), lineRanges: lineRangesObj }, null, 2) ) } catch (error) { console.error('Error saving included files:', error) } } /** * Toggles whether a file is included in the AI context * If a URI is provided, it uses that. Otherwise, uses the active editor. * @param uri Optional URI to toggle, if not provided uses active editor */ public async toggleFileContext(uri?: vscode.Uri): Promise<void> { // Use active editor if no URI is provided if (!uri) { const activeEditor = EditorUtils.getWorkspaceActiveEditor(); if (activeEditor) { uri = activeEditor.document.uri; } else { // No active editor to work with vscode.window.showErrorMessage('No file is currently open'); return; } } const filePath = uri.fsPath; const isIncluded = this.includedFiles.has(filePath); if (isIncluded) { this.removeFromContext(uri); } else { this.addToContext(uri); } } /** * Toggles whether a file is included in the AI context by file path * This is a direct method that doesn't perform UI updates * @param filePath The path to the file * @returns Whether the file is now included */ public toggleFileInclusion(filePath: string): boolean { if (this.includedFiles.has(filePath)) { this.includedFiles.delete(filePath); // Always clear any line ranges for this file when removing this.fileLineRanges.delete(filePath); } else { this.includedFiles.add(filePath); } this.saveIncludedFiles(); // Update UI elements this.updateAllEditorDecorations(); this.updateStatusBarForFile(filePath); this.treeDataProvider.refresh(); this.updateStatusBar(); this.updateContextForAllEditors(); return this.includedFiles.has(filePath); } /** * Updates the status bar with context count */ private updateStatusBar(): void { const count = this.includedFiles.size; this.statusBarItem.text = `$(symbol-file) AI Context: ${count} file${count === 1 ? '' : 's'}`; this.statusBarItem.tooltip = 'Click to show AI context files'; } /** * Updates status bar and decoration for a specific file * @param filePath The file path to update for */ private async updateStatusBarForFile(filePath: string): Promise<void> { const isIncluded = this.isFileIncluded(filePath) // Get editors showing this file const editors = EditorUtils.getEditorsForFile(filePath); if (editors.length === 0) { return } // Update decorations for each editor showing this file editors.forEach(editor => { this.updateEditorDecorations(editor, filePath, isIncluded); }) } /** * Update decorations for all open editors */ private updateAllEditorDecorations(): void { const editors = EditorUtils.getWorkspaceEditors(); // Update decorations for each editor editors.forEach(editor => { const filePath = editor.document.uri.fsPath const isIncluded = this.isFileIncluded(filePath) this.updateEditorDecorations(editor, filePath, isIncluded); }) } /** * Updates decorations for a single editor * @param editor The editor to update decorations for * @param filePath The file path of the editor * @param isIncluded Whether the file is included in the context */ private updateEditorDecorations(editor: vscode.TextEditor, filePath: string, isIncluded: boolean): void { if (isIncluded) { const lineRanges = this.getLineRanges(filePath); if (lineRanges && lineRanges.length > 0) { // If we have specific line ranges, only highlight those const decorationRanges = lineRanges.map(range => { // Convert from 1-based to 0-based indexing const startLine = range.startLine - 1; const endLine = range.endLine - 1; const startPos = new vscode.Position(startLine, 0); const endPos = new vscode.Position(endLine, editor.document.lineAt(Math.min(endLine, editor.document.lineCount - 1)).text.length); return new vscode.Range(startPos, endPos); }); editor.setDecorations(this.decorationType, decorationRanges); } else { // If no line ranges, highlight the entire file const lastLine = editor.document.lineCount - 1; const fullDocRange = new vscode.Range( 0, 0, lastLine, editor.document.lineAt(lastLine).text.length ); editor.setDecorations(this.decorationType, [fullDocRange]); } } else { // Clear decorations for files that are no longer included editor.setDecorations(this.decorationType, []) } } /** * Gets all files currently marked for inclusion in AI context */ public getIncludedFiles(): string[] { return Array.from(this.includedFiles) } /** * Checks if a file is marked for inclusion in AI context */ public isFileIncluded(filePath: string): boolean { return this.includedFiles.has(filePath) } /** * Clears all context-included files */ private clearAllContext(): void { this.includedFiles.clear(); // Also clear all line ranges this.fileLineRanges.clear(); // Save to persistent storage this.saveIncludedFiles(); // Update decorations this.updateAllEditorDecorations(); // Refresh the tree view this.treeDataProvider.refresh(); // Update status bar this.updateStatusBar(); vscode.window.showInformationMessage('All files removed from AI context'); } /** * Adds a file to be included in AI context * @param uri The URI of the file to add, or undefined to use active editor */ public async addFileToContext(uri?: vscode.Uri): Promise<void> { // Use active editor if no URI is provided if (!uri) { const activeEditor = EditorUtils.getWorkspaceActiveEditor(); if (activeEditor) { uri = activeEditor.document.uri; } else { // No active editor to work with vscode.window.showErrorMessage('No file is currently open'); return; } } this.addToContext(uri); } /** * Shows the context files view */ public showContextFiles(): void { // Focus the context files explorer vscode.commands.executeCommand('mcp-sidebar.focus'); } /** * Removes a file from being included in AI context * @param uri The URI of the file to remove, or undefined to use active editor */ public async removeFileFromContext(uri?: vscode.Uri): Promise<void> { // Use active editor if no URI is provided if (!uri) { const activeEditor = EditorUtils.getWorkspaceActiveEditor(); if (activeEditor) { uri = activeEditor.document.uri; } else { // No active editor to work with vscode.window.showErrorMessage('No file is currently open'); return; } } this.removeFromContext(uri); } /** * Clears all files from the AI context */ public async clearContext(): Promise<void> { this.clearAllContext(); } /** * Loads the list of included files from storage */ private loadIncludedFiles(): void { try { if (fs.existsSync(this.storageFilePath)) { const data = JSON.parse(fs.readFileSync(this.storageFilePath, 'utf-8')) // Load included files if (Array.isArray(data.files)) { this.includedFiles = new Set(data.files) } // Load line ranges if they exist if (data.lineRanges && typeof data.lineRanges === 'object') { this.fileLineRanges = new Map(Object.entries(data.lineRanges)) } } } catch (error) { console.error('Error loading included files:', error) } } /** * Sets specific line ranges for a file * @param filePath The path to the file * @param ranges Array of line ranges to include */ public setLineRanges(filePath: string, ranges: LineRange[]): void { // Ensure file is in the included list if (!this.includedFiles.has(filePath)) { this.includedFiles.add(filePath) } this.fileLineRanges.set(filePath, ranges) this.saveIncludedFiles() // Update UI elements to reflect the changes this.updateStatusBarForFile(filePath); this.updateAllEditorDecorations(); } /** * Clears any line ranges for a file * @param filePath The file path to clear line ranges for */ public clearLineRanges(filePath: string): void { console.log(`Clearing line ranges for file: ${filePath}`); // Clear the entry from the map this.fileLineRanges.delete(filePath); // Save changes to persistent storage this.saveIncludedFiles(); // Update UI elements this.updateStatusBarForFile(filePath); this.updateAllEditorDecorations(); this.treeDataProvider.refresh(); } /** * Gets the line ranges for a file * @param filePath The path to the file * @returns Array of line ranges or undefined if no ranges are set */ public getLineRanges(filePath: string): LineRange[] | undefined { return this.fileLineRanges.get(filePath) } async revealLineRange(uri: vscode.Uri, startLine: number, endLine: number): Promise<void> { try { const document = await vscode.workspace.openTextDocument(uri); const editor = await vscode.window.showTextDocument(document); // Create a selection from start to end line const startPos = new vscode.Position(startLine, 0); const endPos = new vscode.Position(endLine, document.lineAt(endLine).text.length); const range = new vscode.Range(startPos, endPos); // Set selection and reveal editor.selection = new vscode.Selection(startPos, endPos); editor.revealRange(range, vscode.TextEditorRevealType.InCenter); } catch (error) { console.error('Error revealing line range:', error); vscode.window.showErrorMessage(`Could not reveal line range: ${error}`); } } removeLineRange(item: FileTreeItem): void { if (!item.lineRange || !item.uri) { return; } const filePath = item.uri.fsPath; const lineRanges = this.getLineRanges(filePath) || []; // Find and remove the specific line range const updatedRanges = lineRanges.filter( range => !(range.startLine === item.lineRange!.startLine && range.endLine === item.lineRange!.endLine) ); if (updatedRanges.length === 0) { // No more ranges, remove the entry this.clearLineRanges(filePath); } else { // Update with remaining ranges this.setLineRanges(filePath, updatedRanges); } // Update decorations explicitly (in case the above methods didn't) this.updateAllEditorDecorations(); // Refresh the tree view this.treeDataProvider.refresh(); vscode.window.showInformationMessage(`Removed lines ${item.lineRange.startLine}-${item.lineRange.endLine} from context.`); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/block/vscode-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server