├── .husky └── pre-commit ├── assets ├── demo.gif ├── icon.png └── periscope.png ├── .gitignore ├── .prettierrc ├── src ├── utils │ ├── jsonUtils.ts │ ├── getSelectedText.ts │ ├── quickpickUtils.ts │ ├── searchCurrentFile.ts │ ├── ripgrepPath.ts │ ├── getConfig.ts │ ├── formatPathLabel.ts │ ├── log.ts │ ├── createPeekDecorationManager.ts │ └── findRipgrepSystemPath.ts ├── types │ ├── ripgrep.ts │ └── index.ts ├── extension.ts └── lib │ ├── periscope.ts │ ├── storage.ts │ ├── context.ts │ ├── globalActions.ts │ ├── editorActions.ts │ ├── quickpickActions.ts │ └── ripgrep.ts ├── .vscodeignore ├── .vscode ├── extensions.json ├── settings.json ├── tasks.json └── launch.json ├── test ├── fixtures │ ├── workspace │ │ ├── folder with spaces │ │ │ ├── another file.js │ │ │ └── file with spaces.ts │ │ ├── config │ │ │ ├── settings.json │ │ │ └── .env.example │ │ ├── .vscode │ │ │ └── settings.json │ │ ├── package.json │ │ ├── src │ │ │ ├── components │ │ │ │ ├── Button.tsx │ │ │ │ └── Header.tsx │ │ │ ├── index.ts │ │ │ ├── utils │ │ │ │ ├── helpers.ts │ │ │ │ └── logger.ts │ │ │ └── special-config-test.ts │ │ ├── build │ │ │ └── compiled.js │ │ └── tests │ │ │ ├── unit.test.ts │ │ │ └── integration.test.ts │ └── fixtureLoader.ts ├── runTest.ts ├── suite │ ├── index.ts │ ├── extension.test.ts │ ├── search.test.ts │ └── ripgrep.test.ts └── utils │ └── periscopeTestHelper.ts ├── .prettierignore ├── tsconfig.json ├── LICENSE.txt ├── release.config.cjs ├── .eslintrc.json ├── .github └── workflows │ ├── test.yml │ └── publish-vscode-extension.yml ├── scripts ├── cleanCompiledFiles.js └── postCompile.js ├── CONTRIBUTING.md ├── README.md ├── package.json └── CHANGELOG.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshmu/periscope/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshmu/periscope/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/periscope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshmu/periscope/HEAD/assets/periscope.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | .env.local 7 | .cursorrules 8 | .clinerules 9 | docs/ 10 | requirements/ 11 | TODO.md 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "tabWidth": 2, 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/jsonUtils.ts: -------------------------------------------------------------------------------- 1 | export function tryJsonParse(meta: string): T | undefined { 2 | try { 3 | return JSON.parse(meta); 4 | } catch { 5 | return undefined; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | src/** 4 | .gitignore 5 | .yarnrc 6 | vsc-extension-quickstart.md 7 | **/tsconfig.json 8 | **/.eslintrc.json 9 | **/*.map 10 | **/*.ts 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint"] 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/getSelectedText.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | // retrieve current highlighted text 4 | export function getSelectedText() { 5 | let selectedText = ''; 6 | const editor = vscode.window.activeTextEditor; 7 | if (editor) { 8 | selectedText = editor.document.getText(editor.selection); 9 | } 10 | return selectedText; 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/workspace/folder with spaces/another file.js: -------------------------------------------------------------------------------- 1 | // Another test file in a folder with spaces 2 | function handleSpacePath() { 3 | console.log('This file is in a folder with spaces'); 4 | } 5 | 6 | // Search test marker 7 | const SPACES_IN_PATH_TEST = 'Found in space path'; 8 | 9 | module.exports = { 10 | handleSpacePath, 11 | SPACES_IN_PATH_TEST 12 | }; -------------------------------------------------------------------------------- /test/fixtures/workspace/config/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "application": { 3 | "name": "Test Fixture App", 4 | "version": "1.0.0", 5 | "environment": "test" 6 | }, 7 | "database": { 8 | "host": "localhost", 9 | "port": 5432, 10 | "name": "testdb" 11 | }, 12 | "features": { 13 | "enableLogging": true, 14 | "debugMode": false 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/fixtures/workspace/config/.env.example: -------------------------------------------------------------------------------- 1 | # Environment Configuration 2 | NODE_ENV=development 3 | PORT=3000 4 | 5 | # Database 6 | DB_HOST=localhost 7 | DB_PORT=5432 8 | DB_NAME=myapp 9 | DB_USER=admin 10 | DB_PASSWORD=secret 11 | 12 | # API Keys 13 | API_KEY=your-api-key-here 14 | SECRET_KEY=your-secret-key 15 | 16 | # Feature Flags 17 | ENABLE_DEBUG=true 18 | ENABLE_CACHE=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore all files in the node_modules directory 2 | node_modules 3 | 4 | # Ignore all files in the dist directory 5 | dist 6 | 7 | # Ignore all files in the build directory 8 | build 9 | 10 | # Ignore all files in the coverage directory 11 | coverage 12 | 13 | # Ignore all files with a .min.js extension 14 | *.min.js 15 | 16 | # Ignore all files with a .min.css extension 17 | *.min.css 18 | 19 | # Ignore test fixtures 20 | test/fixtures/**/* -------------------------------------------------------------------------------- /test/fixtures/workspace/folder with spaces/file with spaces.ts: -------------------------------------------------------------------------------- 1 | // Test file with spaces in the path 2 | export function testSpacesInPath(): string { 3 | return 'This file has spaces in its path'; 4 | } 5 | 6 | export class SpacePathTest { 7 | private message = 'Testing path with spaces'; 8 | 9 | getMessage(): string { 10 | return this.message; 11 | } 12 | } 13 | 14 | // TODO: Verify this file can be searched 15 | const SEARCH_TEST = 'ripgrep should find this content'; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /test/fixtures/workspace/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "periscope.rgGlobExcludes": [], 3 | "periscope.rgOptions": [], 4 | "periscope.showPreviousResultsWhenNoMatches": false, 5 | "periscope.rgQueryParams": [], 6 | "periscope.rgMenuActions": [], 7 | "periscope.alwaysShowRgMenuActions": false, 8 | "periscope.enableGotoNativeSearch": true, 9 | "periscope.gotoNativeSearchSuffix": ">>", 10 | "periscope.peekBorderColor": null, 11 | "periscope.peekMatchColor": null, 12 | "periscope.showLineNumbers": true 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/workspace/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-fixture-app", 3 | "version": "1.0.0", 4 | "description": "Test fixture for Periscope extension", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "test": "jest", 8 | "build": "tsc", 9 | "dev": "ts-node src/index.ts" 10 | }, 11 | "keywords": [ 12 | "test", 13 | "fixture", 14 | "periscope" 15 | ], 16 | "dependencies": { 17 | "react": "^18.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/jest": "^29.0.0", 21 | "jest": "^29.0.0", 22 | "typescript": "^5.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/workspace/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ButtonProps { 4 | label: string; 5 | onClick: () => void; 6 | variant?: 'primary' | 'secondary'; 7 | } 8 | 9 | // TODO: Add prop validation for accessibility 10 | export function Button({ label, onClick, variant = 'primary' }: ButtonProps) { 11 | const handleClick = () => { 12 | console.log('Button clicked'); 13 | onClick(); 14 | }; 15 | 16 | return ( 17 | 20 | ); 21 | } 22 | 23 | // TODO: Implement button group component 24 | -------------------------------------------------------------------------------- /test/fixtures/workspace/build/compiled.js: -------------------------------------------------------------------------------- 1 | // Another file that should be excluded by rgGlobExcludes 2 | // Used for testing multiple exclusion patterns 3 | 4 | function compiledCode() { 5 | return 'This is compiled code that should be excluded'; 6 | } 7 | 8 | function buildArtifact() { 9 | // This function is in the build directory 10 | console.log('Build output should not appear in searches'); 11 | } 12 | 13 | // FIXME: This FIXME in build folder should not be found 14 | const buildVersion = '1.0.0-compiled'; 15 | 16 | class CompiledClass { 17 | constructor() { 18 | this.name = 'Should be excluded from search'; 19 | } 20 | } 21 | 22 | module.exports = { 23 | compiledCode, 24 | buildArtifact, 25 | CompiledClass, 26 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*", "test/**/*"], 3 | "exclude": ["node_modules", "test/fixtures/**/*"], 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "target": "ES2020", 7 | "outDir": "out", 8 | "rootDir": ".", 9 | "lib": ["ES2020"], 10 | "types": ["node", "mocha", "vscode", "sinon"], 11 | "sourceMap": true, 12 | "strict": true /* enable all strict type-checking options */ 13 | /* Additional Checks */ 14 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 15 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 16 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures/workspace/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Button } from './components/Button'; 2 | import { Header } from './components/Header'; 3 | import { getUserById, formatDate } from './utils/helpers'; 4 | import { Logger } from './utils/logger'; 5 | 6 | // Application entry point 7 | export function initializeApp() { 8 | const logger = new Logger('Main'); 9 | logger.log('Application starting...'); 10 | 11 | const user = getUserById('123'); 12 | const today = formatDate(new Date()); 13 | 14 | return { 15 | user, 16 | date: today, 17 | components: { 18 | Button, 19 | Header, 20 | }, 21 | }; 22 | } 23 | 24 | // Export all modules 25 | export { Button, Header }; 26 | export * from './utils/helpers'; 27 | export { Logger } from './utils/logger'; 28 | -------------------------------------------------------------------------------- /test/fixtures/workspace/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from './Button'; 3 | 4 | function renderHeader(title: string) { 5 | return `

${title}

`; 6 | } 7 | 8 | export function Header() { 9 | const handleLogoClick = () => { 10 | window.location.href = '/'; 11 | }; 12 | 13 | function getNavigationItems() { 14 | return ['Home', 'About', 'Contact']; 15 | } 16 | 17 | return ( 18 |
19 |
20 | Periscope Test 21 |
22 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /test/fixtures/workspace/tests/unit.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { getUserById, validateEmail } from '../src/utils/helpers'; 3 | 4 | describe('User Service', () => { 5 | it('should fetch user by ID', () => { 6 | const user = getUserById('123'); 7 | expect(user.id).toBe('123'); 8 | expect(user.name).toBeDefined(); 9 | }); 10 | 11 | it('should validate email addresses', () => { 12 | expect(validateEmail('test@example.com')).toBe(true); 13 | expect(validateEmail('invalid-email')).toBe(false); 14 | }); 15 | }); 16 | 17 | describe('Logger Tests', () => { 18 | it('should log messages with prefix', () => { 19 | // Test implementation here 20 | const result = true; 21 | expect(result).toBe(true); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/types/ripgrep.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the raw JSON output from ripgrep for a single match 3 | */ 4 | export type RgMatchRawResult = { 5 | type: string; 6 | data: { 7 | path: { text: string }; 8 | lines: { text: string }; 9 | // eslint-disable-next-line @typescript-eslint/naming-convention 10 | line_number: number; 11 | // eslint-disable-next-line @typescript-eslint/naming-convention 12 | absolute_offset: number; 13 | submatches: { 14 | end: number; 15 | match: { 16 | text: string; 17 | }; 18 | start: number; 19 | }[]; 20 | }; 21 | }; 22 | 23 | /** 24 | * Represents a processed ripgrep match result with extracted data 25 | * and the original raw ripgrep output 26 | */ 27 | export type RgMatchResult = { 28 | filePath: string; 29 | linePos: number; 30 | colPos: number; 31 | textResult: string; 32 | rawResult: RgMatchRawResult; 33 | }; 34 | -------------------------------------------------------------------------------- /test/fixtures/workspace/tests/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeAll, afterAll } from '@jest/globals'; 2 | 3 | describe('Integration Tests', () => { 4 | beforeAll(() => { 5 | // FIXME: Setup test database connection 6 | console.log('Setting up test environment'); 7 | }); 8 | 9 | afterAll(() => { 10 | // FIXME: Clean up test data 11 | console.log('Cleaning up test environment'); 12 | }); 13 | 14 | it('should handle full user workflow', async () => { 15 | // Test user registration 16 | // Test user login 17 | // Test user actions 18 | 19 | // FIXME: Implement proper async handling 20 | const result = await Promise.resolve(true); 21 | expect(result).toBe(true); 22 | }); 23 | 24 | it('should process API requests correctly', () => { 25 | // TODO: Add API mocking 26 | // Test various endpoints 27 | expect(true).toBe(true); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/fixtures/workspace/src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | export function getUserById(id: string) { 2 | // Mock implementation for testing 3 | return { 4 | id, 5 | name: 'Test User', 6 | email: 'test@example.com', 7 | }; 8 | } 9 | 10 | export function formatDate(date: Date): string { 11 | return date.toISOString().split('T')[0]; 12 | } 13 | 14 | export function validateEmail(email: string): boolean { 15 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 16 | return emailRegex.test(email); 17 | } 18 | 19 | // Helper function to parse query strings 20 | export function parseQueryString(query: string): Record { 21 | const params: Record = {}; 22 | const pairs = query.split('&'); 23 | 24 | pairs.forEach((pair) => { 25 | const [key, value] = pair.split('='); 26 | if (key) { 27 | params[key] = decodeURIComponent(value || ''); 28 | } 29 | }); 30 | 31 | return params; 32 | } 33 | -------------------------------------------------------------------------------- /test/fixtures/fixtureLoader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | 4 | /** 5 | * Test fixture loader for Periscope tests 6 | * Provides utilities to work with the test fixture workspace 7 | */ 8 | export class FixtureLoader { 9 | private static readonly FIXTURE_PATH = path.join(__dirname, 'workspace'); 10 | 11 | /** 12 | * Get the absolute path to the fixture workspace 13 | */ 14 | static getWorkspacePath(): string { 15 | return this.FIXTURE_PATH; 16 | } 17 | 18 | /** 19 | * Get the path to a specific file in the fixture workspace 20 | */ 21 | static getFilePath(relativePath: string): string { 22 | return path.join(this.FIXTURE_PATH, relativePath); 23 | } 24 | 25 | 26 | /** 27 | * Create a mock workspace folder for testing 28 | */ 29 | static getMockWorkspaceFolder(): vscode.WorkspaceFolder { 30 | return { 31 | uri: vscode.Uri.file(this.FIXTURE_PATH), 32 | name: 'test-workspace', 33 | index: 0, 34 | }; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/workspace/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export class Logger { 2 | private prefix: string; 3 | 4 | constructor(prefix: string) { 5 | this.prefix = prefix; 6 | } 7 | 8 | log(message: string) { 9 | console.log(`[${this.prefix}] ${message}`); 10 | } 11 | 12 | error(message: string) { 13 | console.error(`[${this.prefix}] ERROR: ${message}`); 14 | } 15 | 16 | warn(message: string) { 17 | console.warn(`[${this.prefix}] WARNING: ${message}`); 18 | } 19 | } 20 | 21 | const log = new Logger('App'); 22 | 23 | // Error scenarios for testing 24 | export function connectToDatabase() { 25 | try { 26 | // Simulate database connection 27 | throw new Error('Connection timeout'); 28 | } catch (err) { 29 | log.error('Database connection failed'); 30 | log.writeError('Failed to establish database connection'); 31 | } 32 | } 33 | 34 | export function processRequest(data: any) { 35 | if (!data) { 36 | log.fatalError('No data provided'); 37 | return; 38 | } 39 | 40 | log.log('Processing request'); 41 | } 42 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { RgMatchResult } from './ripgrep'; 3 | 4 | export interface QPItemDefault extends vscode.QuickPickItem { 5 | _type: 'QuickPickItemDefault'; 6 | } 7 | export interface QPItemQuery extends vscode.QuickPickItem { 8 | _type: 'QuickPickItemQuery'; 9 | // custom payload 10 | data: RgMatchResult; 11 | } 12 | export interface QPItemRgMenuAction extends vscode.QuickPickItem { 13 | _type: 'QuickPickItemRgMenuAction'; 14 | // custom payload 15 | data: { 16 | rgOption: string; 17 | }; 18 | } 19 | export interface QPItemFile extends vscode.QuickPickItem { 20 | _type: 'QuickPickItemFile'; 21 | // custom payload 22 | data: { 23 | filePath: string; 24 | }; 25 | } 26 | 27 | export type AllQPItemVariants = QPItemDefault | QPItemQuery | QPItemRgMenuAction | QPItemFile; 28 | 29 | export type DisposablesMap = { 30 | general: vscode.Disposable[]; 31 | rgMenuActions: vscode.Disposable[]; 32 | query: vscode.Disposable[]; 33 | }; 34 | 35 | export type SearchMode = 'all' | 'currentFile' | 'files'; 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | vscode-extension-periscope 2 | 3 | The MIT License (MIT) 4 | 5 | Copyright (c) Microsoft Corporation 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { runTests } from '@vscode/test-electron'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to test runner 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Set environment variables for testing 16 | // Open the fixture workspace for testing - use source directory not compiled 17 | const testWorkspace = path.resolve(__dirname, '../../test/fixtures/workspace'); 18 | const launchArgs: string[] = [testWorkspace]; 19 | const env = { 20 | ...process.env, 21 | NODE_ENV: 'test', 22 | VSCODE_TEST: 'true', 23 | }; 24 | 25 | // Download VS Code, unzip it and run the integration test 26 | await runTests({ 27 | extensionDevelopmentPath, 28 | extensionTestsPath, 29 | launchArgs, 30 | extensionTestsEnv: env, 31 | }); 32 | } catch (err) { 33 | console.error('Failed to run tests', err); 34 | process.exit(1); 35 | } 36 | } 37 | 38 | void main(); 39 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('semantic-release').GlobalConfig} 3 | */ 4 | module.exports = { 5 | branches: ['master'], 6 | plugins: [ 7 | [ 8 | '@semantic-release/commit-analyzer', 9 | { 10 | releaseRules: [ 11 | { type: 'docs', release: 'patch' }, 12 | { type: 'style', release: 'patch' }, 13 | { type: 'refactor', release: 'patch' }, 14 | { type: 'perf', release: 'patch' }, 15 | { type: 'test', release: 'patch' }, 16 | { type: 'build', release: 'patch' }, 17 | { type: 'ci', release: 'patch' }, 18 | { type: 'chore', release: 'patch' }, 19 | ], 20 | }, 21 | ], 22 | ['@semantic-release/release-notes-generator'], 23 | [ 24 | '@semantic-release/changelog', 25 | { 26 | changelogTitle: 27 | '# Changelog\n\nAll notable changes to this project will be documented in this file. See [Conventional Commits](https://www.conventionalcommits.org) for commit guidelines.\n\n', 28 | changelogFile: 'CHANGELOG.md', 29 | }, 30 | ], 31 | [ 32 | '@semantic-release/npm', 33 | { 34 | npmPublish: false, 35 | }, 36 | ], 37 | [ 38 | '@semantic-release/git', 39 | { 40 | assets: ['package.json', 'CHANGELOG.md'], 41 | }, 42 | ], 43 | '@semantic-release/github', 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /src/utils/quickpickUtils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { QPItemQuery, QPItemFile } from '../types'; 3 | import { RgMatchResult } from '../types/ripgrep'; 4 | import { formatPathLabel } from './formatPathLabel'; 5 | 6 | // required to update the quick pick item with result information 7 | export function createResultItem(searchResult: RgMatchResult): QPItemQuery { 8 | return { 9 | _type: 'QuickPickItemQuery', 10 | label: searchResult.textResult?.trim(), 11 | data: searchResult, 12 | // description: `${folders.join(path.sep)}`, 13 | detail: formatPathLabel(searchResult.filePath, { lineNumber: searchResult.linePos }), 14 | /** 15 | * ! required to support regex 16 | * otherwise quick pick will automatically remove results that don't have an exact match 17 | */ 18 | alwaysShow: true, 19 | buttons: [ 20 | { 21 | iconPath: new vscode.ThemeIcon('split-horizontal'), 22 | tooltip: 'Open in Horizontal split', 23 | }, 24 | ], 25 | }; 26 | } 27 | 28 | // create a quick pick item for file path results 29 | export function createFileItem(filePath: string): QPItemFile { 30 | return { 31 | _type: 'QuickPickItemFile', 32 | label: formatPathLabel(filePath), 33 | data: { filePath }, 34 | alwaysShow: true, 35 | buttons: [ 36 | { 37 | iconPath: new vscode.ThemeIcon('split-horizontal'), 38 | tooltip: 'Open in Horizontal split', 39 | }, 40 | ], 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/searchCurrentFile.ts: -------------------------------------------------------------------------------- 1 | import { context as cx } from '../lib/context'; 2 | import { SearchMode } from '../types'; 3 | 4 | /** 5 | * Centralized function to set search mode and update UI accordingly 6 | */ 7 | export function setSearchMode(mode: SearchMode) { 8 | cx.searchMode = mode; 9 | updateSearchModeUI(mode); 10 | } 11 | 12 | /** 13 | * Update UI elements based on search mode and injected flags 14 | */ 15 | function updateSearchModeUI(mode: SearchMode) { 16 | // Handle specific search modes first 17 | switch (mode) { 18 | case 'currentFile': 19 | cx.qp.title = 'Search current file only'; 20 | cx.qp.placeholder = '🫧 Search in current file...'; 21 | break; 22 | case 'files': 23 | cx.qp.title = 'File Search'; 24 | cx.qp.placeholder = '🫧 Search for files...'; 25 | break; 26 | case 'all': 27 | default: 28 | // Show injected flags in title if any are present (and not already handled by mode) 29 | if (cx.injectedRgFlags && cx.injectedRgFlags.length > 0 && mode === 'all') { 30 | cx.qp.title = `Search [${cx.injectedRgFlags.join(' ')}]`; 31 | cx.qp.placeholder = '🫧'; 32 | } else { 33 | cx.qp.title = undefined; 34 | cx.qp.placeholder = '🫧'; 35 | } 36 | break; 37 | } 38 | } 39 | 40 | /** 41 | * Reset search mode to default 42 | */ 43 | export function resetSearchMode() { 44 | setSearchMode('all'); 45 | } 46 | 47 | export function getCurrentFilePath() { 48 | return cx.previousActiveEditor?.document.uri.fsPath; 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/ripgrepPath.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { rgPath as vscodeRgPath } from '@vscode/ripgrep'; 3 | import { log, notifyError } from './log'; 4 | import { findRipgrepSystemPath } from './findRipgrepSystemPath'; 5 | 6 | /** 7 | * Resolve ripgrep binary path from various sources 8 | */ 9 | export function resolveRipgrepPath(userPath?: string): string { 10 | // Try user-specified override path first 11 | const userPathTrimmed = userPath?.trim(); 12 | if (userPathTrimmed) { 13 | const path = userPathTrimmed; 14 | let isValid = false; 15 | try { 16 | isValid = fs.existsSync(path); 17 | if (isValid) { 18 | fs.accessSync(path); 19 | return path; 20 | } 21 | } catch { 22 | // Path is not valid, continue to next option 23 | } 24 | 25 | log(`User-specified ripgrep path not found: ${path}`); 26 | } 27 | 28 | // Try system PATH if user path is provided and not valid 29 | const systemPath = findRipgrepSystemPath(); 30 | if (systemPath) { 31 | log( 32 | `User-specified path not found, did you mean to use ripgrep from system PATH? ${systemPath}`, 33 | ); 34 | return systemPath; 35 | } 36 | 37 | // Default to vscode ripgrep 38 | if (vscodeRgPath) { 39 | log(`Using @vscode/ripgrep bundled binary: ${vscodeRgPath}`); 40 | return vscodeRgPath; 41 | } 42 | 43 | // If all else fails, show error and throw 44 | notifyError('Ripgrep not found. Please install ripgrep or configure a valid path.'); 45 | throw new Error('Ripgrep not found'); 46 | } 47 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "rules": { 10 | "@typescript-eslint/naming-convention": [ 11 | "warn", 12 | { 13 | "selector": "default", 14 | "format": ["camelCase"], 15 | "filter": { 16 | "regex": "^_type$", 17 | "match": false 18 | } 19 | }, 20 | { 21 | "selector": "variable", 22 | "format": ["camelCase", "UPPER_CASE"] 23 | }, 24 | { 25 | "selector": "parameter", 26 | "format": ["camelCase"], 27 | "leadingUnderscore": "allow" 28 | }, 29 | { 30 | "selector": "memberLike", 31 | "modifiers": ["private"], 32 | "format": ["camelCase"], 33 | "leadingUnderscore": "require" 34 | }, 35 | { 36 | "selector": "typeLike", 37 | "format": ["PascalCase"] 38 | }, 39 | { 40 | "selector": "property", 41 | "format": null, 42 | "filter": { 43 | "regex": "^_type$", 44 | "match": true 45 | } 46 | }, 47 | { 48 | "selector": "objectLiteralProperty", 49 | "format": ["camelCase", "UPPER_CASE"], 50 | "filter": { 51 | "regex": "^(NODE_ENV|VSCODE_TEST)$", 52 | "match": true 53 | } 54 | } 55 | ], 56 | "@typescript-eslint/semi": "warn", 57 | "curly": "warn", 58 | "eqeqeq": "warn", 59 | "no-throw-literal": "warn", 60 | "semi": "off" 61 | }, 62 | "ignorePatterns": ["out", "dist", "**/*.d.ts", "test/fixtures/**/*"] 63 | } 64 | -------------------------------------------------------------------------------- /test/fixtures/workspace/src/special-config-test.ts: -------------------------------------------------------------------------------- 1 | // File for testing various configuration options 2 | // Contains multiple occurrences of patterns for testing max-count and other options 3 | 4 | export function configTestFunction() { 5 | // Multiple occurrences of "function" for max-count testing 6 | const result1 = 'function call 1'; 7 | const result2 = 'function call 2'; 8 | const result3 = 'function call 3'; 9 | const result4 = 'function call 4'; 10 | const result5 = 'function call 5'; 11 | 12 | return [result1, result2, result3, result4, result5]; 13 | } 14 | 15 | // Case sensitivity testing 16 | export function CaseSensitiveTest() { 17 | const TODO = 'uppercase TODO'; 18 | const todo = 'lowercase todo'; 19 | const Todo = 'mixed case Todo'; 20 | 21 | return { TODO, todo, Todo }; 22 | } 23 | 24 | // For testing word boundaries 25 | export function testWordBoundaries() { 26 | const test = 'exact word test'; 27 | const testing = 'testing is different'; 28 | const pretest = 'pretest should not match'; 29 | const contest = 'contest contains test'; 30 | 31 | return { test, testing, pretest, contest }; 32 | } 33 | 34 | // Multiple function definitions for testing 35 | export function helperFunction1() { return 1; } 36 | export function helperFunction2() { return 2; } 37 | export function helperFunction3() { return 3; } 38 | export function helperFunction4() { return 4; } 39 | export function helperFunction5() { return 5; } 40 | 41 | // For testing context lines (before/after) 42 | export class ContextTestClass { 43 | methodBefore() { 44 | console.log('Line before target'); 45 | } 46 | 47 | targetMethod() { 48 | console.log('TARGET_CONTEXT_TEST'); // This is what we search for 49 | } 50 | 51 | methodAfter() { 52 | console.log('Line after target'); 53 | } 54 | } -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { PERISCOPE } from './lib/periscope'; 3 | import { log, initializeOutputChannel } from './utils/log'; 4 | import { finished } from './lib/globalActions'; 5 | 6 | /** 7 | * @see https://code.visualstudio.com/api/get-started/extension-anatomy#extension-entry-file 8 | */ 9 | 10 | export function activate(context: vscode.ExtensionContext) { 11 | // Initialize the output channel for logging 12 | initializeOutputChannel(context); 13 | 14 | log('activate'); 15 | 16 | const periscopeQpCmd = vscode.commands.registerCommand( 17 | 'periscope.search', 18 | (args?: { rgFlags?: string[] }) => PERISCOPE.search(context, { rgFlags: args?.rgFlags }), 19 | ); 20 | 21 | const periscopeSearchCurrentFileQpCmd = vscode.commands.registerCommand( 22 | 'periscope.searchCurrentFile', 23 | () => PERISCOPE.search(context, { currentFileOnly: true }), 24 | ); 25 | 26 | const periscopeSplitCmd = vscode.commands.registerCommand('periscope.openInHorizontalSplit', () => 27 | PERISCOPE.openInHorizontalSplit(), 28 | ); 29 | 30 | const periscopeResumeCmd = vscode.commands.registerCommand('periscope.resumeSearch', () => 31 | PERISCOPE.resumeSearch(context), 32 | ); 33 | 34 | const periscopeResumeCurrentFileCmd = vscode.commands.registerCommand( 35 | 'periscope.resumeSearchCurrentFile', 36 | () => PERISCOPE.resumeSearchCurrentFile(context), 37 | ); 38 | 39 | const periscopeSearchFilesCmd = vscode.commands.registerCommand('periscope.searchFiles', () => 40 | vscode.commands.executeCommand('periscope.search', { rgFlags: ['--files'] }), 41 | ); 42 | 43 | context.subscriptions.push( 44 | periscopeQpCmd, 45 | periscopeSearchCurrentFileQpCmd, 46 | periscopeSplitCmd, 47 | periscopeResumeCmd, 48 | periscopeResumeCurrentFileCmd, 49 | periscopeSearchFilesCmd, 50 | ); 51 | } 52 | 53 | export function deactivate() { 54 | log('deactivate'); 55 | finished(); 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | branches: [main, master] 9 | # Add workflow_call to allow other workflows to depend on this 10 | workflow_call: 11 | 12 | jobs: 13 | test: 14 | name: Test (${{ matrix.os }}) 15 | strategy: 16 | # Keep fail-fast true to ensure all tests must pass 17 | fail-fast: true 18 | matrix: 19 | os: [macos-latest, ubuntu-latest, windows-latest] 20 | node-version: [20.x] 21 | 22 | runs-on: ${{ matrix.os }} 23 | 24 | steps: 25 | - name: Checkout Repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | 33 | - name: Install Dependencies 34 | run: npm install 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | # Format code before testing 39 | - name: Format Code 40 | run: npm run lint:fix 41 | 42 | # Linux requires xvfb to run vscode tests 43 | - name: Install xvfb (Linux) 44 | if: runner.os == 'Linux' 45 | run: | 46 | sudo apt-get update 47 | sudo apt-get install -y xvfb 48 | 49 | # Run tests based on platform 50 | - name: Run Tests (Linux) 51 | if: runner.os == 'Linux' 52 | run: xvfb-run -a npm test 53 | 54 | - name: Run Tests (macOS/Windows) 55 | if: runner.os != 'Linux' 56 | run: npm test 57 | 58 | # Report test status on PR (only for PRs from the main repository) 59 | - name: Update PR Status 60 | if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository 61 | uses: actions/github-script@v7 62 | with: 63 | script: | 64 | const { owner, repo, number } = context.issue; 65 | const jobName = process.env.MATRIX_OS; 66 | github.rest.issues.createComment({ 67 | owner, 68 | repo, 69 | issue_number: number, 70 | body: `✅ Tests passed on ${jobName}` 71 | }); 72 | env: 73 | MATRIX_OS: ${{ matrix.os }} 74 | -------------------------------------------------------------------------------- /src/utils/getConfig.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { resolveRipgrepPath } from './ripgrepPath'; 3 | 4 | // * resolve ripgrep path on initial load only as its a blocking operation 5 | const userRgPath = vscode.workspace 6 | .getConfiguration('periscope') 7 | .get('rgPath', undefined); 8 | const rgPath = resolveRipgrepPath(userRgPath); 9 | 10 | export function getConfig() { 11 | const vsConfig = vscode.workspace.getConfiguration('periscope'); 12 | 13 | return { 14 | rgPath, 15 | rgOptions: vsConfig.get('rgOptions', ['--smart-case', '--sortr path']), 16 | addSrcPaths: vsConfig.get('addSrcPaths', []), 17 | rgGlobExcludes: vsConfig.get('rgGlobExcludes', []), 18 | rgMenuActions: vsConfig.get<{ label?: string; value: string }[]>('rgMenuActions', []), 19 | rgQueryParams: vsConfig.get<{ param?: string; regex: string }[]>('rgQueryParams', []), 20 | rgQueryParamsShowTitle: vsConfig.get('rgQueryParamsShowTitle', true), 21 | showWorkspaceFolderInFilePath: vsConfig.get('showWorkspaceFolderInFilePath', true), 22 | startFolderDisplayIndex: vsConfig.get('startFolderDisplayIndex', 0), 23 | startFolderDisplayDepth: vsConfig.get('startFolderDisplayDepth', 1), 24 | endFolderDisplayDepth: vsConfig.get('endFolderDisplayDepth', 4), 25 | alwaysShowRgMenuActions: vsConfig.get('alwaysShowRgMenuActions', false), 26 | showPreviousResultsWhenNoMatches: vsConfig.get( 27 | 'showPreviousResultsWhenNoMatches', 28 | false, 29 | ), 30 | gotoRgMenuActionsPrefix: vsConfig.get('gotoRgMenuActionsPrefix', '<<') || '<<', 31 | enableGotoNativeSearch: vsConfig.get('enableGotoNativeSearch', true), 32 | gotoNativeSearchSuffix: vsConfig.get('gotoNativeSearchSuffix', '>>') || '>>', 33 | peekBorderColor: vsConfig.get('peekBorderColor', null), 34 | peekBorderWidth: vsConfig.get('peekBorderWidth', '2px'), 35 | peekBorderStyle: vsConfig.get('peekBorderStyle', 'solid'), 36 | peekMatchColor: vsConfig.get('peekMatchColor', null), 37 | peekMatchBorderColor: vsConfig.get('peekMatchBorderColor', null), 38 | peekMatchBorderWidth: vsConfig.get('peekMatchBorderWidth', '1px'), 39 | peekMatchBorderStyle: vsConfig.get('peekMatchBorderStyle', 'solid'), 40 | showLineNumbers: vsConfig.get('showLineNumbers', true), 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 14 | "preLaunchTask": "${defaultBuildTask}" 15 | }, 16 | { 17 | "name": "Extension Tests", 18 | "type": "extensionHost", 19 | "request": "launch", 20 | "args": [ 21 | "--extensionDevelopmentPath=${workspaceFolder}", 22 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 23 | ], 24 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 25 | "preLaunchTask": "${defaultBuildTask}" 26 | }, 27 | { 28 | "name": "Extension Tests (Single)", 29 | "type": "extensionHost", 30 | "request": "launch", 31 | "args": [ 32 | "--extensionDevelopmentPath=${workspaceFolder}", 33 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 34 | ], 35 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 36 | "preLaunchTask": "${defaultBuildTask}", 37 | "env": { 38 | "MOCHA_GREP": "${input:testGrep}" 39 | } 40 | }, 41 | { 42 | "name": "Extension Tests (File)", 43 | "type": "extensionHost", 44 | "request": "launch", 45 | "args": [ 46 | "--extensionDevelopmentPath=${workspaceFolder}", 47 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 48 | ], 49 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 50 | "preLaunchTask": "${defaultBuildTask}", 51 | "env": { 52 | "TEST_FILE": "${input:testFile}" 53 | } 54 | } 55 | ], 56 | "inputs": [ 57 | { 58 | "id": "testGrep", 59 | "type": "promptString", 60 | "description": "Enter test pattern to match (e.g., 'should handle', 'Configuration')", 61 | "default": "" 62 | }, 63 | { 64 | "id": "testFile", 65 | "type": "promptString", 66 | "description": "Enter test file name without .test.ts extension (e.g., 'extension', 'search')", 67 | "default": "" 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/periscope.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { context as cx } from './context'; 3 | import { onDidHide, setupQuickPickForQuery, setupRgMenuActions } from './quickpickActions'; 4 | import { start } from './globalActions'; 5 | import { openInHorizontalSplit } from './editorActions'; 6 | import { setSearchMode } from '../utils/searchCurrentFile'; 7 | import { getLastQuery } from './storage'; 8 | 9 | function search( 10 | extensionContext?: vscode.ExtensionContext, 11 | { 12 | currentFileOnly = false, 13 | initialQuery = '', 14 | rgFlags = [], 15 | }: { currentFileOnly?: boolean; initialQuery?: string; rgFlags?: string[] } = {}, 16 | ) { 17 | start(); 18 | 19 | // Store the extension context for storage operations 20 | cx.extensionContext = extensionContext; 21 | 22 | // Store injected ripgrep flags in context 23 | cx.injectedRgFlags = rgFlags || []; 24 | 25 | if (currentFileOnly) { 26 | setSearchMode('currentFile'); 27 | } else if (rgFlags?.includes('--files')) { 28 | // Detect file search mode from injected flags 29 | setSearchMode('files'); 30 | } 31 | 32 | // if ripgrep actions are available then open preliminary quickpick 33 | const showRgMenuActions = cx.config.alwaysShowRgMenuActions && cx.config.rgMenuActions.length > 0; 34 | if (showRgMenuActions) { 35 | setupRgMenuActions(initialQuery); 36 | } else { 37 | setupQuickPickForQuery(initialQuery); 38 | } 39 | 40 | cx.disposables.general.push(cx.qp.onDidHide(onDidHide)); 41 | 42 | // search logic is triggered from the QuickPick event handlers... 43 | cx.qp.show(); 44 | } 45 | 46 | function resumeSearch(extensionContext: vscode.ExtensionContext) { 47 | const lastSearch = getLastQuery(extensionContext); 48 | if (lastSearch) { 49 | search(extensionContext, { 50 | currentFileOnly: false, 51 | initialQuery: lastSearch.query, 52 | }); 53 | } else { 54 | // No history, just open empty search 55 | search(extensionContext); 56 | } 57 | } 58 | 59 | function resumeSearchCurrentFile(extensionContext: vscode.ExtensionContext) { 60 | const lastSearch = getLastQuery(extensionContext); 61 | if (lastSearch) { 62 | search(extensionContext, { 63 | currentFileOnly: true, 64 | initialQuery: lastSearch.query, 65 | }); 66 | } else { 67 | // No history, just open empty search with current file context 68 | search(extensionContext, { currentFileOnly: true }); 69 | } 70 | } 71 | 72 | export const PERISCOPE = { 73 | search, 74 | resumeSearch, 75 | resumeSearchCurrentFile, 76 | openInHorizontalSplit, 77 | }; 78 | -------------------------------------------------------------------------------- /src/lib/storage.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { log } from '../utils/log'; 3 | 4 | const STORAGE_KEY = 'periscope.searchHistory'; 5 | const MAX_HISTORY_SIZE = 10; 6 | 7 | interface StoredQuery { 8 | query: string; 9 | timestamp: number; 10 | } 11 | 12 | // In-memory fallback storage 13 | let inMemoryHistory: StoredQuery[] = []; 14 | 15 | export function saveQuery( 16 | extensionContext: vscode.ExtensionContext | undefined, 17 | query: string, 18 | ): void { 19 | if (!query.trim()) { 20 | return; 21 | } 22 | 23 | const storedQuery: StoredQuery = { 24 | query, 25 | timestamp: Date.now(), 26 | }; 27 | 28 | try { 29 | if (extensionContext) { 30 | // Get existing history 31 | const history = extensionContext.workspaceState.get(STORAGE_KEY, []); 32 | 33 | // Add new query to the front 34 | history.unshift(storedQuery); 35 | 36 | // Limit history size 37 | if (history.length > MAX_HISTORY_SIZE) { 38 | history.splice(MAX_HISTORY_SIZE); 39 | } 40 | 41 | // Save updated history 42 | extensionContext.workspaceState.update(STORAGE_KEY, history); 43 | log(`Saved query to workspace state history`); 44 | } else { 45 | // Fallback to in-memory storage 46 | inMemoryHistory.unshift(storedQuery); 47 | if (inMemoryHistory.length > MAX_HISTORY_SIZE) { 48 | inMemoryHistory.splice(MAX_HISTORY_SIZE); 49 | } 50 | log(`Saved query to in-memory history`); 51 | } 52 | } catch (error) { 53 | // Silent fallback to in-memory storage 54 | inMemoryHistory.unshift(storedQuery); 55 | if (inMemoryHistory.length > MAX_HISTORY_SIZE) { 56 | inMemoryHistory.splice(MAX_HISTORY_SIZE); 57 | } 58 | log(`Storage error, using in-memory fallback: ${error}`); 59 | } 60 | } 61 | 62 | export function getLastQuery( 63 | extensionContext: vscode.ExtensionContext | undefined, 64 | ): StoredQuery | undefined { 65 | try { 66 | if (extensionContext) { 67 | // Try to get from workspace state 68 | const history = extensionContext.workspaceState.get(STORAGE_KEY, []); 69 | if (history.length > 0) { 70 | log(`Retrieved query from workspace state history`); 71 | return history[0]; 72 | } 73 | } 74 | 75 | // Check in-memory storage as fallback 76 | if (inMemoryHistory.length > 0) { 77 | log(`Retrieved query from in-memory history`); 78 | return inMemoryHistory[0]; 79 | } 80 | } catch (error) { 81 | log(`Error retrieving query: ${error}`); 82 | } 83 | 84 | return undefined; 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/formatPathLabel.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { getConfig } from './getConfig'; 4 | 5 | /** 6 | * Util to improve formatting of file paths 7 | * provides control to abbreviate paths that are too long 8 | * exposes initial folder display depth and end folder display depth 9 | * workspace folder name is displayed at the start of the path to provide additional context 10 | */ 11 | export function formatPathLabel(filePath: string, options?: { lineNumber?: number }) { 12 | const { workspaceFolders } = vscode.workspace; 13 | const config = getConfig(); 14 | 15 | // Validate and format line number with proper checks 16 | const lineNumberSuffix = 17 | config.showLineNumbers && 18 | typeof options?.lineNumber === 'number' && 19 | Number.isInteger(options.lineNumber) && 20 | options.lineNumber > 0 21 | ? `:${options.lineNumber}` 22 | : ''; 23 | 24 | if (!workspaceFolders) { 25 | return `${filePath}${lineNumberSuffix}`; 26 | } 27 | 28 | // Handle root path consistently across platforms 29 | if (filePath === '/' || filePath === '\\' || /^[A-Z]:\\$/i.test(filePath)) { 30 | return ['workspace', '..', '..'].join(path.sep); 31 | } 32 | 33 | // Normalize path separators for consistent handling 34 | const normalizedFilePath = filePath.split(/[/\\]/).join(path.sep); 35 | 36 | // find correct workspace folder 37 | const workspaceFolder = 38 | workspaceFolders.find((folder) => { 39 | const normalizedFolderPath = folder.uri.fsPath.split(/[/\\]/).join(path.sep); 40 | return normalizedFilePath.startsWith(normalizedFolderPath); 41 | }) || workspaceFolders[0]; 42 | 43 | const workspaceFolderName = workspaceFolder.name; 44 | let relativeFilePath; 45 | let folders; 46 | 47 | if (config.showWorkspaceFolderInFilePath) { 48 | relativeFilePath = path.relative(workspaceFolder.uri.fsPath, normalizedFilePath); 49 | folders = [workspaceFolderName, ...relativeFilePath.split(path.sep)]; 50 | } else { 51 | relativeFilePath = path 52 | .relative(workspaceFolder.uri.fsPath, normalizedFilePath) 53 | .replace(/(\.\.\/)+/, ''); 54 | folders = [...relativeFilePath.split(path.sep)]; 55 | } 56 | 57 | // abbreviate path if too long 58 | if (folders.length > config.startFolderDisplayDepth + config.endFolderDisplayDepth) { 59 | const initialFolders = folders.splice( 60 | config.startFolderDisplayIndex, 61 | config.startFolderDisplayDepth, 62 | ); 63 | folders.splice(0, folders.length - config.endFolderDisplayDepth); 64 | folders.unshift(...initialFolders, '...'); 65 | } 66 | 67 | return `${folders.join(path.sep)}${lineNumberSuffix}`; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/context.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { ChildProcessWithoutNullStreams } from 'child_process'; 3 | import { AllQPItemVariants, DisposablesMap, SearchMode } from '../types'; 4 | import { getConfig } from '../utils/getConfig'; 5 | import { createPeekDecorationManager } from '../utils/createPeekDecorationManager'; 6 | 7 | // simple context for each invoke of periscope search 8 | // @see https://code.visualstudio.com/api/references/vscode-api#QuickPick 9 | const qp = vscode.window.createQuickPick(); 10 | const { workspaceFolders } = vscode.workspace; 11 | const previousActiveEditor = vscode.window.activeTextEditor; 12 | const query = ''; 13 | const spawnRegistry: ChildProcessWithoutNullStreams[] = []; 14 | const config = getConfig(); 15 | const rgMenuActionsSelected: string[] = []; 16 | const matchDecoration = createPeekDecorationManager(); 17 | const disposables: DisposablesMap = { 18 | general: [], 19 | rgMenuActions: [], 20 | query: [], 21 | }; 22 | const appState = updateAppState('IDLE'); 23 | 24 | export const context = { 25 | resetContext, 26 | qp, 27 | workspaceFolders, 28 | previousActiveEditor, 29 | query, 30 | spawnRegistry, 31 | config, 32 | rgMenuActionsSelected, 33 | matchDecoration, 34 | disposables, 35 | appState, 36 | /** 37 | * Search mode for the current operation 38 | */ 39 | searchMode: 'all' as SearchMode, 40 | /** 41 | * Extension context for storage operations 42 | */ 43 | extensionContext: undefined as vscode.ExtensionContext | undefined, 44 | /** 45 | * Last executed ripgrep command (for debugging) 46 | */ 47 | lastRgCommand: undefined as string | undefined, 48 | /** 49 | * Injected ripgrep flags from command arguments 50 | */ 51 | injectedRgFlags: [] as string[], 52 | }; 53 | 54 | // reset the context 55 | function resetContext() { 56 | context.qp = vscode.window.createQuickPick(); 57 | context.workspaceFolders = vscode.workspace.workspaceFolders; 58 | context.previousActiveEditor = vscode.window.activeTextEditor; 59 | context.query = ''; 60 | context.spawnRegistry = []; 61 | context.config = getConfig(); 62 | context.rgMenuActionsSelected = []; 63 | context.matchDecoration = createPeekDecorationManager(); 64 | context.disposables = { 65 | general: [], 66 | rgMenuActions: [], 67 | query: [], 68 | }; 69 | context.searchMode = 'all'; 70 | context.injectedRgFlags = []; 71 | // Keep 'extensionContext' across resets to preserve search history 72 | } 73 | 74 | type AppState = 'IDLE' | 'SEARCHING' | 'FINISHED'; 75 | export function updateAppState(state: AppState) { 76 | if (context?.appState) { 77 | context.appState = state; 78 | } 79 | return state; 80 | } 81 | -------------------------------------------------------------------------------- /scripts/cleanCompiledFiles.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | /** 7 | * Safely clean up compiled .js and .js.map files that have corresponding .ts files 8 | */ 9 | 10 | function findCompiledFiles(dir, files = []) { 11 | const items = fs.readdirSync(dir); 12 | 13 | for (const item of items) { 14 | const fullPath = path.join(dir, item); 15 | const stat = fs.statSync(fullPath); 16 | 17 | if (stat.isDirectory() && !item.includes('node_modules') && !item.includes('.git')) { 18 | findCompiledFiles(fullPath, files); 19 | } else if (item.endsWith('.js') || item.endsWith('.js.map')) { 20 | files.push(fullPath); 21 | } 22 | } 23 | 24 | return files; 25 | } 26 | 27 | function hasTypeScriptSource(jsFile) { 28 | // Remove .js or .js.map extension to get base name 29 | let baseName = jsFile; 30 | if (jsFile.endsWith('.js.map')) { 31 | baseName = jsFile.slice(0, -7); // Remove .js.map 32 | } else if (jsFile.endsWith('.js')) { 33 | baseName = jsFile.slice(0, -3); // Remove .js 34 | } 35 | 36 | // Check for .ts or .tsx file 37 | return fs.existsSync(baseName + '.ts') || fs.existsSync(baseName + '.tsx'); 38 | } 39 | 40 | // Find all .js and .js.map files in src and test directories 41 | const srcFiles = findCompiledFiles(path.join(__dirname, '..', 'src')); 42 | const testFiles = findCompiledFiles(path.join(__dirname, '..', 'test')); 43 | const allFiles = [...srcFiles, ...testFiles]; 44 | 45 | console.log(`Found ${allFiles.length} compiled files to check\n`); 46 | 47 | const filesToRemove = []; 48 | const filesToKeep = []; 49 | 50 | for (const file of allFiles) { 51 | if (hasTypeScriptSource(file)) { 52 | filesToRemove.push(file); 53 | } else { 54 | filesToKeep.push(file); 55 | } 56 | } 57 | 58 | if (filesToKeep.length > 0) { 59 | console.log('Files to KEEP (no TypeScript source found):'); 60 | filesToKeep.forEach((f) => console.log(` - ${f.replace(path.join(__dirname, '..') + '/', '')}`)); 61 | console.log(); 62 | } 63 | 64 | if (filesToRemove.length > 0) { 65 | console.log(`Files to REMOVE (${filesToRemove.length} files with TypeScript sources):`); 66 | filesToRemove.forEach((f) => 67 | console.log(` - ${f.replace(path.join(__dirname, '..') + '/', '')}`), 68 | ); 69 | console.log(); 70 | 71 | // Ask for confirmation 72 | const readline = require('readline'); 73 | const rl = readline.createInterface({ 74 | input: process.stdin, 75 | output: process.stdout, 76 | }); 77 | 78 | rl.question('Do you want to remove these files? (y/n): ', (answer) => { 79 | if (answer.toLowerCase() === 'y') { 80 | filesToRemove.forEach((file) => { 81 | fs.unlinkSync(file); 82 | }); 83 | console.log(`\n✓ Removed ${filesToRemove.length} compiled files`); 84 | } else { 85 | console.log('\nCancelled - no files removed'); 86 | } 87 | rl.close(); 88 | }); 89 | } else { 90 | console.log('No compiled files with TypeScript sources found to remove'); 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | const PREFIX = 'PERISCOPE:'; 4 | 5 | // Lazy-initialized LogOutputChannel 6 | let outputChannel: vscode.LogOutputChannel | undefined; 7 | 8 | // Check if we're in test mode 9 | function isTestMode(): boolean { 10 | return process.env.NODE_ENV === 'test' || process.env.VSCODE_TEST === 'true'; 11 | } 12 | 13 | // Get or create the output channel 14 | function getOutputChannel(): vscode.LogOutputChannel { 15 | if (!outputChannel) { 16 | outputChannel = vscode.window.createOutputChannel('Periscope', { log: true }); 17 | } 18 | return outputChannel; 19 | } 20 | 21 | // Initialize the output channel (called from extension activation) 22 | export function initializeOutputChannel(context: vscode.ExtensionContext): vscode.LogOutputChannel { 23 | const channel = getOutputChannel(); 24 | context.subscriptions.push(channel); 25 | return channel; 26 | } 27 | 28 | // Helper function to format arguments into a message string 29 | function formatMessage(args: unknown[]): string { 30 | return args.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))).join(' '); 31 | } 32 | 33 | // Generic logging function to reduce repetition 34 | function logMessage( 35 | level: 'info' | 'error' | 'warn' | 'debug' | 'trace', 36 | consoleMethod: 'log' | 'error' | 'warn' | null, 37 | args: unknown[], 38 | ) { 39 | if (isTestMode()) { 40 | return; 41 | } 42 | 43 | const message = formatMessage(args); 44 | 45 | // Log to console for development (skip for trace, conditionally for debug) 46 | if (consoleMethod) { 47 | if (level === 'debug' && !process.env.DEBUG) { 48 | // Skip console logging for debug when DEBUG env is not set 49 | } else { 50 | const prefix = level === 'debug' ? [PREFIX, '[DEBUG]'] : [PREFIX]; 51 | console[consoleMethod](...prefix, ...args); 52 | } 53 | } 54 | 55 | // Log to output channel 56 | if (outputChannel) { 57 | outputChannel[level](message); 58 | } 59 | } 60 | 61 | // Main log function 62 | export function log(...args: unknown[]) { 63 | logMessage('info', 'log', args); 64 | } 65 | 66 | // Error logging 67 | log.error = function error(...args: unknown[]) { 68 | logMessage('error', 'error', args); 69 | }; 70 | 71 | // Debug logging 72 | log.debug = function debug(...args: unknown[]) { 73 | logMessage('debug', 'log', args); 74 | }; 75 | 76 | // Warning logging 77 | log.warn = function warn(...args: unknown[]) { 78 | logMessage('warn', 'warn', args); 79 | }; 80 | 81 | // Trace logging (console output disabled for trace) 82 | log.trace = function trace(...args: unknown[]) { 83 | logMessage('trace', null, args); 84 | }; 85 | 86 | // Notify the user of an error 87 | export function notifyError(msg: string, ...items: T[]) { 88 | // Log the error (will handle test mode check internally) 89 | log.error(msg); 90 | return vscode.window.showErrorMessage(`${PREFIX} ${msg}`, ...items); 91 | } 92 | 93 | // Export the output channel getter for external use if needed 94 | export { getOutputChannel }; 95 | -------------------------------------------------------------------------------- /src/utils/createPeekDecorationManager.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getConfig } from './getConfig'; 3 | 4 | /** 5 | * Creates and manages decorations for both the current line and matching text when 'peeking' at a file 6 | */ 7 | export function createPeekDecorationManager() { 8 | const { 9 | peekBorderColor: borderColor, 10 | peekBorderWidth: borderWidth, 11 | peekBorderStyle: borderStyle, 12 | peekMatchColor: matchColor, 13 | peekMatchBorderColor: matchBorderColor, 14 | peekMatchBorderWidth: matchBorderWidth = '1px', 15 | peekMatchBorderStyle: matchBorderStyle = 'solid', 16 | } = getConfig(); 17 | 18 | // Create decorations for both line and text match highlighting 19 | function createDecorationTypes() { 20 | // Use theme color for match highlighting if no custom color is set 21 | const matchBackgroundColor = 22 | matchColor ?? new vscode.ThemeColor('editor.findMatchHighlightBackground'); 23 | 24 | // Use theme color for match border if no custom color is set 25 | const matchBorderThemeColor = 26 | matchBorderColor ?? new vscode.ThemeColor('editor.findMatchHighlightBorder'); 27 | 28 | // Use theme color for peek border if no custom color is set 29 | const peekBorderThemeColor = 30 | borderColor ?? new vscode.ThemeColor('editor.findMatchHighlightBorder'); 31 | 32 | return { 33 | line: vscode.window.createTextEditorDecorationType({ 34 | isWholeLine: true, 35 | borderWidth: `0 0 ${borderWidth} 0`, 36 | borderStyle: `${borderStyle}`, 37 | borderColor: peekBorderThemeColor, 38 | }), 39 | match: vscode.window.createTextEditorDecorationType({ 40 | backgroundColor: matchBackgroundColor, 41 | borderRadius: '2px', 42 | borderWidth: matchBorderWidth, 43 | borderStyle: matchBorderStyle, 44 | borderColor: matchBorderThemeColor, 45 | }), 46 | }; 47 | } 48 | 49 | const decorations = createDecorationTypes(); 50 | 51 | /** 52 | * Apply decorations to highlight both the current line and matching text 53 | * @param editor Active text editor 54 | * @param matchRanges Array of {start, end} positions from ripgrep matches 55 | */ 56 | function set(editor: vscode.TextEditor, matchRanges: Array<{ start: number; end: number }>) { 57 | const pos = editor.selection.active; 58 | 59 | // Apply line decoration 60 | editor.setDecorations(decorations.line, [ 61 | { 62 | range: new vscode.Range(pos, pos), 63 | }, 64 | ]); 65 | 66 | // Apply match decorations using ripgrep's exact positions 67 | const matches = matchRanges.map(({ start, end }) => ({ 68 | range: new vscode.Range(pos.line, start, pos.line, end), 69 | })); 70 | editor.setDecorations(decorations.match, matches); 71 | } 72 | 73 | function remove() { 74 | const editor = vscode.window.activeTextEditor; 75 | if (editor) { 76 | editor.setDecorations(decorations.line, []); 77 | editor.setDecorations(decorations.match, []); 78 | } 79 | decorations.line.dispose(); 80 | decorations.match.dispose(); 81 | } 82 | 83 | return { 84 | set, 85 | remove, 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /scripts/postCompile.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | /** 7 | * Post-compile script to flatten the out/src directory structure. 8 | * Moves all contents from out/src/* to out/* and removes the empty out/src directory. 9 | * Also updates import paths in test files to reflect the new structure. 10 | */ 11 | 12 | const outDir = path.join(__dirname, '..', 'out'); 13 | const srcOutDir = path.join(outDir, 'src'); 14 | const testOutDir = path.join(outDir, 'test'); 15 | 16 | function moveDirectory(source, destination) { 17 | // Ensure destination exists 18 | if (!fs.existsSync(destination)) { 19 | fs.mkdirSync(destination, { recursive: true }); 20 | } 21 | 22 | // Read all items in source directory 23 | const items = fs.readdirSync(source); 24 | 25 | items.forEach((item) => { 26 | const srcPath = path.join(source, item); 27 | const destPath = path.join(destination, item); 28 | const stat = fs.statSync(srcPath); 29 | 30 | if (stat.isDirectory()) { 31 | // Recursively move directories 32 | moveDirectory(srcPath, destPath); 33 | } else { 34 | // Move files 35 | fs.renameSync(srcPath, destPath); 36 | } 37 | }); 38 | 39 | // Remove empty source directory 40 | fs.rmdirSync(source); 41 | } 42 | 43 | // Check if out/src exists 44 | if (fs.existsSync(srcOutDir)) { 45 | console.log('Moving files from out/src to out...'); 46 | 47 | try { 48 | // Get all items in out/src 49 | const items = fs.readdirSync(srcOutDir); 50 | 51 | items.forEach((item) => { 52 | const srcPath = path.join(srcOutDir, item); 53 | const destPath = path.join(outDir, item); 54 | 55 | // If destination exists and is a directory, we need to merge 56 | if (fs.existsSync(destPath) && fs.statSync(destPath).isDirectory()) { 57 | moveDirectory(srcPath, destPath); 58 | } else { 59 | // Simple move for files or non-existing destinations 60 | fs.renameSync(srcPath, destPath); 61 | } 62 | }); 63 | 64 | // Remove the now-empty src directory 65 | fs.rmdirSync(srcOutDir); 66 | console.log('Successfully flattened out/src directory structure'); 67 | 68 | // Update import paths in test files 69 | console.log('Updating import paths in test files...'); 70 | updateTestImports(testOutDir); 71 | console.log('Successfully updated test import paths'); 72 | } catch (error) { 73 | console.error('Error during post-compile processing:', error); 74 | process.exit(1); 75 | } 76 | } else { 77 | console.log('No out/src directory found, skipping post-compile step'); 78 | } 79 | 80 | /** 81 | * Recursively update import paths in test files 82 | */ 83 | function updateTestImports(dir) { 84 | const items = fs.readdirSync(dir); 85 | 86 | items.forEach((item) => { 87 | const fullPath = path.join(dir, item); 88 | const stat = fs.statSync(fullPath); 89 | 90 | if (stat.isDirectory()) { 91 | updateTestImports(fullPath); 92 | } else if (item.endsWith('.js')) { 93 | let content = fs.readFileSync(fullPath, 'utf8'); 94 | 95 | // Replace require paths that reference ../../src/xxx with ../../xxx 96 | content = content.replace(/require\("\.\.\/\.\.\/src\//g, 'require("../../'); 97 | 98 | // Also update source map references 99 | content = content.replace(/\/\/# sourceMappingURL=/g, '//# sourceMappingURL='); 100 | 101 | fs.writeFileSync(fullPath, content, 'utf8'); 102 | } 103 | }); 104 | } 105 | -------------------------------------------------------------------------------- /src/utils/findRipgrepSystemPath.ts: -------------------------------------------------------------------------------- 1 | import { accessSync, constants as F, existsSync } from 'node:fs'; 2 | import { delimiter, join } from 'node:path'; 3 | import { log } from './log'; 4 | 5 | /** 6 | * Common installation paths for ripgrep. 7 | * These are checked before the system PATH. 8 | */ 9 | const COMMON_RIPGREP_PATHS: string[] = [ 10 | // macOS (Homebrew) 11 | '/usr/local/bin/rg', 12 | '/opt/homebrew/bin/rg', 13 | // Linux (common package manager locations) 14 | '/usr/bin/rg', 15 | '/usr/local/bin/rg', // Also common for source installs 16 | // Windows (Scoop, Chocolatey - adjust if rg.exe is in a subfolder) 17 | // Assuming rg.exe is directly in these paths or their common bin subdirectories 18 | // Note: These paths might need adjustment based on typical installations 19 | process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'scoop', 'shims', 'rg.exe') : '', 20 | process.env.ProgramData ? join(process.env.ProgramData, 'chocolatey', 'bin', 'rg.exe') : '', 21 | // Common user-specific install paths for Windows if installed manually or via other means 22 | process.env.USERPROFILE ? join(process.env.USERPROFILE, 'bin', 'rg.exe') : '', 23 | process.env.USERPROFILE ? join(process.env.USERPROFILE, 'scoop', 'shims', 'rg.exe') : '', 24 | ].filter(Boolean); // Filter out empty strings from process.env paths that might not exist 25 | 26 | /** 27 | * Find ripgrep binary in system PATH or common installation locations. 28 | */ 29 | export function findRipgrepSystemPath(): string | null { 30 | log(`Starting ripgrep search. Node process PATH: ${process.env.PATH ?? 'Not set'}`); 31 | 32 | const exts = 33 | process.platform === 'win32' 34 | ? (process.env.PATHEXT ?? '.EXE;.BAT;.CMD;.COM').toLowerCase().split(';').filter(Boolean) 35 | : ['']; 36 | 37 | // 1. Check common installation paths first 38 | log('Checking common ripgrep paths...'); 39 | for (const commonPath of COMMON_RIPGREP_PATHS) { 40 | const pathToCheck = process.platform === 'win32' ? commonPath : `${commonPath}${exts[0]}`; 41 | if (existsSync(pathToCheck)) { 42 | try { 43 | accessSync(pathToCheck, F.X_OK); 44 | log(`Permissions OK. Found ripgrep binary in common path: ${pathToCheck}`); 45 | return pathToCheck; 46 | } catch (error) { 47 | log( 48 | `Error accessing common path ${pathToCheck}: ${error instanceof Error ? error.message : String(error)}`, 49 | ); 50 | // Not executable or accessible, try next common path 51 | } 52 | } 53 | } 54 | 55 | // 2. If not found in common paths, check system PATH 56 | log('Checking system PATH for ripgrep...'); 57 | const PATH = process.env.PATH ?? ''; 58 | const dirs = PATH.split(delimiter); 59 | 60 | // Windows honours PATHEXT; everywhere else we just try the bare name. 61 | 62 | // Check each directory in PATH for ripgrep binary 63 | for (let i = 0; i < dirs.length; i++) { 64 | const dir = dirs[i]; 65 | if (!dir) { 66 | continue; 67 | } 68 | 69 | for (let j = 0; j < exts.length; j++) { 70 | const candidate = join(dir, `rg${exts[j]}`); 71 | 72 | // 1. Cheap existence check avoids throwing in the common "not here" case. 73 | if (!existsSync(candidate)) { 74 | continue; 75 | } 76 | 77 | try { 78 | // 2. Ensure it's executable where the concept exists. 79 | accessSync(candidate, F.X_OK); 80 | log(`Found ripgrep binary in system PATH: ${candidate}`); 81 | return candidate; 82 | } catch { 83 | /* not executable here – keep looking */ 84 | } 85 | } 86 | } 87 | return null; 88 | } 89 | -------------------------------------------------------------------------------- /src/lib/globalActions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { log } from '../utils/log'; 4 | import { checkKillProcess } from './ripgrep'; 5 | import { context as cx, updateAppState } from './context'; 6 | import { QPItemFile, QPItemQuery } from '../types'; 7 | import { closePreviewEditor, setCursorPosition } from './editorActions'; 8 | 9 | type ConfirmPayload = ConfirmPayloadDefault | ConfirmHorizontalSplitPayload; 10 | 11 | type ConfirmPayloadDefault = { context: 'unknown' }; 12 | 13 | type ConfirmHorizontalSplitPayload = { 14 | item: QPItemQuery | QPItemFile; 15 | context: 'openInHorizontalSplit'; 16 | }; 17 | 18 | export function confirm(payload: ConfirmPayload = { context: 'unknown' }) { 19 | checkKillProcess(); 20 | 21 | let currentItem = cx.qp.selectedItems[0] as QPItemQuery | QPItemFile; 22 | if (payload.context === 'openInHorizontalSplit') { 23 | currentItem = payload.item; 24 | } 25 | 26 | if (!currentItem?.data) { 27 | return; 28 | } 29 | 30 | // Handle file items differently from query items 31 | if (currentItem._type === 'QuickPickItemFile') { 32 | const { filePath } = currentItem.data; 33 | vscode.workspace.openTextDocument(path.resolve(filePath)).then((document) => { 34 | const options: vscode.TextDocumentShowOptions = {}; 35 | 36 | if (payload.context === 'openInHorizontalSplit') { 37 | options.viewColumn = vscode.ViewColumn.Beside; 38 | closePreviewEditor(); 39 | } 40 | 41 | vscode.window.showTextDocument(document, options).then((editor) => { 42 | // For file items, position at the beginning of the file 43 | const position = new vscode.Position(0, 0); 44 | editor.selection = new vscode.Selection(position, position); 45 | editor.revealRange( 46 | new vscode.Range(position, position), 47 | vscode.TextEditorRevealType.InCenter, 48 | ); 49 | cx.qp.dispose(); 50 | }); 51 | }); 52 | } else { 53 | const { filePath, linePos, colPos, rawResult } = currentItem.data; 54 | vscode.workspace.openTextDocument(path.resolve(filePath)).then((document) => { 55 | const options: vscode.TextDocumentShowOptions = {}; 56 | 57 | if (payload.context === 'openInHorizontalSplit') { 58 | options.viewColumn = vscode.ViewColumn.Beside; 59 | closePreviewEditor(); 60 | } 61 | 62 | vscode.window.showTextDocument(document, options).then((editor) => { 63 | setCursorPosition(editor, linePos, colPos, rawResult); 64 | cx.qp.dispose(); 65 | }); 66 | }); 67 | } 68 | } 69 | 70 | // start periscope extension/search 71 | export function start() { 72 | log('start'); 73 | cx.resetContext(); 74 | setExtensionActiveContext(true); 75 | } 76 | 77 | // end periscope extension 78 | export function finished() { 79 | setExtensionActiveContext(false); 80 | updateAppState('FINISHED'); 81 | checkKillProcess(); 82 | cx.matchDecoration.remove(); 83 | disposeAll(); 84 | cx.previousActiveEditor = undefined; 85 | log('finished'); 86 | } 87 | 88 | // create vscode context for the extension for targeted keybindings 89 | /** 90 | * eg: 91 | * { 92 | "key": "ctrl+\\", 93 | "command": "periscope.openInHorizontalSplit", 94 | "when": "periscopeActive" <<< this is the context 95 | } 96 | */ 97 | export function setExtensionActiveContext(flag: boolean) { 98 | log(`setContext ${flag}`); 99 | vscode.commands.executeCommand('setContext', 'periscopeActive', flag); 100 | } 101 | 102 | function disposeAll() { 103 | cx.disposables.general.forEach((d) => d.dispose()); 104 | cx.disposables.rgMenuActions.forEach((d) => d.dispose()); 105 | cx.disposables.query.forEach((d) => d.dispose()); 106 | } 107 | -------------------------------------------------------------------------------- /.github/workflows/publish-vscode-extension.yml: -------------------------------------------------------------------------------- 1 | name: Publish VSCode Extension 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | # Add test job dependency 10 | test: 11 | uses: ./.github/workflows/test.yml 12 | 13 | semantic-release: 14 | needs: test # Require tests to pass before release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - run: npm ci 22 | - name: Semantic Release 23 | id: semantic 24 | uses: cycjimmy/semantic-release-action@v4 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | - name: Force patch bump if no release 28 | if: steps.semantic.outputs.new_release_published != 'true' 29 | run: | 30 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 31 | git config --local user.name "github-actions[bot]" 32 | npm version patch -m "chore(release): bump version to %s [skip ci]" 33 | git push --follow-tags 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | build: 38 | needs: semantic-release 39 | runs-on: ${{ matrix.os }} 40 | strategy: 41 | matrix: 42 | include: 43 | - os: windows-latest 44 | platform: win32 45 | arch: x64 46 | npm_config_arch: x64 47 | - os: windows-latest 48 | platform: win32 49 | arch: arm64 50 | npm_config_arch: arm 51 | - os: ubuntu-latest 52 | platform: linux 53 | arch: x64 54 | npm_config_arch: x64 55 | - os: ubuntu-latest 56 | platform: linux 57 | arch: arm64 58 | npm_config_arch: arm64 59 | - os: ubuntu-latest 60 | platform: linux 61 | arch: armhf 62 | npm_config_arch: arm 63 | - os: ubuntu-latest 64 | platform: alpine 65 | arch: x64 66 | npm_config_arch: x64 67 | - os: macos-latest 68 | platform: darwin 69 | arch: x64 70 | npm_config_arch: x64 71 | - os: macos-latest 72 | platform: darwin 73 | arch: arm64 74 | npm_config_arch: arm64 75 | steps: 76 | - uses: actions/checkout@v4 77 | with: 78 | fetch-depth: 0 79 | fetch-tags: true 80 | - uses: actions/setup-node@v4 81 | with: 82 | node-version: 20 83 | - name: Checkout the latest tag 84 | shell: pwsh 85 | run: git checkout tags/$(git describe --tags $(git rev-list --tags --max-count=1)) 86 | - run: npm install 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | npm_config_arch: ${{ matrix.npm_config_arch }} 90 | - shell: pwsh 91 | run: echo "target=${{ matrix.platform }}-${{ matrix.arch }}" >> $env:GITHUB_ENV 92 | - run: npx @vscode/vsce package --target ${{ env.target }} 93 | - uses: actions/upload-artifact@v4 94 | with: 95 | name: ${{ env.target }} 96 | path: '*.vsix' 97 | retention-days: 1 98 | 99 | publish: 100 | runs-on: ubuntu-latest 101 | needs: build 102 | if: success() 103 | steps: 104 | - uses: actions/download-artifact@v4 105 | - run: npx @vscode/vsce publish --packagePath $(find . -iname *.vsix) 106 | env: 107 | VSCE_PAT: ${{ secrets.VS_MARKETPLACE_TOKEN }} 108 | - run: npx ovsx publish --packagePath $(find . -iname *.vsix) 109 | env: 110 | OVSX_PAT: ${{ secrets.OPEN_VSX_TOKEN }} 111 | -------------------------------------------------------------------------------- /test/suite/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | import * as path from 'path'; 3 | import * as Mocha from 'mocha'; 4 | import * as glob from 'glob'; 5 | import * as vscode from 'vscode'; 6 | 7 | // Set test environment variables 8 | process.env.NODE_ENV = 'test'; 9 | process.env.VSCODE_TEST = 'true'; 10 | 11 | async function verifyWorkspace(): Promise { 12 | // Verify workspace is loaded 13 | const workspaceFolders = vscode.workspace.workspaceFolders; 14 | if (!workspaceFolders || workspaceFolders.length === 0) { 15 | throw new Error('No workspace folder is opened for testing'); 16 | } 17 | 18 | const workspacePath = workspaceFolders[0].uri.fsPath; 19 | console.log(`[Test Suite] Workspace loaded: ${workspacePath}`); 20 | 21 | // Verify it's the fixture workspace 22 | if (!workspacePath.includes('fixtures/workspace')) { 23 | console.warn('[Test Suite] Warning: Not using fixture workspace'); 24 | } 25 | 26 | // Ensure Periscope extension is activated 27 | const ext = vscode.extensions.getExtension('JoshMu.periscope'); 28 | if (ext && !ext.isActive) { 29 | await ext.activate(); 30 | console.log('[Test Suite] Periscope extension activated'); 31 | } 32 | } 33 | 34 | export function run(): Promise { 35 | // Configure Mocha with optional grep pattern from environment 36 | const mochaOptions: Mocha.MochaOptions = { 37 | ui: 'tdd', 38 | color: true, 39 | }; 40 | 41 | // Support filtering tests by pattern via MOCHA_GREP environment variable 42 | if (process.env.MOCHA_GREP) { 43 | mochaOptions.grep = process.env.MOCHA_GREP; 44 | console.log(`[Test Suite] Filtering tests with pattern: ${process.env.MOCHA_GREP}`); 45 | } 46 | 47 | // Create the mocha test 48 | const mocha = new Mocha(mochaOptions); 49 | 50 | const testsRoot = path.resolve(__dirname, '..'); 51 | 52 | return new Promise(async (c, e) => { 53 | try { 54 | // Verify workspace before running tests 55 | await verifyWorkspace(); 56 | } catch (error) { 57 | console.error('[Test Suite] Workspace verification failed:', error); 58 | return e(error); 59 | } 60 | 61 | // Support filtering by specific test file via TEST_FILE environment variable 62 | const testFilePattern = process.env.TEST_FILE 63 | ? `**/${process.env.TEST_FILE}.test.js` 64 | : '**/**.test.js'; 65 | 66 | if (process.env.TEST_FILE) { 67 | console.log(`[Test Suite] Running specific test file: ${process.env.TEST_FILE}`); 68 | } 69 | 70 | glob(testFilePattern, { cwd: testsRoot, ignore: '**/fixtures/**' }, (err, files) => { 71 | if (err) { 72 | return e(err); 73 | } 74 | 75 | if (files.length === 0) { 76 | const errorMsg = process.env.TEST_FILE 77 | ? `No test file found matching: ${process.env.TEST_FILE}` 78 | : 'No test files found'; 79 | console.error(`[Test Suite] ${errorMsg}`); 80 | return e(new Error(errorMsg)); 81 | } 82 | 83 | console.log(`[Test Suite] Found ${files.length} test file(s)`); 84 | 85 | // Add files to the test suite 86 | files.forEach((f) => { 87 | const fullPath = path.resolve(testsRoot, f); 88 | console.log(`[Test Suite] Adding test file: ${f}`); 89 | mocha.addFile(fullPath); 90 | }); 91 | 92 | try { 93 | // Run the mocha test 94 | mocha.run((failures) => { 95 | if (failures > 0) { 96 | e(new Error(`${failures} tests failed.`)); 97 | } else { 98 | c(); 99 | } 100 | }); 101 | } catch (error) { 102 | console.error(error); 103 | if (error instanceof Error) { 104 | e(error); 105 | } 106 | } 107 | }); 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Periscope 2 | 3 | Thank you for your interest in contributing to Periscope! We welcome contributions from the community to help improve and enhance the extension. 4 | 5 | ## Getting Started 6 | 7 | ### Prerequisites 8 | 9 | - [Git](https://git-scm.com/) 10 | - [NodeJS](https://nodejs.org/en/) 11 | - [Npm](https://www.npmjs.com/get-npm) 12 | 13 | ### General Process 14 | 15 | To get started with contributing, please follow these steps: 16 | 17 | 1. Fork the repository and clone it to your local machine. 18 | 2. Install the required dependencies by running `npm install`. 19 | 3. Make your changes or additions to the codebase. 20 | 4. Test your changes to ensure they work as expected. 21 | 5. Commit your changes and push them to your forked repository. 22 | 6. Open a pull request to the main repository. 23 | 24 | ### Running Tests 25 | 26 | - Run all tests: `npm test` 27 | - Run tests matching a pattern: `npm run test:single --grep="pattern"` 28 | - Run a specific test file: `npm run test:file --file=extension` (omit .test.ts) 29 | - Run tests without linting: `npm run test:no-lint` 30 | - Tests run against the fixture workspace in `test/fixtures/workspace/` 31 | - Test helpers are available in `test/utils/periscopeTestHelper.ts` 32 | - If a test needs specific files/content, add them to the fixtures workspace 33 | 34 | Test files go in `test/suite/` with naming convention `*.test.ts` 35 | 36 | For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md) 37 | 38 | ### Running the extension 39 | 40 | Open the extension project in VS Code (e.g. by running `code .` in the project folder). 41 | 42 | To run the extension in development mode: 43 | 44 | 1. Run the `View: Show Run and Debug` command (Cmd+Shift+P). 45 | 1. Select the green play icon, or press F5. 46 | 47 | You can read through the [Running and debugging your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension#run-the-extension) section of the official documentation. 48 | 49 | #### Make changes 50 | 51 | - You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 52 | - You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 53 | 54 | #### Explore the API 55 | 56 | - You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. 57 | 58 | #### Run tests 59 | 60 | - Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown: 61 | - `Extension Tests` - runs all tests 62 | - `Extension Tests (Single)` - prompts for pattern to filter tests 63 | - `Extension Tests (File)` - prompts for specific test file 64 | - Press `F5` to run the tests in a new window with your extension loaded. 65 | - See the output of the test result in the debug console. 66 | - Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. 67 | - The provided test runner will only consider files matching the name pattern `**.test.ts`. 68 | - You can create folders inside the `test` folder to structure your tests any way you want. 69 | 70 | ### Troubleshooting 71 | 72 | Logs can be found by running `Developer: Show Logs ...` command (using `cmd+shift+p`) and selecting `Extension Host`. 73 | 74 | You can always use debugger when you are running the extension in development mode. 75 | 76 | ## Code Guidelines 77 | 78 | Please adhere to the following guidelines when contributing to the project: 79 | 80 | - Follow the coding style and conventions used in the existing codebase. 81 | - Write clear and concise code with appropriate comments where necessary. 82 | - Ensure your code is well-tested and does not introduce any regressions. 83 | - Document any new features or changes in the appropriate sections. 84 | 85 | ## Issue Reporting 86 | 87 | If you encounter any bugs, issues, or have feature requests, please open an issue on the repository. Provide as much detail as possible, including steps to reproduce the issue and any relevant error messages. 88 | 89 | ## Thank you 90 | 91 | We appreciate your contributions and look forward to your involvement in improving Periscope! 92 | 93 | Happy coding! 94 | -------------------------------------------------------------------------------- /src/lib/editorActions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { AllQPItemVariants, QPItemFile, QPItemQuery } from '../types'; 4 | import { context as cx } from './context'; 5 | import { RgMatchResult } from '../types/ripgrep'; 6 | 7 | export function closePreviewEditor() { 8 | if (cx.previousActiveEditor) { 9 | vscode.commands.executeCommand('workbench.action.closeActiveEditor'); 10 | cx.previousActiveEditor = undefined; // prevent focus onDidHide 11 | } 12 | } 13 | 14 | // Open the current qp selected item in a horizontal split 15 | export const openInHorizontalSplit = async () => { 16 | if (!cx.qp) { 17 | return; 18 | } 19 | 20 | // grab the current selected item 21 | const currentItem = cx.qp.activeItems[0] as QPItemQuery | QPItemFile; 22 | 23 | if (!currentItem?.data) { 24 | return; 25 | } 26 | 27 | const options: vscode.TextDocumentShowOptions = { 28 | viewColumn: vscode.ViewColumn.Beside, 29 | }; 30 | 31 | closePreviewEditor(); 32 | 33 | // Handle file items differently from query items 34 | if (currentItem._type === 'QuickPickItemFile') { 35 | const { filePath } = currentItem.data; 36 | const document = await vscode.workspace.openTextDocument(filePath); 37 | const editor = await vscode.window.showTextDocument(document, options); 38 | 39 | if (editor) { 40 | // For file items, just position at the beginning of the file 41 | const position = new vscode.Position(0, 0); 42 | editor.selection = new vscode.Selection(position, position); 43 | editor.revealRange( 44 | new vscode.Range(position, position), 45 | vscode.TextEditorRevealType.InCenter, 46 | ); 47 | } 48 | } else { 49 | const { filePath, linePos, colPos, rawResult } = currentItem.data; 50 | const document = await vscode.workspace.openTextDocument(filePath); 51 | const editor = await vscode.window.showTextDocument(document, options); 52 | 53 | if (editor) { 54 | setCursorPosition(editor, linePos, colPos, rawResult); 55 | } 56 | } 57 | 58 | cx.qp?.dispose(); 59 | }; 60 | 61 | /** 62 | * Util to ensure correct value is passed to the native vscode search 63 | */ 64 | export function formatNativeVscodeQuery(query: string, suffix: string): string { 65 | // remove the config suffix from the query and trim any whitespace 66 | let output = query; 67 | if (suffix) { 68 | const index = output.indexOf(suffix); 69 | if (index !== -1) { 70 | output = output.slice(0, index); 71 | } 72 | } 73 | return output.trim(); 74 | } 75 | 76 | // Open the native VSCode search with the provided query and enable regex 77 | export function openNativeVscodeSearch(query: string, qp: vscode.QuickPick) { 78 | vscode.commands.executeCommand('workbench.action.findInFiles', { 79 | query: formatNativeVscodeQuery(query, cx.config.gotoNativeSearchSuffix), 80 | isRegex: true, 81 | isCaseSensitive: false, 82 | matchWholeWord: false, 83 | triggerSearch: true, 84 | }); 85 | 86 | // close extension down 87 | qp.hide(); 88 | } 89 | 90 | export function setCursorPosition( 91 | editor: vscode.TextEditor, 92 | linePos: number, 93 | colPos: number, 94 | rgLine: RgMatchResult['rawResult'], 95 | ) { 96 | // Check if editor is still valid 97 | if (!editor || editor.document.isClosed) { 98 | return; 99 | } 100 | 101 | const lineNumber = Math.max(linePos ? linePos - 1 : 0, 0); 102 | const charNumber = Math.max(colPos ? colPos - 1 : 0, 0); 103 | 104 | const newPosition = new vscode.Position(lineNumber, charNumber); 105 | const { range } = editor.document.lineAt(newPosition); 106 | 107 | // Set cursor position and reveal range synchronously 108 | editor.selection = new vscode.Selection(newPosition, newPosition); 109 | editor.revealRange(range, vscode.TextEditorRevealType.InCenter); 110 | 111 | // Extract submatches from rgLine and apply decorations 112 | const matches = rgLine.data.submatches.map(({ start, end }) => ({ start, end })); 113 | cx.matchDecoration.set(editor, matches); 114 | } 115 | 116 | export function handleNoResultsFound() { 117 | if (cx.config.showPreviousResultsWhenNoMatches) { 118 | return; 119 | } 120 | 121 | // hide the previous results if no results found 122 | cx.qp.items = []; 123 | // no peek preview available, show the origin document instead 124 | showPreviewOfOriginDocument(); 125 | } 126 | 127 | export function showPreviewOfOriginDocument() { 128 | if (!cx.previousActiveEditor) { 129 | return; 130 | } 131 | vscode.window.showTextDocument(cx.previousActiveEditor.document, { 132 | preserveFocus: true, 133 | preview: true, 134 | }); 135 | } 136 | 137 | export function peekItem(items: readonly (QPItemQuery | QPItemFile)[]) { 138 | if (items.length === 0) { 139 | return; 140 | } 141 | 142 | const currentItem = items[0]; 143 | if (!currentItem.data) { 144 | return; 145 | } 146 | 147 | // Handle file items differently from query items 148 | if (currentItem._type === 'QuickPickItemFile') { 149 | const { filePath } = currentItem.data; 150 | vscode.workspace.openTextDocument(path.resolve(filePath)).then((document) => { 151 | vscode.window 152 | .showTextDocument(document, { 153 | preview: true, 154 | preserveFocus: true, 155 | }) 156 | .then((editor) => { 157 | // For file items, position at the beginning of the file 158 | const position = new vscode.Position(0, 0); 159 | editor.selection = new vscode.Selection(position, position); 160 | editor.revealRange( 161 | new vscode.Range(position, position), 162 | vscode.TextEditorRevealType.InCenter, 163 | ); 164 | }); 165 | }); 166 | } else { 167 | const { filePath, linePos, colPos, rawResult } = currentItem.data; 168 | vscode.workspace.openTextDocument(path.resolve(filePath)).then((document) => { 169 | vscode.window 170 | .showTextDocument(document, { 171 | preview: true, 172 | preserveFocus: true, 173 | }) 174 | .then((editor) => { 175 | setCursorPosition(editor, linePos, colPos, rawResult); 176 | }); 177 | }); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/lib/quickpickActions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { AllQPItemVariants, QPItemFile, QPItemQuery, QPItemRgMenuAction } from '../types'; 3 | import { openNativeVscodeSearch, peekItem } from './editorActions'; 4 | import { checkKillProcess, checkAndExtractRgFlagsFromQuery, rgSearch } from './ripgrep'; 5 | import { context as cx, updateAppState } from './context'; 6 | import { getSelectedText } from '../utils/getSelectedText'; 7 | import { log } from '../utils/log'; 8 | import { confirm, finished } from './globalActions'; 9 | import { saveQuery } from './storage'; 10 | import { setSearchMode, resetSearchMode } from '../utils/searchCurrentFile'; 11 | 12 | // update quickpick event listeners for the query 13 | export function setupQuickPickForQuery(initialQuery: string = '') { 14 | // Placeholder is already set by setSearchMode, don't override it 15 | cx.qp.items = []; 16 | cx.qp.canSelectMany = false; 17 | cx.qp.value = initialQuery || getSelectedText(); 18 | cx.disposables.query.push( 19 | cx.qp.onDidChangeValue(onDidChangeValue), 20 | cx.qp.onDidChangeActive(onDidChangeActive), 21 | cx.qp.onDidAccept(onDidAccept), 22 | cx.qp.onDidTriggerItemButton(onDidTriggerItemButton), 23 | ); 24 | } 25 | 26 | export function reset() { 27 | checkKillProcess(); 28 | cx.disposables.rgMenuActions.forEach((d) => d.dispose()); 29 | cx.disposables.query.forEach((d) => d.dispose()); 30 | cx.qp.busy = false; 31 | cx.qp.value = ''; 32 | cx.query = ''; 33 | cx.rgMenuActionsSelected = []; 34 | resetSearchMode(); 35 | } 36 | 37 | // when input query 'CHANGES' 38 | function onDidChangeValue(value: string) { 39 | checkKillProcess(); 40 | 41 | if (!value) { 42 | cx.qp.items = []; 43 | return; 44 | } 45 | 46 | // Check if user wants to search for files using --files flag 47 | const hasFilesFlag = value.includes('--files'); 48 | 49 | // If --files flag is present and we're not already in file search mode, switch to it 50 | if (hasFilesFlag && cx.searchMode !== 'files') { 51 | setSearchMode('files'); 52 | } else if ( 53 | !hasFilesFlag && 54 | cx.searchMode === 'files' && 55 | !cx.injectedRgFlags.includes('--files') 56 | ) { 57 | // If --files was removed and we're in file mode (but not from injected flags), switch back 58 | resetSearchMode(); 59 | } 60 | 61 | // Remove --files from the query as it's handled by search mode 62 | const cleanedValue = hasFilesFlag ? value.replace('--files', '').trim() : value; 63 | 64 | // update the query if rgQueryParams are available and found 65 | const { rgQuery, extraRgFlags } = checkAndExtractRgFlagsFromQuery(cleanedValue); 66 | cx.query = rgQuery; 67 | 68 | // jump to rg custom menu if the prefix is found in the query 69 | if (cx.config.gotoRgMenuActionsPrefix && value.startsWith(cx.config.gotoRgMenuActionsPrefix)) { 70 | setupRgMenuActions(value.substring(cx.config.gotoRgMenuActionsPrefix.length)); 71 | return; 72 | } 73 | 74 | // jump to native vscode search if the suffix is found in the query 75 | if ( 76 | cx.config.enableGotoNativeSearch && 77 | cx.config.gotoNativeSearchSuffix && 78 | value.endsWith(cx.config.gotoNativeSearchSuffix) 79 | ) { 80 | openNativeVscodeSearch(rgQuery, cx.qp); 81 | return; 82 | } 83 | 84 | // update the quickpick title with a preview of the rgQueryParam command if utilised 85 | if (cx.config.rgQueryParamsShowTitle && cx.searchMode !== 'files') { 86 | cx.qp.title = getRgQueryParamsTitle(rgQuery, extraRgFlags); 87 | } 88 | 89 | // Perform search using the unified function 90 | rgSearch(rgQuery, extraRgFlags); 91 | 92 | // Save the query for resume functionality 93 | if (rgQuery.trim()) { 94 | saveQuery(cx.extensionContext, rgQuery); 95 | } 96 | } 97 | 98 | // when item is 'FOCUSSED' 99 | function onDidChangeActive(items: readonly AllQPItemVariants[]) { 100 | // Filter to only pass items that can be peeked (QPItemQuery or QPItemFile) 101 | const peekableItems = items.filter( 102 | (item) => item._type === 'QuickPickItemQuery' || item._type === 'QuickPickItemFile', 103 | ); 104 | if (peekableItems.length > 0) { 105 | peekItem(peekableItems as readonly (QPItemQuery | QPItemFile)[]); 106 | } 107 | } 108 | 109 | // when item is 'SELECTED' 110 | function onDidAccept() { 111 | confirm(); 112 | } 113 | 114 | // when item button is 'TRIGGERED' 115 | // this is the rightmost button on the quickpick item 116 | function onDidTriggerItemButton(e: vscode.QuickPickItemButtonEvent) { 117 | log('item button triggered'); 118 | if (e.item._type === 'QuickPickItemQuery' || e.item._type === 'QuickPickItemFile') { 119 | // as there is only horizontal split as an option we can assume this 120 | confirm({ 121 | context: 'openInHorizontalSplit', 122 | item: e.item, 123 | }); 124 | } 125 | } 126 | 127 | // when prompt is 'CANCELLED' 128 | export function onDidHide() { 129 | if (!cx.qp.selectedItems[0]) { 130 | if (cx.previousActiveEditor) { 131 | vscode.window.showTextDocument( 132 | cx.previousActiveEditor.document, 133 | cx.previousActiveEditor.viewColumn, 134 | ); 135 | } 136 | } 137 | 138 | finished(); 139 | } 140 | 141 | // when ripgrep actions are available show preliminary quickpick for those options to add to the query 142 | export function setupRgMenuActions(initialQuery: string = '') { 143 | reset(); 144 | updateAppState('IDLE'); 145 | cx.qp.placeholder = '🫧 Select actions or type custom rg options (Space key to check/uncheck)'; 146 | cx.qp.canSelectMany = true; 147 | 148 | // add items from the config 149 | cx.qp.items = cx.config.rgMenuActions.map(({ value, label }) => ({ 150 | _type: 'QuickPickItemRgMenuAction', 151 | label: label ?? value, 152 | description: label ? value : undefined, 153 | data: { 154 | rgOption: value, 155 | }, 156 | })); 157 | 158 | function next() { 159 | cx.rgMenuActionsSelected = (cx.qp.selectedItems as QPItemRgMenuAction[]).map( 160 | (item) => item.data.rgOption, 161 | ); 162 | 163 | // if no actions selected, then use the current query as a custom command to rg 164 | if (!cx.rgMenuActionsSelected.length && cx.qp.value) { 165 | cx.rgMenuActionsSelected.push(cx.qp.value); 166 | cx.qp.value = ''; 167 | } 168 | 169 | setupQuickPickForQuery(initialQuery); 170 | } 171 | 172 | cx.disposables.rgMenuActions.push(cx.qp.onDidTriggerButton(next), cx.qp.onDidAccept(next)); 173 | } 174 | 175 | // get rgQueryParams info title 176 | export function getRgQueryParamsTitle(rgQuery: string, extraRgFlags: string[]): string | undefined { 177 | // don't bother showing if there are no extraRgFlags 178 | if (!extraRgFlags.length) { 179 | return undefined; 180 | } 181 | 182 | // hint in the title the expanded rgQueryParams command 183 | return `rg '${rgQuery}' ${extraRgFlags.join(' ')}`; 184 | } 185 | -------------------------------------------------------------------------------- /src/lib/ripgrep.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import * as vscode from 'vscode'; 3 | import { getConfig } from '../utils/getConfig'; 4 | import { context as cx, updateAppState } from './context'; 5 | import { tryJsonParse } from '../utils/jsonUtils'; 6 | import { QPItemFile, QPItemQuery } from '../types'; 7 | import { RgMatchResult } from '../types/ripgrep'; 8 | import { log, notifyError } from '../utils/log'; 9 | import { createResultItem, createFileItem } from '../utils/quickpickUtils'; 10 | import { handleNoResultsFound } from './editorActions'; 11 | import { getCurrentFilePath } from '../utils/searchCurrentFile'; 12 | 13 | function getRgCommand(value: string, extraFlags?: string[]) { 14 | const config = getConfig(); 15 | const { workspaceFolders } = vscode.workspace; 16 | const { rgPath } = config; 17 | 18 | const rootPaths = workspaceFolders ? workspaceFolders.map((folder) => folder.uri.fsPath) : []; 19 | const paths = cx.searchMode === 'currentFile' ? [getCurrentFilePath()] : rootPaths; 20 | const excludes = config.rgGlobExcludes.map((exclude) => `--glob "!${exclude}"`); 21 | 22 | // Check if this is a file search (either from searchMode or injected flags) 23 | const isFileSearch = cx.searchMode === 'files' || cx.injectedRgFlags.includes('--files'); 24 | 25 | // Common flags for both search modes 26 | const commonFlags = [ 27 | ...cx.rgMenuActionsSelected, 28 | ...paths.filter((path): path is string => typeof path === 'string').map(ensureQuotedPath), 29 | ...config.addSrcPaths.map(ensureQuotedPath), 30 | ...excludes, 31 | ]; 32 | 33 | let searchPattern = ''; 34 | let modeSpecificFlags: string[]; 35 | 36 | if (isFileSearch) { 37 | // File search mode - use --files flag and glob pattern 38 | const fileGlob = value ? `--glob "*${value}*"` : ''; 39 | // Only add --files if it's not already in injectedRgFlags 40 | const filesFlag = cx.injectedRgFlags.includes('--files') ? [] : ['--files']; 41 | 42 | modeSpecificFlags = [ 43 | ...filesFlag, 44 | ...cx.injectedRgFlags, 45 | fileGlob, 46 | ...config.rgOptions.filter((opt) => !opt.includes('--json')), // remove json flag if present 47 | ]; 48 | } else { 49 | // Content search mode - use standard flags with search pattern 50 | searchPattern = handleSearchTermWithAdditionalRgParams(value); 51 | 52 | const rgRequiredFlags = [ 53 | '--line-number', 54 | '--column', 55 | '--no-heading', 56 | '--with-filename', 57 | '--color=never', 58 | '--json', 59 | ]; 60 | 61 | modeSpecificFlags = [ 62 | ...rgRequiredFlags, 63 | ...config.rgOptions, 64 | ...cx.injectedRgFlags, 65 | ...(extraFlags || []), 66 | ]; 67 | } 68 | 69 | const rgFlags = [...modeSpecificFlags, ...commonFlags].filter(Boolean); 70 | return `"${rgPath}" ${searchPattern} ${rgFlags.join(' ')}`.trim(); 71 | } 72 | 73 | /** 74 | * Support for passing raw ripgrep queries by detection of a search_term within quotes within the input query 75 | * if found we can assume the rest of the query are additional ripgrep parameters 76 | */ 77 | function handleSearchTermWithAdditionalRgParams(query: string): string { 78 | const valueWithinQuotes = /".*?"/.exec(query); 79 | if (valueWithinQuotes) { 80 | return query; 81 | } 82 | return `"${query}"`; 83 | } 84 | 85 | export function rgSearch(value: string, rgExtraFlags?: string[]) { 86 | performSearch(value, rgExtraFlags); 87 | } 88 | 89 | function performSearch(value: string, rgExtraFlags?: string[]) { 90 | updateAppState('SEARCHING'); 91 | cx.qp.busy = true; 92 | 93 | const isFileSearch = cx.searchMode === 'files' || cx.injectedRgFlags.includes('--files'); 94 | const rgCmd = getRgCommand(value, rgExtraFlags); 95 | 96 | log(isFileSearch ? 'rgCmd (files):' : 'rgCmd:', rgCmd); 97 | cx.lastRgCommand = rgCmd; 98 | checkKillProcess(); 99 | 100 | // Storage for results based on search type 101 | const searchResults: RgMatchResult[] = []; 102 | const fileResults: string[] = []; 103 | 104 | const spawnProcess = spawn(rgCmd, [], { shell: true }); 105 | cx.spawnRegistry.push(spawnProcess); 106 | 107 | spawnProcess.stdout.on('data', (data: Buffer) => { 108 | const lines = data.toString().split('\n').filter(Boolean); 109 | 110 | if (isFileSearch) { 111 | // File search - just collect file paths 112 | fileResults.push(...lines); 113 | } else { 114 | // Content search - parse JSON results 115 | lines.forEach((line) => { 116 | const parsedLine = tryJsonParse(line); 117 | if (parsedLine?.type === 'match') { 118 | searchResults.push(normaliseRgResult(parsedLine)); 119 | } 120 | }); 121 | } 122 | }); 123 | 124 | spawnProcess.stderr.on('data', (data: Buffer) => { 125 | handleRipgrepError(data); 126 | }); 127 | 128 | spawnProcess.on('exit', (code: number) => { 129 | handleRipgrepExit(code, () => { 130 | if (isFileSearch) { 131 | processFileResults(fileResults); 132 | } else { 133 | processContentResults(searchResults); 134 | } 135 | }); 136 | }); 137 | } 138 | 139 | // Common error handler for ripgrep processes 140 | function handleRipgrepError(data: Buffer) { 141 | const errorMsg = data.toString(); 142 | 143 | if (errorMsg.includes('unrecognized')) { 144 | cx.qp.title = errorMsg; 145 | } 146 | 147 | log.error(errorMsg); 148 | handleNoResultsFound(); 149 | } 150 | 151 | // Process file search results and update QuickPick items 152 | function processFileResults(fileResults: string[]) { 153 | if (fileResults.length) { 154 | cx.qp.items = fileResults.map((filePath) => createFileItem(filePath.trim())) as QPItemFile[]; 155 | } 156 | } 157 | 158 | // Process content search results and update QuickPick items 159 | function processContentResults(searchResults: RgMatchResult[]) { 160 | if (searchResults.length) { 161 | cx.qp.items = searchResults 162 | .map((searchResult) => { 163 | const { filePath, linePos, colPos, textResult } = searchResult; 164 | 165 | // if all data is not available then remove the item 166 | if (!filePath || !linePos || !colPos || !textResult) { 167 | return false; 168 | } 169 | 170 | return createResultItem(searchResult); 171 | }) 172 | .filter(Boolean) as QPItemQuery[]; 173 | } 174 | } 175 | 176 | // Common exit handler for ripgrep processes 177 | function handleRipgrepExit(code: number | null, onSuccess: () => void) { 178 | if (code === 0 && cx.appState === 'SEARCHING') { 179 | onSuccess(); 180 | } else if (code === null || code === 0) { 181 | log('Nothing to do...'); 182 | } else if (code === 127) { 183 | notifyError( 184 | `PERISCOPE: Ripgrep exited with code ${code} (Ripgrep not found. Please install ripgrep)`, 185 | ); 186 | } else if (code === 1) { 187 | log(`Ripgrep exited with code ${code} (no results found)`); 188 | handleNoResultsFound(); 189 | } else if (code === 2) { 190 | log.error(`Ripgrep exited with code ${code} (error during search operation)`); 191 | } else { 192 | const msg = `Ripgrep exited with code ${code}`; 193 | log.error(msg); 194 | notifyError(`PERISCOPE: ${msg}`); 195 | } 196 | cx.qp.busy = false; 197 | } 198 | 199 | function normaliseRgResult(parsedLine: RgMatchResult['rawResult']): RgMatchResult { 200 | // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention 201 | const { path, lines, line_number } = parsedLine.data; 202 | const filePath = path.text; 203 | // eslint-disable-next-line camelcase 204 | const linePos = line_number; 205 | const colPos = parsedLine.data.submatches[0].start + 1; 206 | const textResult = lines.text.trim(); 207 | 208 | return { 209 | filePath, 210 | linePos, 211 | colPos, 212 | textResult, 213 | rawResult: parsedLine, 214 | }; 215 | } 216 | 217 | export function checkKillProcess() { 218 | const { spawnRegistry } = cx; 219 | spawnRegistry.forEach((spawnProcess) => { 220 | if (!spawnProcess.killed) { 221 | // Check if the process is not already killed 222 | spawnProcess.stdout.destroy(); 223 | spawnProcess.stderr.destroy(); 224 | spawnProcess.kill(); 225 | } 226 | }); 227 | 228 | // Clear the registry after attempting to kill all processes 229 | cx.spawnRegistry = []; 230 | } 231 | 232 | // extract rg flags from the query, can match multiple regex's 233 | export function checkAndExtractRgFlagsFromQuery(userInput: string): { 234 | rgQuery: string; 235 | extraRgFlags: string[]; 236 | } { 237 | const extraRgFlags: string[] = []; 238 | const queries = [userInput]; 239 | 240 | cx.config.rgQueryParams.forEach(({ param, regex }) => { 241 | if (param && regex) { 242 | const match = userInput.match(regex); 243 | if (match && match.length > 1) { 244 | let newParam = param; 245 | match.slice(2).forEach((value, index) => { 246 | newParam = newParam.replace(`$${index + 1}`, value); 247 | }); 248 | extraRgFlags.push(newParam); 249 | queries.push(match[1]); 250 | } 251 | } 252 | }); 253 | 254 | // prefer the first query match or the original one 255 | const rgQuery = queries.length > 1 ? queries[1] : queries[0]; 256 | return { rgQuery, extraRgFlags }; 257 | } 258 | 259 | /** 260 | * Ensure that the src path provided is quoted 261 | * Required when config paths contain whitespace 262 | */ 263 | function ensureQuotedPath(path: string): string { 264 | // support for paths already quoted via config 265 | if (path.startsWith('"') && path.endsWith('"')) { 266 | return path; 267 | } 268 | // else quote the path 269 | return `"${path}"`; 270 | } 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Periscope 🫧 2 | 3 | ![Visual Studio Marketplace Last Updated](https://img.shields.io/visual-studio-marketplace/last-updated/JoshMu.periscope) 4 | 5 | Periscope is a VSCode extension that supercharges your ability to search workspace contents using [ripgrep](https://github.com/BurntSushi/ripgrep), providing an intuitive interface with real-time previews of search results. 6 | 7 | _Inspired by nvim's [telescope](https://github.com/nvim-telescope/telescope.nvim)_ 8 | 9 | ## Key Features 10 | 11 | - **Fast Search**: Utilizes `ripgrep` for lightning-fast search capabilities. 12 | - **Real-Time Preview**: See preview of files right in the search pane as you navigate through search results. 13 | - **Customizable**: Extensive configuration options to tailor search behavior and UI to your needs. 14 | - **Resume Search**: Quickly resume your last search with a single command. 15 | - **File Search**: Dedicated file search mode to quickly find files by name across your workspace. 16 | 17 | ![Demo](https://github.com/joshmu/periscope/blob/master/assets/demo.gif?raw=true) 18 | 19 | ## Usage Instructions 20 | 21 | 1. **Invoke Search**: Assign a keybinding such as ` + p` to invoke the `periscope.search` command. You can also access it via the command palette (`Ctrl+Shift+P` or `Cmd+Shift+P`) and search for **periscope**. 22 | 2. **Search and Preview**: Enter your query to see the search results dynamically. Navigate through results to preview files directly in the editor. 23 | 3. **Open or Cancel**: Press `Enter` to open the highlighted file or `Esc` to cancel and return to your work. 24 | 25 | ## Requirements 26 | 27 | For optimal performance, ensure that the VSCode configuration _Editor: Enable Preview_ is enabled. This allows files to be previewed before opening them completely. 28 | 29 | ## Tips 30 | 31 | - **Search with Regex**: Use regex in your search query to find specific patterns in your codebase. 32 | - **Selected Text Search**: Highlight text in the editor and invoke `periscope.search` to have it automatically used as the initial query. 33 | - **Raw Queries**: Enclosing your `search_term` in quotes will allow additional ripgrep parameters to be passed through. eg: `"foobar" -t js` will search for `foobar` in js files. 34 | - **Utilise `rgQueryParams`**: Create shortcuts for common ripgrep search queries via regex matching against your current query. This provides a way to map your query to ripgrep parameters via capture groups in the regex for faster lookups. 35 | - **Search Current File Only**: Use `periscope.searchCurrentFile` command if you wish to narrow your search to the current file only 36 | - **Resume Last Search**: Use `periscope.resumeSearch` to instantly restore your previous search query, or `periscope.resumeSearchCurrentFile` to resume in the current file. 37 | - **File Search**: Use `periscope.searchFiles` command for dedicated file name searching, or add `--files` flag to your regular search query (e.g., `--files mycomponent` will find all files with "mycomponent" in their path). 38 | 39 | If you use vim within vscode you can bind `periscope.search` in your `settings.json`: 40 | 41 | ```json 42 | "vim.normalModeKeyBindingsNonRecursive": [ 43 | { 44 | "before": ["", "f", "w"], 45 | "commands": [ 46 | { 47 | "command": "periscope.search", 48 | "when": "editorTextFocus" 49 | } 50 | ] 51 | } 52 | ] 53 | ``` 54 | 55 | ## Configuration 56 | 57 | - `rgOptions`: Additional options to pass to the 'rg' command, you can view all options in your terminal via 'rg --help'. 58 | - `rgGlobExcludes`: Additional glob paths to exclude from the 'rg' search, eg: '**/dist/**'. 59 | - `rgPath`: Option to explicitly set the `rg` binary to use. If not specified, an attempt to locate your rg binary occurs otherwise falling back to `@vscode/ripgrep`. 60 | - `addSrcPaths`: Additional source paths to include in the rg search. You may want to add this as a workspace specific setting. 61 | - `rgMenuActions`: Create menu items which can be selected prior to any query, these items will be added to the ripgrep command to generate the results. Eg: Add `{ "label": "JS/TS", "value": "--type-add 'jsts:*.{js|ts|tsx|jsx}' -t jsts" },` as a menu option to only show js & ts files in the results. 62 | - `rgQueryParams`: Match ripgrep parameters from the input query directly. E.g: `{ "regex": \"^(.+) -t ?(\\w+)$\", "param": \"-t $1\" },` will translate the query `hello -t rust` to `rg 'hello' -t rust` to enable a filetype filter. 63 | - `rgQueryParamsShowTitle`: When a ripgrep parameter match from the list in `rgQueryParams`, the quick pick will show the matched result as a preview in the title bar. 64 | - `showWorkspaceFolderInFilePath`: Include workspace folder name in the folder depth display. 65 | - `startFolderDisplayIndex`: The folder index to display in the results before '...'. 66 | - `startFolderDisplayDepth`: The folder depth to display in the results before '...'. 67 | - `endFolderDisplayDepth`: The folder depth to display in the results after '...'. 68 | - `alwaysShowRgMenuActions`: If true, then open rg menu actions every time the search is invoked. 69 | - `showPreviousResultsWhenNoMatches`: If true, when there are no matches for the current query, the previous results will still be shown. 70 | - `gotoRgMenuActionsPrefix`: If the query starts with this prefix, then open rg menu actions. 71 | - `enableGotoNativeSearch`: If true, then swap to native vscode search if the custom suffix is entered using the current query. 72 | - `gotoNativeSearchSuffix`: If the query ends with this suffix, then swap to the native search with the query applied. 73 | - `showLineNumbers`: If true enabled, append `:` to file path details in results (default: `true`) 74 | - `peekBorderColor`: Color of the peek border. If not set, uses the editor's find match highlight border color. 75 | - `peekBorderWidth`: Width of the peek border (default: '2px') 76 | - `peekBorderStyle`: Style of the peek border (solid, dashed, inset, double, groove, outset, ridge) 77 | - `peekMatchColor`: Color used to highlight matching text. If not set, uses the editor's find match highlight color. 78 | - `peekMatchBorderColor`: Border color for highlighted matching text. If not set, uses the editor's find match highlight border color. 79 | - `peekMatchBorderWidth`: Border width for highlighted matching text (default: '1px') 80 | - `peekMatchBorderStyle`: Border style for highlighted matching text (solid, dashed, inset, double, groove, outset, ridge) 81 | 82 | ## Advanced Configurations 83 | 84 | Detailed examples for setting up advanced search parameters and UI customization are provided below to help you tailor Periscope to fit your workflow. 85 | 86 | ### periscope.rgQueryParams 87 | 88 | Create shortcuts for common ripgrep search queries via regex matching against your current query. This provides a way to map your query to ripgrep parameters via capture groups in the regex. 89 | 90 | Add the following to your `settings.json`: 91 | 92 | ```json 93 | "periscope.rgQueryParams": [ 94 | { 95 | // filter the results to a folder 96 | // Query: "redis -m module1" 97 | // After: "rg 'redis' -g '**/*module1*/**'" 98 | "regex": "^(.+) -m ([\\w-_]+)$", 99 | "param": "-g '**/*$1*/**' -g '!**/node_modules/**'" 100 | }, 101 | { 102 | // filter the results to a folder and filetype 103 | // Query: "redis -m module1 yaml" 104 | // After: "rg 'redis' -g '**/*module1*/**/*.yaml'" 105 | "regex": "^(.+) -m ([\\w-_]+) ([\\w]+)$", 106 | "param": "-g '**/*$1*/**/*.$2'" 107 | }, 108 | { 109 | // filter the results that match a glob 110 | // Query: "redis -g *module" 111 | // After: "rg 'redis' -g '*module'" 112 | "regex": "^(.+) -g (.+)$", 113 | "param": "-g '$1'" 114 | }, 115 | { 116 | // filter the results to rg filetypes 117 | // Query: "redis -t yaml" 118 | // After: "rg 'redis' -t yaml" 119 | "regex": "^(.+) -t ?(\\w+)$", 120 | "param": "-t $1" 121 | }, 122 | { 123 | // filter the results that match a file extension through a glob 124 | // Query: redis *.rs => rg 'redis' -g '*.rs' 125 | "regex": "^(.+) \\*\\.(\\w+)$", 126 | "param": "-g '*.$1'" 127 | } 128 | ], 129 | ``` 130 | 131 | ### periscope.searchCurrentFileOnly 132 | 133 | Scope the ripgrep search to only the current file. 134 | 135 | ### periscope.openInHorizontalSplit 136 | 137 | Open the result preview in a horizontal split. 138 | 139 | Add a keybinding (`keybindings.json`): 140 | 141 | ```json 142 | { 143 | "key": "ctrl+v", 144 | "command": "periscope.openInHorizontalSplit", 145 | "when": "periscopeActive" 146 | } 147 | ``` 148 | 149 | ## Extension Settings 150 | 151 | This extension contributes the following settings: 152 | 153 | - `periscope.search`: Enable Periscope Search 154 | - `periscope.searchCurrentFile`: Enable Periscope Search (current file only) 155 | - `periscope.resumeSearch`: Resume the last search query 156 | - `periscope.resumeSearchCurrentFile`: Resume the last search query (current file) 157 | - `periscope.openInHorizontalSplit`: Open the result preview in a horizontal split. 158 | - `periscope.searchFiles`: Enable Periscope File Search (search for file names only) 159 | 160 | ## Troubleshooting 161 | 162 | For common issues and troubleshooting guidance, please visit the [Issues](https://github.com/joshmu/periscope/issues) section of our GitHub repository. If you encounter a problem not covered there, feel free to open a new issue. 163 | 164 | ## Contributing 165 | 166 | Interested in contributing to Periscope? We welcome contributions of all forms. Please visit our [Contributions Page](https://github.com/joshmu/periscope/blob/master/CONTRIBUTING.md) for more information on how to get involved. 167 | 168 | ## Feedback and Support 169 | 170 | For support with using Periscope or to provide feedback, please [open an issue](https://github.com/joshmu/periscope/issues/new) in our GitHub repository. 171 | -------------------------------------------------------------------------------- /test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as path from 'path'; 3 | import * as sinon from 'sinon'; 4 | import * as vscode from 'vscode'; 5 | import { activate } from '../../src/extension'; 6 | import { context as cx } from '../../src/lib/context'; 7 | import { 8 | waitForQuickPick, 9 | waitForCondition, 10 | openDocumentWithContent, 11 | TEST_TIMEOUTS, 12 | } from '../utils/periscopeTestHelper'; 13 | 14 | suite('Periscope Extension', () => { 15 | let sandbox: sinon.SinonSandbox; 16 | 17 | setup(() => { 18 | sandbox = sinon.createSandbox(); 19 | cx.resetContext(); 20 | }); 21 | 22 | teardown(() => { 23 | sandbox.restore(); 24 | }); 25 | 26 | suite('Command Registration', () => { 27 | test('registers all required commands on activation', () => { 28 | const mockContext = createMockExtensionContext(); 29 | const registerCommandStub = sandbox.stub(vscode.commands, 'registerCommand').returns({ 30 | dispose: () => undefined, 31 | }); 32 | 33 | activate(mockContext); 34 | 35 | // Verify all commands are registered 36 | const expectedCommands = [ 37 | 'periscope.search', 38 | 'periscope.searchCurrentFile', 39 | 'periscope.openInHorizontalSplit', 40 | 'periscope.resumeSearch', 41 | 'periscope.resumeSearchCurrentFile', 42 | 'periscope.searchFiles', 43 | ]; 44 | 45 | assert.strictEqual(registerCommandStub.callCount, expectedCommands.length); 46 | expectedCommands.forEach((cmd, index) => { 47 | assert.strictEqual( 48 | registerCommandStub.getCall(index).args[0], 49 | cmd, 50 | `Should register ${cmd} command`, 51 | ); 52 | }); 53 | }); 54 | 55 | test('adds all disposables to context subscriptions', () => { 56 | const mockContext = createMockExtensionContext(); 57 | sandbox.stub(vscode.commands, 'registerCommand').returns({ 58 | dispose: () => undefined, 59 | }); 60 | 61 | activate(mockContext); 62 | 63 | // 6 commands + 1 output channel 64 | assert.strictEqual(mockContext.subscriptions.length, 7); 65 | }); 66 | }); 67 | 68 | suite('Search Modes', () => { 69 | test('periscope.search uses "all" mode', async () => { 70 | cx.resetContext(); 71 | 72 | // Execute the actual command 73 | await vscode.commands.executeCommand('periscope.search'); 74 | 75 | // Wait for QuickPick to be ready 76 | await waitForQuickPick(); 77 | 78 | // Assert the mode and title set by the command 79 | assert.strictEqual(cx.searchMode, 'all'); 80 | // Title should be undefined for 'all' mode 81 | assert.strictEqual(cx.qp?.title, undefined); 82 | 83 | // Clean up 84 | if (cx.qp) { 85 | cx.qp.hide(); 86 | cx.qp.dispose(); 87 | } 88 | }); 89 | 90 | test('periscope.searchCurrentFile uses "currentFile" mode', async () => { 91 | cx.resetContext(); 92 | 93 | // Open a file first 94 | await openDocumentWithContent('test content', 'typescript'); 95 | 96 | // Execute the actual command 97 | await vscode.commands.executeCommand('periscope.searchCurrentFile'); 98 | 99 | // Wait for QuickPick to be ready 100 | await waitForQuickPick(); 101 | 102 | // Assert the mode and title set by the command 103 | assert.strictEqual(cx.searchMode, 'currentFile'); 104 | assert.strictEqual(cx.qp?.title, 'Search current file only'); 105 | 106 | // Clean up 107 | if (cx.qp) { 108 | cx.qp.hide(); 109 | cx.qp.dispose(); 110 | } 111 | }); 112 | 113 | test('periscope.searchFiles uses "files" mode', async () => { 114 | cx.resetContext(); 115 | 116 | // Execute the actual command 117 | await vscode.commands.executeCommand('periscope.searchFiles'); 118 | 119 | // Wait for QuickPick to be ready 120 | await waitForQuickPick(); 121 | 122 | // Assert the mode and title set by the command 123 | assert.strictEqual(cx.searchMode, 'files'); 124 | assert.strictEqual(cx.qp?.title, 'File Search'); 125 | 126 | // Clean up 127 | if (cx.qp) { 128 | cx.qp.hide(); 129 | cx.qp.dispose(); 130 | } 131 | }); 132 | 133 | test('injected --files flag sets file search mode', async () => { 134 | cx.resetContext(); 135 | 136 | // Execute search command with --files flag injected 137 | await vscode.commands.executeCommand('periscope.search', { rgFlags: ['--files'] }); 138 | 139 | // Wait for QuickPick to be ready 140 | await waitForQuickPick(); 141 | 142 | // Assert that the mode is set correctly from the injected flag 143 | assert.strictEqual(cx.searchMode, 'files'); 144 | assert.strictEqual(cx.qp.title, 'File Search'); 145 | 146 | // Verify we can search for files 147 | if (cx.qp) { 148 | cx.qp.value = 'package.json'; 149 | // Wait a moment for search to process 150 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 151 | } else { 152 | assert.fail('QuickPick not initialized'); 153 | } 154 | 155 | // Clean up 156 | if (cx.qp) { 157 | cx.qp.hide(); 158 | cx.qp.dispose(); 159 | } 160 | }); 161 | }); 162 | 163 | suite('User Workflows', () => { 164 | test('search shows quickpick interface and accepts input', async () => { 165 | // Execute real search command 166 | await vscode.commands.executeCommand('periscope.search'); 167 | 168 | // Wait for QuickPick to be ready 169 | await waitForQuickPick(); 170 | 171 | assert.ok(cx.qp, 'QuickPick should be initialized'); 172 | // QuickPick is visible after being shown 173 | 174 | // Test that we can set a value 175 | cx.qp.value = 'test search'; 176 | assert.strictEqual(cx.qp.value, 'test search', 'Should accept search input'); 177 | 178 | // Clean up 179 | cx.qp.hide(); 180 | cx.qp.dispose(); 181 | }); 182 | 183 | test('resume search restores last query', async () => { 184 | // First, perform a search with a specific query 185 | await vscode.commands.executeCommand('periscope.search'); 186 | await waitForQuickPick(); 187 | 188 | const originalQuery = 'original test query'; 189 | cx.qp.value = originalQuery; 190 | 191 | // Wait for search to process 192 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.QUICKPICK_INIT)); 193 | 194 | // Hide the search 195 | cx.qp.hide(); 196 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 197 | 198 | // Now resume the search 199 | await vscode.commands.executeCommand('periscope.resumeSearch'); 200 | await waitForQuickPick(); 201 | 202 | // Verify the query was restored 203 | assert.strictEqual(cx.qp.value, originalQuery, 'Should restore previous query'); 204 | 205 | // Clean up 206 | cx.qp.hide(); 207 | cx.qp.dispose(); 208 | }); 209 | 210 | test('horizontal split opens document in new column', async () => { 211 | // Perform a real search first 212 | await vscode.commands.executeCommand('periscope.search'); 213 | await waitForQuickPick(); 214 | 215 | cx.qp.value = 'function'; 216 | await waitForCondition(() => cx.qp.items.length > 0, TEST_TIMEOUTS.SEARCH_COMPLEX); 217 | 218 | assert.ok(cx.qp.items.length > 0, 'Should have search results'); 219 | 220 | // Select the first item 221 | cx.qp.activeItems = [cx.qp.items[0]]; 222 | 223 | // Get current active editor before split 224 | const editorBefore = vscode.window.activeTextEditor; 225 | 226 | // Execute horizontal split command 227 | await vscode.commands.executeCommand('periscope.openInHorizontalSplit'); 228 | 229 | // Wait for new editor to open 230 | await waitForCondition(() => { 231 | const currentEditor = vscode.window.activeTextEditor; 232 | return !!(currentEditor && currentEditor !== editorBefore); 233 | }, TEST_TIMEOUTS.EDITOR_OPEN); 234 | 235 | const editorAfter = vscode.window.activeTextEditor; 236 | assert.ok(editorAfter, 'Should have opened a new editor'); 237 | assert.notStrictEqual(editorAfter, editorBefore, 'Should be a different editor'); 238 | 239 | // Clean up 240 | if (cx.qp) { 241 | cx.qp.hide(); 242 | cx.qp.dispose(); 243 | } 244 | }); 245 | 246 | test('switches to native search with gotoNativeSearchSuffix', async () => { 247 | const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand'); 248 | executeCommandStub.withArgs('periscope.search').callThrough(); 249 | executeCommandStub.withArgs('workbench.action.findInFiles').resolves(); 250 | 251 | // Start periscope search 252 | await vscode.commands.executeCommand('periscope.search'); 253 | await waitForQuickPick(); 254 | 255 | // Set query with native search suffix 256 | cx.qp.value = 'search term>>>'; 257 | 258 | // Wait a bit for the suffix to be processed 259 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.CURSOR_POSITION)); 260 | 261 | // Check if native search was triggered 262 | // Note: The actual implementation should handle this 263 | // For now, we verify the suffix detection logic 264 | const suffix = '>>>'; 265 | const hasNativeSuffix = cx.qp.value.endsWith(suffix); 266 | assert.strictEqual(hasNativeSuffix, true, 'Should detect native search suffix'); 267 | 268 | // Clean up 269 | cx.qp.hide(); 270 | cx.qp.dispose(); 271 | executeCommandStub.restore(); 272 | }); 273 | 274 | test('handles multi-root workspace search', async () => { 275 | // Execute search in multi-root workspace 276 | await vscode.commands.executeCommand('periscope.search'); 277 | await waitForQuickPick(); 278 | 279 | cx.qp.value = 'function'; 280 | await waitForCondition(() => cx.qp.items.length > 0, TEST_TIMEOUTS.SEARCH_COMPLEX); 281 | 282 | // Check that we have results from multiple directories 283 | const filePaths = new Set(); 284 | cx.qp.items.forEach((item: any) => { 285 | if (item.data?.filePath) { 286 | const parts = item.data.filePath.split(path.sep); 287 | if (parts.length > 2) { 288 | // Get the directory structure 289 | filePaths.add(parts.slice(0, -1).join(path.sep)); 290 | } 291 | } 292 | }); 293 | 294 | // In a multi-file project, we should find results in different directories 295 | assert.ok(filePaths.size > 0, 'Should find results in workspace folders'); 296 | 297 | // Clean up 298 | cx.qp.hide(); 299 | cx.qp.dispose(); 300 | }); 301 | }); 302 | 303 | // Helper functions 304 | function createMockExtensionContext(): vscode.ExtensionContext { 305 | return { 306 | subscriptions: [] as { dispose(): void }[], 307 | globalState: { 308 | get: () => undefined, 309 | update: () => Promise.resolve(), 310 | keys: () => [], 311 | }, 312 | workspaceState: { 313 | get: () => undefined, 314 | update: () => Promise.resolve(), 315 | keys: () => [], 316 | }, 317 | extensionMode: vscode.ExtensionMode.Test, 318 | extensionUri: vscode.Uri.file(''), 319 | } as unknown as vscode.ExtensionContext; 320 | } 321 | }); 322 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "periscope", 3 | "displayName": "Periscope", 4 | "description": "ripgrep workspace search with file peek", 5 | "keywords": [ 6 | "rg", 7 | "ripgrep", 8 | "search", 9 | "telescope", 10 | "peek", 11 | "grep", 12 | "file search", 13 | "workspace search", 14 | "search in files", 15 | "search in folder", 16 | "search in file", 17 | "search preview" 18 | ], 19 | "version": "1.15.1", 20 | "publisher": "JoshMu", 21 | "icon": "assets/icon.png", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/joshmu/periscope" 25 | }, 26 | "engines": { 27 | "vscode": "^1.88.0" 28 | }, 29 | "categories": [ 30 | "Other" 31 | ], 32 | "activationEvents": [], 33 | "main": "./out/extension.js", 34 | "contributes": { 35 | "commands": [ 36 | { 37 | "command": "periscope.search", 38 | "title": "Periscope: Search" 39 | }, 40 | { 41 | "command": "periscope.searchCurrentFile", 42 | "title": "Periscope: Search Current File" 43 | }, 44 | { 45 | "command": "periscope.openInHorizontalSplit", 46 | "title": "Periscope: Open Result in Horizontal Split", 47 | "enablement": "periscopeActive" 48 | }, 49 | { 50 | "command": "periscope.resumeSearch", 51 | "title": "Periscope: Resume Search" 52 | }, 53 | { 54 | "command": "periscope.resumeSearchCurrentFile", 55 | "title": "Periscope: Resume Search (Current File)" 56 | }, 57 | { 58 | "command": "periscope.searchFiles", 59 | "title": "Periscope: Search Files" 60 | } 61 | ], 62 | "configuration": { 63 | "title": "Periscope", 64 | "properties": { 65 | "periscope.rgOptions": { 66 | "type": "array", 67 | "default": [ 68 | "--smart-case", 69 | "--sortr path" 70 | ], 71 | "items": { 72 | "type": "string" 73 | }, 74 | "description": "Additional options to pass to the 'rg' command, you can view all options in your terminal via 'rg --help'." 75 | }, 76 | "periscope.rgPath": { 77 | "type": "string", 78 | "description": "Override the path to the 'rg' command, eg: '/usr/local/bin/rg'. If not specified the @vscode/ripgrep package will be used." 79 | }, 80 | "periscope.rgGlobExcludes": { 81 | "type": "array", 82 | "default": [], 83 | "items": { 84 | "type": "string" 85 | }, 86 | "description": "Additional glob paths to exclude from the 'rg' search, eg: '**/dist/**'." 87 | }, 88 | "periscope.addSrcPaths": { 89 | "type": "array", 90 | "default": [], 91 | "items": { 92 | "type": "string" 93 | }, 94 | "description": "Additional source paths to include in the rg search. You may want to add this as a workspace specific setting." 95 | }, 96 | "periscope.rgMenuActions": { 97 | "type": "array", 98 | "default": [], 99 | "items": { 100 | "type": "object", 101 | "properties": { 102 | "label": { 103 | "type": "string", 104 | "description": "The label of the menu item to display in the menu." 105 | }, 106 | "value": { 107 | "type": "string", 108 | "description": "The value of ripgrep options you would like to include." 109 | } 110 | }, 111 | "required": [ 112 | "value" 113 | ] 114 | }, 115 | "description": "Create menu items which can be selected prior to any query, these items will be added to the ripgrep command to generate the results. Eg: Add `{ label: \"JS/TS\", value: \"--type-add 'jsts:*.{js|ts|tsx|jsx}' -t jsts\" },` as a menu option to only show js & ts files in the results." 116 | }, 117 | "periscope.rgQueryParams": { 118 | "type": "array", 119 | "default": [ 120 | { 121 | "param": "-t $1", 122 | "regex": "^(.+) -t ?(\\w+)$" 123 | } 124 | ], 125 | "items": { 126 | "type": "object", 127 | "properties": { 128 | "param": { 129 | "type": "string", 130 | "description": "The rg params to translate to (e.g -t $1, -g '$1')." 131 | }, 132 | "regex": { 133 | "type": "string", 134 | "description": "The regex to match the query and capture the value to pass to the rg param (e.g `^(.+) -t ?(\\w+)$`)" 135 | } 136 | }, 137 | "required": [ 138 | "param", 139 | "regex" 140 | ] 141 | }, 142 | "description": "Match ripgrep parameters from the input query directly. E.g: `{ param: \"-t $1\", regex: \"^(.+) -t ?(\\w+)$\" },` will translate the query `hello -t rust` to `rg 'hello' -t rust`" 143 | }, 144 | "periscope.startFolderDisplayIndex": { 145 | "type": "number", 146 | "default": 0, 147 | "description": "The folder index to display in the results before truncating with '...'." 148 | }, 149 | "periscope.startFolderDisplayDepth": { 150 | "type": "number", 151 | "default": 1, 152 | "description": "The folder depth to display in the results before truncating with '...'." 153 | }, 154 | "periscope.endFolderDisplayDepth": { 155 | "type": "number", 156 | "default": 4, 157 | "description": "The folder depth to display in the results after truncating with '...'." 158 | }, 159 | "periscope.showWorkspaceFolderInFilePath": { 160 | "type": "boolean", 161 | "default": true, 162 | "description": "Include workspace folder name in the folder depth display." 163 | }, 164 | "periscope.alwaysShowRgMenuActions": { 165 | "type": "boolean", 166 | "default": false, 167 | "description": "If true, then open rg menu actions every time the search is invoked." 168 | }, 169 | "periscope.rgQueryParamsShowTitle": { 170 | "type": "boolean", 171 | "default": true, 172 | "description": "If true, when a ripgrep parameter match from the list in `rgQueryParams`, the quick pick will show the matched result as a preview in the title bar." 173 | }, 174 | "periscope.showPreviousResultsWhenNoMatches": { 175 | "type": "boolean", 176 | "default": false, 177 | "description": "If true, when there are no matches for the current query, the previous results will still be shown." 178 | }, 179 | "periscope.gotoRgMenuActionsPrefix": { 180 | "type": "string", 181 | "default": "<<", 182 | "description": "If the query starts with this prefix, then open rg menu actions." 183 | }, 184 | "periscope.enableGotoNativeSearch": { 185 | "type": "boolean", 186 | "default": true, 187 | "description": "If true, then swap to native vscode search if the custom suffix is entered using the current query." 188 | }, 189 | "periscope.gotoNativeSearchSuffix": { 190 | "type": "string", 191 | "default": ">>", 192 | "description": "If the query ends with this suffix, then swap to the native search with the query applied." 193 | }, 194 | "periscope.peekBorderColor": { 195 | "type": [ 196 | "string", 197 | "null" 198 | ], 199 | "default": null, 200 | "description": "Color of the peek border. If not set, uses the editor's find match highlight border color. ('white', '#FFF', 'rgb(255,255,255)', 'rgba(255,255,255,0.5)')" 201 | }, 202 | "periscope.peekBorderWidth": { 203 | "type": "string", 204 | "default": "2px", 205 | "description": "Width of the peek border" 206 | }, 207 | "periscope.peekBorderStyle": { 208 | "type": "string", 209 | "enum": [ 210 | "solid", 211 | "dashed", 212 | "inset", 213 | "double", 214 | "groove", 215 | "outset", 216 | "ridge" 217 | ], 218 | "default": "solid", 219 | "description": "Style of the peek border" 220 | }, 221 | "periscope.peekMatchColor": { 222 | "type": [ 223 | "string", 224 | "null" 225 | ], 226 | "default": null, 227 | "description": "Color used to highlight matching text in preview. If not set, uses the editor's find match highlight color. ('white', '#FFF', 'rgb(255,255,255)', 'rgba(255,255,255,0.5)')" 228 | }, 229 | "periscope.peekMatchBorderColor": { 230 | "type": [ 231 | "string", 232 | "null" 233 | ], 234 | "default": null, 235 | "description": "Border color for highlighted matching text. If not set, uses a slightly darker version of the highlight color." 236 | }, 237 | "periscope.peekMatchBorderWidth": { 238 | "type": "string", 239 | "default": "1px", 240 | "description": "Border width for highlighted matching text" 241 | }, 242 | "periscope.peekMatchBorderStyle": { 243 | "type": "string", 244 | "enum": [ 245 | "solid", 246 | "dashed", 247 | "inset", 248 | "double", 249 | "groove", 250 | "outset", 251 | "ridge" 252 | ], 253 | "default": "solid", 254 | "description": "Border style for highlighted matching text" 255 | }, 256 | "periscope.showLineNumbers": { 257 | "type": "boolean", 258 | "default": true, 259 | "description": "Show the line number of each match alongside the file path in results." 260 | } 261 | } 262 | } 263 | }, 264 | "scripts": { 265 | "vscode:prepublish": "npm run compile", 266 | "compile": "tsc -p ./ && node scripts/postCompile.js", 267 | "watch": "tsc -watch -p ./", 268 | "pretest": "npm run compile", 269 | "lint": "eslint src --ext ts && prettier --check .", 270 | "lint:fix": "eslint src --ext ts --fix && prettier --write .", 271 | "test": "npm run lint && node ./out/test/runTest.js", 272 | "test:single": "npm run compile && MOCHA_GREP=\"$npm_config_grep\" node ./out/test/runTest.js", 273 | "test:file": "npm run compile && TEST_FILE=\"$npm_config_file\" node ./out/test/runTest.js", 274 | "test:watch": "npm run compile && TEST_FILE=\"$npm_config_file\" MOCHA_GREP=\"$npm_config_grep\" nodemon --watch src --watch test --ext ts --exec \"npm run compile && node ./out/test/runTest.js\"", 275 | "test:no-lint": "node ./out/test/runTest.js", 276 | "prepare": "husky" 277 | }, 278 | "dependencies": { 279 | "@vscode/ripgrep": "^1.15.9" 280 | }, 281 | "devDependencies": { 282 | "@semantic-release/changelog": "^6.0.3", 283 | "@semantic-release/git": "^10.0.1", 284 | "@types/glob": "^8.1.0", 285 | "@types/mocha": "^10.0.1", 286 | "@types/mock-fs": "^4.13.4", 287 | "@types/node": "16.x", 288 | "@types/sinon": "^17.0.3", 289 | "@types/vscode": "^1.88.0", 290 | "@typescript-eslint/eslint-plugin": "^5.53.0", 291 | "@typescript-eslint/parser": "^5.53.0", 292 | "@vscode/test-electron": "^2.2.3", 293 | "eslint": "^8.34.0", 294 | "eslint-config-airbnb-base": "^15.0.0", 295 | "eslint-config-prettier": "^9.1.0", 296 | "eslint-plugin-import": "^2.29.1", 297 | "glob": "^8.1.0", 298 | "husky": "^9.0.11", 299 | "lint-staged": "^15.2.2", 300 | "mocha": "^10.2.0", 301 | "mock-fs": "^5.4.1", 302 | "prettier": "3.2.5", 303 | "semantic-release": "^23.0.8", 304 | "sinon": "^19.0.2", 305 | "typescript": "^5.5.4" 306 | }, 307 | "lint-staged": { 308 | "**/*": "prettier --write --ignore-unknown", 309 | "src/**/*": "eslint --fix" 310 | }, 311 | "license": "SEE LICENSE IN LICENSE.txt" 312 | } 313 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [Conventional Commits](https://www.conventionalcommits.org) for commit guidelines. 4 | 5 | ## [1.15.1](https://github.com/joshmu/periscope/compare/v1.15.0...v1.15.1) (2025-08-31) 6 | 7 | # [1.15.0](https://github.com/joshmu/periscope/compare/v1.14.0...v1.15.0) (2025-08-31) 8 | 9 | ### Bug Fixes 10 | 11 | - **editor:** remove unnecessary async edit in setCursorPosition ([018dce6](https://github.com/joshmu/periscope/commit/018dce690a2be74d56543424428bdc5f302ae572)) 12 | - **search:** restore dynamic --files flag detection in query ([35140f5](https://github.com/joshmu/periscope/commit/35140f5d7d4a1066e2c19f02b57e5d9e1462de15)) 13 | - **test:** increase Windows CI timeout multiplier to 3x for stability ([f4124e2](https://github.com/joshmu/periscope/commit/f4124e20b06b827be891d9a2cead609ff97832eb)) 14 | - **test:** normalize startFile paths for cross-platform compatibility ([456d92b](https://github.com/joshmu/periscope/commit/456d92b86a5e82578d41afb10d823195554db500)) 15 | - **test:** prevent settings.json modifications after test runs ([cfa6a61](https://github.com/joshmu/periscope/commit/cfa6a61816338fe0e01f2031b3a040b66e8a459c)) 16 | - **test:** remove color overrides causing config test failures ([0168064](https://github.com/joshmu/periscope/commit/0168064898b504260275477979be8b77e5e0197f)) 17 | - **test:** resolve CI race condition in file search mode test ([27283d7](https://github.com/joshmu/periscope/commit/27283d7479dcc0416250a7cc9935392966a2fa63)) 18 | - **test:** resolve failing escape key test with proper editor state setup ([596368c](https://github.com/joshmu/periscope/commit/596368c60d07bad855f7979130e00288f513d3b9)) 19 | - **test:** resolve flaky preview tests and enhance waitForPreviewUpdate helper ([4b3518b](https://github.com/joshmu/periscope/commit/4b3518b8cf79067ee20ed1d9266ba05b496cb932)) 20 | - **test:** resolve Windows CI test failures ([5da81c2](https://github.com/joshmu/periscope/commit/5da81c2ad260934d74d7cdfd64892bedad0719ab)) 21 | - **tests:** simplify wait time calculation for CI-aware timeouts ([e6145b0](https://github.com/joshmu/periscope/commit/e6145b09e1bc081dbcbabad3a26913a62cf95f0d)) 22 | 23 | ### Features 24 | 25 | - implement file search with improved architecture ([#89](https://github.com/joshmu/periscope/issues/89)) ([43ae1b2](https://github.com/joshmu/periscope/commit/43ae1b223068307f02789feb41b774f48daf91c4)) 26 | - merge master branch with line numbers feature from PR [#97](https://github.com/joshmu/periscope/issues/97) ([5ebe3cd](https://github.com/joshmu/periscope/commit/5ebe3cdd9633701a59e2b9849f3c4b07c6ba34b4)) 27 | - **search:** add injectable arguments for flexible ripgrep configuration ([b119b2e](https://github.com/joshmu/periscope/commit/b119b2e1fca16a4882e2ad53934703a0344a8ea4)) 28 | - **search:** integrate line numbers feature with updated test architecture ([4b4e929](https://github.com/joshmu/periscope/commit/4b4e929b170f6f7c3065a45cd986551bbb4879b1)) 29 | - **test:** add CI-aware timeout multipliers ([dbfbb3e](https://github.com/joshmu/periscope/commit/dbfbb3ef029a347e27c7d607b07ff601e17a1c63)) 30 | - **test:** add single test execution capabilities ([d92b208](https://github.com/joshmu/periscope/commit/d92b208561569cf8539a0961663a7436569dce3e)) 31 | - **test:** fix configuration exclusion test and improve test helper ([34c3e72](https://github.com/joshmu/periscope/commit/34c3e728541825103e2d0f945ae11ab56e27bbb5)) 32 | 33 | ### Performance Improvements 34 | 35 | - **test:** reduce test execution time by 58% with optimized timeouts ([7cae1a4](https://github.com/joshmu/periscope/commit/7cae1a4b248e4e944f9f6cd6f382769fb0e12e41)) 36 | 37 | # [1.14.0](https://github.com/joshmu/periscope/compare/v1.13.3...v1.14.0) (2025-08-31) 38 | 39 | ### Features 40 | 41 | - **results:** add option to show line numbers (on by default) ([fc46707](https://github.com/joshmu/periscope/commit/fc4670765d0256c4b4ec1036b2cc8e97073acc7a)) 42 | 43 | ## [1.13.3](https://github.com/joshmu/periscope/compare/v1.13.2...v1.13.3) (2025-08-15) 44 | 45 | ### Bug Fixes 46 | 47 | - **ci:** prevent comment posting errors for external contributor PRs ([e7103ee](https://github.com/joshmu/periscope/commit/e7103ee06a44cc5c2ee25958784a270b1682c430)) 48 | 49 | ## [1.13.2](https://github.com/joshmu/periscope/compare/v1.13.1...v1.13.2) (2025-08-10) 50 | 51 | ### Bug Fixes 52 | 53 | - **ripgrep:** handle paths with spaces by quoting paths ([7511939](https://github.com/joshmu/periscope/commit/75119390aa8f91b3e92433a08107137f8fad68c6)) 54 | 55 | ## [1.13.1](https://github.com/joshmu/periscope/compare/v1.13.0...v1.13.1) (2025-08-08) 56 | 57 | ### Bug Fixes 58 | 59 | - resolve sticky context bug in resume search ([5409bf6](https://github.com/joshmu/periscope/commit/5409bf6634550a4fc93d5dce71f1804e15dcf3d5)) 60 | 61 | # [1.13.0](https://github.com/joshmu/periscope/compare/v1.12.0...v1.13.0) (2025-08-06) 62 | 63 | ### Features 64 | 65 | - **logging:** add output channel for extension logs ([10fd944](https://github.com/joshmu/periscope/commit/10fd944fc43771a48d9d030423c1b375822e862e)) 66 | 67 | # [1.12.0](https://github.com/joshmu/periscope/compare/v1.11.1...v1.12.0) (2025-07-12) 68 | 69 | ### Features 70 | 71 | - add periscope.resumeSearch command to restore previous queries ([ea21ac5](https://github.com/joshmu/periscope/commit/ea21ac569c61040f2e143ce96a3d9c08844ca10a)), closes [#90](https://github.com/joshmu/periscope/issues/90) 72 | 73 | ## [1.11.1](https://github.com/joshmu/periscope/compare/v1.11.0...v1.11.1) (2025-06-15) 74 | 75 | ### Bug Fixes 76 | 77 | - **current-file:** resolves current file search persisting across search invocations ([e1b8132](https://github.com/joshmu/periscope/commit/e1b8132ff6fc0555ee7a7fe976c5eca5b54fbf76)) 78 | 79 | # [1.11.0](https://github.com/joshmu/periscope/compare/v1.10.4...v1.11.0) (2025-06-11) 80 | 81 | ### Features 82 | 83 | - **current-file:** support for extension command to automatically scope search to current file ([2cf7544](https://github.com/joshmu/periscope/commit/2cf75448db362eceb2804f6338ee262b28219756)), closes [#84](https://github.com/joshmu/periscope/issues/84) 84 | 85 | ## [1.10.4](https://github.com/joshmu/periscope/compare/v1.10.3...v1.10.4) (2025-05-26) 86 | 87 | ### Bug Fixes 88 | 89 | - **kill-process:** ensure all process are killed on extension deactivation ([24872cb](https://github.com/joshmu/periscope/commit/24872cbae9404e9b39b93d76aeae2ddb92bbaf43)) 90 | 91 | ## [1.10.3](https://github.com/joshmu/periscope/compare/v1.10.2...v1.10.3) (2025-05-16) 92 | 93 | ## [1.10.2](https://github.com/joshmu/periscope/compare/v1.10.1...v1.10.2) (2025-05-15) 94 | 95 | ## [1.10.1](https://github.com/joshmu/periscope/compare/v1.10.0...v1.10.1) (2025-05-15) 96 | 97 | ### Bug Fixes 98 | 99 | - **rg:** fix resolving rg system path ([09a7d35](https://github.com/joshmu/periscope/commit/09a7d3531bcd1c1b83a34887344cf46ba9f4d9a1)), closes [#78](https://github.com/joshmu/periscope/issues/78) 100 | 101 | # [1.10.0](https://github.com/joshmu/periscope/compare/v1.9.2...v1.10.0) (2025-03-22) 102 | 103 | ### Features 104 | 105 | - **config:** enhance peek decoration options ([5a5b7be](https://github.com/joshmu/periscope/commit/5a5b7bec7e29452624c5a15d2d9de8b2c193b47f)) 106 | - **highlight:** highlight text match ([d245bbd](https://github.com/joshmu/periscope/commit/d245bbd6dd1547f086210956d1fe2c3367023921)) 107 | - **ripgrep:** update rgMatch handling and configuration defaults ([31d78fd](https://github.com/joshmu/periscope/commit/31d78fd6bbe395676af74699f8ec18c60401532f)) 108 | 109 | ## [1.9.2](https://github.com/joshmu/periscope/compare/v1.9.1...v1.9.2) (2025-01-25) 110 | 111 | ### Bug Fixes 112 | 113 | - **native-search:** update to trim whitespace to return valid results ([462b66f](https://github.com/joshmu/periscope/commit/462b66fb5f3669be4d8ca0c491ea1ebdc2402ec0)) 114 | - **rgQueryParams:** show correct query in title info after extracting ([219a820](https://github.com/joshmu/periscope/commit/219a82041a9b35a23648eef03349abdbfbac0881)) 115 | 116 | ## [1.9.1](https://github.com/joshmu/periscope/compare/v1.9.0...v1.9.1) (2024-12-27) 117 | 118 | ### Bug Fixes 119 | 120 | - correct async support for openInHorizontalSplit ([493f5dd](https://github.com/joshmu/periscope/commit/493f5dd13201c834c1d551719389a65d26868436)) 121 | - update ripgrep path handling and improve query extraction logic ([04d1ad3](https://github.com/joshmu/periscope/commit/04d1ad32080f4201128c49efb3acda4f8a9407d2)) 122 | 123 | # [1.9.0](https://github.com/joshmu/periscope/compare/v1.8.2...v1.9.0) (2024-12-21) 124 | 125 | ### Features 126 | 127 | - support for platform specific extension on open-vsx.org ([143923b](https://github.com/joshmu/periscope/commit/143923bce29fa93ff18e259ad4f10885285e4c04)) 128 | 129 | ## [1.8.1](https://github.com/joshmu/periscope/compare/v1.8.0...v1.8.1) (2024-10-10) 130 | 131 | # [1.8.0](https://github.com/joshmu/periscope/compare/v1.7.4...v1.8.0) (2024-08-17) 132 | 133 | ### Features 134 | 135 | - support for passing rg parameters directly when search term is enclosed by quotation marks ([ea125af](https://github.com/joshmu/periscope/commit/ea125af3d33021415a90f4ea48125555bab8e15b)) 136 | 137 | ## [1.7.4](https://github.com/joshmu/periscope/compare/v1.7.3...v1.7.4) (2024-08-04) 138 | 139 | ## [1.7.3](https://github.com/joshmu/periscope/compare/v1.7.2...v1.7.3) (2024-08-03) 140 | 141 | ## [1.7.2](https://github.com/joshmu/periscope/compare/v1.7.1...v1.7.2) (2024-08-03) 142 | 143 | ## [1.7.1](https://github.com/joshmu/periscope/compare/v1.7.0...v1.7.1) (2024-08-02) 144 | 145 | ### Bug Fixes 146 | 147 | - update vsce ([39dae80](https://github.com/joshmu/periscope/commit/39dae8078e714a5f185a18ebcc79a75ba981728f)) 148 | 149 | # [1.7.0](https://github.com/joshmu/periscope/compare/v1.6.10...v1.7.0) (2024-08-02) 150 | 151 | ### Features 152 | 153 | - support paths which contain whitespace ([c001fe2](https://github.com/joshmu/periscope/commit/c001fe2297e1a8a3d4c5ac48b4ae0ce9898e1b57)) 154 | 155 | ## [1.6.10](https://github.com/joshmu/periscope/compare/v1.6.9...v1.6.10) (2024-04-28) 156 | 157 | ## [1.6.9](https://github.com/joshmu/periscope/compare/v1.6.8...v1.6.9) (2024-04-28) 158 | 159 | ## [1.6.8](https://github.com/joshmu/periscope/compare/v1.6.7...v1.6.8) (2024-04-28) 160 | 161 | ## [1.6.7](https://github.com/joshmu/periscope/compare/v1.6.6...v1.6.7) (2024-04-28) 162 | 163 | ## [1.6.6](https://github.com/joshmu/periscope/compare/v1.6.5...v1.6.6) (2024-04-28) 164 | 165 | ## [1.6.5](https://github.com/joshmu/periscope/compare/v1.6.4...v1.6.5) (2024-04-28) 166 | 167 | ## [1.6.4](https://github.com/joshmu/periscope/compare/v1.6.3...v1.6.4) (2024-04-28) 168 | 169 | ## [1.6.3](https://github.com/joshmu/periscope/compare/v1.6.2...v1.6.3) (2024-04-28) 170 | 171 | ### Bug Fixes 172 | 173 | - update tsconfig to resolve ci ([fa7bafe](https://github.com/joshmu/periscope/commit/fa7bafed1a032748b0ef82ebb9cb0134d59f4e27)) 174 | 175 | ## [1.6.2](https://github.com/joshmu/periscope/compare/v1.6.1...v1.6.2) (2024-04-28) 176 | 177 | ## 1.6.1 (2024-04-28) 178 | 179 | ##### New Features 180 | 181 | - with shared context ([d6cf1693](https://github.com/joshmu/periscope/commit/d6cf169376cfc59ea68bb2b8d666e22ffe13e81c)) 182 | - when no results show origin document ([dfd176d7](https://github.com/joshmu/periscope/commit/dfd176d791fef6152a222ed1077d58d36d6faf28)) 183 | - improve logs ([cbfc7916](https://github.com/joshmu/periscope/commit/cbfc7916aef333f4e54dd86aef2138fc68d7c79f)) 184 | - include @semantic-release/git to update package.json versioning ([c994ade2](https://github.com/joshmu/periscope/commit/c994ade2d0fffb970cf1e5130f8bdee765e5c311)) 185 | - update vscode ripgrep package ([a6c15c6f](https://github.com/joshmu/periscope/commit/a6c15c6f9e1fa9a24b3d60328f3c92ffbd3b078b)) 186 | - pipeline publish integration ([416b4ec0](https://github.com/joshmu/periscope/commit/416b4ec084c2fb8f60f37a7de61fcd179a9d9b83)) 187 | - option to apply custom rg commands ([7e38da92](https://github.com/joshmu/periscope/commit/7e38da929499a7065c5a8e77b346f568c88283f6)) 188 | - rg actions shortcut ([dc7c622d](https://github.com/joshmu/periscope/commit/dc7c622d07a9881c8fb2f674bd329a6f9fc42e77)) 189 | - improved rg menu actions ([13002dbc](https://github.com/joshmu/periscope/commit/13002dbcd10a50c994ca1944168574fc69f85b84)) 190 | - optional rg menu actions ([37d1829c](https://github.com/joshmu/periscope/commit/37d1829c911ed97efe7ad89fc53acadabd285d3b)) 191 | - update default color so light theme is valid ([a44d82b6](https://github.com/joshmu/periscope/commit/a44d82b6288e426781cca968b2b9d63902cb07db)) 192 | - use white color for peek highlight default ([bec3d889](https://github.com/joshmu/periscope/commit/bec3d889f50a4db8d77015374bf8f0313f424678)) 193 | - peek highlight ([47d38dc0](https://github.com/joshmu/periscope/commit/47d38dc077079360d752760c8952354985f17e14)) 194 | - jump to native search, additional display options ([fe56a836](https://github.com/joshmu/periscope/commit/fe56a836348bad8a4e1ad17c7767d725b7daa6ee)) 195 | 196 | ##### Bug Fixes 197 | 198 | - rg menu actions resolved via app state machine, additional refactor also ([c34ae789](https://github.com/joshmu/periscope/commit/c34ae7898442be053faa7c252b68d6a5daa740b6)) 199 | - cursor col position ([a75608e3](https://github.com/joshmu/periscope/commit/a75608e3586bc4fa9f12190aea624798db8bdd00)) 200 | - improve handling of child processes ([49580f8e](https://github.com/joshmu/periscope/commit/49580f8ea6ff72cc892ea505c20c227d67b8a6bf)) 201 | - rg line json parse error handling ([1c4d9589](https://github.com/joshmu/periscope/commit/1c4d9589492d952275e57f8ebc3003142507d823)) 202 | - pipeline publish condition to invoke once matrix complete, semantic-release will automate tag creation ([7db87476](https://github.com/joshmu/periscope/commit/7db874761d768774c5d1deb2f03786bab304bf01)) 203 | - pipline vscode publish platform compatibility ([f9a52cee](https://github.com/joshmu/periscope/commit/f9a52cee6f77b84571b78d4974833d09d4c2f369)) 204 | - extension OS compatibility ([ec117db7](https://github.com/joshmu/periscope/commit/ec117db7f65ab2d2e2b1108bf00050912af52af2)) 205 | - allow new rg install to define correct platform binary ([c0ac93ff](https://github.com/joshmu/periscope/commit/c0ac93ff05b6480eef89c89ae4ee0696b6cd79fb)) 206 | - quick pick item show all ([e113b22c](https://github.com/joshmu/periscope/commit/e113b22cc09ae3234de85b5c09a8b2b0130ceced)) 207 | -------------------------------------------------------------------------------- /test/suite/search.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as vscode from 'vscode'; 3 | import * as sinon from 'sinon'; 4 | import { context as cx } from '../../src/lib/context'; 5 | import { QPItemQuery } from '../../src/types'; 6 | import { 7 | periscopeTestHelpers, 8 | waitForCondition, 9 | waitForPreviewUpdate, 10 | withConfiguration, 11 | hasLineNumbersInDetails, 12 | LINE_NUMBER_REGEX, 13 | TEST_TIMEOUTS, 14 | } from '../utils/periscopeTestHelper'; 15 | 16 | suite('Search Functionality with Fixtures', function () { 17 | // Increase timeout for all tests in this suite 18 | this.timeout(TEST_TIMEOUTS.SUITE_EXTENDED); 19 | 20 | let sandbox: sinon.SinonSandbox; 21 | 22 | setup(async () => { 23 | sandbox = sinon.createSandbox(); 24 | cx.resetContext(); 25 | 26 | // Ensure extension is activated 27 | const ext = vscode.extensions.getExtension('JoshMu.periscope'); 28 | if (ext && !ext.isActive) { 29 | await ext.activate(); 30 | } 31 | }); 32 | 33 | teardown(async () => { 34 | // Make sure to hide QuickPick to avoid interference 35 | if (cx.qp) { 36 | cx.qp.hide(); 37 | cx.qp.dispose(); 38 | } 39 | sandbox.restore(); 40 | cx.resetContext(); 41 | // Small delay to ensure cleanup 42 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 43 | }); 44 | 45 | suite('Text Search in Fixture Workspace', () => { 46 | test('finds TODO comments using periscope.search', async function () { 47 | this.timeout(TEST_TIMEOUTS.SUITE_EXTENDED); 48 | 49 | const results = await periscopeTestHelpers.search('TODO', { debug: false }); 50 | 51 | assert.ok(results.count > 0, `Should find TODO comments. Found ${results.count} items`); 52 | 53 | // Verify we found TODOs in expected files 54 | assert.ok( 55 | results.files.includes('Button.tsx'), 56 | `Should find TODO in Button.tsx. Found files: ${results.files.join(', ')}`, 57 | ); 58 | assert.ok( 59 | results.files.includes('integration.test.ts'), 60 | `Should find TODO in integration.test.ts. Found files: ${results.files.join(', ')}`, 61 | ); 62 | 63 | // Should NOT include node_modules 64 | assert.ok( 65 | !results.files.some((f) => f.includes('node_modules')), 66 | 'Should exclude node_modules', 67 | ); 68 | 69 | // Verify we have a reasonable number of TODOs (not hundreds, but at least a few) 70 | assert.ok(results.count >= 2, 'Should find at least 2 TODOs'); 71 | assert.ok(results.count < 20, 'Should not find an excessive number of TODOs'); 72 | }); 73 | 74 | test('finds getUserById function', async () => { 75 | const results = await periscopeTestHelpers.search('getUserById'); 76 | 77 | assert.ok(results.count > 0, 'Should find getUserById'); 78 | assert.ok(results.files.includes('helpers.ts'), 'Should find in helpers.ts'); 79 | assert.ok(results.files.includes('index.ts'), 'Should find in index.ts'); 80 | assert.ok(results.files.includes('unit.test.ts'), 'Should find in unit.test.ts'); 81 | 82 | // Verify we found it in multiple files (function definition + imports/usage) 83 | assert.ok(results.count >= 3, 'Should find at least 3 occurrences (definition + usages)'); 84 | assert.ok(results.count < 10, 'Should not find an excessive number of occurrences'); 85 | }); 86 | 87 | test('searches with regex patterns', async () => { 88 | // Search for log.*Error pattern 89 | const results = await periscopeTestHelpers.search('log.*Error', { isRegex: true }); 90 | 91 | assert.ok(results.count > 0, 'Should find regex matches'); 92 | 93 | // All matches should be in logger.ts 94 | const uniqueFiles = [...new Set(results.files)]; 95 | assert.strictEqual(uniqueFiles.length, 1, 'Should only find in one file'); 96 | assert.ok(uniqueFiles[0].includes('logger.ts'), 'Should be in logger.ts'); 97 | 98 | // Verify we found some matches but not too many 99 | assert.ok(results.count >= 1, 'Should find at least 1 match'); 100 | assert.ok(results.count < 10, 'Should not find excessive matches'); 101 | }); 102 | }); 103 | 104 | suite('File Search in Fixture Workspace', () => { 105 | test('lists files matching pattern', async () => { 106 | const results = await periscopeTestHelpers.searchFiles('Button'); 107 | 108 | assert.ok(results.count > 0, 'Should find files'); 109 | assert.ok(results.files.includes('Button.tsx'), 'Should find Button.tsx'); 110 | 111 | // Verify all items are file type 112 | assert.ok( 113 | results.items.every((item) => item._type === 'QuickPickItemFile'), 114 | 'All items should be file type', 115 | ); 116 | }); 117 | 118 | test('finds test files using periscope.searchFiles', async () => { 119 | const results = await periscopeTestHelpers.searchFiles('.test'); 120 | 121 | assert.ok(results.files.includes('unit.test.ts'), 'Should find unit.test.ts'); 122 | assert.ok(results.files.includes('integration.test.ts'), 'Should find integration.test.ts'); 123 | 124 | // Verify we found test files 125 | assert.ok(results.count >= 2, 'Should find at least 2 test files'); 126 | assert.ok( 127 | results.files.every((f) => f.includes('.test')), 128 | 'All files should contain .test in the name', 129 | ); 130 | }); 131 | }); 132 | 133 | suite('Search Modes', () => { 134 | test('searches only current file in currentFile mode', async () => { 135 | const results = await periscopeTestHelpers.searchCurrentFile( 136 | 'function', 137 | 'src/utils/helpers.ts', 138 | ); 139 | 140 | assert.ok(results.count > 0, 'Should find functions'); 141 | 142 | // All results should be from helpers.ts only 143 | const uniqueFiles = [...new Set(results.files)]; 144 | assert.strictEqual(uniqueFiles.length, 1, 'Should only search current file'); 145 | assert.strictEqual(uniqueFiles[0], 'helpers.ts', 'Should only find in helpers.ts'); 146 | 147 | // Should find multiple functions in helpers.ts 148 | assert.ok( 149 | results.count >= 3, 150 | 'Should find at least 3 occurrences of "function" in helpers.ts', 151 | ); 152 | assert.ok(results.count < 20, 'Should not find excessive occurrences'); 153 | }); 154 | 155 | test('correctly sets search mode for each command', () => { 156 | cx.resetContext(); 157 | assert.strictEqual(cx.searchMode, 'all', 'Default mode should be all'); 158 | 159 | // File search mode 160 | vscode.commands.executeCommand('periscope.searchFiles'); 161 | // Would need to wait for command to complete, but we can test the mode is set 162 | }); 163 | }); 164 | 165 | suite('Preview and Decorations', () => { 166 | test('shows file preview when navigating search results', async function () { 167 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 168 | 169 | // Close all editors to ensure clean starting state 170 | await vscode.commands.executeCommand('workbench.action.closeAllEditors'); 171 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 172 | 173 | // Perform search 174 | const results = await periscopeTestHelpers.search('TODO'); 175 | 176 | assert.ok(results.count > 0, 'Should find TODO comments'); 177 | assert.ok(cx.qp, 'QuickPick should be initialized'); 178 | 179 | // Get the first item and set it as active to trigger its preview 180 | const firstItem = cx.qp.items[0] as any; 181 | cx.qp.activeItems = [firstItem]; 182 | 183 | // Wait for preview to fully update (including cursor positioning) 184 | const previewEditor = await waitForPreviewUpdate(); 185 | assert.ok(previewEditor, 'Should open preview for active item'); 186 | 187 | // Verify preview is at correct location 188 | if (firstItem.data?.linePos) { 189 | const expectedLine = firstItem.data.linePos - 1; // Convert to 0-based 190 | const cursorLine = previewEditor.selection.active.line; 191 | 192 | // Allow some flexibility - the cursor might be slightly off due to timing 193 | assert.ok( 194 | Math.abs(cursorLine - expectedLine) <= 1, 195 | `Preview should position near match line. Expected: ${expectedLine}, Actual: ${cursorLine}`, 196 | ); 197 | } 198 | }); 199 | 200 | test('updates preview when changing active item', async function () { 201 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 202 | 203 | // Perform search with multiple results 204 | const results = await periscopeTestHelpers.search('function'); 205 | 206 | assert.ok(results.count > 1, 'Should have multiple results'); 207 | assert.ok(cx.qp, 'QuickPick should be initialized'); 208 | 209 | // Navigate to first item 210 | const firstItem = cx.qp.items[0] as any; 211 | cx.qp.activeItems = [firstItem]; 212 | 213 | // Wait for first preview 214 | const firstEditor = await waitForPreviewUpdate(undefined); 215 | const firstFile = firstEditor?.document.uri.fsPath; 216 | const firstLine = firstEditor?.selection.active.line; 217 | 218 | // Navigate to second item 219 | const secondItem = cx.qp.items[1] as any; 220 | cx.qp.activeItems = [secondItem]; 221 | 222 | // Wait for preview to update 223 | const secondEditor = await waitForPreviewUpdate(firstEditor); 224 | 225 | // Verify preview changed 226 | const differentFile = firstFile !== secondEditor?.document.uri.fsPath; 227 | const differentLine = firstLine !== secondEditor?.selection.active.line; 228 | assert.ok(differentFile || differentLine, 'Preview should update when changing active item'); 229 | }); 230 | 231 | test('applies peek decorations at match location', async function () { 232 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 233 | 234 | // Close all editors to ensure clean starting state 235 | await vscode.commands.executeCommand('workbench.action.closeAllEditors'); 236 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 237 | 238 | // Perform search 239 | const results = await periscopeTestHelpers.search('TODO'); 240 | 241 | assert.ok(results.count > 0, 'Should find matches'); 242 | assert.ok(cx.qp, 'QuickPick should be initialized'); 243 | 244 | // Get the first item and set it as active to trigger its preview 245 | const item = cx.qp.items[0] as any; 246 | cx.qp.activeItems = [item]; 247 | 248 | // Wait for preview to fully update (including cursor positioning) 249 | const editor = await waitForPreviewUpdate(); 250 | assert.ok(editor, 'Should open preview for active item'); 251 | 252 | // Check that cursor/selection is at the match 253 | if (item.data?.linePos && item.data?.colPos) { 254 | const expectedLine = item.data.linePos - 1; 255 | const expectedCol = item.data.colPos - 1; 256 | 257 | const selection = editor.selection; 258 | 259 | // Allow some flexibility in line positioning 260 | assert.ok( 261 | Math.abs(selection.active.line - expectedLine) <= 1, 262 | `Cursor should be near match line. Expected: ${expectedLine}, Actual: ${selection.active.line}`, 263 | ); 264 | 265 | // The match text should be near the cursor position 266 | const lineToCheck = editor.document.lineAt(selection.active.line).text; 267 | const matchText = item.data.textResult || ''; 268 | assert.ok( 269 | lineToCheck.includes(matchText.trim()) || lineToCheck.includes('TODO'), 270 | 'Line should contain match text or TODO', 271 | ); 272 | } 273 | }); 274 | 275 | test('preserves preview mode for navigation', async function () { 276 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 277 | 278 | // Perform search 279 | const results = await periscopeTestHelpers.search('function'); 280 | 281 | assert.ok(results.count > 1, 'Should have multiple results'); 282 | assert.ok(cx.qp, 'QuickPick should be initialized'); 283 | 284 | // Navigate through multiple items 285 | for (let i = 0; i < Math.min(3, cx.qp.items.length); i++) { 286 | cx.qp.activeItems = [cx.qp.items[i]]; 287 | 288 | // Wait for preview 289 | await waitForCondition(() => !!vscode.window.activeTextEditor, TEST_TIMEOUTS.EDITOR_ACTIVE); 290 | 291 | const editor = vscode.window.activeTextEditor; 292 | assert.ok(editor, `Should have editor for item ${i}`); 293 | 294 | // Check if document is in preview mode 295 | // In VSCode test environment, preview mode detection might not work as expected 296 | // We'll check that the document is at least open and not modified 297 | const isNotDirty = !editor.document.isDirty; 298 | const hasContent = editor.document.lineCount > 0; 299 | 300 | // The document should be open and viewable 301 | assert.ok(hasContent, 'Document should have content'); 302 | assert.ok(isNotDirty, 'Document should not be dirty during navigation'); 303 | } 304 | }); 305 | }); 306 | 307 | suite('Search Results Verification', () => { 308 | test('excludes node_modules from results', async () => { 309 | const results = await periscopeTestHelpers.search('getUserById'); 310 | 311 | // Get full file paths from items 312 | const filePaths = results.items.map((item: any) => item.data?.filePath || '').filter(Boolean); 313 | 314 | // Verify no results from node_modules 315 | assert.ok( 316 | !filePaths.some((p) => p.includes('node_modules')), 317 | 'Should not include node_modules in results', 318 | ); 319 | }); 320 | 321 | test('creates correct QuickPick item types for text search', async () => { 322 | // Test text search creates QPItemQuery 323 | const textResults = await periscopeTestHelpers.search('TODO'); 324 | assert.ok( 325 | textResults.items.some((item) => item._type === 'QuickPickItemQuery'), 326 | 'Text search should create QuickPickItemQuery items', 327 | ); 328 | }); 329 | 330 | test('creates correct QuickPick item types for file search', async () => { 331 | // Test file search creates QPItemFile 332 | const fileResults = await periscopeTestHelpers.searchFiles('Button'); 333 | 334 | assert.ok( 335 | fileResults.items.length > 0, 336 | `File search should find items. Found ${fileResults.items.length}`, 337 | ); 338 | assert.ok( 339 | fileResults.items.some((item) => item._type === 'QuickPickItemFile'), 340 | `File search should create QuickPickItemFile items. Types found: ${[...new Set(fileResults.items.map((i) => i._type))].join(', ')}`, 341 | ); 342 | }); 343 | }); 344 | 345 | suite('Line Number Display in Search Results', () => { 346 | test('verifies line numbers appear in search result details by default', async function () { 347 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 348 | 349 | // Test default behavior - no configuration override needed 350 | // Search for something that will have multiple results 351 | const results = await periscopeTestHelpers.search('function'); 352 | assert.ok(results.count > 0, 'Should find results'); 353 | 354 | // Check the QuickPick items 355 | const items = cx.qp.items as QPItemQuery[]; 356 | const queryItems = items.filter((item) => item._type === 'QuickPickItemQuery'); 357 | assert.ok(queryItems.length > 0, 'Should have query items'); 358 | 359 | // Verify that items have line numbers in their details 360 | const itemsWithLineNumbers = queryItems.filter( 361 | (item) => item.detail && LINE_NUMBER_REGEX.test(item.detail), 362 | ); 363 | assert.ok( 364 | itemsWithLineNumbers.length > 0, 365 | `Should have items with line numbers by default. Found ${itemsWithLineNumbers.length} items with line numbers out of ${queryItems.length} total query items`, 366 | ); 367 | 368 | // Verify specific line number format 369 | const firstItemWithLineNumber = itemsWithLineNumbers[0]; 370 | if (firstItemWithLineNumber && firstItemWithLineNumber.detail) { 371 | const lineNumberMatch = firstItemWithLineNumber.detail.match(LINE_NUMBER_REGEX); 372 | assert.ok(lineNumberMatch, 'Should have line number at end of detail'); 373 | if (lineNumberMatch) { 374 | const lineNumber = parseInt(lineNumberMatch[1], 10); 375 | assert.ok(lineNumber > 0, `Line number should be positive: ${lineNumber}`); 376 | } 377 | } 378 | }); 379 | 380 | test('verifies line numbers are hidden when disabled', async function () { 381 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 382 | 383 | // Configure to hide line numbers 384 | await withConfiguration( 385 | { 386 | showLineNumbers: false, 387 | }, 388 | async () => { 389 | // Search for something that will have multiple results 390 | const results = await periscopeTestHelpers.search('function'); 391 | assert.ok(results.count > 0, 'Should find results'); 392 | 393 | // Check the QuickPick items 394 | const items = cx.qp.items as QPItemQuery[]; 395 | const queryItems = items.filter((item) => item._type === 'QuickPickItemQuery'); 396 | assert.ok(queryItems.length > 0, 'Should have query items'); 397 | 398 | // Verify that items DO NOT have line numbers in their details 399 | const itemsWithLineNumbers = queryItems.filter( 400 | (item) => item.detail && /:\d+$/.test(item.detail), 401 | ); 402 | assert.strictEqual( 403 | itemsWithLineNumbers.length, 404 | 0, 405 | `Should not have items with line numbers when disabled. Found ${itemsWithLineNumbers.length} items with line numbers`, 406 | ); 407 | }, 408 | ); 409 | }); 410 | 411 | test('verifies correct line numbers for specific matches', async function () { 412 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 413 | 414 | // Test default behavior - no configuration override needed 415 | // Search for a specific known pattern in our test fixtures 416 | const results = await periscopeTestHelpers.search('export function'); 417 | assert.ok(results.count > 0, 'Should find export function declarations'); 418 | 419 | // Get the items with line numbers 420 | const items = cx.qp.items as QPItemQuery[]; 421 | const queryItems = items.filter((item) => item._type === 'QuickPickItemQuery'); 422 | 423 | // Each match should have a line number that makes sense (> 0) 424 | queryItems.forEach((item) => { 425 | if (item.detail) { 426 | const lineNumberMatch = item.detail.match(LINE_NUMBER_REGEX); 427 | if (lineNumberMatch) { 428 | const lineNumber = parseInt(lineNumberMatch[1], 10); 429 | assert.ok( 430 | lineNumber > 0 && lineNumber < 10000, 431 | `Line number should be reasonable: ${lineNumber}`, 432 | ); 433 | } 434 | } 435 | 436 | // Also verify that the line position in the data matches 437 | if (item.data && typeof item.data.linePos === 'number') { 438 | assert.ok(item.data.linePos > 0, 'Line position in data should be positive'); 439 | } 440 | }); 441 | }); 442 | }); 443 | }); 444 | -------------------------------------------------------------------------------- /test/utils/periscopeTestHelper.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { context as cx } from '../../src/lib/context'; 4 | import { AllQPItemVariants } from '../../src/types'; 5 | 6 | // Detect CI environment 7 | const isCI = process.env.CI === 'true'; 8 | const isWindows = process.platform === 'win32'; 9 | 10 | // Common regex patterns 11 | export const LINE_NUMBER_REGEX = /:(\d+)$/; 12 | 13 | // CI needs longer timeouts, especially on Windows 14 | // Windows CI: 3x slower, Other CI: 1.5x slower, Local: 1x (normal speed) 15 | const CI_TIMEOUT_MULTIPLIER = isCI ? (isWindows ? 3 : 1.5) : 1; 16 | 17 | // Log environment for debugging CI issues 18 | if (isCI) { 19 | console.log( 20 | `[Test Environment] Running in CI (Platform: ${process.platform}, Windows: ${isWindows}, Timeout Multiplier: ${CI_TIMEOUT_MULTIPLIER}x)`, 21 | ); 22 | } 23 | 24 | /** 25 | * Base timeout values for tests (in milliseconds) 26 | * These values are optimized based on test performance analysis 27 | */ 28 | const BASE_TIMEOUTS = { 29 | // === Basic Operations (fast) === 30 | QUICKPICK_INIT: 100, 31 | UI_STABILIZATION: 100, 32 | CONFIG_APPLY: 100, 33 | CONDITION_DEFAULT: 100, 34 | CURSOR_POSITION: 100, 35 | 36 | // === Search Operations (variable speed) === 37 | SEARCH_RESULTS: 250, 38 | SEARCH_COMPLEX: 350, 39 | 40 | // === Editor Operations === 41 | PREVIEW_UPDATE: 250, 42 | EDITOR_OPEN: 350, 43 | EDITOR_ACTIVE: 250, 44 | 45 | // === State Changes === 46 | MODE_SWITCH: 250, 47 | QUICKPICK_DISPOSE: 250, 48 | 49 | // === Test Suite Timeouts === 50 | SUITE_DEFAULT: 3000, 51 | SUITE_EXTENDED: 5000, 52 | }; 53 | 54 | /** 55 | * Centralized timeout configuration for tests 56 | * Automatically adjusted for CI environments 57 | */ 58 | export const TEST_TIMEOUTS = Object.entries(BASE_TIMEOUTS).reduce( 59 | (acc, [key, value]) => ({ 60 | ...acc, 61 | [key]: value * CI_TIMEOUT_MULTIPLIER, 62 | }), 63 | {} as Record, 64 | ); 65 | 66 | /** 67 | * Check if QuickPick items have line numbers in their details 68 | */ 69 | export function hasLineNumbersInDetails(items: any[]): boolean { 70 | return items.filter((item) => item.detail).some((item) => LINE_NUMBER_REGEX.test(item.detail)); 71 | } 72 | 73 | /** 74 | * Wait for a condition to be true 75 | */ 76 | export async function waitForCondition( 77 | condition: () => boolean, 78 | maxWait = TEST_TIMEOUTS.CONDITION_DEFAULT, 79 | checkInterval = 10, 80 | ): Promise { 81 | const start = Date.now(); 82 | while (!condition() && Date.now() - start < maxWait) { 83 | await new Promise((resolve) => setTimeout(resolve, checkInterval)); 84 | } 85 | return condition(); 86 | } 87 | 88 | /** 89 | * Wait for QuickPick to be initialized 90 | */ 91 | export async function waitForQuickPick( 92 | maxWait = TEST_TIMEOUTS.QUICKPICK_INIT, 93 | ): Promise | undefined> { 94 | await waitForCondition(() => cx.qp !== undefined, maxWait); 95 | return cx.qp; 96 | } 97 | 98 | /** 99 | * Wait for search results to appear 100 | */ 101 | export async function waitForSearchResults( 102 | minResults = 1, 103 | maxWait = TEST_TIMEOUTS.SEARCH_RESULTS, 104 | ): Promise { 105 | await waitForCondition(() => (cx.qp?.items.length ?? 0) >= minResults, maxWait, 20); 106 | return cx.qp?.items; 107 | } 108 | 109 | /** 110 | * Open a document with specified content 111 | */ 112 | export async function openDocumentWithContent( 113 | content: string, 114 | language = 'typescript', 115 | ): Promise { 116 | const doc = await vscode.workspace.openTextDocument({ 117 | content, 118 | language, 119 | }); 120 | return await vscode.window.showTextDocument(doc); 121 | } 122 | 123 | /** 124 | * Select text in the editor 125 | */ 126 | export async function selectText(editor: vscode.TextEditor, searchText: string): Promise { 127 | const text = editor.document.getText(); 128 | const index = text.indexOf(searchText); 129 | 130 | if (index === -1) { 131 | return false; 132 | } 133 | 134 | const startPos = editor.document.positionAt(index); 135 | const endPos = editor.document.positionAt(index + searchText.length); 136 | editor.selection = new vscode.Selection(startPos, endPos); 137 | 138 | return true; 139 | } 140 | 141 | /** 142 | * Select a text range in the editor 143 | */ 144 | export function selectTextRange( 145 | editor: vscode.TextEditor, 146 | startPos: vscode.Position, 147 | endPos: vscode.Position, 148 | ): void { 149 | editor.selection = new vscode.Selection(startPos, endPos); 150 | } 151 | 152 | /** 153 | * Open a file at a specific position 154 | */ 155 | export async function openFileAtPosition( 156 | filePath: string, 157 | line: number, 158 | column = 0, 159 | ): Promise { 160 | const absolutePath = path.isAbsolute(filePath) 161 | ? filePath 162 | : path.join(vscode.workspace.rootPath || '', filePath); 163 | 164 | const doc = await vscode.workspace.openTextDocument(absolutePath); 165 | const editor = await vscode.window.showTextDocument(doc); 166 | 167 | const position = new vscode.Position(line, column); 168 | editor.selection = new vscode.Selection(position, position); 169 | editor.revealRange(new vscode.Range(position, position)); 170 | 171 | return editor; 172 | } 173 | 174 | /** 175 | * Wait for preview editor to update 176 | */ 177 | export async function waitForPreviewUpdate( 178 | previousEditor?: vscode.TextEditor | undefined, 179 | maxWait = TEST_TIMEOUTS.PREVIEW_UPDATE, 180 | ): Promise { 181 | await waitForCondition(() => { 182 | const currentEditor = vscode.window.activeTextEditor; 183 | if (!currentEditor) return false; 184 | 185 | if (!previousEditor) return true; 186 | 187 | // Check if it's a different file or different position 188 | return ( 189 | currentEditor.document.uri.fsPath !== previousEditor.document.uri.fsPath || 190 | currentEditor.selection.active.line !== previousEditor.selection.active.line 191 | ); 192 | }, maxWait); 193 | 194 | // Add small delay for cursor positioning to complete 195 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.CURSOR_POSITION)); 196 | 197 | return vscode.window.activeTextEditor; 198 | } 199 | 200 | /** 201 | * Set cursor position in editor 202 | */ 203 | export function setCursorPosition(editor: vscode.TextEditor, line: number, column = 0): void { 204 | const position = new vscode.Position(line, column); 205 | editor.selection = new vscode.Selection(position, position); 206 | editor.revealRange(new vscode.Range(position, position)); 207 | } 208 | 209 | /** 210 | * Helper to temporarily update configuration for a test 211 | * Automatically restores original configuration after the test function completes 212 | */ 213 | export async function withConfiguration( 214 | configUpdates: { [key: string]: any }, 215 | testFn: () => Promise, 216 | ): Promise { 217 | const config = vscode.workspace.getConfiguration('periscope'); 218 | const originalValues = new Map(); 219 | 220 | // Save original values and apply updates 221 | for (const [key, value] of Object.entries(configUpdates)) { 222 | originalValues.set(key, config.get(key)); 223 | await config.update(key, value, vscode.ConfigurationTarget.Workspace); 224 | } 225 | 226 | // Wait for config to apply 227 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.CONFIG_APPLY)); 228 | 229 | try { 230 | // Run the test 231 | return await testFn(); 232 | } finally { 233 | // Always restore original values 234 | for (const [key, originalValue] of originalValues) { 235 | await config.update(key, originalValue, vscode.ConfigurationTarget.Workspace); 236 | } 237 | // Wait for config to restore 238 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.CONFIG_APPLY)); 239 | } 240 | } 241 | 242 | export interface TestOptions { 243 | command?: string; 244 | query?: string; 245 | startFile?: string; 246 | position?: { line: number; character: number }; 247 | waitTime?: number; 248 | isRegex?: boolean; 249 | debug?: boolean; 250 | selectedText?: string; 251 | menuAction?: { label: string; value: string }; 252 | configuration?: { [key: string]: any }; 253 | workspaceFolders?: vscode.WorkspaceFolder[]; 254 | keepOpen?: boolean; 255 | } 256 | 257 | export interface TestResults { 258 | items: AllQPItemVariants[]; 259 | count: number; 260 | files: string[]; 261 | fileToItemsMap: Map; 262 | raw: { 263 | labels: string[]; 264 | details: string[]; 265 | types: string[]; 266 | }; 267 | lineNumbers?: number[]; 268 | } 269 | 270 | /** 271 | * Core test utility for executing Periscope commands and collecting results 272 | */ 273 | export async function executePeriscopeTest(options: TestOptions): Promise { 274 | const { 275 | command = 'periscope.search', 276 | query = '', 277 | startFile, 278 | position, 279 | waitTime, 280 | isRegex = false, 281 | debug = false, 282 | selectedText, 283 | menuAction, 284 | configuration, 285 | workspaceFolders, 286 | keepOpen = true, 287 | } = options; 288 | 289 | if (debug) { 290 | console.log(`[PeriscopeTest] Executing command: ${command}`); 291 | console.log(`[PeriscopeTest] Query: ${query}`); 292 | if (startFile) { 293 | console.log(`[PeriscopeTest] Start file: ${startFile}`); 294 | } 295 | } 296 | 297 | // Ensure extension is activated 298 | const ext = vscode.extensions.getExtension('JoshMu.periscope'); 299 | if (ext && !ext.isActive) { 300 | await ext.activate(); 301 | if (debug) { 302 | console.log('[PeriscopeTest] Extension activated'); 303 | } 304 | } 305 | 306 | // Apply configuration if specified 307 | if (configuration) { 308 | // Mock configuration for testing 309 | const getConfigStub = (key: string) => configuration[key]; 310 | // In real tests, this would be stubbed with sinon 311 | if (debug) { 312 | console.log(`[PeriscopeTest] Applied configuration:`, configuration); 313 | } 314 | } 315 | 316 | // Set workspace folders if specified 317 | if (workspaceFolders) { 318 | // In real tests, this would be stubbed with sinon 319 | if (debug) { 320 | console.log( 321 | `[PeriscopeTest] Set workspace folders:`, 322 | workspaceFolders.map((f) => f.name), 323 | ); 324 | } 325 | } 326 | 327 | // Open start file if specified 328 | if (startFile) { 329 | // Normalize the path to use correct separators for the platform 330 | const normalizedStartFile = startFile.split('/').join(path.sep); 331 | const filePath = path.isAbsolute(normalizedStartFile) 332 | ? normalizedStartFile 333 | : path.join(vscode.workspace.rootPath || '', normalizedStartFile); 334 | const doc = await vscode.workspace.openTextDocument(filePath); 335 | const editor = await vscode.window.showTextDocument(doc); 336 | 337 | // Set cursor position if specified 338 | if (position) { 339 | const pos = new vscode.Position(position.line, position.character); 340 | editor.selection = new vscode.Selection(pos, pos); 341 | editor.revealRange(new vscode.Range(pos, pos)); 342 | } 343 | 344 | // Set selected text if specified 345 | if (selectedText) { 346 | // Find the text in the document and select it 347 | const text = doc.getText(); 348 | const index = text.indexOf(selectedText); 349 | if (index >= 0) { 350 | const startPos = doc.positionAt(index); 351 | const endPos = doc.positionAt(index + selectedText.length); 352 | editor.selection = new vscode.Selection(startPos, endPos); 353 | if (debug) { 354 | console.log(`[PeriscopeTest] Selected text: "${selectedText}"`); 355 | } 356 | } 357 | } 358 | 359 | if (debug) { 360 | console.log(`[PeriscopeTest] Opened file: ${filePath}`); 361 | } 362 | } 363 | 364 | // Execute the command 365 | await vscode.commands.executeCommand(command); 366 | 367 | // Wait for QuickPick to be ready using smart waiting 368 | const qp = await waitForQuickPick(); 369 | 370 | if (!qp) { 371 | throw new Error('QuickPick not initialized after command execution'); 372 | } 373 | 374 | if (debug) { 375 | console.log('[PeriscopeTest] QuickPick active:', cx.qp !== undefined); 376 | console.log('[PeriscopeTest] Initial QuickPick title:', cx.qp.title); 377 | } 378 | 379 | // Handle menu action if specified 380 | if (menuAction) { 381 | // Simulate selecting a menu action 382 | // This would typically be done through the QuickPick interface 383 | if (debug) { 384 | console.log(`[PeriscopeTest] Applying menu action: ${menuAction.label}`); 385 | } 386 | // Apply the menu action to the context 387 | cx.rgMenuActionsSelected = [menuAction.value]; 388 | } 389 | 390 | // Set the search query if provided 391 | if (query) { 392 | // Clear the value first to ensure onDidChangeValue fires 393 | // This is important when running multiple searches with the same query 394 | cx.qp.value = ''; 395 | // Small delay to ensure the clear is processed 396 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 397 | 398 | // Now set the actual query 399 | cx.qp.value = query; 400 | if (debug) { 401 | console.log(`[PeriscopeTest] Set query to: ${query}`); 402 | } 403 | } 404 | 405 | // Use provided wait time or default to SEARCH_RESULTS timeout (already CI-adjusted) 406 | const actualWaitTime = waitTime ?? TEST_TIMEOUTS.SEARCH_RESULTS; 407 | 408 | // Wait for results to appear using smart waiting 409 | if (query || command === 'periscope.searchFiles') { 410 | // Small delay for ripgrep to start 411 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 412 | await waitForSearchResults(1, actualWaitTime); 413 | } else { 414 | // If no query, just wait a bit for UI to stabilize 415 | await new Promise((resolve) => 416 | setTimeout(resolve, Math.min(actualWaitTime, TEST_TIMEOUTS.UI_STABILIZATION)), 417 | ); 418 | } 419 | 420 | // Collect results 421 | const items = cx.qp.items as AllQPItemVariants[]; 422 | 423 | if (debug) { 424 | console.log(`[PeriscopeTest] Found ${items.length} items`); 425 | if (items.length > 0 && items.length <= 5) { 426 | console.log( 427 | '[PeriscopeTest] Items:', 428 | items.map((item: any) => ({ 429 | type: item._type, 430 | label: item.label?.substring(0, 50), 431 | filePath: item.data?.filePath, 432 | })), 433 | ); 434 | } 435 | } 436 | 437 | // Process results 438 | const results = processResults(items); 439 | 440 | if (debug) { 441 | console.log(`[PeriscopeTest] Processed results:`, { 442 | count: results.count, 443 | files: results.files.slice(0, 5), 444 | types: [...new Set(results.raw.types)], 445 | }); 446 | } 447 | 448 | // Conditionally hide the QuickPick based on keepOpen parameter 449 | // By default, keep it open for tests that need to interact with it 450 | // Set keepOpen: false for tests that need clean state between searches 451 | if (!keepOpen) { 452 | cx.qp.hide(); 453 | if (debug) { 454 | console.log('[PeriscopeTest] QuickPick hidden for clean state'); 455 | } 456 | } 457 | 458 | return results; 459 | } 460 | 461 | /** 462 | * Process QuickPick items into structured test results 463 | */ 464 | function processResults(items: AllQPItemVariants[]): TestResults { 465 | const files = new Set(); 466 | const fileToItemsMap = new Map(); 467 | const labels: string[] = []; 468 | const details: string[] = []; 469 | const types: string[] = []; 470 | const lineNumbers: number[] = []; 471 | 472 | for (const item of items) { 473 | // Collect raw data 474 | if (item.label) { 475 | labels.push(item.label); 476 | } 477 | if (item.detail) { 478 | details.push(item.detail); 479 | 480 | // Extract line number from detail if present 481 | const lineNumberMatch = item.detail.match(/:(\d+)$/); 482 | if (lineNumberMatch) { 483 | lineNumbers.push(parseInt(lineNumberMatch[1], 10)); 484 | } 485 | } 486 | types.push(item._type); 487 | 488 | // Extract file information 489 | let filePath: string | undefined; 490 | 491 | if (item._type === 'QuickPickItemQuery' && item.data?.filePath) { 492 | filePath = item.data.filePath; 493 | // Also collect line numbers from data 494 | if (item.data.linePos) { 495 | if (!lineNumbers.includes(item.data.linePos)) { 496 | lineNumbers.push(item.data.linePos); 497 | } 498 | } 499 | } else if (item._type === 'QuickPickItemFile' && item.data?.filePath) { 500 | filePath = item.data.filePath; 501 | } 502 | 503 | if (filePath) { 504 | const fileName = path.basename(filePath); 505 | files.add(fileName); 506 | 507 | // Map items to files 508 | if (!fileToItemsMap.has(fileName)) { 509 | fileToItemsMap.set(fileName, []); 510 | } 511 | fileToItemsMap.get(fileName)!.push(item); 512 | } 513 | } 514 | 515 | return { 516 | items, 517 | count: items.length, 518 | files: Array.from(files), 519 | fileToItemsMap, 520 | raw: { 521 | labels, 522 | details, 523 | types, 524 | }, 525 | lineNumbers: lineNumbers.length > 0 ? lineNumbers : undefined, 526 | }; 527 | } 528 | 529 | /** 530 | * Helper functions for common Periscope commands 531 | */ 532 | export const periscopeTestHelpers = { 533 | /** 534 | * Search content in workspace (handles both text and regex) 535 | */ 536 | search: (query: string, opts?: Partial) => 537 | executePeriscopeTest({ 538 | command: 'periscope.search', 539 | query, 540 | ...opts, 541 | }), 542 | 543 | /** 544 | * Search for file names in workspace 545 | */ 546 | searchFiles: (pattern: string, opts?: Partial) => 547 | executePeriscopeTest({ 548 | command: 'periscope.searchFiles', 549 | query: pattern, 550 | ...opts, 551 | }), 552 | 553 | /** 554 | * Search within the current file 555 | */ 556 | searchCurrentFile: (query: string, file: string, opts?: Partial) => 557 | executePeriscopeTest({ 558 | command: 'periscope.searchCurrentFile', 559 | query, 560 | startFile: file, 561 | ...opts, 562 | }), 563 | 564 | /** 565 | * Resume the previous search 566 | */ 567 | resumeSearch: (opts?: Partial) => 568 | executePeriscopeTest({ 569 | command: 'periscope.resumeSearch', 570 | ...opts, 571 | }), 572 | 573 | /** 574 | * Resume search in current file 575 | */ 576 | resumeSearchCurrentFile: (opts?: Partial) => 577 | executePeriscopeTest({ 578 | command: 'periscope.resumeSearchCurrentFile', 579 | ...opts, 580 | }), 581 | 582 | /** 583 | * Search with selected text 584 | */ 585 | searchWithSelection: (selectedText: string, file: string, opts?: Partial) => 586 | executePeriscopeTest({ 587 | command: 'periscope.search', 588 | startFile: file, 589 | selectedText, 590 | ...opts, 591 | }), 592 | 593 | /** 594 | * Search with menu action 595 | */ 596 | searchWithMenuAction: ( 597 | query: string, 598 | menuAction: { label: string; value: string }, 599 | opts?: Partial, 600 | ) => 601 | executePeriscopeTest({ 602 | command: 'periscope.search', 603 | query, 604 | menuAction, 605 | ...opts, 606 | }), 607 | 608 | /** 609 | * Search with custom configuration 610 | */ 611 | searchWithConfig: (query: string, config: { [key: string]: any }, opts?: Partial) => 612 | executePeriscopeTest({ 613 | command: 'periscope.search', 614 | query, 615 | configuration: config, 616 | ...opts, 617 | }), 618 | 619 | /** 620 | * Search in multi-root workspace 621 | */ 622 | searchInMultiRoot: ( 623 | query: string, 624 | folders: vscode.WorkspaceFolder[], 625 | opts?: Partial, 626 | ) => 627 | executePeriscopeTest({ 628 | command: 'periscope.search', 629 | query, 630 | workspaceFolders: folders, 631 | ...opts, 632 | }), 633 | 634 | /** 635 | * Open result in horizontal split 636 | */ 637 | openInHorizontalSplit: (opts?: Partial) => 638 | executePeriscopeTest({ 639 | command: 'periscope.openInHorizontalSplit', 640 | ...opts, 641 | }), 642 | 643 | /** 644 | * Trigger RG menu with prefix and wait for menu to appear 645 | */ 646 | triggerRgMenu: async (prefix: string = '<<') => { 647 | // Execute search command to open QuickPick 648 | await vscode.commands.executeCommand('periscope.search'); 649 | await waitForQuickPick(); 650 | 651 | // Type the prefix to trigger menu 652 | if (cx.qp) { 653 | cx.qp.value = prefix; 654 | 655 | // Wait for menu to trigger (multi-select mode indicates menu) 656 | await waitForCondition( 657 | () => cx.qp?.canSelectMany === true || cx.qp?.value === '', 658 | TEST_TIMEOUTS.SEARCH_COMPLEX, 659 | ).catch(() => { 660 | // Menu might not trigger in test environment 661 | }); 662 | } 663 | 664 | return cx.qp; 665 | }, 666 | 667 | /** 668 | * Start search and return QuickPick instance for direct manipulation 669 | */ 670 | startSearch: async () => { 671 | await vscode.commands.executeCommand('periscope.search'); 672 | await waitForQuickPick(); 673 | return cx.qp; 674 | }, 675 | }; 676 | -------------------------------------------------------------------------------- /test/suite/ripgrep.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as sinon from 'sinon'; 3 | import * as vscode from 'vscode'; 4 | import { context as cx } from '../../src/lib/context'; 5 | import { 6 | periscopeTestHelpers, 7 | waitForQuickPick, 8 | waitForCondition, 9 | withConfiguration, 10 | TEST_TIMEOUTS, 11 | } from '../utils/periscopeTestHelper'; 12 | 13 | suite('Ripgrep Integration', function () { 14 | this.timeout(TEST_TIMEOUTS.SUITE_EXTENDED); 15 | 16 | let sandbox: sinon.SinonSandbox; 17 | 18 | setup(async () => { 19 | sandbox = sinon.createSandbox(); 20 | cx.resetContext(); 21 | 22 | // Ensure extension is activated 23 | const ext = vscode.extensions.getExtension('JoshMu.periscope'); 24 | if (ext && !ext.isActive) { 25 | await ext.activate(); 26 | } 27 | }); 28 | 29 | teardown(async () => { 30 | if (cx.qp) { 31 | cx.qp.hide(); 32 | cx.qp.dispose(); 33 | } 34 | 35 | sandbox.restore(); 36 | cx.resetContext(); 37 | await new Promise((resolve) => setTimeout(resolve, TEST_TIMEOUTS.UI_STABILIZATION)); 38 | }); 39 | 40 | suite('Basic Ripgrep Functionality', () => { 41 | test('performs basic text search', async () => { 42 | const results = await periscopeTestHelpers.search('function'); 43 | assert.ok(results.count > 0, 'Should find functions in the codebase'); 44 | assert.ok(results.files.length > 0, 'Should find files containing functions'); 45 | }); 46 | 47 | test('performs case-sensitive search', async () => { 48 | // Case-sensitive search for 'TODO' (all caps) 49 | const caseSensitiveResults = await periscopeTestHelpers.search('TODO'); 50 | assert.ok(caseSensitiveResults.count > 0, 'Should find TODO in caps'); 51 | 52 | // These should be different if case sensitivity matters 53 | // In our fixtures, TODO appears but not 'todo' in lowercase 54 | }); 55 | 56 | test('performs regex search', async () => { 57 | const regexResults = await periscopeTestHelpers.search('function\\s+\\w+', { isRegex: true }); 58 | assert.ok(regexResults.count > 0, 'Should find function declarations'); 59 | 60 | // All matches should be function declarations 61 | regexResults.raw.labels.forEach((label) => { 62 | if (label.includes('function')) { 63 | assert.ok(/function\s+\w+/.test(label), 'Should match function pattern'); 64 | } 65 | }); 66 | }); 67 | }); 68 | 69 | suite('Raw Query Parameters', () => { 70 | test('handles quoted queries with additional parameters', () => { 71 | const rawQueries = [ 72 | { 73 | input: '"foobar" -t js', 74 | expectedTerm: 'foobar', 75 | expectedParams: ['-t', 'js'], 76 | }, 77 | { 78 | input: '"test function" -g "*.test.ts" --max-count=1', 79 | expectedTerm: 'test function', 80 | expectedParams: ['-g', '"*.test.ts"', '--max-count=1'], 81 | }, 82 | { 83 | input: '"TODO" --case-sensitive', 84 | expectedTerm: 'TODO', 85 | expectedParams: ['--case-sensitive'], 86 | }, 87 | ]; 88 | 89 | rawQueries.forEach(({ input, expectedTerm, expectedParams }) => { 90 | // Parse quoted query 91 | const match = input.match(/^"([^"]+)"(.*)$/); 92 | assert.ok(match, 'Should match quoted pattern'); 93 | assert.strictEqual(match[1], expectedTerm); 94 | 95 | // Parse parameters 96 | const params = match[2].trim().split(/\s+/); 97 | if (params[0]) { 98 | assert.ok(expectedParams.some((p) => params.includes(p))); 99 | } 100 | }); 101 | }); 102 | 103 | test('passes through ripgrep type filters', () => { 104 | const typeFilters = [ 105 | { param: '-t js', description: 'JavaScript files' }, 106 | { param: '-t rust', description: 'Rust files' }, 107 | { param: '-t md', description: 'Markdown files' }, 108 | { param: '-t json', description: 'JSON files' }, 109 | ]; 110 | 111 | typeFilters.forEach(({ param }) => { 112 | assert.ok(param.startsWith('-t '), 'Type filter should use -t flag'); 113 | }); 114 | }); 115 | 116 | test('handles glob patterns', () => { 117 | const globPatterns = [ 118 | { pattern: '-g "*.test.ts"', description: 'Test files' }, 119 | { pattern: '-g "**/src/**"', description: 'Source directory' }, 120 | { pattern: '-g "!**/node_modules/**"', description: 'Exclude node_modules' }, 121 | { pattern: '-g "*.{js,ts}"', description: 'JS and TS files' }, 122 | ]; 123 | 124 | globPatterns.forEach(({ pattern }) => { 125 | assert.ok(pattern.includes('-g'), 'Glob pattern should use -g flag'); 126 | }); 127 | }); 128 | }); 129 | 130 | suite('Paths with Spaces', () => { 131 | test('finds content in folders with spaces', async () => { 132 | // Search for unique content in our space-path fixtures 133 | const results = await periscopeTestHelpers.search('SPACES_IN_PATH_TEST'); 134 | 135 | assert.ok(results.count > 0, 'Should find content in folder with spaces'); 136 | 137 | // Verify we found the file (the path format might vary in tests) 138 | const foundFile = results.files.some( 139 | (file) => file.includes('another file.js') || file.includes('folder with spaces'), 140 | ); 141 | assert.ok(foundFile, 'Should find "another file.js" in results'); 142 | }); 143 | 144 | test('finds content in files with spaces in name', async () => { 145 | // Search for unique content in file with spaces 146 | const results = await periscopeTestHelpers.search('testSpacesInPath'); 147 | 148 | assert.ok(results.count > 0, 'Should find content in file with spaces'); 149 | 150 | // Verify the file name contains spaces 151 | const foundInFileWithSpaces = results.files.some((file) => 152 | file.includes('file with spaces.ts'), 153 | ); 154 | assert.ok(foundInFileWithSpaces, 'Should find "file with spaces.ts"'); 155 | }); 156 | 157 | test('handles multiple files in space path folder', async () => { 158 | // Search for content that appears in the space folder 159 | const results = await periscopeTestHelpers.search('handleSpacePath'); 160 | 161 | assert.ok(results.count > 0, 'Should find function in space path'); 162 | 163 | // Check that we find the file (path format might vary in tests) 164 | const foundFile = results.files.some((file) => file.includes('another file.js')); 165 | assert.ok(foundFile, 'Should find "another file.js" in results'); 166 | }); 167 | 168 | test('searches current file with spaces in path', async () => { 169 | // Search in current file with spaces in its path 170 | const results = await periscopeTestHelpers.searchCurrentFile( 171 | 'SpacePathTest', 172 | 'folder with spaces/file with spaces.ts', 173 | ); 174 | 175 | assert.ok(results.count > 0, 'Should find content when searching current file with spaces'); 176 | assert.strictEqual(results.files.length, 1, 'Should only search current file'); 177 | assert.ok( 178 | results.files[0].includes('file with spaces.ts'), 179 | 'Should be searching the correct file', 180 | ); 181 | }); 182 | }); 183 | 184 | suite('Ripgrep Options', () => { 185 | test('applies max-count option', () => { 186 | const options = ['--max-count=1', '--max-count=5']; 187 | 188 | options.forEach((opt) => { 189 | const match = opt.match(/--max-count=(\d+)/); 190 | assert.ok(match, 'Should match max-count pattern'); 191 | const count = parseInt(match[1]); 192 | assert.ok(count > 0, 'Max count should be positive'); 193 | }); 194 | }); 195 | 196 | test('applies case sensitivity options', () => { 197 | const caseOptions = [ 198 | { flag: '--case-sensitive', description: 'Force case sensitive' }, 199 | { flag: '-s', description: 'Short form case sensitive' }, 200 | { flag: '--ignore-case', description: 'Force case insensitive' }, 201 | { flag: '-i', description: 'Short form case insensitive' }, 202 | ]; 203 | 204 | caseOptions.forEach(({ flag }) => { 205 | assert.ok(flag.startsWith('-'), 'Option should be a command flag'); 206 | }); 207 | }); 208 | 209 | test('applies context line options', () => { 210 | const contextOptions = [ 211 | { flag: '-A 2', description: 'Show 2 lines after match' }, 212 | { flag: '-B 3', description: 'Show 3 lines before match' }, 213 | { flag: '-C 2', description: 'Show 2 lines before and after' }, 214 | ]; 215 | 216 | contextOptions.forEach(({ flag }) => { 217 | assert.ok(flag.match(/-[ABC]\s+\d+/), 'Should match context line pattern'); 218 | }); 219 | }); 220 | 221 | test('handles word boundary option', () => { 222 | const wordBoundaryOptions = [ 223 | { flag: '-w', description: 'Match whole words only' }, 224 | { flag: '--word-regexp', description: 'Long form word boundary' }, 225 | ]; 226 | 227 | wordBoundaryOptions.forEach(({ flag }) => { 228 | assert.ok(flag === '-w' || flag === '--word-regexp'); 229 | }); 230 | }); 231 | }); 232 | 233 | suite('File Type Filters', () => { 234 | test('filters by built-in file types', () => { 235 | const builtInTypes = [ 236 | 'js', 237 | 'ts', 238 | 'rust', 239 | 'python', 240 | 'go', 241 | 'java', 242 | 'cpp', 243 | 'c', 244 | 'html', 245 | 'css', 246 | 'json', 247 | 'xml', 248 | 'yaml', 249 | 'md', 250 | ]; 251 | 252 | builtInTypes.forEach((type) => { 253 | const param = `-t ${type}`; 254 | assert.ok(param.includes(type), `Should create type filter for ${type}`); 255 | }); 256 | }); 257 | 258 | test('creates custom file type definitions', () => { 259 | const customTypes = [ 260 | { 261 | definition: "--type-add 'jsts:*.{js,ts,tsx,jsx}' -t jsts", 262 | name: 'jsts', 263 | extensions: ['js', 'ts', 'tsx', 'jsx'], 264 | }, 265 | { 266 | definition: "--type-add 'web:*.{html,css,scss}' -t web", 267 | name: 'web', 268 | extensions: ['html', 'css', 'scss'], 269 | }, 270 | ]; 271 | 272 | customTypes.forEach(({ definition, name, extensions }) => { 273 | assert.ok(definition.includes('--type-add'), 'Should use --type-add'); 274 | assert.ok(definition.includes(`-t ${name}`), 'Should reference custom type'); 275 | extensions.forEach((ext) => { 276 | assert.ok(definition.includes(ext), `Should include ${ext} extension`); 277 | }); 278 | }); 279 | }); 280 | }); 281 | 282 | suite('Exclusion Patterns', () => { 283 | test('excludes node_modules by default', async () => { 284 | const results = await periscopeTestHelpers.search('function'); 285 | 286 | // Verify no results from node_modules 287 | const hasNodeModules = results.files.some((f) => f.includes('node_modules')); 288 | assert.strictEqual(hasNodeModules, false, 'Should exclude node_modules'); 289 | }); 290 | 291 | test('applies custom exclusion globs', () => { 292 | const exclusions = [ 293 | '**/dist/**', 294 | '**/build/**', 295 | '**/*.min.js', 296 | '**/coverage/**', 297 | '**/.git/**', 298 | ]; 299 | 300 | exclusions.forEach((pattern) => { 301 | // Each would be passed as -g "!pattern" 302 | const rgParam = `-g "!${pattern}"`; 303 | assert.ok(rgParam.includes('!'), 'Exclusion should use ! prefix'); 304 | assert.ok(rgParam.includes(pattern), 'Should include pattern'); 305 | }); 306 | }); 307 | }); 308 | 309 | suite('Search Modes', () => { 310 | test('searches all files in default mode', async () => { 311 | const results = await periscopeTestHelpers.search('function'); 312 | assert.ok(results.count > 0, 'Should find results across all files'); 313 | 314 | // Should find in multiple file types 315 | const fileTypes = new Set(results.files.map((f) => f.split('.').pop())); 316 | assert.ok(fileTypes.size > 1, 'Should search multiple file types'); 317 | }); 318 | 319 | test('searches only current file in currentFile mode', async () => { 320 | const results = await periscopeTestHelpers.searchCurrentFile( 321 | 'function', 322 | 'src/utils/helpers.ts', 323 | ); 324 | 325 | // All results should be from the specified file 326 | const uniqueFiles = [...new Set(results.files)]; 327 | assert.strictEqual(uniqueFiles.length, 1, 'Should only search one file'); 328 | assert.strictEqual(uniqueFiles[0], 'helpers.ts'); 329 | }); 330 | 331 | test('lists files in file search mode', async () => { 332 | const results = await periscopeTestHelpers.searchFiles('test'); 333 | 334 | // Should find test files 335 | assert.ok( 336 | results.files.some((f) => f.includes('test')), 337 | 'Should find files with "test" in name', 338 | ); 339 | 340 | // All items should be file type 341 | assert.ok( 342 | results.items.every((item) => item._type === 'QuickPickItemFile'), 343 | 'Should only return file items', 344 | ); 345 | }); 346 | }); 347 | 348 | suite('Performance Optimizations', () => { 349 | test('handles large result sets', async () => { 350 | // Search for common pattern that might return many results 351 | const results = await periscopeTestHelpers.search('import'); 352 | 353 | // Should handle results efficiently 354 | assert.ok(results.count >= 0, 'Should return results'); 355 | assert.ok(Array.isArray(results.items), 'Should return array of items'); 356 | }); 357 | 358 | test('supports result limiting', () => { 359 | const limitOptions = ['--max-count=10', '--max-filesize=1M', '--max-columns=200']; 360 | 361 | limitOptions.forEach((opt) => { 362 | assert.ok(opt.startsWith('--max'), 'Limit option should use --max prefix'); 363 | }); 364 | }); 365 | }); 366 | 367 | suite('Command Construction Verification', () => { 368 | test('constructs ripgrep command with required flags', async function () { 369 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 370 | 371 | // Perform a search and intercept the command 372 | await vscode.commands.executeCommand('periscope.search'); 373 | await waitForQuickPick(); 374 | 375 | // Trigger a search 376 | cx.qp.value = 'test'; 377 | await waitForCondition(() => cx.qp.items.length >= 0, TEST_TIMEOUTS.SEARCH_COMPLEX); 378 | 379 | // The command should include required flags 380 | // In a real test, we would intercept the spawn call to capture the command 381 | // For now, verify that the search produces results (indicating valid command) 382 | assert.ok(cx.qp, 'Search should execute with valid ripgrep command'); 383 | 384 | // Clean up 385 | cx.qp.hide(); 386 | cx.qp.dispose(); 387 | }); 388 | 389 | test('includes configuration options in command', async function () { 390 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 391 | 392 | await withConfiguration( 393 | { 394 | rgOptions: ['--max-count=5', '--case-sensitive'], 395 | }, 396 | async () => { 397 | // Perform search 398 | const results = await periscopeTestHelpers.search('function'); 399 | 400 | // The search should respect the configuration 401 | // Count occurrences per file - should be max 5 402 | const fileOccurrences = new Map(); 403 | results.items.forEach((item: any) => { 404 | if (item.data?.filePath) { 405 | const fileName = item.data.filePath.split('/').pop(); 406 | fileOccurrences.set(fileName, (fileOccurrences.get(fileName) || 0) + 1); 407 | } 408 | }); 409 | 410 | // Each file should have max 5 matches due to --max-count=5 411 | for (const [file, count] of fileOccurrences) { 412 | assert.ok(count <= 5, `File ${file} should respect --max-count=5`); 413 | } 414 | }, 415 | ); 416 | }); 417 | 418 | test('applies exclusion globs to command', async function () { 419 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 420 | 421 | await withConfiguration( 422 | { 423 | rgGlobExcludes: ['**/test/**', '**/spec/**'], 424 | }, 425 | async () => { 426 | // Search for something that would be in test files 427 | const results = await periscopeTestHelpers.search('test'); 428 | 429 | // Should not find results in test directories 430 | const testFiles = results.files.filter( 431 | (f) => f.includes('/test/') || f.includes('/spec/'), 432 | ); 433 | 434 | assert.strictEqual(testFiles.length, 0, 'Should exclude test and spec directories'); 435 | }, 436 | ); 437 | }); 438 | 439 | test('includes current file path in currentFile mode', async function () { 440 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 441 | 442 | // Open a specific file 443 | const filePath = 'src/utils/helpers.ts'; 444 | const results = await periscopeTestHelpers.searchCurrentFile('function', filePath); 445 | 446 | // All results should be from the current file only 447 | const uniqueFiles = [...new Set(results.files)]; 448 | assert.strictEqual(uniqueFiles.length, 1, 'Should only search current file'); 449 | assert.strictEqual(uniqueFiles[0], 'helpers.ts', 'Should search specified file'); 450 | }); 451 | 452 | test('includes menu actions in command', async function () { 453 | this.timeout(TEST_TIMEOUTS.SUITE_DEFAULT); 454 | 455 | // Search with a menu action that filters file types 456 | const results = await periscopeTestHelpers.searchWithMenuAction('function', { 457 | label: 'TypeScript Only', 458 | value: '-t ts', 459 | }); 460 | 461 | // All results should be TypeScript files 462 | const allTypeScript = results.files.every((f) => f.endsWith('.ts') || f.endsWith('.tsx')); 463 | 464 | assert.ok(allTypeScript, 'Menu action should filter to TypeScript files only'); 465 | }); 466 | }); 467 | 468 | suite('Query Parameter Transformations', () => { 469 | test('transforms type filter shortcuts', () => { 470 | const transforms = [ 471 | { input: 'search -t js', output: "rg 'search' -t js" }, 472 | { input: 'find -t rust', output: "rg 'find' -t rust" }, 473 | { input: 'TODO -t md', output: "rg 'TODO' -t md" }, 474 | ]; 475 | 476 | transforms.forEach(({ input, output }) => { 477 | // Extract pattern and type 478 | const match = input.match(/^(.+) -t (\w+)$/); 479 | assert.ok(match, 'Should match type filter pattern'); 480 | 481 | const [, searchTerm, fileType] = match; 482 | const transformed = `rg '${searchTerm}' -t ${fileType}`; 483 | assert.strictEqual(transformed, output); 484 | }); 485 | }); 486 | 487 | test('transforms glob shortcuts', () => { 488 | const transforms = [ 489 | { input: 'search *.ts', expected: '-g "*.ts"' }, 490 | { input: 'find *.test.js', expected: '-g "*.test.js"' }, 491 | ]; 492 | 493 | transforms.forEach(({ input, expected }) => { 494 | const match = input.match(/^(.+) (\*\.\w+(?:\.\w+)?)$/); 495 | if (match) { 496 | const glob = `-g "${match[2]}"`; 497 | assert.strictEqual(glob, expected); 498 | } 499 | }); 500 | }); 501 | 502 | test('transforms module path shortcuts', () => { 503 | const transforms = [ 504 | { input: 'redis -m auth', expected: '-g "**/auth/**"' }, 505 | { input: 'search -m user-service', expected: '-g "**/user-service/**"' }, 506 | ]; 507 | 508 | transforms.forEach(({ input }) => { 509 | const match = input.match(/^(.+) -m ([\w-_]+)$/); 510 | assert.ok(match, 'Should match module pattern'); 511 | }); 512 | }); 513 | }); 514 | 515 | suite('Multi-line and Special Patterns', () => { 516 | test('handles multi-line patterns', () => { 517 | // Multi-line patterns need special handling 518 | const multilinePatterns = [ 519 | 'function.*\\n.*return', 520 | 'class.*\\{[\\s\\S]*constructor', 521 | 'TODO.*\\n.*FIXME', 522 | ]; 523 | 524 | multilinePatterns.forEach((pattern) => { 525 | // Would need -U flag for multiline 526 | assert.ok( 527 | pattern.includes('\\n') || pattern.includes('[\\s\\S]'), 528 | 'Pattern suggests multiline matching', 529 | ); 530 | }); 531 | }); 532 | 533 | test('escapes special regex characters', () => { 534 | const specialChars = ['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '[', ']', '|', '\\']; 535 | 536 | specialChars.forEach((char) => { 537 | // When searching literally, these should be escaped 538 | const escaped = `\\${char}`; 539 | assert.ok(escaped.startsWith('\\'), 'Special char should be escaped'); 540 | }); 541 | }); 542 | }); 543 | 544 | suite('Error Handling', () => { 545 | test('handles invalid regex patterns gracefully', () => { 546 | const invalidPatterns = ['[unclosed', '(unclosed', '*invalid', '?invalid']; 547 | 548 | invalidPatterns.forEach((pattern) => { 549 | // Should handle these without crashing 550 | assert.ok(pattern, 'Pattern exists'); 551 | // In real implementation, would catch regex errors 552 | }); 553 | }); 554 | 555 | test('handles missing file paths gracefully', async () => { 556 | // Searching in non-existent file should handle gracefully 557 | try { 558 | const results = await periscopeTestHelpers.searchCurrentFile( 559 | 'test', 560 | 'non/existent/file.ts', 561 | ); 562 | // Should either return empty or handle error 563 | assert.ok(results !== undefined, 'Should return some result'); 564 | } catch (error) { 565 | // Should handle error gracefully 566 | assert.ok(error, 'Should handle missing file'); 567 | } 568 | }); 569 | }); 570 | }); 571 | --------------------------------------------------------------------------------