diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 74d50e9ea8..eb3edf8f4a 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -56,7 +56,6 @@ import { ExportOptions } from 'src/lib/import-export/export/types'; import { ImportExportEventData } from 'src/lib/import-export/handle-events'; import { defaultImporterOptions, importBackup } from 'src/lib/import-export/import/import-manager'; import { BackupArchiveInfo } from 'src/lib/import-export/import/types'; -import { isInstalled } from 'src/lib/is-installed'; import { getUserLocaleWithFallback } from 'src/lib/locale-node'; import * as oauthClient from 'src/lib/oauth'; import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper'; @@ -71,7 +70,7 @@ import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers'; import { STABLE_BIN_DIR_PATH } from 'src/modules/cli/lib/windows-installation-manager'; import { shouldExcludeFromSync, shouldLimitDepth } from 'src/modules/sync/lib/tree-utils'; import { supportedEditorConfig, SupportedEditor } from 'src/modules/user-settings/lib/editor'; -import { getUserTerminal } from 'src/modules/user-settings/lib/ipc-handlers'; +import { getUserEditor, getUserTerminal } from 'src/modules/user-settings/lib/ipc-handlers'; import { winFindEditorPath } from 'src/modules/user-settings/lib/win-editor-path'; import { SiteServer, stopAllServers as triggerStopAllServers } from 'src/site-server'; import { DEFAULT_SITE_PATH, getSiteThumbnailPath } from 'src/storage/paths'; @@ -1038,23 +1037,30 @@ export async function openTerminalAtPath( _event: IpcMainInvokeEvent, targetPath export async function openAppAtPath( event: IpcMainInvokeEvent, editorKey: SupportedEditor, - filePath: string + filePath: string, + otherFiles: string[] = [] ): Promise< void > { const platform = process.platform; const editor = supportedEditorConfig[ editorKey ]; + const allPaths = [ filePath, ...otherFiles ]; + const quotedPaths = allPaths.map( ( p ) => `"${ p }"` ).join( ' ' ); if ( platform === 'darwin' ) { - return promiseExec( `open -b ${ editor.macOSBundleId } "${ filePath }"` ); + const cmd = `open -b ${ editor.macOSBundleId } ${ quotedPaths }`; + return promiseExec( cmd ); } if ( platform === 'win32' ) { const editorPath = await winFindEditorPath( editorKey ); if ( ! editorPath ) { - // Fall back to using openURL if no editor path is found - return openURL( event, editor.url( filePath ) ); + // Fall back to URL scheme for each path + for ( const p of allPaths ) { + openURL( event, editor.url( p ) ); + } + return; } - return promiseExec( `"${ editorPath }" "${ filePath }"` ); + return promiseExec( `"${ editorPath }" ${ quotedPaths }` ); } throw new Error( `Platform ${ platform } is not supported` ); @@ -1165,9 +1171,10 @@ export async function getAbsolutePathFromSite( /** * Opens a file in the IDE with the site context. + * Uses the user's preferred editor, falling back to the first installed editor. */ export async function openFileInIDE( - _event: IpcMainInvokeEvent, + event: IpcMainInvokeEvent, relativePath: string, siteId: string ) { @@ -1176,19 +1183,28 @@ export async function openFileInIDE( throw new Error( 'Site not found.' ); } - const path = await getAbsolutePathFromSite( _event, siteId, relativePath ); - if ( ! path ) { + const filepath = await getAbsolutePathFromSite( event, siteId, relativePath ); + if ( ! filepath ) { return; } - if ( isInstalled( 'vscode' ) ) { - // Open site first to ensure the file is opened within the site context - await shellOpenExternalWrapper( `vscode://file/${ server.details.path }?windowId=_blank` ); - await shellOpenExternalWrapper( `vscode://file/${ path }` ); - } else if ( isInstalled( 'phpstorm' ) ) { - // Open site first to ensure the file is opened within the site context - await shellOpenExternalWrapper( `phpstorm://open?file=${ path }` ); + const editorKey = await getUserEditor(); + if ( ! editorKey ) { + return; + } + + const openSingleFileExceptions = [ { platform: 'darwin', editorKey: 'phpstorm' } ]; + + if ( + openSingleFileExceptions.some( + ( f ) => f.platform === process.platform && f.editorKey === editorKey + ) + ) { + await openAppAtPath( event, editorKey, filepath ); + return; } + // Open site folder and file in a single call + await openAppAtPath( event, editorKey, server.details.path, [ filepath ] ); } export async function isImportExportSupported( _event: IpcMainInvokeEvent, siteId: string ) { diff --git a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts index 25019a3a94..392bc18239 100644 --- a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts @@ -3,7 +3,7 @@ import { DEFAULT_TERMINAL } from 'src/constants'; import { sendIpcEventToRenderer, sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; import { isInstalled } from 'src/lib/is-installed'; import { getUserLocaleWithFallback } from 'src/lib/locale-node'; -import { SupportedEditor } from 'src/modules/user-settings/lib/editor'; +import { SUPPORTED_EDITORS, SupportedEditor } from 'src/modules/user-settings/lib/editor'; import { SupportedTerminal } from 'src/modules/user-settings/lib/terminal'; import { UserSettingsTabName } from 'src/modules/user-settings/user-settings-types'; import { loadUserData, updateAppdata } from 'src/storage/user-data'; @@ -54,8 +54,17 @@ export async function getUserLocale() { } export async function getUserEditor(): Promise< SupportedEditor | null > { + function getDefaultInstalledEditor(): SupportedEditor | null { + const installedApps = getInstalledAppsAndTerminals(); + for ( const editor of SUPPORTED_EDITORS ) { + if ( installedApps[ editor ] ) { + return editor; + } + } + return null; + } const userData = await loadUserData(); - return userData.preferredEditor ?? null; + return userData.preferredEditor ?? getDefaultInstalledEditor(); } export function showUserSettings( event: IpcMainInvokeEvent, tabName?: UserSettingsTabName ) { diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 2fd0971b0a..b21dca6454 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -101,8 +101,8 @@ const api: IpcApi = { saveOnboarding: ( onboardingCompleted ) => ipcRendererInvoke( 'saveOnboarding', onboardingCompleted ), getBetaFeatures: () => ipcRendererInvoke( 'getBetaFeatures' ), - openAppAtPath: ( editorKey, filePath ) => - ipcRendererInvoke( 'openAppAtPath', editorKey, filePath ), + openAppAtPath: ( editorKey, filePath, otherFiles?: string[] ) => + ipcRendererInvoke( 'openAppAtPath', editorKey, filePath, otherFiles ), openTerminalAtPath: ( targetPath ) => ipcRendererInvoke( 'openTerminalAtPath', targetPath ), showMessageBox: ( options ) => ipcRendererInvoke( 'showMessageBox', options ), showErrorMessageBox: ( options ) => ipcRendererSend( 'showErrorMessageBox', options ), diff --git a/apps/studio/src/stores/installed-apps-api.ts b/apps/studio/src/stores/installed-apps-api.ts index b5684ebd18..3bf869fc72 100644 --- a/apps/studio/src/stores/installed-apps-api.ts +++ b/apps/studio/src/stores/installed-apps-api.ts @@ -5,7 +5,6 @@ import { SupportedEditorConfig, SupportedEditor, supportedEditorConfig, - SUPPORTED_EDITORS, } from 'src/modules/user-settings/lib/editor'; import { SupportedTerminal, @@ -13,17 +12,6 @@ import { getTerminalsSupportedOnPlatform, } from 'src/modules/user-settings/lib/terminal'; -const getFirstInstalledEditor = async (): Promise< SupportedEditor | null > => { - const installedApps = await getIpcApi().getInstalledAppsAndTerminals(); - for ( const editor of SUPPORTED_EDITORS ) { - if ( installedApps[ editor ] ) { - return editor; - } - } - - return null; -}; - export const installedAppsApi = createApi( { reducerPath: 'installedAppsApi', baseQuery: fetchBaseQuery(), @@ -46,16 +34,7 @@ export const installedAppsApi = createApi( { getUserEditor: builder.query< SupportedEditor | null, void >( { queryFn: async () => { const editor = await getIpcApi().getUserEditor(); - // Respect user preference if it is set - if ( editor ) { - return { data: editor }; - } - - // If no user preference is set, check for installed editors - // and set the default to the first one found in priority order - const defaultEditor = await getFirstInstalledEditor(); - - return { data: defaultEditor }; + return { data: editor }; }, providesTags: [ 'UserEditor' ], } ), diff --git a/apps/studio/src/stores/tests/installed-apps-api.test.ts b/apps/studio/src/stores/tests/installed-apps-api.test.ts index 83f75c393f..8805dc85bd 100644 --- a/apps/studio/src/stores/tests/installed-apps-api.test.ts +++ b/apps/studio/src/stores/tests/installed-apps-api.test.ts @@ -1,6 +1,9 @@ import { configureStore } from '@reduxjs/toolkit'; import { vi } from 'vitest'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { isInstalled } from 'src/lib/is-installed'; +import { getUserEditor } from 'src/modules/user-settings/lib/ipc-handlers'; +import { loadUserData } from 'src/storage/user-data'; import { installedAppsApi, selectInstalledEditors, @@ -9,9 +12,9 @@ import { selectUninstalledTerminals, } from 'src/stores/installed-apps-api'; -vi.mock( 'src/lib/get-ipc-api', () => ( { - getIpcApi: vi.fn(), -} ) ); +vi.mock( 'src/lib/get-ipc-api' ); +vi.mock( 'src/storage/user-data' ); +vi.mock( 'src/lib/is-installed' ); vi.mock( 'src/lib/app-globals', () => ( { getAppGlobals: vi.fn().mockReturnValue( { @@ -21,13 +24,13 @@ vi.mock( 'src/lib/app-globals', () => ( { const mockIpcApi = { getInstalledAppsAndTerminals: vi.fn(), - getUserEditor: vi.fn(), + getUserEditor: vi.fn().mockImplementation( async () => getUserEditor() ), getUserTerminal: vi.fn(), saveUserEditor: vi.fn(), saveUserTerminal: vi.fn(), }; -vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( mockIpcApi ); +vi.mocked( getIpcApi ).mockReturnValue( mockIpcApi as unknown as IpcApi ); const createTestStore = () => { return configureStore( { @@ -78,42 +81,52 @@ describe( 'Installed Apps API', () => { } ); describe( 'getUserEditor', () => { + const mockIsInstalled = ( installedApps: Partial< InstalledApps > = {} ) => { + const apps = createMockInstalledApps( installedApps ); + vi.mocked( isInstalled ).mockImplementation( ( key ) => apps[ key ] ); + }; + + const mockUserData = ( preferredEditor?: string ) => { + vi.mocked( loadUserData ).mockResolvedValue( { + sites: [], + snapshots: [], + preferredEditor, + } as Awaited< ReturnType< typeof loadUserData > > ); + }; + it( 'should return user preference when set', async () => { - mockIpcApi.getUserEditor.mockResolvedValueOnce( 'windsurf' ); + mockUserData( 'windsurf' ); + mockIsInstalled(); const store = createTestStore(); const result = await store.dispatch( installedAppsApi.endpoints.getUserEditor.initiate( undefined ) ); - expect( mockIpcApi.getUserEditor ).toHaveBeenCalledTimes( 1 ); - expect( mockIpcApi.getInstalledAppsAndTerminals ).not.toHaveBeenCalled(); expect( result.data ).toBe( 'windsurf' ); } ); it( 'should respect priority order when multiple editors are installed', async () => { - mockIpcApi.getUserEditor.mockResolvedValueOnce( null ); - const mockInstalledApps = createMockInstalledApps( { + mockUserData( undefined ); + mockIsInstalled( { webstorm: true, phpstorm: true, windsurf: true, cursor: true, } ); - mockIpcApi.getInstalledAppsAndTerminals.mockResolvedValueOnce( mockInstalledApps ); const store = createTestStore(); const result = await store.dispatch( installedAppsApi.endpoints.getUserEditor.initiate( undefined ) ); - // Should return cursor since it has the highest priority + // Should return cursor since it has the highest priority in SUPPORTED_EDITORS expect( result.data ).toBe( 'cursor' ); } ); it( 'should return the installed editor when no preference is set and only one editor is installed', async () => { - mockIpcApi.getUserEditor.mockResolvedValueOnce( null ); - const mockInstalledApps = createMockInstalledApps( { cursor: true } ); - mockIpcApi.getInstalledAppsAndTerminals.mockResolvedValueOnce( mockInstalledApps ); + mockUserData( undefined ); + mockIsInstalled( { cursor: true } ); const store = createTestStore(); const result = await store.dispatch( @@ -124,9 +137,8 @@ describe( 'Installed Apps API', () => { } ); it( 'should return phpstorm when cursor and vscode are not installed but phpstorm is', async () => { - mockIpcApi.getUserEditor.mockResolvedValueOnce( null ); - const mockInstalledApps = createMockInstalledApps( { phpstorm: true, webstorm: true } ); - mockIpcApi.getInstalledAppsAndTerminals.mockResolvedValueOnce( mockInstalledApps ); + mockUserData( undefined ); + mockIsInstalled( { phpstorm: true, webstorm: true } ); const store = createTestStore(); const result = await store.dispatch( @@ -137,9 +149,8 @@ describe( 'Installed Apps API', () => { } ); it( 'should return null when no preference set and no editors are installed', async () => { - mockIpcApi.getUserEditor.mockResolvedValueOnce( null ); - const mockInstalledApps = createMockInstalledApps(); - mockIpcApi.getInstalledAppsAndTerminals.mockResolvedValueOnce( mockInstalledApps ); + mockUserData( undefined ); + mockIsInstalled(); const store = createTestStore(); const result = await store.dispatch( diff --git a/apps/studio/src/tests/open-file-in-ide.test.ts b/apps/studio/src/tests/open-file-in-ide.test.ts new file mode 100644 index 0000000000..54b337422f --- /dev/null +++ b/apps/studio/src/tests/open-file-in-ide.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ +import { exec } from 'child_process'; +import { IpcMainInvokeEvent } from 'electron'; +import fs from 'fs'; +import { normalize, join } from 'path'; +import { readFile } from 'atomically'; +import { vi } from 'vitest'; +import { openFileInIDE } from 'src/ipc-handlers'; +import { isInstalled } from 'src/lib/is-installed'; +import { getUserEditor } from 'src/modules/user-settings/lib/ipc-handlers'; +import { SiteServer } from 'src/site-server'; + +vi.mock( 'child_process', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('child_process') >(); + return { + ...actual, + exec: vi.fn( ( _cmd: string, _opts: unknown, callback: ( err: null ) => void ) => + callback( null ) + ), + }; +} ); +vi.mock( 'fs' ); +vi.mock( 'fs-extra' ); +vi.mock( '@studio/common/lib/fs-utils', () => ( { + pathExists: vi.fn().mockResolvedValue( true ), + isWordPressDirectory: vi.fn(), + arePathsEqual: vi.fn(), + isEmptyDir: vi.fn(), + calculateDirectorySizeForArchive: vi.fn(), + recursiveCopyDirectory: vi.fn(), +} ) ); +vi.mock( '@sentry/electron/main', () => ( { + captureException: vi.fn(), + captureMessage: vi.fn(), +} ) ); +vi.mock( 'src/storage/paths', () => ( { + getResourcesPath: vi.fn().mockReturnValue( '/mock/resources' ), + getUserDataFilePath: vi.fn().mockReturnValue( '/mock/userdata.json' ), + getUserDataLockFilePath: vi.fn().mockReturnValue( '/mock/userdata.json.lock' ), + getUserDataCertificatesPath: vi.fn().mockReturnValue( '/mock/certificates' ), + getServerFilesPath: vi.fn().mockReturnValue( '/mock/server/files' ), + getCliPath: vi.fn().mockReturnValue( '/mock/cli/path' ), + getBundledNodeBinaryPath: vi.fn().mockReturnValue( '/mock/node/binary' ), + getSiteThumbnailPath: vi.fn().mockReturnValue( '/mock/thumbnail.png' ), + DEFAULT_SITE_PATH: '/mock/default/site/path', +} ) ); +vi.mock( 'src/site-server' ); +vi.mock( 'src/lib/is-installed' ); +vi.mock( 'src/lib/shell-open-external-wrapper' ); +vi.mock( 'src/modules/user-settings/lib/win-editor-path', () => ( { + winFindEditorPath: vi.fn().mockResolvedValue( 'C:\\mock\\editor.exe' ), +} ) ); +vi.mock( 'src/modules/user-settings/lib/ipc-handlers', async () => ( { + getUserEditor: vi.fn().mockResolvedValue( null ), +} ) ); +vi.mock( 'src/main-window' ); +vi.mock( '@studio/common/lib/bump-stat' ); +vi.mock( 'atomically' ); +vi.mock( 'src/lib/get-image-data', () => ( { + getImageData: vi.fn().mockResolvedValue( 'data:image/png;base64,mock' ), +} ) ); +vi.mock( '@studio/common/lib/port-finder', () => ( { + portFinder: { + getOpenPort: vi.fn().mockResolvedValue( 9999 ), + }, +} ) ); + +const mockUserData = { sites: [] }; +if ( '__setFileContents' in fs ) { + ( + fs as typeof fs & { __setFileContents: ( path: string, contents: string | string[] ) => void } + ).__setFileContents( + normalize( '/path/to/app/appData/App Name/appdata-v1.json' ), + JSON.stringify( mockUserData ) + ); +} +vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( mockUserData ) ) ); + +const mockIpcMainInvokeEvent = { + sender: { isDestroyed: vi.fn().mockReturnValue( false ) }, +} as unknown as IpcMainInvokeEvent; + +const mockSiteDetails = { + id: 'site-1', + name: 'Test Site', + path: '/sites/test-site', + port: 8080, + running: true, +}; + +function setupMockServer() { + vi.mocked( SiteServer.get ).mockReturnValue( { + details: mockSiteDetails, + } as unknown as SiteServer ); +} + +function getExecCalls(): string[] { + return vi.mocked( exec ).mock.calls.map( ( call ) => call[ 0 ] as string ); +} + +describe( 'openFileInIDE', () => { + beforeEach( () => { + vi.clearAllMocks(); + setupMockServer(); + } ); + + it( 'should use the user preferred editor when set', async () => { + vi.mocked( getUserEditor ).mockResolvedValue( 'cursor' ); + + await openFileInIDE( mockIpcMainInvokeEvent, 'wp-content/plugins/hello.php', 'site-1' ); + + const calls = getExecCalls(); + expect( calls ).toHaveLength( 1 ); + expect( calls[ 0 ] ).toContain( mockSiteDetails.path ); + expect( calls[ 0 ] ).toContain( join( 'wp-content', 'plugins', 'hello.php' ) ); + } ); + + it( 'should fall back to first installed editor when no preference is set', async () => { + vi.mocked( isInstalled ).mockImplementation( ( key ) => key === 'phpstorm' ); + + await openFileInIDE( mockIpcMainInvokeEvent, 'wp-content/plugins/hello.php', 'site-1' ); + + const calls = getExecCalls(); + expect( calls ).toHaveLength( 1 ); + expect( calls[ 0 ] ).toContain( mockSiteDetails.path ); + expect( calls[ 0 ] ).toContain( join( 'wp-content', 'plugins', 'hello.php' ) ); + } ); + + it( 'should do nothing when no editor is preferred and none is installed', async () => { + vi.mocked( getUserEditor ).mockResolvedValue( null ); + vi.mocked( isInstalled ).mockReturnValue( false ); + + await openFileInIDE( mockIpcMainInvokeEvent, 'wp-content/plugins/hello.php', 'site-1' ); + + expect( exec ).not.toHaveBeenCalled(); + } ); + + it( 'should throw when site is not found', async () => { + vi.mocked( SiteServer.get ).mockReturnValue( undefined ); + + await expect( + openFileInIDE( mockIpcMainInvokeEvent, 'wp-content/plugins/hello.php', 'nonexistent' ) + ).rejects.toThrow( 'Site not found.' ); + } ); + + it( 'should do nothing when file does not exist in site', async () => { + const { pathExists } = await import( '@studio/common/lib/fs-utils' ); + vi.mocked( pathExists ).mockResolvedValueOnce( false ); + vi.mocked( getUserEditor ).mockResolvedValue( 'vscode' ); + + await openFileInIDE( mockIpcMainInvokeEvent, 'wp-content/plugins/nonexistent.php', 'site-1' ); + + expect( exec ).not.toHaveBeenCalled(); + } ); + + it( 'should open site folder and file in a single call', async () => { + vi.mocked( getUserEditor ).mockResolvedValue( 'vscode' ); + + await openFileInIDE( mockIpcMainInvokeEvent, 'wp-content/plugins/hello.php', 'site-1' ); + + const calls = getExecCalls(); + expect( calls ).toHaveLength( 1 ); + // Single call contains both site folder and file path + expect( calls[ 0 ] ).toContain( mockSiteDetails.path ); + expect( calls[ 0 ] ).toContain( join( 'wp-content', 'plugins', 'hello.php' ) ); + } ); +} );