diff --git a/packages/vscode-extension/src/builders/BuildCache.ts b/packages/vscode-extension/src/builders/BuildCache.ts index 73dae4ee9..4c8ff1be8 100644 --- a/packages/vscode-extension/src/builders/BuildCache.ts +++ b/packages/vscode-extension/src/builders/BuildCache.ts @@ -2,7 +2,7 @@ import path from "path"; import fs from "fs"; import { createFingerprintAsync } from "@expo/fingerprint"; import { Logger } from "../Logger"; -import { extensionContext, getAppRootFolder } from "../utilities/extensionContext"; +import { AppRootFolder, extensionContext } from "../utilities/extensionContext"; import { DevicePlatform } from "../common/DeviceManager"; import { IOSBuildResult } from "./buildIOS"; import { AndroidBuildResult } from "./buildAndroid"; @@ -37,12 +37,15 @@ function makeCacheKey(platform: DevicePlatform, appRoot: string) { } export class BuildCache { - private readonly cacheKey: string; - - constructor(private readonly platform: DevicePlatform, private readonly appRoot: string) { - this.cacheKey = makeCacheKey(platform, appRoot); + private get cacheKey() { + return makeCacheKey(this.platform, this.appRootFolder.getAppRoot()); } + constructor( + private readonly platform: DevicePlatform, + private readonly appRootFolder: AppRootFolder + ) {} + /** * Passed fingerprint should be calculated at the time build is started. */ @@ -112,7 +115,7 @@ export class BuildCache { return customFingerprint; } - const fingerprint = await createFingerprintAsync(getAppRootFolder(), { + const fingerprint = await createFingerprintAsync(this.appRootFolder.getAppRoot(), { ignorePaths: IGNORE_PATHS, }); Logger.debug("App folder fingerprint", fingerprint.hash); @@ -134,7 +137,11 @@ export class BuildCache { } Logger.debug(`Using custom fingerprint script '${fingerprintCommand}'`); - const fingerprint = await runfingerprintCommand(fingerprintCommand, env); + const fingerprint = await runfingerprintCommand( + fingerprintCommand, + env, + this.appRootFolder.getAppRoot() + ); if (!fingerprint) { throw new Error("Failed to generate application fingerprint using custom script."); @@ -153,10 +160,8 @@ async function getAppHash(appPath: string) { return (await calculateMD5(appPath)).digest("hex"); } -export async function migrateOldBuildCachesToNewStorage() { +export async function migrateOldBuildCachesToNewStorage(appRoot: string) { try { - const appRoot = getAppRootFolder(); - for (const platform of [DevicePlatform.Android, DevicePlatform.IOS]) { const oldKey = platform === DevicePlatform.Android ? ANDROID_BUILD_CACHE_KEY : IOS_BUILD_CACHE_KEY; diff --git a/packages/vscode-extension/src/builders/BuildManager.ts b/packages/vscode-extension/src/builders/BuildManager.ts index 72fec3402..5f78325e8 100644 --- a/packages/vscode-extension/src/builders/BuildManager.ts +++ b/packages/vscode-extension/src/builders/BuildManager.ts @@ -3,7 +3,6 @@ import { BuildCache } from "./BuildCache"; import { AndroidBuildResult, buildAndroid } from "./buildAndroid"; import { IOSBuildResult, buildIos } from "./buildIOS"; import { DeviceInfo, DevicePlatform } from "../common/DeviceManager"; -import { getAppRootFolder } from "../utilities/extensionContext"; import { DependencyManager } from "../dependency/DependencyManager"; import { CancelToken } from "./cancelToken"; import { getTelemetryReporter } from "../utilities/telemetry"; @@ -16,6 +15,7 @@ export interface DisposableBuild extends Disposable { } type BuildOptions = { + appRoot: string; clean: boolean; progressListener: (newProgress: number) => void; onSuccess: () => void; @@ -47,7 +47,7 @@ export class BuildManager { } public startBuild(deviceInfo: DeviceInfo, options: BuildOptions): DisposableBuild { - const { clean: forceCleanBuild, progressListener, onSuccess } = options; + const { clean: forceCleanBuild, progressListener, onSuccess, appRoot } = options; const { platform } = deviceInfo; getTelemetryReporter().sendTelemetryEvent("build:requested", { @@ -95,7 +95,7 @@ export class BuildManager { }); this.buildOutputChannel.clear(); buildResult = await buildAndroid( - getAppRootFolder(), + appRoot, forceCleanBuild, cancelToken, this.buildOutputChannel, @@ -128,7 +128,7 @@ export class BuildManager { } }; buildResult = await buildIos( - getAppRootFolder(), + appRoot, forceCleanBuild, cancelToken, this.buildOutputChannel, diff --git a/packages/vscode-extension/src/builders/buildAndroid.ts b/packages/vscode-extension/src/builders/buildAndroid.ts index 8e74d13ea..2b8037c58 100644 --- a/packages/vscode-extension/src/builders/buildAndroid.ts +++ b/packages/vscode-extension/src/builders/buildAndroid.ts @@ -74,7 +74,7 @@ function makeBuildTaskName(productFlavor: string, buildType: string) { } export async function buildAndroid( - appRootFolder: string, + appRoot: string, forceCleanBuild: boolean, cancelToken: CancelToken, outputChannel: OutputChannel, @@ -97,7 +97,8 @@ export async function buildAndroid( cancelToken, customBuild.android.buildCommand, env, - DevicePlatform.Android + DevicePlatform.Android, + appRoot ); if (!apkPath) { throw new Error("Failed to build Android app using custom script."); @@ -114,7 +115,7 @@ export async function buildAndroid( getTelemetryReporter().sendTelemetryEvent("build:eas-build-requested", { platform: DevicePlatform.Android, }); - const apkPath = await fetchEasBuild(cancelToken, eas.android, DevicePlatform.Android); + const apkPath = await fetchEasBuild(cancelToken, eas.android, DevicePlatform.Android, appRoot); if (!apkPath) { throw new Error("Failed to build Android app using EAS build."); } @@ -126,11 +127,11 @@ export async function buildAndroid( }; } - if (await isExpoGoProject()) { + if (await isExpoGoProject(appRoot)) { getTelemetryReporter().sendTelemetryEvent("build:expo-go-requested", { platform: DevicePlatform.Android, }); - const apkPath = await downloadExpoGo(DevicePlatform.Android, cancelToken); + const apkPath = await downloadExpoGo(DevicePlatform.Android, cancelToken, appRoot); return { apkPath, packageName: EXPO_GO_PACKAGE_NAME, platform: DevicePlatform.Android }; } @@ -140,7 +141,7 @@ export async function buildAndroid( ); } - const androidSourceDir = getAndroidSourceDir(appRootFolder); + const androidSourceDir = getAndroidSourceDir(appRoot); const productFlavor = android?.productFlavor || ""; const buildType = android?.buildType || "debug"; const gradleArgs = [ @@ -158,7 +159,7 @@ export async function buildAndroid( ), ]; // configureReactNativeOverrides init script is only necessary for RN versions older then 0.74.0 see comments in configureReactNativeOverrides.gradle for more details - if (semver.lt(getReactNativeVersion(), "0.74.0")) { + if (semver.lt(getReactNativeVersion(appRoot), "0.74.0")) { gradleArgs.push( "--init-script", // configureReactNativeOverrides init script is used to patch React Android project, see comments in configureReactNativeOverrides.gradle for more details path.join( @@ -186,6 +187,6 @@ export async function buildAndroid( await buildProcess; Logger.debug("Android build successful"); - const apkInfo = await getAndroidBuildPaths(appRootFolder, cancelToken, productFlavor, buildType); + const apkInfo = await getAndroidBuildPaths(appRoot, cancelToken, productFlavor, buildType); return { ...apkInfo, platform: DevicePlatform.Android }; } diff --git a/packages/vscode-extension/src/builders/buildIOS.ts b/packages/vscode-extension/src/builders/buildIOS.ts index 2be927d08..fd7210576 100644 --- a/packages/vscode-extension/src/builders/buildIOS.ts +++ b/packages/vscode-extension/src/builders/buildIOS.ts @@ -71,7 +71,7 @@ function buildProject( } export async function buildIos( - appRootFolder: string, + appRoot: string, forceCleanBuild: boolean, cancelToken: CancelToken, outputChannel: OutputChannel, @@ -97,7 +97,8 @@ export async function buildIos( cancelToken, customBuild.ios.buildCommand, env, - DevicePlatform.IOS + DevicePlatform.IOS, + appRoot ); if (!appPath) { throw new Error("Failed to build iOS app using custom script."); @@ -114,7 +115,7 @@ export async function buildIos( getTelemetryReporter().sendTelemetryEvent("build:eas-build-requested", { platform: DevicePlatform.IOS, }); - const appPath = await fetchEasBuild(cancelToken, eas.ios, DevicePlatform.IOS); + const appPath = await fetchEasBuild(cancelToken, eas.ios, DevicePlatform.IOS, appRoot); if (!appPath) { throw new Error("Failed to build iOS app using EAS build."); } @@ -126,11 +127,11 @@ export async function buildIos( }; } - if (await isExpoGoProject()) { + if (await isExpoGoProject(appRoot)) { getTelemetryReporter().sendTelemetryEvent("build:expo-go-requested", { platform: DevicePlatform.IOS, }); - const appPath = await downloadExpoGo(DevicePlatform.IOS, cancelToken); + const appPath = await downloadExpoGo(DevicePlatform.IOS, cancelToken, appRoot); return { appPath, bundleID: EXPO_GO_BUNDLE_ID, platform: DevicePlatform.IOS }; } @@ -140,11 +141,11 @@ export async function buildIos( ); } - const sourceDir = getIosSourceDir(appRootFolder); + const sourceDir = getIosSourceDir(appRoot); await installPodsIfNeeded(); - const xcodeProject = await findXcodeProject(appRootFolder); + const xcodeProject = await findXcodeProject(appRoot); if (!xcodeProject) { throw new Error(`Could not find Xcode project files in "${sourceDir}" folder`); diff --git a/packages/vscode-extension/src/builders/customBuild.ts b/packages/vscode-extension/src/builders/customBuild.ts index 6609c559b..17e7bfb24 100644 --- a/packages/vscode-extension/src/builders/customBuild.ts +++ b/packages/vscode-extension/src/builders/customBuild.ts @@ -5,7 +5,6 @@ import { mkdtemp } from "fs/promises"; import { Logger } from "../Logger"; import { command, lineReader } from "../utilities/subprocess"; import { CancelToken } from "./cancelToken"; -import { getAppRootFolder } from "../utilities/extensionContext"; import { extractTarApp, isApkFile, isAppFile } from "./utils"; import { DevicePlatform } from "../common/DeviceManager"; @@ -18,9 +17,10 @@ export async function runExternalBuild( cancelToken: CancelToken, buildCommand: string, env: Env, - platform: DevicePlatform + platform: DevicePlatform, + appRoot: string ) { - const output = await runExternalScript(buildCommand, env, cancelToken); + const output = await runExternalScript(buildCommand, env, appRoot, cancelToken); if (!output) { return undefined; @@ -64,16 +64,21 @@ export async function runExternalBuild( return binaryPath; } -export async function runfingerprintCommand(externalCommand: string, env: Env) { - const output = await runExternalScript(externalCommand, env); +export async function runfingerprintCommand(externalCommand: string, env: Env, appRoot: string) { + const output = await runExternalScript(externalCommand, env, appRoot); if (!output) { return undefined; } return output.lastLine; } -async function runExternalScript(externalCommand: string, env: Env, cancelToken?: CancelToken) { - let process = command(externalCommand, { cwd: getAppRootFolder(), env }); +async function runExternalScript( + externalCommand: string, + env: Env, + appRoot: string, + cancelToken?: CancelToken +) { + let process = command(externalCommand, { cwd: appRoot, env }); process = cancelToken ? cancelToken.adapt(process) : process; Logger.info(`Running external script: ${externalCommand}`); diff --git a/packages/vscode-extension/src/builders/eas.ts b/packages/vscode-extension/src/builders/eas.ts index 52cfa8d57..aff0dacab 100644 --- a/packages/vscode-extension/src/builders/eas.ts +++ b/packages/vscode-extension/src/builders/eas.ts @@ -14,9 +14,10 @@ import { extractTarApp } from "./utils"; export async function fetchEasBuild( cancelToken: CancelToken, config: EasConfig, - platform: DevicePlatform + platform: DevicePlatform, + appRoot: string ): Promise { - const build = await fetchBuild(config, platform); + const build = await fetchBuild(config, platform, appRoot); if (!build) { return undefined; } @@ -30,9 +31,9 @@ export async function fetchEasBuild( return easBinaryPath; } -async function fetchBuild(config: EasConfig, platform: DevicePlatform) { +async function fetchBuild(config: EasConfig, platform: DevicePlatform, appRoot: string) { if (config.buildUUID) { - const build = await viewEasBuild(config.buildUUID, platform); + const build = await viewEasBuild(config.buildUUID, platform, appRoot); if (!build) { Logger.error( `Failed to find EAS build artifact with ID ${config.buildUUID} for platform ${platform}.` @@ -48,7 +49,7 @@ async function fetchBuild(config: EasConfig, platform: DevicePlatform) { return build; } - const builds = await listEasBuilds(platform, config.profile); + const builds = await listEasBuilds(platform, config.profile, appRoot); if (!builds || builds.length === 0) { Logger.error( `Failed to find any EAS build artifacts for ${platform} with ${config.profile} profile. If you're building iOS app, make sure you set '"ios.simulator": true' option in eas.json.` diff --git a/packages/vscode-extension/src/builders/easCommand.ts b/packages/vscode-extension/src/builders/easCommand.ts index aa63aca39..8ced4fe10 100644 --- a/packages/vscode-extension/src/builders/easCommand.ts +++ b/packages/vscode-extension/src/builders/easCommand.ts @@ -1,5 +1,4 @@ import { DevicePlatform } from "../common/DeviceManager"; -import { getAppRootFolder } from "../utilities/extensionContext"; import { exec } from "../utilities/subprocess"; type UnixTimestamp = number; @@ -53,7 +52,7 @@ type EASBuildJson = { isForIosSimulator: false; }; -export async function listEasBuilds(platform: DevicePlatform, profile: string) { +export async function listEasBuilds(platform: DevicePlatform, profile: string, appRoot: string) { const platformMapping = { [DevicePlatform.Android]: "android", [DevicePlatform.IOS]: "ios" }; const { stdout } = await exec( @@ -67,14 +66,14 @@ export async function listEasBuilds(platform: DevicePlatform, profile: string) { "--profile", profile, ], - { cwd: getAppRootFolder() } + { cwd: appRoot } ); return parseEasBuildOutput(stdout, platform); } -export async function viewEasBuild(buildUUID: UUID, platform: DevicePlatform) { +export async function viewEasBuild(buildUUID: UUID, platform: DevicePlatform, appRoot: string) { const { stdout } = await exec("eas", ["build:view", buildUUID, "--json"], { - cwd: getAppRootFolder(), + cwd: appRoot, }); return parseEasBuildOutput(stdout, platform)?.at(0); } diff --git a/packages/vscode-extension/src/builders/expoGo.ts b/packages/vscode-extension/src/builders/expoGo.ts index 05e1c8f23..fd6c072f8 100644 --- a/packages/vscode-extension/src/builders/expoGo.ts +++ b/packages/vscode-extension/src/builders/expoGo.ts @@ -1,7 +1,7 @@ import path from "path"; import http from "http"; import fs from "fs"; -import { extensionContext, getAppRootFolder } from "../utilities/extensionContext"; +import { extensionContext } from "../utilities/extensionContext"; import { exec } from "../utilities/subprocess"; import { DevicePlatform } from "../common/DeviceManager"; import { CancelToken } from "./cancelToken"; @@ -15,7 +15,7 @@ function fileExists(filePath: string, ...additionalPaths: string[]) { return fs.existsSync(path.join(filePath, ...additionalPaths)); } -export async function isExpoGoProject(): Promise { +export async function isExpoGoProject(appRoot: string): Promise { // There is no straightforward way to tell apart different react native project // setups. i.e. expo-go, expo-dev-client, bare react native, etc. // Here, we are using a heuristic to determine if the project is expo-go based @@ -24,7 +24,6 @@ export async function isExpoGoProject(): Promise { // 2) The project doesn't have an android or ios folder // 3) The expo_go_project_tester.js script runs successfully – the script uses expo-cli // internals to resolve project config and tells expo-go and dev-client apart. - const appRoot = getAppRootFolder(); if (!fileExists(appRoot, "app.json") && !fileExists(appRoot, "app.config.js")) { // app.json or app.config.js is required for expo-go projects @@ -43,7 +42,7 @@ export async function isExpoGoProject(): Promise { ); try { const result = await exec("node", [expoGoProjectTesterScript], { - cwd: getAppRootFolder(), + cwd: appRoot, allowNonZeroExit: true, }); return result.exitCode === 0; @@ -89,11 +88,15 @@ export function fetchExpoLaunchDeeplink( }); } -export async function downloadExpoGo(platform: DevicePlatform, cancelToken: CancelToken) { +export async function downloadExpoGo( + platform: DevicePlatform, + cancelToken: CancelToken, + appRoot: string +) { const downloadScript = path.join(extensionContext.extensionPath, "lib", "expo_go_download.js"); const { stdout } = await cancelToken.adapt( exec("node", [downloadScript, platform], { - cwd: getAppRootFolder(), + cwd: appRoot, }) ); diff --git a/packages/vscode-extension/src/common/LaunchConfig.ts b/packages/vscode-extension/src/common/LaunchConfig.ts index 9637f30e9..147bb5a89 100644 --- a/packages/vscode-extension/src/common/LaunchConfig.ts +++ b/packages/vscode-extension/src/common/LaunchConfig.ts @@ -33,6 +33,7 @@ export type LaunchConfigurationOptions = { export interface LaunchConfigEventMap { launchConfigChange: LaunchConfigurationOptions; + applicationRootsChanged: void; } export interface LaunchConfigEventListener { @@ -44,10 +45,14 @@ export type LaunchConfigUpdater = ( value: LaunchConfigurationOptions[K] | "Auto" ) => void; +export type AddCustomApplicationRoot = (appRoot: string) => void; + export interface LaunchConfig { getConfig(): Promise; update: LaunchConfigUpdater; + addCustomApplicationRoot: AddCustomApplicationRoot; getAvailableXcodeSchemes(): Promise; + getAvailableApplicationRoots(): Promise; addListener( eventType: K, listener: LaunchConfigEventListener diff --git a/packages/vscode-extension/src/dependency/DependencyManager.ts b/packages/vscode-extension/src/dependency/DependencyManager.ts index edec6cf15..a10390436 100644 --- a/packages/vscode-extension/src/dependency/DependencyManager.ts +++ b/packages/vscode-extension/src/dependency/DependencyManager.ts @@ -6,7 +6,7 @@ import semver, { SemVer } from "semver"; import { Logger } from "../Logger"; import { EMULATOR_BINARY } from "../devices/AndroidEmulatorDevice"; import { command, lineReader } from "../utilities/subprocess"; -import { getAppRootFolder } from "../utilities/extensionContext"; +import { AppRootFolder } from "../utilities/extensionContext"; import { getIosSourceDir } from "../builders/buildIOS"; import { isExpoGoProject } from "../builders/expoGo"; import { @@ -31,6 +31,7 @@ import { getTelemetryReporter } from "../utilities/telemetry"; import { DevicePlatform } from "../common/DeviceManager"; export class DependencyManager implements Disposable, DependencyManagerInterface { + constructor(private readonly appRootFolder: AppRootFolder) {} // React Native prepares build scripts based on node_modules, we need to reinstall pods if they change private eventEmitter = new EventEmitter(); private packageManagerInternal: PackageManagerInfo | undefined; @@ -52,6 +53,7 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async runAllDependencyChecks() { + const appRoot = this.appRootFolder.getAppRoot(); this.checkAndroidEmulatorBinaryStatus(); this.checkAndroidDirectoryExits(); @@ -67,13 +69,13 @@ export class DependencyManager implements Disposable, DependencyManagerInterface this.checkNodeModulesInstallationStatus(); this.emitEvent("reactNative", { - status: npmPackageVersionCheck("react-native", MinSupportedVersion.reactNative), + status: npmPackageVersionCheck("react-native", appRoot, MinSupportedVersion.reactNative), isOptional: false, }); this.emitEvent("expo", { - status: npmPackageVersionCheck("expo", MinSupportedVersion.expo), - isOptional: !shouldUseExpoCLI(), + status: npmPackageVersionCheck("expo", appRoot, MinSupportedVersion.expo), + isOptional: !shouldUseExpoCLI(appRoot), }); this.checkProjectUsesExpoRouter(); @@ -81,10 +83,10 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async checkAndroidDirectoryExits() { - const appRootFolder = getAppRootFolder(); - const androidDirPath = getAndroidSourceDir(appRootFolder); + const appRoot = this.appRootFolder.getAppRoot(); + const androidDirPath = getAndroidSourceDir(appRoot); - const isOptional = !(await projectRequiresNativeBuild()); + const isOptional = !(await projectRequiresNativeBuild(appRoot)); try { await fs.promises.access(androidDirPath); @@ -97,10 +99,10 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async checkIOSDirectoryExists() { - const appRootFolder = getAppRootFolder(); - const iosDirPath = getIosSourceDir(appRootFolder); + const appRoot = this.appRootFolder.getAppRoot(); + const iosDirPath = getIosSourceDir(appRoot); - const isOptional = !(await projectRequiresNativeBuild()); + const isOptional = !(await projectRequiresNativeBuild(appRoot)); try { await fs.promises.access(iosDirPath); this.emitEvent("ios", { status: "installed", isOptional }); @@ -112,8 +114,9 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async checkProjectUsesExpoRouter() { - const dependsOnExpoRouter = appDependsOnExpoRouter(); - const hasExpoRouterInstalled = npmPackageVersionCheck("expo-router"); + const appRoot = this.appRootFolder.getAppRoot(); + const dependsOnExpoRouter = appDependsOnExpoRouter(appRoot); + const hasExpoRouterInstalled = npmPackageVersionCheck("expo-router", appRoot); this.emitEvent("expoRouter", { status: hasExpoRouterInstalled, @@ -124,8 +127,11 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async checkProjectUsesStorybook() { + const appRoot = this.appRootFolder.getAppRoot(); const hasStotybookInstalled = npmPackageVersionCheck( "@storybook/react-native", + + appRoot, MinSupportedVersion.storybook ); this.emitEvent("storybook", { @@ -136,7 +142,8 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async installNodeModules(): Promise { - const packageManager = await this.getPackageManager(); + const appRoot = this.appRootFolder.getAppRoot(); + const packageManager = await this.getPackageManager(appRoot); if (!packageManager) { return false; } @@ -145,7 +152,7 @@ export class DependencyManager implements Disposable, DependencyManagerInterface // all package managers support the `install` command await command(`${packageManager.name} install`, { - cwd: packageManager.workspacePath ?? getAppRootFolder(), + cwd: packageManager.workspacePath ?? appRoot, quietErrorsOnExit: true, }); @@ -155,8 +162,8 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async installPods(buildOutputChannel: OutputChannel, cancelToken: CancelToken) { - const appRootFolder = getAppRootFolder(); - const iosDirPath = getIosSourceDir(appRootFolder); + const appRoot = this.appRootFolder.getAppRoot(); + const iosDirPath = getIosSourceDir(appRoot); if (!iosDirPath) { this.emitEvent("pods", { status: "notInstalled", isOptional: false }); @@ -189,9 +196,9 @@ export class DependencyManager implements Disposable, DependencyManagerInterface Logger.debug("Project pods installed"); } - private async getPackageManager() { + private async getPackageManager(appRoot: string) { if (!this.packageManagerInternal) { - this.packageManagerInternal = await resolvePackageManager(); + this.packageManagerInternal = await resolvePackageManager(appRoot); } return this.packageManagerInternal; } @@ -218,7 +225,8 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } private async shouldUseBundleCommand() { - const gemfile = path.join(getAppRootFolder(), "Gemfile"); + const appRoot = this.appRootFolder.getAppRoot(); + const gemfile = path.join(appRoot, "Gemfile"); try { await fs.promises.access(gemfile); return true; @@ -232,9 +240,10 @@ export class DependencyManager implements Disposable, DependencyManagerInterface const installed = await testCommand( shouldUseBundle ? "bundle exec pod --version" : "pod --version" ); + const appRoot = this.appRootFolder.getAppRoot(); this.emitEvent("cocoaPods", { status: installed ? "installed" : "notInstalled", - isOptional: !(await projectRequiresNativeBuild()), + isOptional: !(await projectRequiresNativeBuild(appRoot)), }); } @@ -247,9 +256,11 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } private async checkPackageManagerInstallationStatus() { + const appRoot = this.appRootFolder.getAppRoot(); + // the resolvePackageManager function in getPackageManager checks // if a package manager is installed and otherwise returns undefined - const packageManager = await this.getPackageManager(); + const packageManager = await this.getPackageManager(appRoot); this.emitEvent("packageManager", { status: packageManager ? "installed" : "notInstalled", isOptional: false, @@ -259,13 +270,14 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async checkNodeModulesInstallationStatus() { - const packageManager = await this.getPackageManager(); + const appRoot = this.appRootFolder.getAppRoot(); + const packageManager = await this.getPackageManager(appRoot); if (!packageManager) { this.emitEvent("nodeModules", { status: "notInstalled", isOptional: false }); return false; } - const installed = await isNodeModulesInstalled(packageManager); + const installed = await isNodeModulesInstalled(packageManager, appRoot); this.emitEvent("nodeModules", { status: installed ? "installed" : "notInstalled", isOptional: false, @@ -274,14 +286,14 @@ export class DependencyManager implements Disposable, DependencyManagerInterface } public async checkPodsInstallationStatus() { - const requiresNativeBuild = await projectRequiresNativeBuild(); + const appRoot = this.appRootFolder.getAppRoot(); + const requiresNativeBuild = await projectRequiresNativeBuild(appRoot); if (!requiresNativeBuild) { this.emitEvent("pods", { status: "notInstalled", isOptional: true }); return true; } - const appRootFolder = getAppRootFolder(); - const iosDirPath = getIosSourceDir(appRootFolder); + const iosDirPath = getIosSourceDir(appRoot); const podfileLockExists = fs.existsSync(path.join(iosDirPath, "Podfile.lock")); const podsDirExists = fs.existsSync(path.join(iosDirPath, "Pods")); @@ -326,10 +338,14 @@ async function testCommand(cmd: string) { } } -function npmPackageVersionCheck(dependency: string, minVersion?: string | semver.SemVer) { +function npmPackageVersionCheck( + dependency: string, + appRoot: string, + minVersion?: string | semver.SemVer +) { try { const module = requireNoCache(path.join(dependency, "package.json"), { - paths: [getAppRootFolder()], + paths: [appRoot], }); if (!minVersion) { @@ -357,10 +373,9 @@ export async function checkXcodeExists() { return isXcodebuildInstalled && isXcrunInstalled && isSimctlInstalled; } -function appDependsOnExpoRouter() { +function appDependsOnExpoRouter(appRoot: string) { // we assume that a expo router based project contain // the package "expo-router" in its dependencies or devDependencies - const appRoot = getAppRootFolder(); try { const packageJson = requireNoCache(path.join(appRoot, "package.json")); const allDependencies = [ @@ -383,11 +398,11 @@ function appDependsOnExpoRouter() { * or uses Expo Go, the IDE is not responsible for building the project, and hence * we don't want to report missing directories or tools as errors. */ -async function projectRequiresNativeBuild() { +async function projectRequiresNativeBuild(appRoot: string) { const launchConfiguration = getLaunchConfiguration(); if (launchConfiguration.customBuild || launchConfiguration.eas) { return false; } - return !(await isExpoGoProject()); + return !(await isExpoGoProject(appRoot)); } diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 80e28aaff..903d497f2 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -1,11 +1,9 @@ -import fs from "fs"; import { commands, languages, debug, window, workspace, - Uri, ExtensionContext, ExtensionMode, ConfigurationChangeEvent, @@ -20,15 +18,15 @@ import { DebugConfigProvider } from "./providers/DebugConfigProvider"; import { DebugAdapterDescriptorFactory } from "./debugging/DebugAdapterDescriptorFactory"; import { Logger, enableDevModeLogging } from "./Logger"; import { + configureAppRootFolder, extensionContext, - setAppRootFolder, + getAppRootFolder, + getCurrentLaunchConfig, setExtensionContext, } from "./utilities/extensionContext"; import { setupPathEnv } from "./utilities/subprocess"; import { SidePanelViewProvider } from "./panels/SidepanelViewProvider"; import { PanelLocation } from "./common/WorkspaceConfig"; -import { getLaunchConfiguration } from "./utilities/launchConfiguration"; -import { findFilesInWorkspace, isWorkspaceRoot } from "./utilities/common"; import { Platform } from "./utilities/platform"; import { migrateOldBuildCachesToNewStorage } from "./builders/BuildCache"; import { IDE } from "./project/ide"; @@ -89,7 +87,7 @@ export async function activate(context: ExtensionContext) { if (panelLocation !== "tab") { SidePanelViewProvider.showView(); } else { - TabPanel.render(context); + TabPanel.render(context, getAppRootFolder()); } } @@ -138,7 +136,7 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push( window.registerWebviewViewProvider( SidePanelViewProvider.viewType, - new SidePanelViewProvider(context), + new SidePanelViewProvider(context, getAppRootFolder()), { webviewOptions: { retainContextWhenHidden: true } } ) ); @@ -247,33 +245,56 @@ export async function activate(context: ExtensionContext) { ) ); + const setupAppRoot = async () => { + const appRoot = await configureAppRootFolder(); + if (!appRoot) { + return; + } + + if (Platform.OS === "macos") { + try { + await setupPathEnv(appRoot); + } catch (error) { + window.showWarningMessage( + "Error when setting up PATH environment variable, RN IDE may not work correctly.", + "Dismiss" + ); + } + } + return appRoot; + }; + context.subscriptions.push( - workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { + workspace.onDidChangeConfiguration(async (event: ConfigurationChangeEvent) => { if (event.affectsConfiguration("RadonIDE.panelLocation")) { showIDEPanel(); } + if (event.affectsConfiguration("launch")) { + const config = getCurrentLaunchConfig(); + const oldAppRoot = getAppRootFolder().getAppRoot(); + if (config.appRoot === oldAppRoot) { + return; + } + const appRoot = await setupAppRoot(); + + if (appRoot === undefined) { + window.showErrorMessage( + "Unable to find the new app root, after a change in launch configuration. Radon IDE might not work properly.", + "Dismiss" + ); + return; + } + } }) ); - const appRoot = await configureAppRootFolder(); - if (!appRoot) { - return; - } + const appRoot = await setupAppRoot(); - if (Platform.OS === "macos") { - try { - await setupPathEnv(appRoot); - } catch (error) { - window.showWarningMessage( - "Error when setting up PATH environment variable, RN IDE may not work correctly.", - "Dismiss" - ); - } + if (appRoot) { + // this needs to be run after app root is set + migrateOldBuildCachesToNewStorage(appRoot); } - // this needs to be run after app root is set - migrateOldBuildCachesToNewStorage(); - extensionActivated(); } @@ -294,133 +315,6 @@ function extensionActivated() { } } -async function configureAppRootFolder() { - const appRootFolder = await findAppRootFolder(); - if (appRootFolder) { - Logger.info(`Found app root folder: ${appRootFolder}`); - setAppRootFolder(appRootFolder); - commands.executeCommand("setContext", "RNIDE.extensionIsActive", true); - } - return appRootFolder; -} - -async function findAppRootCandidates(): Promise { - const candidates: string[] = []; - - const metroConfigUris = await findFilesInWorkspace("**/metro.config.{js,ts}", "**/node_modules"); - metroConfigUris.forEach((metroConfigUri) => { - candidates.push(Uri.joinPath(metroConfigUri, "..").fsPath); - }); - - const appConfigUris = await findFilesInWorkspace("**/app.config.{js,ts}", "**/node_modules"); - appConfigUris.forEach((appConfigUri) => { - const appRootFsPath = Uri.joinPath(appConfigUri, "..").fsPath; - if (!candidates.includes(appRootFsPath)) { - candidates.push(appRootFsPath); - } - }); - - // given that if the user uses workspaces his node_modules are installed not in the root of an application, - // but in the root of the workspace we need to detect workspaces root and exclude it. - let excludePattern = null; - workspace.workspaceFolders?.forEach((folder) => { - if (isWorkspaceRoot(folder.uri.fsPath)) { - excludePattern = "node_modules/react-native/package.json"; - } - }); - - const rnPackageLocations = await findFilesInWorkspace( - "**/node_modules/react-native/package.json", - excludePattern - ); - rnPackageLocations.forEach((rnPackageLocation) => { - const appRootFsPath = Uri.joinPath(rnPackageLocation, "../../..").fsPath; - if (!candidates.includes(appRootFsPath)) { - candidates.push(appRootFsPath); - } - }); - - // app json is often used in non react-native projects, but in worst case scenario we can use it as a fallback - const appJsonUris = await findFilesInWorkspace("**/app.json", "**/node_modules"); - appJsonUris.forEach((appJsonUri) => { - const appRootFsPath = Uri.joinPath(appJsonUri, "..").fsPath; - if (!candidates.includes(appRootFsPath)) { - candidates.push(appRootFsPath); - } - }); - - return candidates; -} - -async function findAppRootFolder() { - const launchConfiguration = getLaunchConfiguration(); - const appRootFromLaunchConfig = launchConfiguration.appRoot; - if (appRootFromLaunchConfig) { - let appRoot: string | undefined; - workspace.workspaceFolders?.forEach((folder) => { - const possibleAppRoot = Uri.joinPath(folder.uri, appRootFromLaunchConfig).fsPath; - if (fs.existsSync(possibleAppRoot)) { - appRoot = possibleAppRoot; - } - }); - if (!appRoot) { - // when relative app location setting is set, we expect app root exists - const openLaunchConfigButton = "Open Launch Configuration"; - window - .showErrorMessage( - `The app root folder does not exist in the workspace at ${appRootFromLaunchConfig}.`, - openLaunchConfigButton - ) - .then((item) => { - if (item === openLaunchConfigButton) { - commands.executeCommand("workbench.action.debug.configure"); - } - }); - return undefined; - } - return appRoot; - } - - const appRootCandidates = await findAppRootCandidates(); - - if (appRootCandidates.length > 1) { - const openLaunchConfigButton = "Open Launch Configuration"; - window - .showWarningMessage( - `Multiple react-native applications were detected in the workspace. "${appRootCandidates[0]}" was automatically chosen as your application root. To change that or remove this warning in the future, you can setup a permanent appRoot in Launch Configuration.`, - openLaunchConfigButton - ) - .then((item) => { - if (item === openLaunchConfigButton) { - commands.executeCommand("workbench.action.debug.configure"); - } - }); - } - - if (appRootCandidates.length > 0) { - return appRootCandidates[0]; - } - - const manageLaunchConfigButton = "Manage Launch Configuration"; - window - .showErrorMessage( - ` - Radon IDE couldn't find root application folder in this workspace.\n - Please make sure that the opened workspace contains a valid React Native or Expo project.\n - The way extension verifies the project is by looking for either: app.json, metro.config.js, - or node_modules/react-native folder. If your project structure is different, you can set the - app root using launch configuration.`, - manageLaunchConfigButton, - "Dismiss" - ) - .then((item) => { - if (item === manageLaunchConfigButton) { - commands.executeCommand("debug.addConfiguration"); - } - }); - return undefined; -} - async function openDevMenu() { IDE.getInstanceIfExists()?.project.openDevMenu(); } diff --git a/packages/vscode-extension/src/panels/LaunchConfigController.ts b/packages/vscode-extension/src/panels/LaunchConfigController.ts index a8864220c..248d1f938 100644 --- a/packages/vscode-extension/src/panels/LaunchConfigController.ts +++ b/packages/vscode-extension/src/panels/LaunchConfigController.ts @@ -1,3 +1,4 @@ +import path from "path"; import { EventEmitter } from "stream"; import { ConfigurationChangeEvent, workspace, Disposable } from "vscode"; import { @@ -6,46 +7,32 @@ import { LaunchConfigEventMap, LaunchConfigurationOptions, } from "../common/LaunchConfig"; -import { getAppRootFolder } from "../utilities/extensionContext"; +import { + AppRootFolder, + extensionContext, + findAppRootCandidates, + getCurrentLaunchConfig, +} from "../utilities/extensionContext"; import { findXcodeProject, findXcodeScheme } from "../utilities/xcode"; import { Logger } from "../Logger"; import { getIosSourceDir } from "../builders/buildIOS"; +const CUSTOM_APPLICATION_ROOTS_KEY = "custom_application_roots_key"; + export class LaunchConfigController implements Disposable, LaunchConfig { private config: LaunchConfigurationOptions; private eventEmitter = new EventEmitter(); private configListener: Disposable; - constructor() { - const getCurrentConfig = (): LaunchConfigurationOptions => { - const launchConfiguration = workspace.getConfiguration( - "launch", - workspace.workspaceFolders![0].uri - ); - - const configurations = launchConfiguration.get>>("configurations")!; - - const RNIDEConfiguration = configurations.find( - ({ type }) => type === "react-native-ide" || type === "radon-ide" // for compatibility we want to support old configuration type name - ); - - if (!RNIDEConfiguration) { - return {}; - } - - const { android, appRoot, ios, isExpo, metroConfigPath, env, eas } = RNIDEConfiguration; - - return { android, appRoot, ios, isExpo, metroConfigPath, env, eas }; - }; - - this.config = getCurrentConfig(); + constructor(private readonly appRootFolder: AppRootFolder) { + this.config = getCurrentLaunchConfig(); this.configListener = workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { if (!event.affectsConfiguration("launch")) { return; } - this.config = getCurrentConfig(); + this.config = getCurrentLaunchConfig(); this.eventEmitter.emit("launchConfigChange", this.config); }); @@ -88,11 +75,43 @@ export class LaunchConfigController implements Disposable, LaunchConfig { await configurations.update("configurations", newConfigurations); } + async addCustomApplicationRoot(appRoot: string) { + const oldCustomApplicationRoots = + extensionContext.workspaceState.get(CUSTOM_APPLICATION_ROOTS_KEY) ?? []; + + const newCustomApplicationRoots = [...oldCustomApplicationRoots, appRoot]; + + extensionContext.workspaceState.update( + CUSTOM_APPLICATION_ROOTS_KEY, + newCustomApplicationRoots + ) ?? []; + + this.eventEmitter.emit("applicationRootsChanged"); + } + + async getAvailableApplicationRoots() { + const workspacePath = workspace.workspaceFolders![0].uri.fsPath; + const applicationRootsCandidates = (await findAppRootCandidates()).map((candidate) => { + return "./" + path.relative(workspacePath, candidate); + }); + const customApplicationRoots = + extensionContext.workspaceState.get(CUSTOM_APPLICATION_ROOTS_KEY) ?? []; + + const applicationRoots = [...applicationRootsCandidates, ...customApplicationRoots]; + + if (!applicationRoots) { + Logger.debug(`Could not find any application roots.`); + return []; + } + + return applicationRoots; + } + async getAvailableXcodeSchemes() { - const appRootFolder = getAppRootFolder(); - const sourceDir = getIosSourceDir(appRootFolder); + const appRoot = this.appRootFolder.getAppRoot(); + const sourceDir = getIosSourceDir(appRoot); - const xcodeProject = await findXcodeProject(appRootFolder); + const xcodeProject = await findXcodeProject(appRoot); if (!xcodeProject) { Logger.debug(`Could not find Xcode project files in "${sourceDir}" folder`); diff --git a/packages/vscode-extension/src/panels/SidepanelViewProvider.ts b/packages/vscode-extension/src/panels/SidepanelViewProvider.ts index 724ce74b0..4bf3f269b 100644 --- a/packages/vscode-extension/src/panels/SidepanelViewProvider.ts +++ b/packages/vscode-extension/src/panels/SidepanelViewProvider.ts @@ -11,6 +11,7 @@ import { generateWebviewContent } from "./webviewContentGenerator"; import { WebviewController } from "./WebviewController"; import { Logger } from "../Logger"; +import { AppRootFolder } from "../utilities/extensionContext"; export class SidePanelViewProvider implements WebviewViewProvider, Disposable { public static readonly viewType = "RadonIDE.view"; @@ -21,7 +22,10 @@ export class SidePanelViewProvider implements WebviewViewProvider, Disposable { } private webviewController: any = null; - constructor(private readonly context: ExtensionContext) { + constructor( + private readonly context: ExtensionContext, + private readonly appRootFolder: AppRootFolder + ) { SidePanelViewProvider.currentProvider = this; } @@ -64,7 +68,7 @@ export class SidePanelViewProvider implements WebviewViewProvider, Disposable { this.context.extensionUri ); this._view = webviewView; - this.webviewController = new WebviewController(this._view.webview); + this.webviewController = new WebviewController(this._view.webview, this.appRootFolder); // Set an event listener to listen for when the webview is disposed (i.e. when the user changes // settings or hiddes conteiner view by hand, https://code.visualstudio.com/api/references/vscode-api#WebviewView) webviewView.onDidDispose(() => { diff --git a/packages/vscode-extension/src/panels/Tabpanel.ts b/packages/vscode-extension/src/panels/Tabpanel.ts index 3c0ef2f69..d72c19682 100644 --- a/packages/vscode-extension/src/panels/Tabpanel.ts +++ b/packages/vscode-extension/src/panels/Tabpanel.ts @@ -10,7 +10,7 @@ import { Disposable, } from "vscode"; -import { extensionContext } from "../utilities/extensionContext"; +import { AppRootFolder, extensionContext } from "../utilities/extensionContext"; import { generateWebviewContent } from "./webviewContentGenerator"; import { WebviewController } from "./WebviewController"; import { disposeAll } from "../utilities/disposables"; @@ -23,7 +23,11 @@ export class TabPanel implements Disposable { private disposables: Disposable[] = []; private webviewController: WebviewController; - private constructor(panel: WebviewPanel, context: ExtensionContext) { + private constructor( + panel: WebviewPanel, + context: ExtensionContext, + appRootFolder: AppRootFolder + ) { this._panel = panel; this._panel.iconPath = Uri.joinPath(context.extensionUri, "assets", "logo.svg"); // Set an event listener to listen for when the panel is disposed (i.e. when the user closes @@ -48,7 +52,7 @@ export class TabPanel implements Disposable { extensionContext.extensionUri ); - this.webviewController = new WebviewController(this._panel.webview); + this.webviewController = new WebviewController(this._panel.webview, appRootFolder); this.disposables.push(this._panel, this.webviewController); workspace.onDidChangeConfiguration( @@ -65,7 +69,7 @@ export class TabPanel implements Disposable { ); } - public static render(context: ExtensionContext) { + public static render(context: ExtensionContext, appRootFolder: AppRootFolder) { if (TabPanel.currentPanel) { // If the webview panel already exists reveal it TabPanel.currentPanel._panel.reveal(); @@ -88,7 +92,7 @@ export class TabPanel implements Disposable { retainContextWhenHidden: true, } ); - TabPanel.currentPanel = new TabPanel(panel, context); + TabPanel.currentPanel = new TabPanel(panel, context, appRootFolder); context.workspaceState.update(OPEN_PANEL_ON_ACTIVATION, true); commands.executeCommand("workbench.action.lockEditorGroup"); diff --git a/packages/vscode-extension/src/panels/WebviewController.ts b/packages/vscode-extension/src/panels/WebviewController.ts index ecb1134bb..7b7ded9f1 100644 --- a/packages/vscode-extension/src/panels/WebviewController.ts +++ b/packages/vscode-extension/src/panels/WebviewController.ts @@ -3,6 +3,7 @@ import { Logger } from "../Logger"; import { getTelemetryReporter } from "../utilities/telemetry"; import { IDE } from "../project/ide"; import { disposeAll } from "../utilities/disposables"; +import { AppRootFolder } from "../utilities/extensionContext"; type CallArgs = { callId: string; @@ -27,9 +28,11 @@ export class WebviewController implements Disposable { }); private readonly callableObjects: Map; - private readonly ide = IDE.attach(); + private readonly ide; + + constructor(private webview: Webview, appRootFolder: AppRootFolder) { + this.ide = IDE.attach(appRootFolder); - constructor(private webview: Webview) { // Set an event listener to listen for messages passed from the webview context this.setWebviewMessageListener(webview); diff --git a/packages/vscode-extension/src/project/deviceSession.ts b/packages/vscode-extension/src/project/deviceSession.ts index 14dad1b96..327f59c1d 100644 --- a/packages/vscode-extension/src/project/deviceSession.ts +++ b/packages/vscode-extension/src/project/deviceSession.ts @@ -17,6 +17,7 @@ import { throttle } from "../utilities/throttle"; import { DependencyManager } from "../dependency/DependencyManager"; import { getTelemetryReporter } from "../utilities/telemetry"; import { BuildCache } from "../builders/BuildCache"; +import { AppRootFolder } from "../utilities/extensionContext"; import { CancelToken } from "../builders/cancelToken"; type PreviewReadyCallback = (previewURL: string) => void; @@ -202,10 +203,11 @@ export class DeviceSession implements Disposable { await this.device.bootDevice(deviceSettings); } - private async buildApp({ clean }: { clean: boolean }) { + private async buildApp({ appRoot, clean }: { appRoot: string; clean: boolean }) { const buildStartTime = Date.now(); this.eventDelegate.onStateChange(StartupMessage.Building); this.disposableBuild = this.buildManager.startBuild(this.device.deviceInfo, { + appRoot, clean, onSuccess: this.eventDelegate.onBuildSuccess, progressListener: throttle((stageProgress: number) => { @@ -238,13 +240,14 @@ export class DeviceSession implements Disposable { public async start( deviceSettings: DeviceSettings, + appRootFolder: AppRootFolder, { cleanBuild, previewReadyCallback }: StartOptions ) { this.deviceSettings = deviceSettings; await this.waitForMetroReady(); // TODO(jgonet): Build and boot simultaneously, with predictable state change updates await this.bootDevice(deviceSettings); - await this.buildApp({ clean: cleanBuild }); + await this.buildApp({ appRoot: appRootFolder.getAppRoot(), clean: cleanBuild }); await this.installApp({ reinstall: false }); const previewUrl = await this.launchApp(previewReadyCallback); Logger.debug("Device session started"); diff --git a/packages/vscode-extension/src/project/ide.ts b/packages/vscode-extension/src/project/ide.ts index 051765cba..d0a51c270 100644 --- a/packages/vscode-extension/src/project/ide.ts +++ b/packages/vscode-extension/src/project/ide.ts @@ -5,7 +5,7 @@ import { DependencyManager } from "../dependency/DependencyManager"; import { WorkspaceConfigController } from "../panels/WorkspaceConfigController"; import { LaunchConfigController } from "../panels/LaunchConfigController"; import { Utils } from "../utilities/utils"; -import { extensionContext } from "../utilities/extensionContext"; +import { AppRootFolder, extensionContext } from "../utilities/extensionContext"; import { Logger } from "../Logger"; import { disposeAll } from "../utilities/disposables"; @@ -18,19 +18,23 @@ export class IDE implements Disposable { public readonly workspaceConfigController: WorkspaceConfigController; public readonly launchConfig: LaunchConfigController; public readonly utils: Utils; - private disposed = false; private disposables: Disposable[] = []; private attachSemaphore = 0; - constructor() { + constructor(appRootFolder: AppRootFolder) { this.deviceManager = new DeviceManager(); - this.dependencyManager = new DependencyManager(); + this.dependencyManager = new DependencyManager(appRootFolder); this.utils = new Utils(); - this.project = new Project(this.deviceManager, this.dependencyManager, this.utils); + this.project = new Project( + this.deviceManager, + this.dependencyManager, + this.utils, + appRootFolder + ); this.workspaceConfigController = new WorkspaceConfigController(); - this.launchConfig = new LaunchConfigController(); + this.launchConfig = new LaunchConfigController(appRootFolder); this.disposables.push( this.dependencyManager, @@ -72,9 +76,9 @@ export class IDE implements Disposable { } } - public static attach(): IDE { + public static attach(appRoot: AppRootFolder): IDE { if (!IDE.instance) { - IDE.instance = new IDE(); + IDE.instance = new IDE(appRoot); } const ide = IDE.instance; ide.attachSemaphore += 1; diff --git a/packages/vscode-extension/src/project/metro.ts b/packages/vscode-extension/src/project/metro.ts index 24a719757..e6f2d4038 100644 --- a/packages/vscode-extension/src/project/metro.ts +++ b/packages/vscode-extension/src/project/metro.ts @@ -5,7 +5,7 @@ import { Disposable, ExtensionMode, Uri, workspace } from "vscode"; import stripAnsi from "strip-ansi"; import { exec, ChildProcess, lineReader } from "../utilities/subprocess"; import { Logger } from "../Logger"; -import { extensionContext, getAppRootFolder } from "../utilities/extensionContext"; +import { extensionContext } from "../utilities/extensionContext"; import { shouldUseExpoCLI } from "../utilities/expoCli"; import { Devtools } from "./devtools"; import { getLaunchConfiguration } from "../utilities/launchConfiguration"; @@ -114,12 +114,13 @@ export class Metro implements Disposable { public async start( resetCache: boolean, progressListener: (newStageProgress: number) => void, - dependencies: Promise[] + dependencies: Promise[], + appRoot: string ) { if (this.startPromise) { throw new Error("metro already started"); } - this.startPromise = this.startInternal(resetCache, progressListener, dependencies); + this.startPromise = this.startInternal(resetCache, progressListener, dependencies, appRoot); this.startPromise.then(() => { // start promise is used to indicate that metro has started, however, sometimes // the metro process may exit, in which case we need to update the promise to @@ -182,9 +183,9 @@ export class Metro implements Disposable { public async startInternal( resetCache: boolean, progressListener: (newStageProgress: number) => void, - dependencies: Promise[] + dependencies: Promise[], + appRoot: string ) { - const appRootFolder = getAppRootFolder(); const launchConfiguration = getLaunchConfiguration(); await Promise.all([this.devtools.ready()].concat(dependencies)); @@ -197,7 +198,7 @@ export class Metro implements Disposable { const metroEnv = { ...launchConfiguration.env, ...(metroConfigPath ? { RN_IDE_METRO_CONFIG_PATH: metroConfigPath } : {}), - NODE_PATH: path.join(appRootFolder, "node_modules"), + NODE_PATH: path.join(appRoot, "node_modules"), RCT_METRO_PORT: "0", RCT_DEVTOOLS_PORT: this.devtools.port.toString(), RADON_IDE_LIB_PATH: libPath, @@ -206,10 +207,10 @@ export class Metro implements Disposable { }; let bundlerProcess: ChildProcess; - if (shouldUseExpoCLI()) { - bundlerProcess = this.launchExpoMetro(appRootFolder, libPath, resetCache, metroEnv); + if (shouldUseExpoCLI(appRoot)) { + bundlerProcess = this.launchExpoMetro(appRoot, libPath, resetCache, metroEnv); } else { - bundlerProcess = this.launchPackager(appRootFolder, libPath, resetCache, metroEnv); + bundlerProcess = this.launchPackager(appRoot, libPath, resetCache, metroEnv); } this.subprocess = bundlerProcess; diff --git a/packages/vscode-extension/src/project/project.ts b/packages/vscode-extension/src/project/project.ts index def5fc7a1..b67890c9c 100644 --- a/packages/vscode-extension/src/project/project.ts +++ b/packages/vscode-extension/src/project/project.ts @@ -21,7 +21,7 @@ import { import { Logger } from "../Logger"; import { DeviceInfo } from "../common/DeviceManager"; import { DeviceAlreadyUsedError, DeviceManager } from "../devices/DeviceManager"; -import { extensionContext, getAppRootFolder } from "../utilities/extensionContext"; +import { AppRootFolder, extensionContext } from "../utilities/extensionContext"; import { IosSimulatorDevice } from "../devices/IosSimulatorDevice"; import { AndroidEmulatorDevice } from "../devices/AndroidEmulatorDevice"; import { DependencyManager } from "../dependency/DependencyManager"; @@ -64,10 +64,6 @@ export class Project private isCachedBuildStale: boolean; - private fileWatcher: Disposable; - private licenseWatcher: Disposable; - private licenseUpdater: Disposable; - private deviceSession: DeviceSession | undefined; private projectState: ProjectState = { @@ -79,10 +75,13 @@ export class Project private deviceSettings: DeviceSettings; + private disposables: Disposable[] = []; + constructor( private readonly deviceManager: DeviceManager, private readonly dependencyManager: DependencyManager, - private readonly utils: UtilsInterface + private readonly utils: UtilsInterface, + private readonly appRootFolder: AppRootFolder ) { this.deviceSettings = extensionContext.workspaceState.get(DEVICE_SETTINGS_KEY) ?? { appearance: "dark", @@ -106,14 +105,23 @@ export class Project this.deviceManager.addListener("deviceRemoved", this.removeDeviceListener); this.isCachedBuildStale = false; - this.fileWatcher = watchProjectFiles(() => { - this.checkIfNativeChanged(); - }); - this.licenseUpdater = refreshTokenPeriodically(); - this.licenseWatcher = watchLicenseTokenChange(async () => { - const hasActiveLicense = await this.hasActiveLicense(); - this.eventEmitter.emit("licenseActivationChanged", hasActiveLicense); - }); + this.disposables.push( + watchProjectFiles(() => { + this.checkIfNativeChanged(); + }) + ); + this.disposables.push(refreshTokenPeriodically()); + this.disposables.push( + watchLicenseTokenChange(async () => { + const hasActiveLicense = await this.hasActiveLicense(); + this.eventEmitter.emit("licenseActivationChanged", hasActiveLicense); + }) + ); + this.disposables.push( + appRootFolder.addChangeAppRootListener(() => { + this.reload("reboot"); + }) + ); } //#region Build progress @@ -344,9 +352,9 @@ export class Project this.metro?.dispose(); this.devtools?.dispose(); this.deviceManager.removeListener("deviceRemoved", this.removeDeviceListener); - this.fileWatcher.dispose(); - this.licenseWatcher.dispose(); - this.licenseUpdater.dispose(); + this.disposables.forEach((disposable) => { + disposable.dispose(); + }); } private async reloadMetro() { @@ -485,7 +493,8 @@ export class Project throttle((stageProgress: number) => { this.reportStageProgress(stageProgress, StartupMessage.WaitingForAppToLoad); }, 100), - [waitForNodeModules] + [waitForNodeModules], + this.appRootFolder.getAppRoot() ); } //#endregion @@ -749,13 +758,13 @@ export class Project this.devtools, this.metro, this.dependencyManager, - new BuildCache(device.platform, getAppRootFolder()), + new BuildCache(device.platform, this.appRootFolder), this, this ); this.deviceSession = newDeviceSession; - const previewURL = await newDeviceSession.start(this.deviceSettings, { + const previewURL = await newDeviceSession.start(this.deviceSettings, this.appRootFolder, { cleanBuild: forceCleanBuild, previewReadyCallback: (url) => { this.updateProjectStateForDevice(deviceInfo, { previewURL: url }); diff --git a/packages/vscode-extension/src/utilities/expoCli.ts b/packages/vscode-extension/src/utilities/expoCli.ts index 7b4f2d937..ff38580e9 100644 --- a/packages/vscode-extension/src/utilities/expoCli.ts +++ b/packages/vscode-extension/src/utilities/expoCli.ts @@ -1,8 +1,7 @@ import path from "path"; -import { getAppRootFolder } from "./extensionContext"; import { getLaunchConfiguration } from "./launchConfiguration"; -export function shouldUseExpoCLI() { +export function shouldUseExpoCLI(appRoot: string) { // The mechanism for detecting whether the project should use Expo CLI or React Native Community CLI works as follows: // We check launch configuration, which has an option to force Expo CLI, we verify that first and if it is set to true we use Expo CLI. // When the Expo option isn't set, we need all of the below checks to be true in order to use Expo CLI: @@ -18,18 +17,17 @@ export function shouldUseExpoCLI() { return false; } - const appRootFolder = getAppRootFolder(); let hasExpoCLIInstalled = false, hasExpoCommandsInScripts = false; try { hasExpoCLIInstalled = require.resolve("@expo/cli/build/src/start/index", { - paths: [appRootFolder], + paths: [appRoot], }) !== undefined; } catch (e) {} try { - const packageJson = require(path.join(appRootFolder, "package.json")); + const packageJson = require(path.join(appRoot, "package.json")); hasExpoCommandsInScripts = Object.values(packageJson.scripts).some((script: string) => { return script.includes("expo "); }); diff --git a/packages/vscode-extension/src/utilities/extensionContext.ts b/packages/vscode-extension/src/utilities/extensionContext.ts index 91fb6d792..99bf25246 100644 --- a/packages/vscode-extension/src/utilities/extensionContext.ts +++ b/packages/vscode-extension/src/utilities/extensionContext.ts @@ -1,4 +1,9 @@ -import { ExtensionContext } from "vscode"; +import fs from "fs"; +import { commands, ExtensionContext, Uri, workspace, window, Disposable } from "vscode"; +import { Logger } from "../Logger"; +import { findFilesInWorkspace, isWorkspaceRoot } from "./common"; +import { getLaunchConfiguration } from "./launchConfiguration"; +import { LaunchConfigurationOptions } from "../common/LaunchConfig"; let _extensionContext: ExtensionContext | null = null; @@ -15,15 +20,192 @@ export const extensionContext = new Proxy({} as ExtensionConte }, }); -let _appRootFolder: string | null = null; +export class AppRootFolder { + private appRoot: string | null = null; + private onChangeAppRootListeners: Array<() => void> = []; + + addChangeAppRootListener(listener: () => void): Disposable { + this.onChangeAppRootListeners.push(listener); + return { + dispose: () => { + const index = this.onChangeAppRootListeners.indexOf(listener); + if (index > -1) { + this.onChangeAppRootListeners.splice(index, 1); + } + }, + }; + } + + getAppRoot(): string { + if (!this.appRoot) { + throw new Error("App root not set."); + } + return this.appRoot; + } + + setAppRoot(newAppRoot: string): void { + this.appRoot = newAppRoot; + this.onChangeAppRootListeners.forEach((listener) => { + listener(); + }); + Logger.debug(`App root was set to: ${this.appRoot}.`); + } +} + +const _appRootFolder = new AppRootFolder(); + +export const getCurrentLaunchConfig = (): LaunchConfigurationOptions => { + const launchConfiguration = workspace.getConfiguration( + "launch", + workspace.workspaceFolders![0].uri + ); + + const configurations = launchConfiguration.get>>("configurations")!; + + const RNIDEConfiguration = configurations.find( + ({ type }) => type === "react-native-ide" || type === "radon-ide" // for compatibility we want to support old configuration type name + ); + + if (!RNIDEConfiguration) { + return {}; + } + + const { android, appRoot, ios, isExpo, metroConfigPath, env } = RNIDEConfiguration; + + return { android, appRoot, ios, isExpo, metroConfigPath, env }; +}; export function setAppRootFolder(appRootFolder: string) { - _appRootFolder = appRootFolder; + _appRootFolder.setAppRoot(appRootFolder); } export function getAppRootFolder() { - if (!_appRootFolder) { - throw new Error("App root folder has not been set"); - } return _appRootFolder; } + +export async function configureAppRootFolder() { + const appRootFolder = await findAppRootFolder(); + if (appRootFolder) { + Logger.info(`Found app root folder: ${appRootFolder}`); + setAppRootFolder(appRootFolder); + commands.executeCommand("setContext", "RNIDE.extensionIsActive", true); + } + return appRootFolder; +} + +export async function findAppRootCandidates(): Promise { + const candidates: string[] = []; + + const metroConfigUris = await findFilesInWorkspace("**/metro.config.{js,ts}", "**/node_modules"); + metroConfigUris.forEach((metroConfigUri) => { + candidates.push(Uri.joinPath(metroConfigUri, "..").fsPath); + }); + + const appConfigUris = await findFilesInWorkspace("**/app.config.{js,ts}", "**/node_modules"); + appConfigUris.forEach((appConfigUri) => { + const appRootFsPath = Uri.joinPath(appConfigUri, "..").fsPath; + if (!candidates.includes(appRootFsPath)) { + candidates.push(appRootFsPath); + } + }); + + // given that if the user uses workspaces his node_modules are installed not in the root of an application, + // but in the root of the workspace we need to detect workspaces root and exclude it. + let excludePattern = null; + workspace.workspaceFolders?.forEach((folder) => { + if (isWorkspaceRoot(folder.uri.fsPath)) { + excludePattern = "node_modules/react-native/package.json"; + } + }); + + const rnPackageLocations = await findFilesInWorkspace( + "**/node_modules/react-native/package.json", + excludePattern + ); + rnPackageLocations.forEach((rnPackageLocation) => { + const appRootFsPath = Uri.joinPath(rnPackageLocation, "../../..").fsPath; + if (!candidates.includes(appRootFsPath)) { + candidates.push(appRootFsPath); + } + }); + + // app json is often used in non react-native projects, but in worst case scenario we can use it as a fallback + const appJsonUris = await findFilesInWorkspace("**/app.json", "**/node_modules"); + appJsonUris.forEach((appJsonUri) => { + const appRootFsPath = Uri.joinPath(appJsonUri, "..").fsPath; + if (!candidates.includes(appRootFsPath)) { + candidates.push(appRootFsPath); + } + }); + + return candidates; +} + +async function findAppRootFolder() { + const launchConfiguration = getLaunchConfiguration(); + const appRootFromLaunchConfig = launchConfiguration.appRoot; + if (appRootFromLaunchConfig) { + let appRoot: string | undefined; + workspace.workspaceFolders?.forEach((folder) => { + const possibleAppRoot = Uri.joinPath(folder.uri, appRootFromLaunchConfig).fsPath; + if (fs.existsSync(possibleAppRoot)) { + appRoot = possibleAppRoot; + } + }); + if (!appRoot) { + // when relative app location setting is set, we expect app root exists + const openLaunchConfigButton = "Open Launch Configuration"; + window + .showErrorMessage( + `The app root folder does not exist in the workspace at ${appRootFromLaunchConfig}.`, + openLaunchConfigButton + ) + .then((item) => { + if (item === openLaunchConfigButton) { + commands.executeCommand("workbench.action.debug.configure"); + } + }); + return undefined; + } + return appRoot; + } + + const appRootCandidates = await findAppRootCandidates(); + + if (appRootCandidates.length > 1) { + const openLaunchConfigButton = "Open Launch Configuration"; + window + .showWarningMessage( + `Multiple react-native applications were detected in the workspace. "${appRootCandidates[0]}" was automatically chosen as your application root. To change that or remove this warning in the future, you can setup a permanent appRoot in Launch Configuration.`, + openLaunchConfigButton + ) + .then((item) => { + if (item === openLaunchConfigButton) { + commands.executeCommand("workbench.action.debug.configure"); + } + }); + } + + if (appRootCandidates.length > 0) { + return appRootCandidates[0]; + } + + const manageLaunchConfigButton = "Manage Launch Configuration"; + window + .showErrorMessage( + ` + Radon IDE couldn't find root application folder in this workspace.\n + Please make sure that the opened workspace contains a valid React Native or Expo project.\n + The way extension verifies the project is by looking for either: app.json, metro.config.js, + or node_modules/react-native folder. If your project structure is different, you can set the + app root using launch configuration.`, + manageLaunchConfigButton, + "Dismiss" + ) + .then((item) => { + if (item === manageLaunchConfigButton) { + commands.executeCommand("debug.addConfiguration"); + } + }); + return undefined; +} diff --git a/packages/vscode-extension/src/utilities/packageManager.ts b/packages/vscode-extension/src/utilities/packageManager.ts index 6720cabef..8239a38a4 100644 --- a/packages/vscode-extension/src/utilities/packageManager.ts +++ b/packages/vscode-extension/src/utilities/packageManager.ts @@ -1,7 +1,6 @@ import path from "path"; import fs from "fs"; import { command } from "./subprocess"; -import { getAppRootFolder } from "./extensionContext"; import { isWorkspaceRoot } from "./common"; import { Logger } from "../Logger"; import { getLaunchConfiguration } from "./launchConfiguration"; @@ -31,9 +30,11 @@ async function listFilesSortedByModificationDate(dir: string) { const DEFAULT_PACKAGE_MANAGER = "npm"; -export async function resolvePackageManager(): Promise { - function findWorkspace(appRoot: string) { - let currentDir = appRoot; +export async function resolvePackageManager( + appRoot: string +): Promise { + function findWorkspace(appRootPath: string) { + let currentDir = appRootPath; let parentDir = path.resolve(currentDir, ".."); while (parentDir !== currentDir) { currentDir = parentDir; @@ -45,8 +46,7 @@ export async function resolvePackageManager(): Promise { return false; } -export async function isNodeModulesInstalled(manager: PackageManagerInfo): Promise { - const workspacePath = manager.workspacePath ?? getAppRootFolder(); +export async function isNodeModulesInstalled( + manager: PackageManagerInfo, + appRoot: string +): Promise { + const workspacePath = manager.workspacePath ?? appRoot; switch (manager.name) { case "npm": return await isNpmModulesInstalled(workspacePath); diff --git a/packages/vscode-extension/src/utilities/reactNative.ts b/packages/vscode-extension/src/utilities/reactNative.ts index c2c64cf80..598b2d542 100644 --- a/packages/vscode-extension/src/utilities/reactNative.ts +++ b/packages/vscode-extension/src/utilities/reactNative.ts @@ -1,8 +1,6 @@ import path from "path"; -import { getAppRootFolder } from "./extensionContext"; -export function getReactNativeVersion() { - const workspacePath = getAppRootFolder(); +export function getReactNativeVersion(workspacePath: string) { const reactNativeRoot = path.dirname(require.resolve("react-native", { paths: [workspacePath] })); const packageJsonPath = path.join(reactNativeRoot, "package.json"); const packageJson = require(packageJsonPath); diff --git a/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx b/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx index f2a5c7639..afe474c5f 100644 --- a/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx +++ b/packages/vscode-extension/src/webview/providers/LaunchConfigProvider.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { makeProxy } from "../utilities/rpc"; import { + AddCustomApplicationRoot, EasConfig, LaunchConfig, LaunchConfigUpdater, @@ -20,6 +21,8 @@ const launchConfig = makeProxy("LaunchConfig"); type LaunchConfigContextType = LaunchConfigurationOptions & { update: LaunchConfigUpdater; xcodeSchemes: string[]; + applicationRoots: string[]; + addCustomApplicationRoot: AddCustomApplicationRoot; eas?: { ios?: EasConfig; android?: EasConfig; @@ -29,11 +32,14 @@ type LaunchConfigContextType = LaunchConfigurationOptions & { const LaunchConfigContext = createContext({ update: () => {}, xcodeSchemes: [], + applicationRoots: [], + addCustomApplicationRoot: () => {}, }); export default function LaunchConfigProvider({ children }: PropsWithChildren) { const [config, setConfig] = useState({}); const [xcodeSchemes, setXcodeSchemes] = useState([]); + const [applicationRoots, setApplicationRoots] = useState([]); useEffect(() => { launchConfig.getConfig().then(setConfig); @@ -41,8 +47,15 @@ export default function LaunchConfigProvider({ children }: PropsWithChildren) { launchConfig.getAvailableXcodeSchemes().then(setXcodeSchemes); + const updateApplicationRoots = () => { + launchConfig.getAvailableApplicationRoots().then(setApplicationRoots); + }; + updateApplicationRoots(); + launchConfig.addListener("applicationRootsChanged", updateApplicationRoots); + return () => { launchConfig.removeListener("launchConfigChange", setConfig); + launchConfig.removeListener("applicationRootsChanged", updateApplicationRoots); }; }, []); @@ -58,9 +71,15 @@ export default function LaunchConfigProvider({ children }: PropsWithChildren) { [config, setConfig] ); + const addCustomApplicationRoot = (appRoot: string) => { + const newState = [...applicationRoots, appRoot]; + setApplicationRoots(newState); + launchConfig.addCustomApplicationRoot(appRoot); + }; + const contextValue = useMemo(() => { - return { ...config, update, xcodeSchemes }; - }, [config, update, xcodeSchemes]); + return { ...config, update, xcodeSchemes, applicationRoots, addCustomApplicationRoot }; + }, [config, update, xcodeSchemes, applicationRoots, addCustomApplicationRoot]); return ( {children} diff --git a/packages/vscode-extension/src/webview/views/LaunchConfigurationView.css b/packages/vscode-extension/src/webview/views/LaunchConfigurationView.css index 412d612c8..cd28f5dc9 100644 --- a/packages/vscode-extension/src/webview/views/LaunchConfigurationView.css +++ b/packages/vscode-extension/src/webview/views/LaunchConfigurationView.css @@ -8,6 +8,7 @@ font-size: 13px; line-height: 1; height: 26px; + width: 100%; gap: 5px; color: var(--swm-default-text); background-color: var(--swm-select-background); @@ -34,3 +35,38 @@ .launch-configuration-section-margin { margin-bottom: 10px; } + +.app-root-change-title { + color: var(--swm-default-text); + margin-top: 0; +} + +.app-root-change-subtitle { + color: var(--swm-secondary-text); + margin: 16px 0; +} + +.app-root-change-button-group { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + gap: 24px; + margin-top: 24px; +} + +.app-root-change-button { + width: 100px; +} + +.custom-app-root-container { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.custom-app-root-input { + margin-bottom: 0px; +} diff --git a/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx b/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx index 10d61b753..2dd20c6a3 100644 --- a/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx +++ b/packages/vscode-extension/src/webview/views/LaunchConfigurationView.tsx @@ -1,14 +1,25 @@ import "./View.css"; import "./LaunchConfigurationView.css"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import Label from "../components/shared/Label"; import { useLaunchConfig } from "../providers/LaunchConfigProvider"; -import { LaunchConfigUpdater } from "../../common/LaunchConfig"; +import { AddCustomApplicationRoot, LaunchConfigUpdater } from "../../common/LaunchConfig"; import Select from "../components/shared/Select"; +import { useModal } from "../providers/ModalProvider"; +import Button from "../components/shared/Button"; function LaunchConfigurationView() { - const { android, appRoot, ios, isExpo, metroConfigPath, update, xcodeSchemes } = - useLaunchConfig(); + const { + android, + appRoot, + ios, + isExpo, + metroConfigPath, + update, + xcodeSchemes, + applicationRoots, + addCustomApplicationRoot, + } = useLaunchConfig(); return ( <> @@ -31,7 +42,12 @@ function LaunchConfigurationView() {
- +
@@ -150,29 +166,137 @@ function AndroidConfiguration({ buildType, productFlavor, update }: androidConfi interface appRootConfigurationProps { appRoot?: string; update: LaunchConfigUpdater; + applicationRoots: string[]; + addCustomApplicationRoot: AddCustomApplicationRoot; } -function AppRootConfiguration({ appRoot, update }: appRootConfigurationProps) { - const appRootInputRef = useRef(null); +function AppRootConfiguration({ + appRoot, + update, + applicationRoots, + addCustomApplicationRoot, +}: appRootConfigurationProps) { + const customAppRootInputRef = useRef(null); + + const { openModal, closeModal } = useModal(); + + const [customAppRootButtonDisabled, setCustomAppRootButtonDisabled] = useState(true); + + const onConfirmationCancel = () => { + openModal("Launch Configuration", ); + }; - const onAppRootBlur = () => { - let newAppRoot = appRootInputRef.current?.value; - if (newAppRoot !== "") { + const AppRootChangeConfirmationView = ({ newAppRoot }: { newAppRoot: string }) => { + return ( +
+

+ Are you sure you want to change the application root? +

+

+ The new application root will be: {newAppRoot} and this action will reboot the + device. +

+
+ + +
+
+ ); + }; + + const CustomAppRootConfirmationView = ({ newAppRoot }: { newAppRoot: string }) => { + return ( +
+

+ Are you sure you want to add custom application root? +

+

+ The new application root will be: {newAppRoot} and this action will reboot the + device. +

+
+ + +
+
+ ); + }; + + const onAppRootChange = (newAppRoot: string | undefined) => { + if (newAppRoot === undefined) { newAppRoot = "Auto"; } - update("appRoot", newAppRoot); + openModal("", ); + }; + + const onCustomAppRootChange = () => { + setCustomAppRootButtonDisabled(!customAppRootInputRef.current?.value); + }; + + const onAddNewAppRoot = () => { + let newAppRoot = customAppRootInputRef.current?.value ?? ""; + openModal("", ); }; + const availableAppRoots = applicationRoots.map((applicationRoot) => { + return { value: applicationRoot, label: applicationRoot }; + }); + + availableAppRoots.push({ value: "Auto", label: "Auto" }); + return (
-
App Root:
- AppRoot:
+ + +
); }