Skip to content

User Settings: return the list of installed editors #1217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/hooks/use-check-installed-apps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { getIpcApi } from 'src/lib/get-ipc-api';
const initState = {
vscode: false,
phpstorm: false,
nova: false,
webstorm: false,
sublime: false,
atom: false,
windsurf: false,
cursor: false,
iterm: false,
};
const checkInstalledAppsContext = createContext< InstalledApps >( initState );

Expand Down
6 changes: 6 additions & 0 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export async function getInstalledApps( _event: IpcMainInvokeEvent ): Promise< I
return {
vscode: isInstalled( 'vscode' ),
phpstorm: isInstalled( 'phpstorm' ),
nova: isInstalled( 'nova' ),
webstorm: isInstalled( 'webstorm' ),
sublime: isInstalled( 'sublime' ),
atom: isInstalled( 'atom' ),
windsurf: isInstalled( 'windsurf' ),
cursor: isInstalled( 'cursor' ),
};
}

Expand Down
10 changes: 8 additions & 2 deletions src/ipc-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,14 @@ interface Snapshot {
}

type InstalledApps = {
vscode: boolean | null;
phpstorm: boolean | null;
vscode: boolean;
phpstorm: boolean;
nova: boolean;
webstorm: boolean;
sublime: boolean;
atom: boolean;
windsurf: boolean;
cursor: boolean;
};

type InstalledTerminals = {
Expand Down
147 changes: 108 additions & 39 deletions src/lib/is-installed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,122 @@ import { app } from 'electron';
import fs from 'fs';
import path from 'path';

let appPaths: Record< keyof InstalledApps, string[] >;
let terminalPaths: Record< 'iterm', string[] >;
type PlatformPaths = {
[ K in keyof InstalledApps | keyof InstalledTerminals ]: string[];
};

if ( process.platform === 'darwin' ) {
const systemApplications = '/Applications';
const userApplications = path.join( app.getPath( 'home' ), 'Applications' );
function getProgramFilesPath(): string {
if ( process.platform !== 'win32' ) {
return 'C:\\Program Files';
}

appPaths = {
// This env var dinamically points to the Program Files path
// See https://stackoverflow.com/a/9608782
const programFiles = process.env.ProgramFiles;
if ( programFiles ) {
return programFiles;
}

// Fallback to default path if environment variable is not available
return 'C:\\Program Files';
}

// Define installation paths for each IDE by platform
const installationPaths: Record< string, PlatformPaths > = {
darwin: {
vscode: [ 'Visual Studio Code.app' ],
phpstorm: [ 'PhpStorm.app' ],
cursor: [ 'Cursor.app' ],
windsurf: [ 'Windsurf.app' ],
nova: [ 'Nova.app' ],
webstorm: [ 'WebStorm.app' ],
sublime: [ 'Sublime Text.app' ],
atom: [ 'Atom.app' ],
iterm: [ 'iTerm.app' ],
terminal: [ 'Terminal.app' ],
},
linux: {
vscode: [ '/usr/bin/code' ],
phpstorm: [ '/usr/bin/phpstorm' ],
cursor: [ '/usr/bin/cursor' ],
windsurf: [ '/usr/bin/windsurf' ],
nova: [],
webstorm: [ '/usr/bin/webstorm' ],
sublime: [ '/usr/bin/sublime' ],
atom: [ '/usr/bin/atom' ],
iterm: [],
terminal: [],
},
Comment on lines +26 to +50
Copy link
Contributor Author

@gcsecsey gcsecsey Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@katinthehatsite when rebasing this PR after #1215 was merged, I found it easier to merge the list of terminals and apps into one list, and do the same path manipulations for these as the IDEs.

I only had some issues with the typing, currently I use InstalledApps | InstalledTerminals where we need to be able to handle both. Longer term, I think it'd make sense to maintain just one type of the possible apps/terminals we support.

win32: {
vscode: [
path.join( systemApplications, 'Visual Studio Code.app' ),
path.join( userApplications, 'Visual Studio Code.app' ),
path.win32.join( getProgramFilesPath(), 'Microsoft VS Code' ),
path.win32.join( app.getPath( 'appData' ), 'Local\\Programs\\Microsoft VS Code' ),
],
phpstorm: [
path.join( systemApplications, 'PhpStorm.app' ),
path.join( userApplications, 'PhpStorm.app' ),
path.win32.join( getProgramFilesPath(), 'JetBrains\\PhpStorm' ),
path.win32.join( app.getPath( 'appData' ), 'JetBrains\\PhpStorm' ),
],
};

terminalPaths = {
iterm: [
path.join( systemApplications, 'iTerm.app' ),
path.join( userApplications, 'iTerm.app' ),
cursor: [
path.win32.join( getProgramFilesPath(), 'Cursor' ),
path.win32.join( app.getPath( 'appData' ), 'Local\\Programs\\Cursor' ),
],
};
} else if ( process.platform === 'linux' ) {
appPaths = {
vscode: [ '/usr/bin/code' ],
phpstorm: [ '/usr/bin/phpstorm' ],
};
terminalPaths = {
iterm: [], // iTerm is macOS only
};
windsurf: [
path.win32.join( getProgramFilesPath(), 'Windsurf' ),
path.win32.join( app.getPath( 'appData' ), 'Windsurf' ),
],
nova: [], // Nova is Mac-only
webstorm: [
path.win32.join( getProgramFilesPath(), 'JetBrains\\WebStorm' ),
path.win32.join( app.getPath( 'appData' ), 'JetBrains\\WebStorm' ),
],
sublime: [
path.win32.join( getProgramFilesPath(), 'Sublime Text' ),
path.win32.join( getProgramFilesPath(), 'Sublime Text 3' ),
],
atom: [
path.win32.join( app.getPath( 'appData' ), 'atom' ),
path.win32.join( getProgramFilesPath(), 'Atom' ),
],
iterm: [],
terminal: [],
},
};

if ( process.platform === 'darwin' ) {
const systemApplications = '/Applications';
const userApplications = path.join( app.getPath( 'home' ), 'Applications' );

Object.keys( installationPaths.darwin ).forEach( ( ide ) => {
const appName = installationPaths.darwin[ ide as keyof InstalledApps ][ 0 ];
if ( appName ) {
installationPaths.darwin[ ide as keyof InstalledApps ] = [
path.join( systemApplications, appName ),
path.join( userApplications, appName ),
];
}
} );
} else if ( process.platform === 'win32' ) {
appPaths = {
vscode: [ path.join( app.getPath( 'appData' ), 'Code' ) ],
phpstorm: [ '' ], // Disable phpStorm for Windows
};
terminalPaths = {
iterm: [], // iTerm is macOS only
};
// For JetBrains IDEs, check for version-specific folders
[ 'phpstorm', 'webstorm' ].forEach( ( ide ) => {
const basePaths = installationPaths.win32[ ide as keyof InstalledApps ];
const jetbrainsDir = path.win32.join( getProgramFilesPath(), 'JetBrains' );

if ( fs.existsSync( jetbrainsDir ) ) {
const entries = fs.readdirSync( jetbrainsDir );

entries.forEach( ( entry ) => {
if ( entry.toLowerCase().includes( ide ) ) {
basePaths.push( path.win32.join( jetbrainsDir, entry ) );
}
} );
}
} );
}

export function isInstalled( key: keyof typeof appPaths | keyof typeof terminalPaths ): boolean {
const paths =
appPaths[ key as keyof typeof appPaths ] || terminalPaths[ key as keyof typeof terminalPaths ];
if ( ! paths ) {
return false;
}
return paths.some( ( path: string ) => path && fs.existsSync( path ) );
export function isInstalled( key: keyof InstalledApps | keyof InstalledTerminals ): boolean {
const platform = process.platform;
const paths = installationPaths[ platform ]?.[ key ];

// Return true if any of the possible paths exist
return paths.some( ( pathStr: string ) => pathStr && fs.existsSync( pathStr ) );
}
132 changes: 132 additions & 0 deletions src/lib/tests/is-installed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @jest-environment node
*/
import { app } from 'electron';
import fs from 'fs';

jest.mock( 'fs', () => ( {
existsSync: jest.fn(),
readdirSync: jest.fn(),
} ) );

jest.mock( 'electron', () => ( {
app: {
getPath: jest.fn(),
},
} ) );

describe( 'isInstalled', () => {
let isInstalled: ( key: string ) => boolean;
let mockPaths: string[];

// Reset mocks before each test
beforeEach( () => {
jest.resetAllMocks();
mockPaths = [];

// Mock fs.existsSync to check against our mockPaths array
( fs.existsSync as jest.Mock ).mockImplementation( ( testPath: string ) => {
// Normalize both the test path and mock paths to use forward slashes
const normalizedTestPath = testPath.replace( /\\/g, '/' );
const normalizedMockPaths = mockPaths.map( ( p ) => p.replace( /\\/g, '/' ) );
return normalizedMockPaths.includes( normalizedTestPath );
} );

// Mock app.getPath
( app.getPath as jest.Mock ).mockImplementation( ( name: string ) => {
switch ( name ) {
case 'home':
return '/mock/home/path';
case 'appData':
return process.platform === 'win32' ? 'C:\\mock\\AppData' : '/mock/home/path/.config';
default:
return '';
}
} );
} );

describe( 'on macOS (darwin)', () => {
beforeEach( () => {
Object.defineProperty( process, 'platform', { value: 'darwin' } );
// Re-import the module to ensure platform-specific paths are set up
jest.isolateModules( () => {
const module = require( '../is-installed' );
isInstalled = module.isInstalled;
} );
} );

it( 'detects VS Code installed in system Applications', () => {
mockPaths = [ '/Applications/Visual Studio Code.app' ];
expect( isInstalled( 'vscode' ) ).toBe( true );
} );

it( 'detects VS Code installed in user Applications', () => {
mockPaths = [ '/mock/home/path/Applications/Visual Studio Code.app' ];
expect( isInstalled( 'vscode' ) ).toBe( true );
} );

it( 'returns false when VS Code is not installed', () => {
mockPaths = [];
expect( isInstalled( 'vscode' ) ).toBe( false );
} );

it( 'detects PhpStorm installed', () => {
mockPaths = [ '/Applications/PhpStorm.app' ];
expect( isInstalled( 'phpstorm' ) ).toBe( true );
} );

it( 'detects Nova installed (Mac-only)', () => {
mockPaths = [ '/Applications/Nova.app' ];
expect( isInstalled( 'nova' ) ).toBe( true );
} );
} );

describe( 'on Windows (win32)', () => {
beforeEach( () => {
Object.defineProperty( process, 'platform', { value: 'win32' } );
process.env.ProgramFiles = 'D:\\Program Files';
// Re-import the module after setting the environment variable
jest.isolateModules( () => {
const module = require( '../is-installed' );
isInstalled = module.isInstalled;
} );
} );

it( 'detects VS Code installed in Program Files', () => {
mockPaths = [ 'D:\\Program Files\\Microsoft VS Code' ];

expect( isInstalled( 'vscode' ) ).toBe( true );
} );

it( 'detects VS Code installed in AppData', () => {
mockPaths = [ 'C:\\mock\\AppData\\Local\\Programs\\Microsoft VS Code' ];
expect( isInstalled( 'vscode' ) ).toBe( true );
} );

it( 'detects PhpStorm with version-specific folder', () => {
mockPaths = [ 'D:\\Program Files\\JetBrains', 'D:\\Program Files\\JetBrains\\PhpStorm' ];

( fs.readdirSync as jest.Mock ).mockReturnValue( [ 'PhpStorm 2023.1', 'WebStorm 2023.1' ] );

expect( isInstalled( 'phpstorm' ) ).toBe( true );
} );

it( 'returns false for Nova on Windows (Mac-only)', () => {
mockPaths = [];
expect( isInstalled( 'nova' ) ).toBe( false );
} );

it( 'falls back to default Program Files path when environment variable is not set', () => {
delete process.env.ProgramFiles;

// Re-import the module after setting the environment variable
jest.isolateModules( () => {
const module = require( '../is-installed' );
isInstalled = module.isInstalled;
} );

mockPaths = [ 'C:\\Program Files\\Microsoft VS Code' ];
expect( isInstalled( 'vscode' ) ).toBe( true );
} );
} );
} );
Loading