Search API
Search APIは、開発環境内でファイル、シンボル、コンテンツを包括的に検索する機能を提供します。
概要
Search APIを使用すると、以下のことができます:
- ファイル名とパスでファイルを検索
- ファイル全体でテキストコンテンツを検索
- シンボル(関数、クラス、変数)を検索
- 正規表現ベースの検索を実行
- 特定のスコープとディレクトリ内で検索
- 検索候補とオートコンプリートを提供
- 検索結果のインデックス化とキャッシュ
- ファジーマッチングとランキングをサポート
基本的な使用方法
Search Manager
import { TraeAPI } from '@trae/api';
// 検索マネージャー
class SearchManager {
private fileIndex: Map<string, FileIndexEntry> = new Map();
private symbolIndex: Map<string, SymbolIndexEntry[]> = new Map();
private contentIndex: Map<string, ContentIndexEntry[]> = new Map();
private searchHistory: SearchHistoryEntry[] = [];
private eventEmitter = new TraeAPI.EventEmitter<SearchEvent>();
private indexingInProgress = false;
private watchers: TraeAPI.FileSystemWatcher[] = [];
constructor() {
this.initializeIndexing();
this.setupFileWatchers();
}
// インデックス化の初期化
private async initializeIndexing(): Promise<void> {
if (this.indexingInProgress) return;
this.indexingInProgress = true;
console.log('検索インデックス化を開始しています...');
this.eventEmitter.fire({ type: 'indexingStarted' });
try {
await this.buildFileIndex();
await this.buildSymbolIndex();
await this.buildContentIndex();
console.log('検索インデックス化が完了しました');
this.eventEmitter.fire({ type: 'indexingCompleted' });
} catch (error) {
console.error('検索インデックス化に失敗しました:', error);
this.eventEmitter.fire({ type: 'indexingFailed', error: error as Error });
} finally {
this.indexingInProgress = false;
}
}
// ファイルインデックスの構築
private async buildFileIndex(): Promise<void> {
const workspaceFolders = TraeAPI.workspace.workspaceFolders;
if (!workspaceFolders) return;
for (const folder of workspaceFolders) {
await this.indexDirectory(folder.uri.fsPath);
}
}
private async indexDirectory(dirPath: string): Promise<void> {
try {
const entries = await TraeAPI.workspace.fs.readDirectory(TraeAPI.Uri.file(dirPath));
for (const [name, type] of entries) {
const fullPath = TraeAPI.path.join(dirPath, name);
if (type === TraeAPI.FileType.Directory) {
// インデックス化すべきでない一般的なディレクトリをスキップ
if (this.shouldSkipDirectory(name)) {
continue;
}
await this.indexDirectory(fullPath);
} else if (type === TraeAPI.FileType.File) {
await this.indexFile(fullPath);
}
}
} catch (error) {
console.error(`ディレクトリ ${dirPath} のインデックス化に失敗しました:`, error);
}
}
private shouldSkipDirectory(name: string): boolean {
const skipDirs = [
'node_modules', '.git', '.svn', '.hg',
'dist', 'build', 'out', 'target',
'.vscode', '.idea', '__pycache__',
'coverage', '.nyc_output'
];
return skipDirs.includes(name) || name.startsWith('.');
}
private async indexFile(filePath: string): Promise<void> {
try {
const stat = await TraeAPI.workspace.fs.stat(TraeAPI.Uri.file(filePath));
const fileName = TraeAPI.path.basename(filePath);
const extension = TraeAPI.path.extname(filePath);
const relativePath = TraeAPI.workspace.asRelativePath(filePath);
const entry: FileIndexEntry = {
path: filePath,
relativePath,
name: fileName,
extension,
size: stat.size,
lastModified: stat.mtime,
language: this.getLanguageFromExtension(extension),
searchTerms: this.generateFileSearchTerms(fileName, relativePath)
};
this.fileIndex.set(filePath, entry);
} catch (error) {
console.error(`ファイル ${filePath} のインデックス化に失敗しました:`, error);
}
}
private getLanguageFromExtension(extension: string): string {
const languageMap: { [key: string]: string } = {
'.js': 'javascript',
'.ts': 'typescript',
'.jsx': 'javascriptreact',
'.tsx': 'typescriptreact',
'.py': 'python',
'.java': 'java',
'.cpp': 'cpp',
'.c': 'c',
'.cs': 'csharp',
'.php': 'php',
'.rb': 'ruby',
'.go': 'go',
'.rs': 'rust',
'.swift': 'swift',
'.kt': 'kotlin',
'.scala': 'scala',
'.html': 'html',
'.css': 'css',
'.scss': 'scss',
'.less': 'less',
'.json': 'json',
'.xml': 'xml',
'.yaml': 'yaml',
'.yml': 'yaml',
'.md': 'markdown',
'.txt': 'plaintext'
};
return languageMap[extension.toLowerCase()] || 'plaintext';
}
private generateFileSearchTerms(fileName: string, relativePath: string): string[] {
const terms = new Set<string>();
// 拡張子なしのファイル名を追加
const nameWithoutExt = TraeAPI.path.parse(fileName).name;
terms.add(nameWithoutExt.toLowerCase());
// 完全なファイル名を追加
terms.add(fileName.toLowerCase());
// パスセグメントを追加
const pathSegments = relativePath.split(TraeAPI.path.sep);
pathSegments.forEach(segment => {
if (segment) {
terms.add(segment.toLowerCase());
}
});
// camelCaseとsnake_caseの分割を追加
const camelCaseSplit = nameWithoutExt.split(/(?=[A-Z])/);
camelCaseSplit.forEach(part => {
if (part) {
terms.add(part.toLowerCase());
}
});
const snakeCaseSplit = nameWithoutExt.split(/[_-]/);
snakeCaseSplit.forEach(part => {
if (part) {
terms.add(part.toLowerCase());
}
});
return Array.from(terms);
}
// シンボルインデックスの構築
private async buildSymbolIndex(): Promise<void> {
for (const [filePath, fileEntry] of this.fileIndex) {
if (this.isCodeFile(fileEntry.language)) {
await this.indexFileSymbols(filePath);
}
}
}
private isCodeFile(language: string): boolean {
const codeLanguages = [
'javascript', 'typescript', 'javascriptreact', 'typescriptreact',
'python', 'java', 'cpp', 'c', 'csharp', 'php', 'ruby',
'go', 'rust', 'swift', 'kotlin', 'scala'
];
return codeLanguages.includes(language);
}
private async indexFileSymbols(filePath: string): Promise<void> {
try {
const document = await TraeAPI.workspace.openTextDocument(TraeAPI.Uri.file(filePath));
const symbols = await TraeAPI.commands.executeCommand<TraeAPI.DocumentSymbol[]>(
'vscode.executeDocumentSymbolProvider',
document.uri
);
if (symbols) {
const symbolEntries = this.flattenSymbols(symbols, filePath);
this.symbolIndex.set(filePath, symbolEntries);
}
} catch (error) {
console.error(`${filePath} のシンボルインデックス化に失敗しました:`, error);
}
}
private flattenSymbols(symbols: TraeAPI.DocumentSymbol[], filePath: string, parent?: string): SymbolIndexEntry[] {
const entries: SymbolIndexEntry[] = [];
for (const symbol of symbols) {
const fullName = parent ? `${parent}.${symbol.name}` : symbol.name;
entries.push({
name: symbol.name,
fullName,
kind: symbol.kind,
filePath,
range: symbol.range,
selectionRange: symbol.selectionRange,
searchTerms: this.generateSymbolSearchTerms(symbol.name, fullName)
});
// 子要素を再帰的に処理
if (symbol.children) {
entries.push(...this.flattenSymbols(symbol.children, filePath, fullName));
}
}
return entries;
}
private generateSymbolSearchTerms(name: string, fullName: string): string[] {
const terms = new Set<string>();
terms.add(name.toLowerCase());
terms.add(fullName.toLowerCase());
// camelCaseの分割を追加
const camelCaseSplit = name.split(/(?=[A-Z])/);
camelCaseSplit.forEach(part => {
if (part) {
terms.add(part.toLowerCase());
}
});
// snake_caseの分割を追加
const snakeCaseSplit = name.split(/[_-]/);
snakeCaseSplit.forEach(part => {
if (part) {
terms.add(part.toLowerCase());
}
});
return Array.from(terms);
}
// コンテンツインデックスの構築
private async buildContentIndex(): Promise<void> {
for (const [filePath, fileEntry] of this.fileIndex) {
if (this.shouldIndexContent(fileEntry)) {
await this.indexFileContent(filePath);
}
}
}
private shouldIndexContent(fileEntry: FileIndexEntry): boolean {
// バイナリファイルと非常に大きなファイルをスキップ
if (fileEntry.size > 1024 * 1024) { // 1MBの制限
return false;
}
const textExtensions = [
'.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.cpp', '.c',
'.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala',
'.html', '.css', '.scss', '.less', '.json', '.xml', '.yaml',
'.yml', '.md', '.txt', '.log', '.config', '.ini'
];
return textExtensions.includes(fileEntry.extension.toLowerCase());
}
private async indexFileContent(filePath: string): Promise<void> {
try {
const document = await TraeAPI.workspace.openTextDocument(TraeAPI.Uri.file(filePath));
const content = document.getText();
const lines = content.split('\n');
const entries: ContentIndexEntry[] = [];
lines.forEach((line, lineNumber) => {
const trimmedLine = line.trim();
if (trimmedLine.length > 0) {
entries.push({
filePath,
lineNumber: lineNumber + 1,
content: line,
trimmedContent: trimmedLine,
searchTerms: this.generateContentSearchTerms(trimmedLine)
});
}
});
this.contentIndex.set(filePath, entries);
} catch (error) {
console.error(`${filePath} のコンテンツインデックス化に失敗しました:`, error);
}
}
private generateContentSearchTerms(content: string): string[] {
const terms = new Set<string>();
// 一般的な区切り文字で分割
const words = content.toLowerCase().split(/[\s\W]+/);
words.forEach(word => {
if (word.length > 2) { // 非常に短い単語をスキップ
terms.add(word);
}
});
return Array.from(terms);
}
// ファイル検索
async searchFiles(query: string, options: FileSearchOptions = {}): Promise<FileSearchResult[]> {
const results: FileSearchResult[] = [];
const queryLower = query.toLowerCase();
const maxResults = options.maxResults || 100;
for (const [filePath, entry] of this.fileIndex) {
if (results.length >= maxResults) break;
// フィルターを適用
if (options.includePatterns && !this.matchesPatterns(entry.relativePath, options.includePatterns)) {
continue;
}
if (options.excludePatterns && this.matchesPatterns(entry.relativePath, options.excludePatterns)) {
continue;
}
if (options.fileTypes && !options.fileTypes.includes(entry.extension)) {
continue;
}
// マッチスコアを計算
const score = this.calculateFileMatchScore(entry, queryLower);
if (score > 0) {
results.push({
file: entry,
score,
matches: this.getFileMatches(entry, queryLower)
});
}
}
// スコア順にソート(降順)
results.sort((a, b) => b.score - a.score);
// 検索履歴に追加
this.addToSearchHistory({
type: 'file',
query,
timestamp: Date.now(),
resultCount: results.length
});
return results;
}
private calculateFileMatchScore(entry: FileIndexEntry, query: string): number {
let score = 0;
// 完全なファイル名マッチ(最高スコア)
if (entry.name.toLowerCase() === query) {
score += 100;
}
// ファイル名がクエリで始まる
if (entry.name.toLowerCase().startsWith(query)) {
score += 80;
}
// ファイル名にクエリが含まれる
if (entry.name.toLowerCase().includes(query)) {
score += 60;
}
// パスにクエリが含まれる
if (entry.relativePath.toLowerCase().includes(query)) {
score += 40;
}
// 検索用語のマッチ
for (const term of entry.searchTerms) {
if (term === query) {
score += 50;
} else if (term.startsWith(query)) {
score += 30;
} else if (term.includes(query)) {
score += 20;
}
}
// ファジーマッチボーナス
const fuzzyScore = this.calculateFuzzyScore(entry.name.toLowerCase(), query);
score += fuzzyScore * 10;
return score;
}
private calculateFuzzyScore(text: string, query: string): number {
if (query.length === 0) return 0;
if (text.length === 0) return 0;
let score = 0;
let queryIndex = 0;
let lastMatchIndex = -1;
for (let i = 0; i < text.length && queryIndex < query.length; i++) {
if (text[i] === query[queryIndex]) {
score += 1;
// 連続マッチのボーナス
if (i === lastMatchIndex + 1) {
score += 0.5;
}
lastMatchIndex = i;
queryIndex++;
}
}
// スコアを正規化
return queryIndex === query.length ? score / query.length : 0;
}
private getFileMatches(entry: FileIndexEntry, query: string): FileMatch[] {
const matches: FileMatch[] = [];
// ファイル名マッチをチェック
const nameIndex = entry.name.toLowerCase().indexOf(query);
if (nameIndex !== -1) {
matches.push({
type: 'filename',
text: entry.name,
startIndex: nameIndex,
length: query.length
});
}
// パスマッチをチェック
const pathIndex = entry.relativePath.toLowerCase().indexOf(query);
if (pathIndex !== -1) {
matches.push({
type: 'path',
text: entry.relativePath,
startIndex: pathIndex,
length: query.length
});
}
return matches;
}
private matchesPatterns(path: string, patterns: string[]): boolean {
return patterns.some(pattern => {
// シンプルなglobパターンマッチング
const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'));
return regex.test(path);
});
}
// シンボル検索
async searchSymbols(query: string, options: SymbolSearchOptions = {}): Promise<SymbolSearchResult[]> {
const results: SymbolSearchResult[] = [];
const queryLower = query.toLowerCase();
const maxResults = options.maxResults || 100;
for (const [filePath, symbols] of this.symbolIndex) {
if (results.length >= maxResults) break;
// ファイルフィルターを適用
const fileEntry = this.fileIndex.get(filePath);
if (!fileEntry) continue;
if (options.fileTypes && !options.fileTypes.includes(fileEntry.extension)) {
continue;
}
for (const symbol of symbols) {
if (results.length >= maxResults) break;
// シンボル種別フィルターを適用
if (options.symbolKinds && !options.symbolKinds.includes(symbol.kind)) {
continue;
}
// マッチスコアを計算
const score = this.calculateSymbolMatchScore(symbol, queryLower);
if (score > 0) {
results.push({
symbol,
file: fileEntry,
score,
matches: this.getSymbolMatches(symbol, queryLower)
});
}
}
}
// スコア順にソート(降順)
results.sort((a, b) => b.score - a.score);
// 検索履歴に追加
this.addToSearchHistory({
type: 'symbol',
query,
timestamp: Date.now(),
resultCount: results.length
});
return results;
}
private calculateSymbolMatchScore(symbol: SymbolIndexEntry, query: string): number {
let score = 0;
// 完全な名前マッチ
if (symbol.name.toLowerCase() === query) {
score += 100;
}
// 名前がクエリで始まる
if (symbol.name.toLowerCase().startsWith(query)) {
score += 80;
}
// 名前にクエリが含まれる
if (symbol.name.toLowerCase().includes(query)) {
score += 60;
}
// 完全名にクエリが含まれる
if (symbol.fullName.toLowerCase().includes(query)) {
score += 40;
}
// 検索用語のマッチ
for (const term of symbol.searchTerms) {
if (term === query) {
score += 50;
} else if (term.startsWith(query)) {
score += 30;
} else if (term.includes(query)) {
score += 20;
}
}
// シンボル種別ボーナス(関数とクラスは高いスコア)
if (symbol.kind === TraeAPI.SymbolKind.Function || symbol.kind === TraeAPI.SymbolKind.Method) {
score += 10;
} else if (symbol.kind === TraeAPI.SymbolKind.Class) {
score += 15;
}
return score;
}
private getSymbolMatches(symbol: SymbolIndexEntry, query: string): SymbolMatch[] {
const matches: SymbolMatch[] = [];
// 名前マッチをチェック
const nameIndex = symbol.name.toLowerCase().indexOf(query);
if (nameIndex !== -1) {
matches.push({
type: 'name',
text: symbol.name,
startIndex: nameIndex,
length: query.length
});
}
// 完全名マッチをチェック
const fullNameIndex = symbol.fullName.toLowerCase().indexOf(query);
if (fullNameIndex !== -1 && symbol.fullName !== symbol.name) {
matches.push({
type: 'fullName',
text: symbol.fullName,
startIndex: fullNameIndex,
length: query.length
});
}
return matches;
}
// コンテンツ検索
async searchContent(query: string, options: ContentSearchOptions = {}): Promise<ContentSearchResult[]> {
const results: ContentSearchResult[] = [];
const maxResults = options.maxResults || 1000;
const isRegex = options.isRegex || false;
const isCaseSensitive = options.isCaseSensitive || false;
let searchRegex: RegExp;
try {
if (isRegex) {
searchRegex = new RegExp(query, isCaseSensitive ? 'g' : 'gi');
} else {
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
searchRegex = new RegExp(escapedQuery, isCaseSensitive ? 'g' : 'gi');
}
} catch (error) {
throw new Error(`無効な検索パターン: ${error}`);
}
for (const [filePath, contentEntries] of this.contentIndex) {
if (results.length >= maxResults) break;
// ファイルフィルターを適用
const fileEntry = this.fileIndex.get(filePath);
if (!fileEntry) continue;
if (options.includePatterns && !this.matchesPatterns(fileEntry.relativePath, options.includePatterns)) {
continue;
}
if (options.excludePatterns && this.matchesPatterns(fileEntry.relativePath, options.excludePatterns)) {
continue;
}
if (options.fileTypes && !options.fileTypes.includes(fileEntry.extension)) {
continue;
}
for (const contentEntry of contentEntries) {
if (results.length >= maxResults) break;
const matches = Array.from(contentEntry.content.matchAll(searchRegex));
if (matches.length > 0) {
results.push({
file: fileEntry,
lineNumber: contentEntry.lineNumber,
content: contentEntry.content,
matches: matches.map(match => ({
startIndex: match.index || 0,
length: match[0].length,
text: match[0]
}))
});
}
}
}
// 検索履歴に追加
this.addToSearchHistory({
type: 'content',
query,
timestamp: Date.now(),
resultCount: results.length
});
return results;
}
// 検索候補
async getSearchSuggestions(query: string, type: SearchType): Promise<string[]> {
const suggestions = new Set<string>();
const queryLower = query.toLowerCase();
// 検索履歴から候補を取得
const historySuggestions = this.searchHistory
.filter(entry => entry.type === type && entry.query.toLowerCase().startsWith(queryLower))
.map(entry => entry.query)
.slice(0, 5);
historySuggestions.forEach(suggestion => suggestions.add(suggestion));
// タイプに基づいて候補を取得
switch (type) {
case 'file':
// ファイル名とパスを提案
for (const entry of this.fileIndex.values()) {
if (suggestions.size >= 10) break;
if (entry.name.toLowerCase().startsWith(queryLower)) {
suggestions.add(entry.name);
}
for (const term of entry.searchTerms) {
if (term.startsWith(queryLower)) {
suggestions.add(term);
}
}
}
break;
case 'symbol':
// シンボル名を提案
for (const symbols of this.symbolIndex.values()) {
if (suggestions.size >= 10) break;
for (const symbol of symbols) {
if (symbol.name.toLowerCase().startsWith(queryLower)) {
suggestions.add(symbol.name);
}
for (const term of symbol.searchTerms) {
if (term.startsWith(queryLower)) {
suggestions.add(term);
}
}
}
}
break;
case 'content':
// コンテンツから一般的な単語を提案
for (const contentEntries of this.contentIndex.values()) {
if (suggestions.size >= 10) break;
for (const entry of contentEntries) {
for (const term of entry.searchTerms) {
if (term.startsWith(queryLower)) {
suggestions.add(term);
}
}
}
}
break;
}
return Array.from(suggestions).slice(0, 10);
}
// 検索履歴管理
private addToSearchHistory(entry: SearchHistoryEntry): void {
// 重複エントリを削除
this.searchHistory = this.searchHistory.filter(
existing => !(existing.type === entry.type && existing.query === entry.query)
);
// 新しいエントリを先頭に追加
this.searchHistory.unshift(entry);
// 履歴サイズを制限
if (this.searchHistory.length > 100) {
this.searchHistory = this.searchHistory.slice(0, 100);
}
}
getSearchHistory(type?: SearchType): SearchHistoryEntry[] {
if (type) {
return this.searchHistory.filter(entry => entry.type === type);
}
return [...this.searchHistory];
}
clearSearchHistory(type?: SearchType): void {
if (type) {
this.searchHistory = this.searchHistory.filter(entry => entry.type !== type);
} else {
this.searchHistory = [];
}
}
// ファイルシステムウォッチャー
private setupFileWatchers(): void {
const workspaceFolders = TraeAPI.workspace.workspaceFolders;
if (!workspaceFolders) return;
for (const folder of workspaceFolders) {
const pattern = new TraeAPI.RelativePattern(folder, '**/*');
const watcher = TraeAPI.workspace.createFileSystemWatcher(pattern);
watcher.onDidCreate(uri => this.onFileCreated(uri.fsPath));
watcher.onDidChange(uri => this.onFileChanged(uri.fsPath));
watcher.onDidDelete(uri => this.onFileDeleted(uri.fsPath));
this.watchers.push(watcher);
}
}
private async onFileCreated(filePath: string): Promise<void> {
await this.indexFile(filePath);
const fileEntry = this.fileIndex.get(filePath);
if (fileEntry) {
if (this.isCodeFile(fileEntry.language)) {
await this.indexFileSymbols(filePath);
}
if (this.shouldIndexContent(fileEntry)) {
await this.indexFileContent(filePath);
}
}
this.eventEmitter.fire({ type: 'fileIndexed', filePath });
}
private async onFileChanged(filePath: string): Promise<void> {
const fileEntry = this.fileIndex.get(filePath);
if (!fileEntry) return;
// ファイルエントリを更新
await this.indexFile(filePath);
// コードファイルの場合はシンボルを更新
if (this.isCodeFile(fileEntry.language)) {
await this.indexFileSymbols(filePath);
}
// 該当する場合はコンテンツを更新
if (this.shouldIndexContent(fileEntry)) {
await this.indexFileContent(filePath);
}
this.eventEmitter.fire({ type: 'fileUpdated', filePath });
}
private onFileDeleted(filePath: string): void {
this.fileIndex.delete(filePath);
this.symbolIndex.delete(filePath);
this.contentIndex.delete(filePath);
this.eventEmitter.fire({ type: 'fileRemoved', filePath });
}
// ユーティリティメソッド
getIndexStats(): IndexStats {
const symbolCount = Array.from(this.symbolIndex.values())
.reduce((total, symbols) => total + symbols.length, 0);
const contentLineCount = Array.from(this.contentIndex.values())
.reduce((total, entries) => total + entries.length, 0);
return {
fileCount: this.fileIndex.size,
symbolCount,
contentLineCount,
isIndexing: this.indexingInProgress
};
}
async refreshIndex(): Promise<void> {
this.fileIndex.clear();
this.symbolIndex.clear();
this.contentIndex.clear();
await this.initializeIndexing();
}
// イベント処理
onDidChangeIndex(listener: (event: SearchEvent) => void): TraeAPI.Disposable {
return this.eventEmitter.event(listener);
}
dispose(): void {
this.watchers.forEach(watcher => watcher.dispose());
this.watchers = [];
this.fileIndex.clear();
this.symbolIndex.clear();
this.contentIndex.clear();
this.searchHistory = [];
this.eventEmitter.dispose();
}
}
// 検索マネージャーを初期化
const searchManager = new SearchManager();検索UI統合
// 検索UIプロバイダー
class SearchUIProvider {
private searchPanel: TraeAPI.WebviewPanel | null = null;
private currentSearchType: SearchType = 'file';
private currentQuery = '';
private searchResults: any[] = [];
constructor(private searchManager: SearchManager) {
this.setupCommands();
this.setupEventListeners();
}
private setupCommands(): void {
// 検索コマンドを登録
TraeAPI.commands.registerCommand('search.showPanel', () => {
this.showSearchPanel();
});
TraeAPI.commands.registerCommand('search.searchFiles', async () => {
const query = await TraeAPI.window.showInputBox({
prompt: 'ファイルを検索',
placeHolder: 'ファイル名またはパターンを入力...'
});
if (query) {
await this.performSearch(query, 'file');
}
});
TraeAPI.commands.registerCommand('search.searchSymbols', async () => {
const query = await TraeAPI.window.showInputBox({
prompt: 'シンボルを検索',
placeHolder: 'シンボル名を入力...'
});
if (query) {
await this.performSearch(query, 'symbol');
}
});
TraeAPI.commands.registerCommand('search.searchContent', async () => {
const query = await TraeAPI.window.showInputBox({
prompt: 'ファイル内を検索',
placeHolder: '検索テキストを入力...'
});
if (query) {
await this.performSearch(query, 'content');
}
});
}
private async showSearchPanel(): Promise<void> {
if (this.searchPanel) {
this.searchPanel.reveal();
return;
}
this.searchPanel = TraeAPI.window.createWebviewPanel(
'search',
'検索',
TraeAPI.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true
}
);
this.searchPanel.webview.html = this.getSearchHTML();
this.setupWebviewMessageHandling();
this.searchPanel.onDidDispose(() => {
this.searchPanel = null;
});
}
private getSearchHTML(): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>検索</title>
<style>
body {
margin: 0;
padding: 20px;
background: #1e1e1e;
color: #d4d4d4;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
}
.search-container {
max-width: 800px;
margin: 0 auto;
}
.search-header {
margin-bottom: 20px;
}
.search-tabs {
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid #3e3e42;
}
.search-tab {
padding: 8px 16px;
background: none;
border: none;
color: #cccccc;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.search-tab.active {
color: #ffffff;
border-bottom-color: #007acc;
}
.search-input-container {
display: flex;
margin-bottom: 15px;
}
.search-input {
flex: 1;
padding: 8px 12px;
background: #2d2d30;
border: 1px solid #3e3e42;
color: #d4d4d4;
border-radius: 3px;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #007acc;
}
.search-button {
margin-left: 8px;
padding: 8px 16px;
background: #0e639c;
border: none;
color: #ffffff;
border-radius: 3px;
cursor: pointer;
}
.search-button:hover {
background: #1177bb;
}
.search-options {
display: flex;
gap: 15px;
margin-bottom: 20px;
font-size: 12px;
}
.search-option {
display: flex;
align-items: center;
gap: 5px;
}
.search-option input[type="checkbox"] {
margin: 0;
}
.search-results {
border-top: 1px solid #3e3e42;
padding-top: 15px;
}
.search-stats {
margin-bottom: 15px;
color: #cccccc;
font-size: 12px;
}
.search-result {
margin-bottom: 10px;
padding: 10px;
background: #2d2d30;
border-radius: 3px;
cursor: pointer;
}
.search-result:hover {
background: #37373d;
}
.result-title {
font-weight: 500;
margin-bottom: 5px;
}
.result-path {
color: #cccccc;
font-size: 11px;
margin-bottom: 5px;
}
.result-content {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
color: #d4d4d4;
white-space: pre-wrap;
}
.result-match {
background: #515c6a;
color: #ffffff;
padding: 1px 2px;
border-radius: 2px;
}
.no-results {
text-align: center;
color: #cccccc;
padding: 40px;
}
.loading {
text-align: center;
color: #cccccc;
padding: 20px;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #2d2d30;
border: 1px solid #3e3e42;
border-top: none;
border-radius: 0 0 3px 3px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.suggestion {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #3e3e42;
}
.suggestion:hover {
background: #37373d;
}
.suggestion:last-child {
border-bottom: none;
}
</style>
</head>
<body>
<div class="search-container">
<div class="search-header">
<div class="search-tabs">
<button class="search-tab active" data-type="file">ファイル</button>
<button class="search-tab" data-type="symbol">シンボル</button>
<button class="search-tab" data-type="content">コンテンツ</button>
</div>
<div class="search-input-container" style="position: relative;">
<input type="text" class="search-input" id="searchInput" placeholder="検索クエリを入力...">
<button class="search-button" onclick="performSearch()">検索</button>
<div class="suggestions" id="suggestions" style="display: none;"></div>
</div>
<div class="search-options">
<div class="search-option">
<input type="checkbox" id="caseSensitive">
<label for="caseSensitive">大文字小文字を区別</label>
</div>
<div class="search-option">
<input type="checkbox" id="useRegex">
<label for="useRegex">正規表現を使用</label>
</div>
<div class="search-option">
<input type="checkbox" id="wholeWord">
<label for="wholeWord">単語全体</label>
</div>
</div>
</div>
<div class="search-results">
<div class="search-stats" id="searchStats"></div>
<div id="resultsContainer">
<div class="no-results">検索クエリを入力して開始してください</div>
</div>
</div>
</div>
<script>
const vscode = acquireVsCodeApi();
let currentSearchType = 'file';
let searchTimeout = null;
// イベントリスナーを設定
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('searchInput');
const tabs = document.querySelectorAll('.search-tab');
// タブ切り替え
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentSearchType = tab.dataset.type;
updatePlaceholder();
clearResults();
});
});
// 検索入力
searchInput.addEventListener('input', (e) => {
const query = e.target.value;
// 検索をデバウンス
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (query.length > 0) {
getSuggestions(query);
if (query.length >= 2) {
performSearch();
}
} else {
hideSuggestions();
clearResults();
}
}, 300);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
performSearch();
hideSuggestions();
} else if (e.key === 'Escape') {
hideSuggestions();
}
});
// 外側をクリックしたときに候補を非表示
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-input-container')) {
hideSuggestions();
}
});
updatePlaceholder();
});
function updatePlaceholder() {
const input = document.getElementById('searchInput');
const placeholders = {
file: '名前でファイルを検索...',
symbol: 'シンボル(関数、クラスなど)を検索...',
content: 'ファイル内のテキストを検索...'
};
input.placeholder = placeholders[currentSearchType];
}
function performSearch() {
const query = document.getElementById('searchInput').value.trim();
if (!query) return;
showLoading();
const options = {
isCaseSensitive: document.getElementById('caseSensitive').checked,
isRegex: document.getElementById('useRegex').checked,
wholeWord: document.getElementById('wholeWord').checked
};
vscode.postMessage({
type: 'search',
searchType: currentSearchType,
query,
options
});
}
function getSuggestions(query) {
vscode.postMessage({
type: 'getSuggestions',
searchType: currentSearchType,
query
});
}
function showSuggestions(suggestions) {
const suggestionsEl = document.getElementById('suggestions');
if (suggestions.length === 0) {
hideSuggestions();
return;
}
suggestionsEl.innerHTML = suggestions.map(suggestion =>
\`<div class="suggestion" onclick="selectSuggestion('\${suggestion}')">\${suggestion}</div>\`
).join('');
suggestionsEl.style.display = 'block';
}
function hideSuggestions() {
document.getElementById('suggestions').style.display = 'none';
}
function selectSuggestion(suggestion) {
document.getElementById('searchInput').value = suggestion;
hideSuggestions();
performSearch();
}
function showLoading() {
document.getElementById('resultsContainer').innerHTML =
'<div class="loading">検索中...</div>';
}
function clearResults() {
document.getElementById('resultsContainer').innerHTML =
'<div class="no-results">検索クエリを入力して開始してください</div>';
document.getElementById('searchStats').textContent = '';
}
function showResults(results, stats) {
const container = document.getElementById('resultsContainer');
const statsEl = document.getElementById('searchStats');
statsEl.textContent = \`\${results.length}件の結果が\${stats.searchTime}msで見つかりました\`;
if (results.length === 0) {
container.innerHTML = '<div class="no-results">結果が見つかりませんでした</div>';
return;
}
container.innerHTML = results.map(result => {
switch (currentSearchType) {
case 'file':
return createFileResult(result);
case 'symbol':
return createSymbolResult(result);
case 'content':
return createContentResult(result);
default:
return '';
}
}).join('');
}
function createFileResult(result) {
return \`
<div class="search-result" onclick="openFile('\${result.file.path}')">
<div class="result-title">\${result.file.name}</div>
<div class="result-path">\${result.file.relativePath}</div>
</div>
\`;
}
function createSymbolResult(result) {
return \`
<div class="search-result" onclick="openSymbol('\${result.file.path}', \${result.symbol.range.start.line})">
<div class="result-title">\${result.symbol.name}</div>
<div class="result-path">\${result.file.relativePath}:\${result.symbol.range.start.line + 1}</div>
</div>
\`;
}
function createContentResult(result) {
let content = result.content;
// マッチをハイライト
result.matches.forEach(match => {
const before = content.substring(0, match.startIndex);
const matchText = content.substring(match.startIndex, match.startIndex + match.length);
const after = content.substring(match.startIndex + match.length);
content = before + \`<span class="result-match">\${matchText}</span>\` + after;
});
return \`
<div class="search-result" onclick="openFile('\${result.file.path}', \${result.lineNumber})">
<div class="result-path">\${result.file.relativePath}:\${result.lineNumber}</div>
<div class="result-content">\${content}</div>
</div>
\`;
}
function openFile(filePath, lineNumber) {
vscode.postMessage({
type: 'openFile',
filePath,
lineNumber
});
}
function openSymbol(filePath, lineNumber) {
vscode.postMessage({
type: 'openFile',
filePath,
lineNumber
});
}
// 拡張機能からのメッセージを処理
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'searchResults':
showResults(message.results, message.stats);
break;
case 'suggestions':
showSuggestions(message.suggestions);
break;
}
});
</script>
</body>
</html>
`;
}
private setupWebviewMessageHandling(): void {
if (!this.searchPanel) return;
this.searchPanel.webview.onDidReceiveMessage(async message => {
switch (message.type) {
case 'search':
await this.handleSearchRequest(message);
break;
case 'getSuggestions':
await this.handleSuggestionsRequest(message);
break;
case 'openFile':
await this.handleOpenFile(message);
break;
}
});
}
private async handleSearchRequest(message: any): Promise<void> {
const startTime = Date.now();
let results: any[] = [];
try {
switch (message.searchType) {
case 'file':
results = await this.searchManager.searchFiles(message.query, message.options);
break;
case 'symbol':
results = await this.searchManager.searchSymbols(message.query, message.options);
break;
case 'content':
results = await this.searchManager.searchContent(message.query, message.options);
break;
}
} catch (error) {
console.error('検索に失敗しました:', error);
results = [];
}
const searchTime = Date.now() - startTime;
this.sendMessage({
type: 'searchResults',
results,
stats: { searchTime }
});
}
private async handleSuggestionsRequest(message: any): Promise<void> {
try {
const suggestions = await this.searchManager.getSearchSuggestions(
message.query,
message.searchType
);
this.sendMessage({
type: 'suggestions',
suggestions
});
} catch (error) {
console.error('候補の取得に失敗しました:', error);
}
}
private async handleOpenFile(message: any): Promise<void> {
try {
const uri = TraeAPI.Uri.file(message.filePath);
const document = await TraeAPI.workspace.openTextDocument(uri);
const editor = await TraeAPI.window.showTextDocument(document);
if (message.lineNumber) {
const line = message.lineNumber - 1; // 0ベースに変換
const position = new TraeAPI.Position(line, 0);
editor.selection = new TraeAPI.Selection(position, position);
editor.revealRange(new TraeAPI.Range(position, position));
}
} catch (error) {
console.error('ファイルのオープンに失敗しました:', error);
TraeAPI.window.showErrorMessage(`ファイルのオープンに失敗しました: ${message.filePath}`);
}
}
private async performSearch(query: string, type: SearchType): Promise<void> {
this.currentQuery = query;
this.currentSearchType = type;
try {
let results: any[] = [];
switch (type) {
case 'file':
results = await this.searchManager.searchFiles(query);
break;
case 'symbol':
results = await this.searchManager.searchSymbols(query);
break;
case 'content':
results = await this.searchManager.searchContent(query);
break;
}
this.searchResults = results;
await this.showSearchResults(results, type);
} catch (error) {
console.error('検索に失敗しました:', error);
TraeAPI.window.showErrorMessage(`検索に失敗しました: ${error}`);
}
}
private async showSearchResults(results: any[], type: SearchType): Promise<void> {
if (results.length === 0) {
TraeAPI.window.showInformationMessage('結果が見つかりませんでした');
return;
}
// 結果のクイックピックを表示
const items = results.slice(0, 50).map(result => {
switch (type) {
case 'file':
return {
label: result.file.name,
description: result.file.relativePath,
detail: `スコア: ${result.score}`,
result
};
case 'symbol':
return {
label: result.symbol.name,
description: `${result.file.relativePath}:${result.symbol.range.start.line + 1}`,
detail: `${this.getSymbolKindName(result.symbol.kind)} - スコア: ${result.score}`,
result
};
case 'content':
return {
label: result.content.trim(),
description: `${result.file.relativePath}:${result.lineNumber}`,
detail: `${result.matches.length}件のマッチ`,
result
};
default:
return { label: '', description: '', result };
}
});
const selected = await TraeAPI.window.showQuickPick(items, {
placeHolder: `${results.length}件の検索結果から選択`,
matchOnDescription: true,
matchOnDetail: true
});
if (selected) {
await this.openSearchResult(selected.result, type);
}
}
private async openSearchResult(result: any, type: SearchType): Promise<void> {
let filePath: string;
let lineNumber: number | undefined;
switch (type) {
case 'file':
filePath = result.file.path;
break;
case 'symbol':
filePath = result.file.path;
lineNumber = result.symbol.range.start.line;
break;
case 'content':
filePath = result.file.path;
lineNumber = result.lineNumber - 1; // 0ベースに変換
break;
default:
return;
}
try {
const uri = TraeAPI.Uri.file(filePath);
const document = await TraeAPI.workspace.openTextDocument(uri);
const editor = await TraeAPI.window.showTextDocument(document);
if (lineNumber !== undefined) {
const position = new TraeAPI.Position(lineNumber, 0);
editor.selection = new TraeAPI.Selection(position, position);
editor.revealRange(new TraeAPI.Range(position, position));
}
} catch (error) {
console.error('検索結果のオープンに失敗しました:', error);
TraeAPI.window.showErrorMessage(`ファイルのオープンに失敗しました: ${filePath}`);
}
}
private getSymbolKindName(kind: TraeAPI.SymbolKind): string {
const kindNames: { [key: number]: string } = {
[TraeAPI.SymbolKind.File]: 'ファイル',
[TraeAPI.SymbolKind.Module]: 'モジュール',
[TraeAPI.SymbolKind.Namespace]: '名前空間',
[TraeAPI.SymbolKind.Package]: 'パッケージ',
[TraeAPI.SymbolKind.Class]: 'クラス',
[TraeAPI.SymbolKind.Method]: 'メソッド',
[TraeAPI.SymbolKind.Property]: 'プロパティ',
[TraeAPI.SymbolKind.Field]: 'フィールド',
[TraeAPI.SymbolKind.Constructor]: 'コンストラクタ',
[TraeAPI.SymbolKind.Enum]: '列挙型',
[TraeAPI.SymbolKind.Interface]: 'インターフェース',
[TraeAPI.SymbolKind.Function]: '関数',
[TraeAPI.SymbolKind.Variable]: '変数',
[TraeAPI.SymbolKind.Constant]: '定数',
[TraeAPI.SymbolKind.String]: '文字列',
[TraeAPI.SymbolKind.Number]: '数値',
[TraeAPI.SymbolKind.Boolean]: 'ブール値',
[TraeAPI.SymbolKind.Array]: '配列',
[TraeAPI.SymbolKind.Object]: 'オブジェクト',
[TraeAPI.SymbolKind.Key]: 'キー',
[TraeAPI.SymbolKind.Null]: 'Null',
[TraeAPI.SymbolKind.EnumMember]: '列挙型メンバー',
[TraeAPI.SymbolKind.Struct]: '構造体',
[TraeAPI.SymbolKind.Event]: 'イベント',
[TraeAPI.SymbolKind.Operator]: '演算子',
[TraeAPI.SymbolKind.TypeParameter]: '型パラメータ'
};
return kindNames[kind] || '不明';
}
private sendMessage(message: any): void {
if (this.searchPanel) {
this.searchPanel.webview.postMessage(message);
}
}
private setupEventListeners(): void {
this.searchManager.onDidChangeIndex(event => {
// 検索パネルが開いている場合は更新
if (this.searchPanel && this.currentQuery) {
// 現在の検索を再実行して更新された結果を取得
this.performSearch(this.currentQuery, this.currentSearchType);
}
});
}
}
// 検索UIを初期化
const searchUIProvider = new SearchUIProvider(searchManager);インターフェース
interface FileIndexEntry {
path: string;
relativePath: string;
name: string;
extension: string;
size: number;
lastModified: number;
language: string;
searchTerms: string[];
}
interface SymbolMatch {
type: 'name' | 'fullName';
text: string;
startIndex: number;
length: number;
}
interface ContentMatch {
startIndex: number;
length: number;
text: string;
}
interface IndexStats {
fileCount: number;
symbolCount: number;
contentLineCount: number;
isIndexing: boolean;
}
interface SearchEvent {
type: 'indexingStarted' | 'indexingCompleted' | 'indexingFailed' | 'fileIndexed' | 'fileUpdated' | 'fileRemoved';
filePath?: string;
error?: Error;
}
type SearchType = 'file' | 'symbol' | 'content';APIリファレンス
SearchManager
メソッド
searchFiles(query: string, options?: FileSearchOptions): Promise<FileSearchResult[]>- ファイル名とパスでファイルを検索
- マッチ情報付きのランク付けされた結果を返す
searchSymbols(query: string, options?: SymbolSearchOptions): Promise<SymbolSearchResult[]>- シンボル(関数、クラス、変数)を検索
- シンボル種別とファイルタイプによるフィルタリングをサポート
searchContent(query: string, options?: ContentSearchOptions): Promise<ContentSearchResult[]>- ファイル内のテキストコンテンツを検索
- 正規表現パターンと大文字小文字の区別をサポート
getSearchSuggestions(query: string, type: SearchType): Promise<string[]>- クエリとタイプに基づいて検索候補を取得
- 履歴とインデックスから関連する候補を返す
getSearchHistory(type?: SearchType): SearchHistoryEntry[]- 検索履歴エントリを取得
- オプションで検索タイプによるフィルタリング
clearSearchHistory(type?: SearchType): void- 検索履歴をクリア
- オプションで特定のタイプのみクリア
getIndexStats(): IndexStats- 現在のインデックス統計を取得
- ファイル、シンボル、コンテンツの数を返す
refreshIndex(): Promise<void>- 検索インデックス全体を再構築
- 大きなファイルシステム変更後に有用
onDidChangeIndex(listener: (event: SearchEvent) => void): TraeAPI.Disposable- インデックス変更イベントをリッスン
- リッスンを停止するためのDisposableを返す
dispose(): void- リソースをクリーンアップしてファイルウォッチャーを停止
SearchUIProvider
メソッド
showSearchPanel(): Promise<void>- 検索Webviewパネルを表示
- 存在しない場合はパネルを作成
performSearch(query: string, type: SearchType): Promise<void>- 検索を実行して結果を表示
- 検索結果でUIを更新
ベストプラクティス
パフォーマンス最適化
// より良いパフォーマンスのために検索結果を制限
const results = await searchManager.searchFiles(query, {
maxResults: 50
});
// 特定のファイルタイプフィルターを使用
const jsResults = await searchManager.searchSymbols(query, {
fileTypes: ['.js', '.ts'],
symbolKinds: [TraeAPI.SymbolKind.Function, TraeAPI.SymbolKind.Class]
});
// より良いパフォーマンスのために除外パターンを使用
const contentResults = await searchManager.searchContent(query, {
excludePatterns: ['node_modules/**', 'dist/**', '*.min.js']
});検索クエリの最適化
// より良い結果のために特定のクエリを使用
const specificResults = await searchManager.searchFiles('UserService.ts');
// 部分マッチにファジーマッチングを使用
const fuzzyResults = await searchManager.searchFiles('usrserv');
// 複雑なパターンに正規表現を使用
const regexResults = await searchManager.searchContent('function\\s+\\w+\\(', {
isRegex: true
});インデックス管理
// インデックス状態を監視
searchManager.onDidChangeIndex(event => {
switch (event.type) {
case 'indexingStarted':
console.log('検索インデックス化が開始されました');
break;
case 'indexingCompleted':
console.log('検索インデックス化が完了しました');
break;
case 'indexingFailed':
console.error('検索インデックス化に失敗しました:', event.error);
break;
}
});
// インデックス統計をチェック
const stats = searchManager.getIndexStats();
console.log(`${stats.fileCount}ファイル、${stats.symbolCount}シンボルをインデックス化しました`);
// 必要に応じてインデックスを更新
if (majorChangesDetected) {
await searchManager.refreshIndex();
}エラーハンドリング
try {
const results = await searchManager.searchContent(userQuery, {
isRegex: true
});
displayResults(results);
} catch (error) {
if (error.message.includes('無効な検索パターン')) {
TraeAPI.window.showErrorMessage('無効な正規表現パターンです。検索クエリを確認してください。');
} else {
TraeAPI.window.showErrorMessage(`検索に失敗しました: ${error.message}`);
}
}メモリ管理
// 不要になったときに検索マネージャーを破棄
class MyExtension {
private searchManager: SearchManager;
activate() {
this.searchManager = new SearchManager();
}
deactivate() {
this.searchManager.dispose();
}
}
// 大きなファイルのコンテンツインデックス化を制限
const shouldIndex = (fileEntry: FileIndexEntry) => {
return fileEntry.size < 1024 * 1024; // 1MBの制限
};関連API
- Workspace API - ファイルシステム操作
- Editor API - テキストエディター統合
- Commands API - 検索コマンド登録
- UI API - 検索UIコンポーネント
- Language Services API - シンボルプロバイダー