From 31de002a3b7912b85fb9c12a07213cf36a7b8f49 Mon Sep 17 00:00:00 2001 From: Bozhidar Date: Fri, 19 Jun 2026 02:14:55 +0300 Subject: [PATCH 1/3] feat: add "Open in Default IDE" with configurable default IDE preference - Add "Open in Default IDE" entries in the toolbar menu, native menu, asset browser context menu, and dashboard project items. - Add a "Default IDE" selector in Edit Preferences (Auto, VS Code, Cursor, Sublime Text, PhpStorm, WebStorm, IntelliJ IDEA, System Default), persisted to localStorage like the other preferences. - Honor the selected IDE in the editor:open-with main process handler, with auto-detection fallback when the chosen IDE can't be launched. - Route command execution through tools/process executeAsync so CLI IDEs resolve correctly when the app is launched from Finder/Dock on macOS. --- editor/src/dashboard/item.tsx | 12 ++ .../edit-preferences/edit-preferences.tsx | 39 +++- .../layout/assets-browser/items/item.tsx | 19 +- editor/src/editor/layout/toolbar.tsx | 15 ++ editor/src/editor/menu.ts | 4 + editor/src/electron/events/shell.ts | 179 +++++++++++++++--- editor/src/tools/local-storage.ts | 39 ++++ 7 files changed, 278 insertions(+), 29 deletions(-) diff --git a/editor/src/dashboard/item.tsx b/editor/src/dashboard/item.tsx index e1f0ff00d..c84ece027 100644 --- a/editor/src/dashboard/item.tsx +++ b/editor/src/dashboard/item.tsx @@ -20,6 +20,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara import { isDarwin } from "../tools/os"; import { ProjectType } from "../tools/project"; +import { tryGetDefaultIdeFromLocalStorage } from "../tools/local-storage"; import { execNodePty, NodePtyInstance } from "../tools/node-pty"; import { IEditorProject } from "../project/typings"; @@ -142,6 +143,11 @@ export function DashboardProjectItem(props: IDashboardProjectItemProps) { execNodePty(`code "${dirname(props.project.absolutePath)}"`); } + function handleOpenInDefaultIde() { + const projectDir = dirname(props.project.absolutePath); + ipcRenderer.send("editor:open-with", projectDir, tryGetDefaultIdeFromLocalStorage()); + } + return ( setContextMenuOpen(o)}> @@ -185,6 +191,9 @@ export function DashboardProjectItem(props: IDashboardProjectItemProps) { {`Show in ${isDarwin() ? "Finder" : "Explorer"}`} + handleOpenInDefaultIde()}> + Open in Default IDE + handleOpenInVisualStudioCode()}> Open in Visual Studio Code @@ -237,6 +246,9 @@ export function DashboardProjectItem(props: IDashboardProjectItemProps) { {`Show in ${isDarwin() ? "Finder" : "Explorer"}`} + handleOpenInDefaultIde()}> + Open in Default IDE + handleOpenInVisualStudioCode()}> Open in Visual Studio Code diff --git a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx index bee5ca074..54282eeb3 100644 --- a/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx +++ b/editor/src/editor/dialogs/edit-preferences/edit-preferences.tsx @@ -7,7 +7,12 @@ import { Separator } from "../../../ui/shadcn/ui/separator"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../ui/shadcn/ui/select"; import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../ui/shadcn/ui/alert-dialog"; -import { trySetExperimentalFeaturesEnabledInLocalStorage } from "../../../tools/local-storage"; +import { + defaultIdeOptions, + tryGetDefaultIdeFromLocalStorage, + trySetDefaultIdeInLocalStorage, + trySetExperimentalFeaturesEnabledInLocalStorage, +} from "../../../tools/local-storage"; import { EditorInspectorKeyField } from "../../layout/inspector/fields/key"; import { EditorInspectorNumberField } from "../../layout/inspector/fields/number"; @@ -28,6 +33,7 @@ export interface IEditorEditPreferencesComponentProps { export interface IEditorEditPreferencesComponentState { theme: "light" | "dark"; + defaultIde: string; } export class EditorEditPreferencesComponent extends Component { @@ -36,6 +42,7 @@ export class EditorEditPreferencesComponent extends Component {this._getThemesComponent()} + {this._getDefaultIdeComponent()} + {this._getCameraControlPreferences()} {this._getExperimentalComponent()} @@ -96,6 +105,34 @@ export class EditorEditPreferencesComponent extends Component +
+ + +
+ + ); + } + private _getCameraControlPreferences(): ReactNode { const camera = this.props.editor.layout?.preview?.camera; if (!camera) { diff --git a/editor/src/editor/layout/assets-browser/items/item.tsx b/editor/src/editor/layout/assets-browser/items/item.tsx index c7165ebd2..b2dcb0264 100644 --- a/editor/src/editor/layout/assets-browser/items/item.tsx +++ b/editor/src/editor/layout/assets-browser/items/item.tsx @@ -12,7 +12,6 @@ import { Grid } from "react-loader-spinner"; import { toast } from "sonner"; -import { ImFinder } from "react-icons/im"; import { BiSolidFileCss } from "react-icons/bi"; import { GiCeilingLight } from "react-icons/gi"; import { GrStatusUnknown } from "react-icons/gr"; @@ -27,6 +26,7 @@ import { Input } from "../../../../ui/shadcn/ui/input"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, ContextMenuSeparator } from "../../../../ui/shadcn/ui/context-menu"; import { isDarwin } from "../../../../tools/os"; +import { tryGetDefaultIdeFromLocalStorage } from "../../../../tools/local-storage"; import { Editor } from "../../../main"; @@ -250,7 +250,10 @@ export class AssetsBrowserItem extends Component { - // Nothing to do by default. + // If it's a file (not a directory), open it in the default editor + if (!this.state.isDirectory) { + ipcRenderer.send("editor:open-with", this.props.absolutePath, tryGetDefaultIdeFromLocalStorage()); + } } private _handleDragStart(ev: DragEvent): void { @@ -392,12 +395,16 @@ export class AssetsBrowserItem extends Component + {!this.state.isDirectory && ( + ipcRenderer.send("editor:open-with", this.props.absolutePath, tryGetDefaultIdeFromLocalStorage())}> + Open + + )} + ipcRenderer.send("editor:show-item", this.props.absolutePath)}> - {`Show in ${isDarwin ? "Finder" : "Explorer"}`} + {`Show in ${isDarwin ? "Finder" : "Explorer"}`} - - {items.map((item, index) => ( {item} ))} @@ -418,7 +425,7 @@ export class AssetsBrowserItem extends Component this._handleTrashItem()}> - Remove + Delete ); diff --git a/editor/src/editor/layout/toolbar.tsx b/editor/src/editor/layout/toolbar.tsx index 5c5d6ebc6..64ba75ea8 100644 --- a/editor/src/editor/layout/toolbar.tsx +++ b/editor/src/editor/layout/toolbar.tsx @@ -19,6 +19,7 @@ import { } from "../../ui/shadcn/ui/menubar"; import { isDarwin } from "../../tools/os"; +import { tryGetDefaultIdeFromLocalStorage } from "../../tools/local-storage"; import { execNodePty } from "../../tools/node-pty"; import { openSingleFileDialog } from "../../tools/dialog"; import { saveSceneScreenshot } from "../../tools/scene/screenshot"; @@ -55,6 +56,7 @@ export class EditorToolbar extends Component { super(props); ipcRenderer.on("editor:open-project", () => this._handleOpenProject()); + ipcRenderer.on("editor:open-default-ide", () => this._handleOpenInDefaultIde()); ipcRenderer.on("editor:open-vscode", () => this._handleOpenVisualStudioCode()); ipcRenderer.on("editor:toggle-marketplace", () => this._handleToggleMarketplace()); @@ -110,6 +112,10 @@ export class EditorToolbar extends Component { + this._handleOpenInDefaultIde()}> + Open in Default IDE + + this._handleOpenVisualStudioCode()}> Open in Visual Studio Code @@ -314,6 +320,15 @@ export class EditorToolbar extends Component { await p.wait(); } + private _handleOpenInDefaultIde(): void { + if (!this.props.editor.state.projectPath) { + return; + } + + const projectDir = dirname(this.props.editor.state.projectPath); + ipcRenderer.send("editor:open-with", projectDir, tryGetDefaultIdeFromLocalStorage()); + } + private _handleToggleMarketplace(): void { if (this.props.editor.state.openedTabs.includes("marketplace")) { return this.props.editor.layout.removeLayoutTab("marketplace"); diff --git a/editor/src/editor/menu.ts b/editor/src/editor/menu.ts index b5e970131..979177f3e 100644 --- a/editor/src/editor/menu.ts +++ b/editor/src/editor/menu.ts @@ -62,6 +62,10 @@ export function setupEditorMenu(options: { enableExperimentalFeatures: boolean; { type: "separator", }, + { + label: "Open in Default IDE", + click: () => BrowserWindow.getFocusedWindow()?.webContents.send("editor:open-default-ide"), + }, { label: "Open in Visual Studio Code", click: () => BrowserWindow.getFocusedWindow()?.webContents.send("editor:open-vscode"), diff --git a/editor/src/electron/events/shell.ts b/editor/src/electron/events/shell.ts index a574f0789..1a1defb7a 100644 --- a/editor/src/electron/events/shell.ts +++ b/editor/src/electron/events/shell.ts @@ -1,22 +1,157 @@ -import { platform } from "os"; -import { ipcMain, shell } from "electron"; - -ipcMain.on("editor:trash-items", async (ev, items) => { - const isWindows = platform() === "win32"; - items = items.map((item) => (isWindows ? item.replace(/\//g, "\\") : item.replace(/\\/g, "/"))); - - try { - await Promise.all(items.map((item) => shell.trashItem(item))); - ev.returnValue = true; - } catch (e) { - console.error(e); - ev.returnValue = false; - } -}); - -ipcMain.on("editor:show-item", (_, item) => { - const isWindows = platform() === "win32"; - item = isWindows ? item.replace(/\//g, "\\") : item.replace(/\\/g, "/"); - - shell.showItemInFolder(item); -}); +import { platform } from "os"; +import { ipcMain, shell } from "electron"; +import { statSync } from "fs"; + +import { executeAsync } from "../../tools/process"; + +ipcMain.on("editor:trash-items", async (ev, items) => { + const isWindows = platform() === "win32"; + items = items.map((item) => (isWindows ? item.replace(/\//g, "\\") : item.replace(/\\/g, "/"))); + + try { + await Promise.all(items.map((item) => shell.trashItem(item))); + ev.returnValue = true; + } catch (e) { + console.error(e); + ev.returnValue = false; + } +}); + +ipcMain.on("editor:show-item", (_, item) => { + const isWindows = platform() === "win32"; + item = isWindows ? item.replace(/\//g, "\\") : item.replace(/\\/g, "/"); + + shell.showItemInFolder(item); +}); + +ipcMain.on("editor:open-in-external-editor", (_, item) => { + const isWindows = platform() === "win32"; + item = isWindows ? item.replace(/\//g, "\\") : item.replace(/\\/g, "/"); + + shell.openPath(item); +}); + +/** + * Runs the given command using `executeAsync` (which also fixes the PATH on macOS so CLI tools + * launched from the bundled app can be resolved). Resolves true on success, false otherwise. + */ +async function runCommand(command: string): Promise { + try { + await executeAsync(command); + return true; + } catch (e) { + return false; + } +} + +/** + * Returns wether or not the given CLI command is available on the system. + */ +async function checkCommandAvailable(command: string): Promise { + return runCommand(`${command} --version`); +} + +/** + * Defines for each supported IDE identifier the CLI command and the macOS application name + * used to launch it. Identifiers must match the ones exposed in the editor preferences. + */ +const ideLaunchers: Record = { + code: { cli: "code", macApp: "Visual Studio Code" }, + cursor: { cli: "cursor", macApp: "Cursor" }, + subl: { cli: "subl", macApp: "Sublime Text" }, + phpstorm: { cli: "phpstorm", macApp: "PhpStorm" }, + webstorm: { cli: "webstorm", macApp: "WebStorm" }, + idea: { cli: "idea", macApp: "IntelliJ IDEA" }, +}; + +/** + * Tries to open the given path with the IDE matching the given identifier. + * Returns true when the IDE could be launched, false otherwise. + */ +async function tryLaunchIde(ide: string, normalizedPath: string): Promise { + const launcher = ideLaunchers[ide]; + if (!launcher) { + return false; + } + + // Prefer the CLI command when available (works on all platforms). + if (await checkCommandAvailable(launcher.cli)) { + if (await runCommand(`${launcher.cli} "${normalizedPath}"`)) { + return true; + } + } + + // On macOS, fallback to launching the application by its name. + if (platform() === "darwin" && launcher.macApp) { + return runCommand(`open -a "${launcher.macApp}" "${normalizedPath}"`); + } + + return false; +} + +async function openInIde(path: string, isDirectory: boolean, ide?: string): Promise { + const isWindows = platform() === "win32"; + const normalizedPath = isWindows ? path.replace(/\//g, "\\") : path.replace(/\\/g, "/"); + + // Open with the system default application when explicitly requested. + if (ide === "system") { + shell.openPath(normalizedPath); + return; + } + + // When a specific IDE is selected in the preferences, try it first. + if (ide && ide !== "auto" && ideLaunchers[ide]) { + if (await tryLaunchIde(ide, normalizedPath)) { + return; + } + // Fall through to auto-detection if the selected IDE could not be launched. + } + + if (isDirectory) { + // Try to open the directory in one of the CLI-based IDEs (works on all platforms). + for (const command of ["code", "cursor", "subl"]) { + if (await checkCommandAvailable(command)) { + if (await runCommand(`${command} "${normalizedPath}"`)) { + return; + } + } + } + + // On macOS, try JetBrains IDEs (PhpStorm, WebStorm, IntelliJ IDEA). + if (platform() === "darwin") { + for (const app of ["PhpStorm", "WebStorm", "IntelliJ IDEA", "IntelliJ IDEA CE"]) { + if (await runCommand(`open -a "${app}" "${normalizedPath}"`)) { + return; + } + } + } + + // On Windows, try JetBrains IDEs via CLI. + if (isWindows) { + for (const command of ["phpstorm", "webstorm", "idea"]) { + if (await checkCommandAvailable(command)) { + if (await runCommand(`${command} "${normalizedPath}"`)) { + return; + } + } + } + } + + // Fallback: open with default application. + shell.openPath(normalizedPath); + } else { + // For files, use shell.openPath which uses OS default application. + shell.openPath(normalizedPath); + } +} + +ipcMain.on("editor:open-with", async (_, item, ide) => { + try { + const stats = statSync(item); + const isDirectory = stats.isDirectory(); + await openInIde(item, isDirectory, ide); + } catch (e) { + // If stat fails, try as directory first, then as file + await openInIde(item, true, ide); + } +}); diff --git a/editor/src/tools/local-storage.ts b/editor/src/tools/local-storage.ts index 520d51e09..1b5ecaa41 100644 --- a/editor/src/tools/local-storage.ts +++ b/editor/src/tools/local-storage.ts @@ -88,6 +88,45 @@ export function trySetCloseDashboardOnProjectOpenInLocalStorage(enabled: boolean } } +/** + * Defines the list of IDEs that can be selected as the default IDE used when opening + * a project or a file/folder from the editor. + */ +export const defaultIdeOptions = [ + { id: "auto", label: "Auto (detect installed)" }, + { id: "code", label: "Visual Studio Code" }, + { id: "cursor", label: "Cursor" }, + { id: "subl", label: "Sublime Text" }, + { id: "phpstorm", label: "PhpStorm" }, + { id: "webstorm", label: "WebStorm" }, + { id: "idea", label: "IntelliJ IDEA" }, + { id: "system", label: "System Default Application" }, +] as const; + +/** + * Returns the identifier of the default IDE to use when opening a project, file or folder. + * Defaults to "auto" when nothing is stored or the local storage can't be accessed. + */ +export function tryGetDefaultIdeFromLocalStorage(): string { + try { + return localStorage.getItem("babylonjs-editor-default-ide") ?? "auto"; + } catch (e) { + return "auto"; + } +} + +/** + * Sets the identifier of the default IDE to use when opening a project, file or folder. + * @param ide defines the identifier of the IDE to set as the default one. + */ +export function trySetDefaultIdeInLocalStorage(ide: string): void { + try { + localStorage.setItem("babylonjs-editor-default-ide", ide); + } catch (e) { + // Catch silently. + } +} + /** * Returns the terminal path stored in the local storage, or null if it fails to access the local storage or if no terminal path is stored. */ From e3283f8557a20c599cb1e997fa89d41314b98a42 Mon Sep 17 00:00:00 2001 From: Bozhidar Date: Fri, 19 Jun 2026 02:16:13 +0300 Subject: [PATCH 2/3] refactor: move IDE opening logic to tools/ide.ts Extract openInIde and its helpers out of the electron shell IPC handler into a dedicated tools/ide.ts module, leaving shell.ts to only wire the editor:open-with IPC event. Addresses review feedback. --- editor/src/electron/events/shell.ts | 116 +------------------------- editor/src/tools/ide.ts | 125 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 115 deletions(-) create mode 100644 editor/src/tools/ide.ts diff --git a/editor/src/electron/events/shell.ts b/editor/src/electron/events/shell.ts index 1a1defb7a..51e913381 100644 --- a/editor/src/electron/events/shell.ts +++ b/editor/src/electron/events/shell.ts @@ -2,7 +2,7 @@ import { platform } from "os"; import { ipcMain, shell } from "electron"; import { statSync } from "fs"; -import { executeAsync } from "../../tools/process"; +import { openInIde } from "../../tools/ide"; ipcMain.on("editor:trash-items", async (ev, items) => { const isWindows = platform() === "win32"; @@ -31,120 +31,6 @@ ipcMain.on("editor:open-in-external-editor", (_, item) => { shell.openPath(item); }); -/** - * Runs the given command using `executeAsync` (which also fixes the PATH on macOS so CLI tools - * launched from the bundled app can be resolved). Resolves true on success, false otherwise. - */ -async function runCommand(command: string): Promise { - try { - await executeAsync(command); - return true; - } catch (e) { - return false; - } -} - -/** - * Returns wether or not the given CLI command is available on the system. - */ -async function checkCommandAvailable(command: string): Promise { - return runCommand(`${command} --version`); -} - -/** - * Defines for each supported IDE identifier the CLI command and the macOS application name - * used to launch it. Identifiers must match the ones exposed in the editor preferences. - */ -const ideLaunchers: Record = { - code: { cli: "code", macApp: "Visual Studio Code" }, - cursor: { cli: "cursor", macApp: "Cursor" }, - subl: { cli: "subl", macApp: "Sublime Text" }, - phpstorm: { cli: "phpstorm", macApp: "PhpStorm" }, - webstorm: { cli: "webstorm", macApp: "WebStorm" }, - idea: { cli: "idea", macApp: "IntelliJ IDEA" }, -}; - -/** - * Tries to open the given path with the IDE matching the given identifier. - * Returns true when the IDE could be launched, false otherwise. - */ -async function tryLaunchIde(ide: string, normalizedPath: string): Promise { - const launcher = ideLaunchers[ide]; - if (!launcher) { - return false; - } - - // Prefer the CLI command when available (works on all platforms). - if (await checkCommandAvailable(launcher.cli)) { - if (await runCommand(`${launcher.cli} "${normalizedPath}"`)) { - return true; - } - } - - // On macOS, fallback to launching the application by its name. - if (platform() === "darwin" && launcher.macApp) { - return runCommand(`open -a "${launcher.macApp}" "${normalizedPath}"`); - } - - return false; -} - -async function openInIde(path: string, isDirectory: boolean, ide?: string): Promise { - const isWindows = platform() === "win32"; - const normalizedPath = isWindows ? path.replace(/\//g, "\\") : path.replace(/\\/g, "/"); - - // Open with the system default application when explicitly requested. - if (ide === "system") { - shell.openPath(normalizedPath); - return; - } - - // When a specific IDE is selected in the preferences, try it first. - if (ide && ide !== "auto" && ideLaunchers[ide]) { - if (await tryLaunchIde(ide, normalizedPath)) { - return; - } - // Fall through to auto-detection if the selected IDE could not be launched. - } - - if (isDirectory) { - // Try to open the directory in one of the CLI-based IDEs (works on all platforms). - for (const command of ["code", "cursor", "subl"]) { - if (await checkCommandAvailable(command)) { - if (await runCommand(`${command} "${normalizedPath}"`)) { - return; - } - } - } - - // On macOS, try JetBrains IDEs (PhpStorm, WebStorm, IntelliJ IDEA). - if (platform() === "darwin") { - for (const app of ["PhpStorm", "WebStorm", "IntelliJ IDEA", "IntelliJ IDEA CE"]) { - if (await runCommand(`open -a "${app}" "${normalizedPath}"`)) { - return; - } - } - } - - // On Windows, try JetBrains IDEs via CLI. - if (isWindows) { - for (const command of ["phpstorm", "webstorm", "idea"]) { - if (await checkCommandAvailable(command)) { - if (await runCommand(`${command} "${normalizedPath}"`)) { - return; - } - } - } - } - - // Fallback: open with default application. - shell.openPath(normalizedPath); - } else { - // For files, use shell.openPath which uses OS default application. - shell.openPath(normalizedPath); - } -} - ipcMain.on("editor:open-with", async (_, item, ide) => { try { const stats = statSync(item); diff --git a/editor/src/tools/ide.ts b/editor/src/tools/ide.ts new file mode 100644 index 000000000..f099a881a --- /dev/null +++ b/editor/src/tools/ide.ts @@ -0,0 +1,125 @@ +import { platform } from "os"; +import { shell } from "electron"; + +import { executeAsync } from "./process"; + +/** + * Runs the given command using `executeAsync` (which also fixes the PATH on macOS so CLI tools + * launched from the bundled app can be resolved). Resolves true on success, false otherwise. + */ +async function runCommand(command: string): Promise { + try { + await executeAsync(command); + return true; + } catch (e) { + return false; + } +} + +/** + * Returns wether or not the given CLI command is available on the system. + */ +async function checkCommandAvailable(command: string): Promise { + return runCommand(`${command} --version`); +} + +/** + * Defines for each supported IDE identifier the CLI command and the macOS application name + * used to launch it. Identifiers must match the ones exposed in the editor preferences. + */ +const ideLaunchers: Record = { + code: { cli: "code", macApp: "Visual Studio Code" }, + cursor: { cli: "cursor", macApp: "Cursor" }, + subl: { cli: "subl", macApp: "Sublime Text" }, + phpstorm: { cli: "phpstorm", macApp: "PhpStorm" }, + webstorm: { cli: "webstorm", macApp: "WebStorm" }, + idea: { cli: "idea", macApp: "IntelliJ IDEA" }, +}; + +/** + * Tries to open the given path with the IDE matching the given identifier. + * Returns true when the IDE could be launched, false otherwise. + */ +async function tryLaunchIde(ide: string, normalizedPath: string): Promise { + const launcher = ideLaunchers[ide]; + if (!launcher) { + return false; + } + + // Prefer the CLI command when available (works on all platforms). + if (await checkCommandAvailable(launcher.cli)) { + if (await runCommand(`${launcher.cli} "${normalizedPath}"`)) { + return true; + } + } + + // On macOS, fallback to launching the application by its name. + if (platform() === "darwin" && launcher.macApp) { + return runCommand(`open -a "${launcher.macApp}" "${normalizedPath}"`); + } + + return false; +} + +/** + * Opens the given path (file or folder) in an IDE. + * @param path defines the absolute path to the file or folder to open. + * @param isDirectory defines wether or not the given path points to a directory. + * @param ide defines the identifier of the preferred IDE to use. When set to "auto" (or omitted) + * the first available IDE is detected and used. When set to "system" the OS default application is used. + */ +export async function openInIde(path: string, isDirectory: boolean, ide?: string): Promise { + const isWindows = platform() === "win32"; + const normalizedPath = isWindows ? path.replace(/\//g, "\\") : path.replace(/\\/g, "/"); + + // Open with the system default application when explicitly requested. + if (ide === "system") { + shell.openPath(normalizedPath); + return; + } + + // When a specific IDE is selected in the preferences, try it first. + if (ide && ide !== "auto" && ideLaunchers[ide]) { + if (await tryLaunchIde(ide, normalizedPath)) { + return; + } + // Fall through to auto-detection if the selected IDE could not be launched. + } + + if (isDirectory) { + // Try to open the directory in one of the CLI-based IDEs (works on all platforms). + for (const command of ["code", "cursor", "subl"]) { + if (await checkCommandAvailable(command)) { + if (await runCommand(`${command} "${normalizedPath}"`)) { + return; + } + } + } + + // On macOS, try JetBrains IDEs (PhpStorm, WebStorm, IntelliJ IDEA). + if (platform() === "darwin") { + for (const app of ["PhpStorm", "WebStorm", "IntelliJ IDEA", "IntelliJ IDEA CE"]) { + if (await runCommand(`open -a "${app}" "${normalizedPath}"`)) { + return; + } + } + } + + // On Windows, try JetBrains IDEs via CLI. + if (isWindows) { + for (const command of ["phpstorm", "webstorm", "idea"]) { + if (await checkCommandAvailable(command)) { + if (await runCommand(`${command} "${normalizedPath}"`)) { + return; + } + } + } + } + + // Fallback: open with default application. + shell.openPath(normalizedPath); + } else { + // For files, use shell.openPath which uses OS default application. + shell.openPath(normalizedPath); + } +} From be7b615a9c1057775752a8112caf127578560bd3 Mon Sep 17 00:00:00 2001 From: Bozhidar Date: Fri, 19 Jun 2026 02:20:29 +0300 Subject: [PATCH 3/3] refactor: launch IDEs via node-pty so they stay open after editor closes Using exec attached the launched IDE to the editor process. Run the launch/detection commands through execNodePty instead (same approach as the VSCode launcher) so the IDE runs detached and keeps open even when the editor is closed. tools/ide.ts now runs in the renderer and is called directly by the toolbar, dashboard and asset items, removing the editor:open-with IPC round-trip. The selected default IDE is resolved from preferences inside openInIde. Addresses review feedback. --- editor/src/dashboard/item.tsx | 4 ++-- .../layout/assets-browser/items/item.tsx | 6 +++--- editor/src/editor/layout/toolbar.tsx | 4 ++-- editor/src/electron/events/shell.ts | 14 ------------- editor/src/tools/ide.ts | 20 +++++++++++-------- 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/editor/src/dashboard/item.tsx b/editor/src/dashboard/item.tsx index c84ece027..8ce168b19 100644 --- a/editor/src/dashboard/item.tsx +++ b/editor/src/dashboard/item.tsx @@ -20,7 +20,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara import { isDarwin } from "../tools/os"; import { ProjectType } from "../tools/project"; -import { tryGetDefaultIdeFromLocalStorage } from "../tools/local-storage"; +import { openInIde } from "../tools/ide"; import { execNodePty, NodePtyInstance } from "../tools/node-pty"; import { IEditorProject } from "../project/typings"; @@ -145,7 +145,7 @@ export function DashboardProjectItem(props: IDashboardProjectItemProps) { function handleOpenInDefaultIde() { const projectDir = dirname(props.project.absolutePath); - ipcRenderer.send("editor:open-with", projectDir, tryGetDefaultIdeFromLocalStorage()); + openInIde(projectDir, true); } return ( diff --git a/editor/src/editor/layout/assets-browser/items/item.tsx b/editor/src/editor/layout/assets-browser/items/item.tsx index b2dcb0264..25d136139 100644 --- a/editor/src/editor/layout/assets-browser/items/item.tsx +++ b/editor/src/editor/layout/assets-browser/items/item.tsx @@ -26,7 +26,7 @@ import { Input } from "../../../../ui/shadcn/ui/input"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, ContextMenuSeparator } from "../../../../ui/shadcn/ui/context-menu"; import { isDarwin } from "../../../../tools/os"; -import { tryGetDefaultIdeFromLocalStorage } from "../../../../tools/local-storage"; +import { openInIde } from "../../../../tools/ide"; import { Editor } from "../../../main"; @@ -252,7 +252,7 @@ export class AssetsBrowserItem extends Component { // If it's a file (not a directory), open it in the default editor if (!this.state.isDirectory) { - ipcRenderer.send("editor:open-with", this.props.absolutePath, tryGetDefaultIdeFromLocalStorage()); + openInIde(this.props.absolutePath, false); } } @@ -396,7 +396,7 @@ export class AssetsBrowserItem extends Component {!this.state.isDirectory && ( - ipcRenderer.send("editor:open-with", this.props.absolutePath, tryGetDefaultIdeFromLocalStorage())}> + openInIde(this.props.absolutePath, false)}> Open )} diff --git a/editor/src/editor/layout/toolbar.tsx b/editor/src/editor/layout/toolbar.tsx index 64ba75ea8..e8d51e48b 100644 --- a/editor/src/editor/layout/toolbar.tsx +++ b/editor/src/editor/layout/toolbar.tsx @@ -19,7 +19,7 @@ import { } from "../../ui/shadcn/ui/menubar"; import { isDarwin } from "../../tools/os"; -import { tryGetDefaultIdeFromLocalStorage } from "../../tools/local-storage"; +import { openInIde } from "../../tools/ide"; import { execNodePty } from "../../tools/node-pty"; import { openSingleFileDialog } from "../../tools/dialog"; import { saveSceneScreenshot } from "../../tools/scene/screenshot"; @@ -326,7 +326,7 @@ export class EditorToolbar extends Component { } const projectDir = dirname(this.props.editor.state.projectPath); - ipcRenderer.send("editor:open-with", projectDir, tryGetDefaultIdeFromLocalStorage()); + openInIde(projectDir, true); } private _handleToggleMarketplace(): void { diff --git a/editor/src/electron/events/shell.ts b/editor/src/electron/events/shell.ts index 51e913381..14ba18eb0 100644 --- a/editor/src/electron/events/shell.ts +++ b/editor/src/electron/events/shell.ts @@ -1,8 +1,5 @@ import { platform } from "os"; import { ipcMain, shell } from "electron"; -import { statSync } from "fs"; - -import { openInIde } from "../../tools/ide"; ipcMain.on("editor:trash-items", async (ev, items) => { const isWindows = platform() === "win32"; @@ -30,14 +27,3 @@ ipcMain.on("editor:open-in-external-editor", (_, item) => { shell.openPath(item); }); - -ipcMain.on("editor:open-with", async (_, item, ide) => { - try { - const stats = statSync(item); - const isDirectory = stats.isDirectory(); - await openInIde(item, isDirectory, ide); - } catch (e) { - // If stat fails, try as directory first, then as file - await openInIde(item, true, ide); - } -}); diff --git a/editor/src/tools/ide.ts b/editor/src/tools/ide.ts index f099a881a..ef118dfa0 100644 --- a/editor/src/tools/ide.ts +++ b/editor/src/tools/ide.ts @@ -1,16 +1,19 @@ import { platform } from "os"; import { shell } from "electron"; -import { executeAsync } from "./process"; +import { execNodePty } from "./node-pty"; +import { tryGetDefaultIdeFromLocalStorage } from "./local-storage"; /** - * Runs the given command using `executeAsync` (which also fixes the PATH on macOS so CLI tools - * launched from the bundled app can be resolved). Resolves true on success, false otherwise. + * Runs the given command in a node-pty process so it is detached from the editor process: the + * launched IDE keeps running even if the editor is closed (same approach as the VSCode launcher). + * Resolves true when the command exited with a success code, false otherwise. */ async function runCommand(command: string): Promise { try { - await executeAsync(command); - return true; + const p = await execNodePty(command); + const code = await p.wait(); + return code === 0; } catch (e) { return false; } @@ -65,10 +68,11 @@ async function tryLaunchIde(ide: string, normalizedPath: string): Promise { +export async function openInIde(path: string, isDirectory: boolean, ide: string = tryGetDefaultIdeFromLocalStorage()): Promise { const isWindows = platform() === "win32"; const normalizedPath = isWindows ? path.replace(/\//g, "\\") : path.replace(/\\/g, "/");