diff --git a/editor/src/dashboard/item.tsx b/editor/src/dashboard/item.tsx index e1f0ff00d..8ce168b19 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 { openInIde } from "../tools/ide"; 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); + openInIde(projectDir, true); + } + 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..25d136139 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 { openInIde } from "../../../../tools/ide"; 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) { + openInIde(this.props.absolutePath, false); + } } private _handleDragStart(ev: DragEvent): void { @@ -392,12 +395,16 @@ export class AssetsBrowserItem extends Component + {!this.state.isDirectory && ( + openInIde(this.props.absolutePath, false)}> + 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..e8d51e48b 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 { openInIde } from "../../tools/ide"; 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); + openInIde(projectDir, true); + } + 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..14ba18eb0 100644 --- a/editor/src/electron/events/shell.ts +++ b/editor/src/electron/events/shell.ts @@ -1,22 +1,29 @@ -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"; + +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); +}); diff --git a/editor/src/tools/ide.ts b/editor/src/tools/ide.ts new file mode 100644 index 000000000..ef118dfa0 --- /dev/null +++ b/editor/src/tools/ide.ts @@ -0,0 +1,129 @@ +import { platform } from "os"; +import { shell } from "electron"; + +import { execNodePty } from "./node-pty"; +import { tryGetDefaultIdeFromLocalStorage } from "./local-storage"; + +/** + * 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 { + const p = await execNodePty(command); + const code = await p.wait(); + return code === 0; + } 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. Defaults to the one configured in + * the editor preferences. When set to "auto" 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 = tryGetDefaultIdeFromLocalStorage()): 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); + } +} 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. */