From c146bd3f4286f24c6f1a62c345b1a57efce4a85f Mon Sep 17 00:00:00 2001 From: Kewin Szlezingier Date: Wed, 19 Feb 2025 17:22:55 +0100 Subject: [PATCH] improve url experience - get routes based on file based routing 2nd point #887 --- packages/vscode-extension/lib/wrapper.js | 14 ++++ .../vscode-extension/src/common/Project.ts | 1 + packages/vscode-extension/src/extension.ts | 2 +- .../src/project/deviceSession.ts | 4 + .../vscode-extension/src/project/project.ts | 69 +++++++++++---- .../src/utilities/getFileBasedRoutes.ts | 84 +++++++++++++++++++ .../src/webview/components/UrlBar.tsx | 18 +++- .../src/webview/components/UrlSelect.tsx | 2 +- 8 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 packages/vscode-extension/src/utilities/getFileBasedRoutes.ts diff --git a/packages/vscode-extension/lib/wrapper.js b/packages/vscode-extension/lib/wrapper.js index dde960c86..b3ce94e43 100644 --- a/packages/vscode-extension/lib/wrapper.js +++ b/packages/vscode-extension/lib/wrapper.js @@ -360,6 +360,20 @@ export function AppWrapper({ children, initialProps, fabric }) { [] ); + useAgentListener(devtoolsAgent, "RNIDE_loadFileBasedRoutes", (payload) => { + // todo: maybe rename it to `navigationState` or something like that because this is not just history anymore. + for (const route of payload) { + navigationHistory.set(route.id, route); + } + devtoolsAgent?._bridge.send( + "RNIDE_navigationInit", + payload.map((route) => ({ + displayName: route.name, + id: route.id, + })) + ); + }); + useEffect(() => { if (devtoolsAgent) { LogBox.uninstall(); diff --git a/packages/vscode-extension/src/common/Project.ts b/packages/vscode-extension/src/common/Project.ts index 8df381ccd..8119126ec 100644 --- a/packages/vscode-extension/src/common/Project.ts +++ b/packages/vscode-extension/src/common/Project.ts @@ -125,6 +125,7 @@ export interface ProjectEventMap { needsNativeRebuild: void; replayDataCreated: MultimediaData; isRecording: boolean; + navigationInit: { displayName: string; id: string }[]; } export interface ProjectEventListener { diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 80e28aaff..56ac258be 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -352,7 +352,7 @@ async function findAppRootCandidates(): Promise { return candidates; } -async function findAppRootFolder() { +export async function findAppRootFolder() { const launchConfiguration = getLaunchConfiguration(); const appRootFromLaunchConfig = launchConfiguration.appRoot; if (appRootFromLaunchConfig) { diff --git a/packages/vscode-extension/src/project/deviceSession.ts b/packages/vscode-extension/src/project/deviceSession.ts index 92de715a2..69ed08c4a 100644 --- a/packages/vscode-extension/src/project/deviceSession.ts +++ b/packages/vscode-extension/src/project/deviceSession.ts @@ -26,6 +26,7 @@ export type AppEvent = { navigationChanged: { displayName: string; id: string }; fastRefreshStarted: undefined; fastRefreshComplete: undefined; + navigationInit: { displayName: string; id: string }[]; }; export type EventDelegate = { @@ -79,6 +80,9 @@ export class DeviceSession implements Disposable { case "RNIDE_navigationChanged": this.eventDelegate.onAppEvent("navigationChanged", payload); break; + case "RNIDE_navigationInit": + this.eventDelegate.onAppEvent("navigationInit", payload); + break; case "RNIDE_fastRefreshStarted": this.eventDelegate.onAppEvent("fastRefreshStarted", undefined); break; diff --git a/packages/vscode-extension/src/project/project.ts b/packages/vscode-extension/src/project/project.ts index 9449cea37..453381a96 100644 --- a/packages/vscode-extension/src/project/project.ts +++ b/packages/vscode-extension/src/project/project.ts @@ -41,6 +41,7 @@ import { import { getTelemetryReporter } from "../utilities/telemetry"; import { ToolKey, ToolsManager } from "./tools"; import { UtilsInterface } from "../common/utils"; +import { getAppRoutes } from "../utilities/getFileBasedRoutes"; const DEVICE_SETTINGS_KEY = "device_settings_v4"; @@ -106,6 +107,11 @@ export class Project this.trySelectingInitialDevice(); this.deviceManager.addListener("deviceRemoved", this.removeDeviceListener); this.isCachedBuildStale = false; + this.dependencyManager.checkProjectUsesExpoRouter().then((result) => { + if (result) { + this.initializeFileBasedRoutes("onAppReady"); + } + }); this.fileWatcher = watchProjectFiles(() => { this.checkIfNativeChanged(); @@ -129,6 +135,11 @@ export class Project onStateChange = (state: StartupMessage): void => { this.updateProjectStateForDevice(this.projectState.selectedDevice!, { startupMessage: state }); + this.dependencyManager.checkProjectUsesExpoRouter().then((result) => { + if (result) { + this.initializeFileBasedRoutes("now"); + } + }); }; //#endregion @@ -138,6 +149,9 @@ export class Project case "navigationChanged": this.eventEmitter.emit("navigationChanged", payload); break; + case "navigationInit": + this.eventEmitter.emit("navigationInit", payload); + break; case "fastRefreshStarted": this.updateProjectState({ status: "refreshing" }); break; @@ -267,6 +281,19 @@ export class Project await this.utils.showToast("Copied from device clipboard", 2000); } + private async initializeFileBasedRoutes(type: "onAppReady" | "now") { + const routes = await getAppRoutes(); + if (type === "onAppReady") { + this.devtools.addListener((name) => { + if (name === "RNIDE_appReady") { + this.devtools.send("RNIDE_loadFileBasedRoutes", routes); + } + }); + } else { + this.devtools.send("RNIDE_loadFileBasedRoutes", routes); + } + } + onBundleError(): void { this.updateProjectState({ status: "bundleError" }); } @@ -450,28 +477,34 @@ export class Project } public async reload(type: ReloadAction): Promise { - this.updateProjectState({ status: "starting", startupMessage: StartupMessage.Restarting }); + try { + this.updateProjectState({ status: "starting", startupMessage: StartupMessage.Restarting }); - getTelemetryReporter().sendTelemetryEvent("url-bar:reload-requested", { - platform: this.projectState.selectedDevice?.platform, - method: type, - }); + getTelemetryReporter().sendTelemetryEvent("url-bar:reload-requested", { + platform: this.projectState.selectedDevice?.platform, + method: type, + }); - // this action needs to be handled outside of device session as it resets the device session itself - if (type === "reboot") { - const deviceInfo = this.projectState.selectedDevice!; - await this.start(true, false); - await this.selectDevice(deviceInfo); - return true; - } + // this action needs to be handled outside of device session as it resets the device session itself + if (type === "reboot") { + const deviceInfo = this.projectState.selectedDevice!; + await this.start(true, false); + await this.selectDevice(deviceInfo); + return true; + } - const success = (await this.deviceSession?.perform(type)) ?? false; - if (success) { - this.updateProjectState({ status: "running" }); - } else { - window.showErrorMessage("Failed to reload, you may try another reload option.", "Dismiss"); + const success = (await this.deviceSession?.perform(type)) ?? false; + if (success) { + this.updateProjectState({ status: "running" }); + } else { + window.showErrorMessage("Failed to reload, you may try another reload option.", "Dismiss"); + } + return success; + } finally { + if (await this.dependencyManager.checkProjectUsesExpoRouter()) { + await this.initializeFileBasedRoutes("onAppReady"); + } } - return success; } private async start(restart: boolean, resetMetroCache: boolean) { diff --git a/packages/vscode-extension/src/utilities/getFileBasedRoutes.ts b/packages/vscode-extension/src/utilities/getFileBasedRoutes.ts new file mode 100644 index 000000000..5f353ebad --- /dev/null +++ b/packages/vscode-extension/src/utilities/getFileBasedRoutes.ts @@ -0,0 +1,84 @@ +import path from "path"; +import fs from "fs"; +import { findAppRootFolder } from "../extension"; + +// assuming that people may put them in the app folder +const DIRS_TO_SKIP = ["components", "(components)", "utils", "hooks"]; + +function computeRouteIdentifier(pathname: string, params = {}) { + return pathname + JSON.stringify(params); +} + +export type Route = { + name: string; + pathname: string; + params: Record; + id: string; +}; + +function createRoute(pathname: string): Route { + pathname = pathname.replace(/\/?\([^)]*\)/g, ""); + return { + id: computeRouteIdentifier(pathname), + pathname, + name: pathname, + params: {}, + }; +} + +function handleIndexRoute(basePath: string): Route { + const pathname = basePath || "/"; + return createRoute(pathname); +} + +function handleParameterizedRoute(basePath: string, route: string): Route { + const pathname = `${basePath}/${route}`; + return createRoute(pathname); +} + +function handleRegularRoute(basePath: string, route: string): Route { + const pathname = `${basePath}/${route}`; + return createRoute(pathname); +} + +async function getRoutes(dir: string, basePath: string = ""): Promise { + let routes: Route[] = []; + try { + const files = await fs.promises.readdir(dir); + + for (const file of files) { + const fullPath = path.join(dir, file); + const stat = await fs.promises.stat(fullPath); + + if (stat.isDirectory()) { + if (DIRS_TO_SKIP.includes(file)) { + continue; + } + routes = routes.concat(await getRoutes(fullPath, `${basePath}/${file}`)); + } else if ( + file.endsWith(".js") || + (file.endsWith(".tsx") && !file.includes("_layout") && !file.includes("[")) // omit [] dynamic routes + ) { + const route = file.replace(/(\.js|\.tsx)$/, ""); + if (route === "index") { + routes.push(handleIndexRoute(basePath)); + } else if (route.startsWith("[") && route.endsWith("]")) { + routes.push(handleParameterizedRoute(basePath, route)); + } else { + routes.push(handleRegularRoute(basePath, route)); + } + } + } + } catch (error) { + console.error(`Error reading directory ${dir}:`, error); + } + return routes; +} + +export async function getAppRoutes() { + const appRoot = await findAppRootFolder(); + if (!appRoot) { + return []; + } + return getRoutes(path.join(appRoot, "app")); +} diff --git a/packages/vscode-extension/src/webview/components/UrlBar.tsx b/packages/vscode-extension/src/webview/components/UrlBar.tsx index 6b313f417..cdf389638 100644 --- a/packages/vscode-extension/src/webview/components/UrlBar.tsx +++ b/packages/vscode-extension/src/webview/components/UrlBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useRef } from "react"; import { useProject } from "../providers/ProjectProvider"; import UrlSelect, { UrlItem } from "./UrlSelect"; import { IconButtonWithOptions } from "./IconButtonWithOptions"; @@ -39,13 +39,25 @@ function UrlBar({ disabled }: { disabled?: boolean }) { const [urlList, setUrlList] = useState([]); const [recentUrlList, setRecentUrlList] = useState([]); const [urlHistory, setUrlHistory] = useState([]); - const [urlSelectValue, setUrlSelectValue] = useState(urlList[0]?.id); + const [urlSelectValue, setUrlSelectValue] = useState(urlList[0]?.id ?? "/{}"); useEffect(() => { function moveAsMostRecent(urls: UrlItem[], newUrl: UrlItem) { return [newUrl, ...urls.filter((record) => record.id !== newUrl.id)]; } + function handleNavigationInit(navigationData: { displayName: string; id: string }[]) { + const entries: Record = {}; + urlList.forEach((item) => { + entries[item.id] = item; + }); + navigationData.forEach((item) => { + entries[item.id] = { ...item, name: item.displayName }; + }); + const merged = Object.values(entries); + setUrlList(merged); + } + function handleNavigationChanged(navigationData: { displayName: string; id: string }) { if (backNavigationPath && backNavigationPath !== navigationData.id) { return; @@ -72,8 +84,10 @@ function UrlBar({ disabled }: { disabled?: boolean }) { setBackNavigationPath(""); } + project.addListener("navigationInit", handleNavigationInit); project.addListener("navigationChanged", handleNavigationChanged); return () => { + project.removeListener("navigationInit", handleNavigationInit); project.removeListener("navigationChanged", handleNavigationChanged); }; }, [recentUrlList, urlHistory, backNavigationPath]); diff --git a/packages/vscode-extension/src/webview/components/UrlSelect.tsx b/packages/vscode-extension/src/webview/components/UrlSelect.tsx index 342903d9d..f766bea15 100644 --- a/packages/vscode-extension/src/webview/components/UrlSelect.tsx +++ b/packages/vscode-extension/src/webview/components/UrlSelect.tsx @@ -58,7 +58,7 @@ function UrlSelect({ onValueChange, recentItems, items, value, disabled }: UrlSe - All visited paths: + All paths: {items.map( (item) => item.name && (