Skip to content

Commit 9210285

Browse files
committed
improve url experience - get routes based on file based routing
2nd point software-mansion#887
1 parent 3b10fe2 commit 9210285

File tree

8 files changed

+169
-22
lines changed

8 files changed

+169
-22
lines changed

packages/vscode-extension/lib/wrapper.js

+14
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,20 @@ export function AppWrapper({ children, initialProps, fabric }) {
360360
[]
361361
);
362362

363+
useAgentListener(devtoolsAgent, "RNIDE_loadFileBasedRoutes", (payload) => {
364+
// todo: maybe rename it to `navigationState` or something like that because this is not just history anymore.
365+
for (const route of payload) {
366+
navigationHistory.set(route.id, route);
367+
}
368+
devtoolsAgent?._bridge.send(
369+
"RNIDE_navigationInit",
370+
payload.map((route) => ({
371+
displayName: route.name,
372+
id: route.id,
373+
}))
374+
);
375+
});
376+
363377
useEffect(() => {
364378
if (devtoolsAgent) {
365379
LogBox.uninstall();

packages/vscode-extension/src/common/Project.ts

+1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export interface ProjectEventMap {
125125
needsNativeRebuild: void;
126126
replayDataCreated: MultimediaData;
127127
isRecording: boolean;
128+
navigationInit: { displayName: string; id: string }[];
128129
}
129130

130131
export interface ProjectEventListener<T> {

packages/vscode-extension/src/extension.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ async function findAppRootCandidates(): Promise<string[]> {
352352
return candidates;
353353
}
354354

355-
async function findAppRootFolder() {
355+
export async function findAppRootFolder() {
356356
const launchConfiguration = getLaunchConfiguration();
357357
const appRootFromLaunchConfig = launchConfiguration.appRoot;
358358
if (appRootFromLaunchConfig) {

packages/vscode-extension/src/project/deviceSession.ts

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type AppEvent = {
2626
navigationChanged: { displayName: string; id: string };
2727
fastRefreshStarted: undefined;
2828
fastRefreshComplete: undefined;
29+
navigationInit: { displayName: string; id: string }[];
2930
};
3031

3132
export type EventDelegate = {
@@ -79,6 +80,9 @@ export class DeviceSession implements Disposable {
7980
case "RNIDE_navigationChanged":
8081
this.eventDelegate.onAppEvent("navigationChanged", payload);
8182
break;
83+
case "RNIDE_navigationInit":
84+
this.eventDelegate.onAppEvent("navigationInit", payload);
85+
break;
8286
case "RNIDE_fastRefreshStarted":
8387
this.eventDelegate.onAppEvent("fastRefreshStarted", undefined);
8488
break;

packages/vscode-extension/src/project/project.ts

+51-18
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import { getTelemetryReporter } from "../utilities/telemetry";
4242
import { ToolKey, ToolsManager } from "./tools";
4343
import { UtilsInterface } from "../common/utils";
44+
import { getAppRoutes } from "../utilities/getFileBasedRoutes";
4445

4546
const DEVICE_SETTINGS_KEY = "device_settings_v4";
4647

@@ -106,6 +107,11 @@ export class Project
106107
this.trySelectingInitialDevice();
107108
this.deviceManager.addListener("deviceRemoved", this.removeDeviceListener);
108109
this.isCachedBuildStale = false;
110+
this.dependencyManager.checkProjectUsesExpoRouter().then((result) => {
111+
if (result) {
112+
this.initializeFileBasedRoutes("onAppReady");
113+
}
114+
});
109115

110116
this.fileWatcher = watchProjectFiles(() => {
111117
this.checkIfNativeChanged();
@@ -129,6 +135,11 @@ export class Project
129135

130136
onStateChange = (state: StartupMessage): void => {
131137
this.updateProjectStateForDevice(this.projectState.selectedDevice!, { startupMessage: state });
138+
this.dependencyManager.checkProjectUsesExpoRouter().then((result) => {
139+
if (result) {
140+
this.initializeFileBasedRoutes("now");
141+
}
142+
});
132143
};
133144
//#endregion
134145

@@ -138,6 +149,9 @@ export class Project
138149
case "navigationChanged":
139150
this.eventEmitter.emit("navigationChanged", payload);
140151
break;
152+
case "navigationInit":
153+
this.eventEmitter.emit("navigationInit", payload);
154+
break;
141155
case "fastRefreshStarted":
142156
this.updateProjectState({ status: "refreshing" });
143157
break;
@@ -267,6 +281,19 @@ export class Project
267281
await this.utils.showToast("Copied from device clipboard", 2000);
268282
}
269283

284+
private async initializeFileBasedRoutes(type: "onAppReady" | "now") {
285+
const routes = await getAppRoutes();
286+
if (type === "onAppReady") {
287+
this.devtools.addListener((name) => {
288+
if (name === "RNIDE_appReady") {
289+
this.devtools.send("RNIDE_loadFileBasedRoutes", routes);
290+
}
291+
});
292+
} else {
293+
this.devtools.send("RNIDE_loadFileBasedRoutes", routes);
294+
}
295+
}
296+
270297
onBundleError(): void {
271298
this.updateProjectState({ status: "bundleError" });
272299
}
@@ -450,28 +477,34 @@ export class Project
450477
}
451478

452479
public async reload(type: ReloadAction): Promise<boolean> {
453-
this.updateProjectState({ status: "starting", startupMessage: StartupMessage.Restarting });
480+
try {
481+
this.updateProjectState({ status: "starting", startupMessage: StartupMessage.Restarting });
454482

455-
getTelemetryReporter().sendTelemetryEvent("url-bar:reload-requested", {
456-
platform: this.projectState.selectedDevice?.platform,
457-
method: type,
458-
});
483+
getTelemetryReporter().sendTelemetryEvent("url-bar:reload-requested", {
484+
platform: this.projectState.selectedDevice?.platform,
485+
method: type,
486+
});
459487

460-
// this action needs to be handled outside of device session as it resets the device session itself
461-
if (type === "reboot") {
462-
const deviceInfo = this.projectState.selectedDevice!;
463-
await this.start(true, false);
464-
await this.selectDevice(deviceInfo);
465-
return true;
466-
}
488+
// this action needs to be handled outside of device session as it resets the device session itself
489+
if (type === "reboot") {
490+
const deviceInfo = this.projectState.selectedDevice!;
491+
await this.start(true, false);
492+
await this.selectDevice(deviceInfo);
493+
return true;
494+
}
467495

468-
const success = (await this.deviceSession?.perform(type)) ?? false;
469-
if (success) {
470-
this.updateProjectState({ status: "running" });
471-
} else {
472-
window.showErrorMessage("Failed to reload, you may try another reload option.", "Dismiss");
496+
const success = (await this.deviceSession?.perform(type)) ?? false;
497+
if (success) {
498+
this.updateProjectState({ status: "running" });
499+
} else {
500+
window.showErrorMessage("Failed to reload, you may try another reload option.", "Dismiss");
501+
}
502+
return success;
503+
} finally {
504+
if (await this.dependencyManager.checkProjectUsesExpoRouter()) {
505+
await this.initializeFileBasedRoutes("onAppReady");
506+
}
473507
}
474-
return success;
475508
}
476509

477510
private async start(restart: boolean, resetMetroCache: boolean) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import path from "path";
2+
import fs from "fs";
3+
import { findAppRootFolder } from "../extension";
4+
5+
// assuming that people may put them in the app folder
6+
const DIRS_TO_SKIP = ["components", "(components)", "utils", "hooks"];
7+
8+
function computeRouteIdentifier(pathname: string, params = {}) {
9+
return pathname + JSON.stringify(params);
10+
}
11+
12+
export type Route = {
13+
name: string;
14+
pathname: string;
15+
params: Record<string, any>;
16+
id: string;
17+
};
18+
19+
function createRoute(pathname: string): Route {
20+
pathname = pathname.replace(/\/?\([^)]*\)/g, "");
21+
return {
22+
id: computeRouteIdentifier(pathname),
23+
pathname,
24+
name: pathname,
25+
params: {},
26+
};
27+
}
28+
29+
function handleIndexRoute(basePath: string): Route {
30+
const pathname = basePath || "/";
31+
return createRoute(pathname);
32+
}
33+
34+
function handleParameterizedRoute(basePath: string, route: string): Route {
35+
const pathname = `${basePath}/${route}`;
36+
return createRoute(pathname);
37+
}
38+
39+
function handleRegularRoute(basePath: string, route: string): Route {
40+
const pathname = `${basePath}/${route}`;
41+
return createRoute(pathname);
42+
}
43+
44+
async function getRoutes(dir: string, basePath: string = ""): Promise<Route[]> {
45+
let routes: Route[] = [];
46+
try {
47+
const files = await fs.promises.readdir(dir);
48+
49+
for (const file of files) {
50+
const fullPath = path.join(dir, file);
51+
const stat = await fs.promises.stat(fullPath);
52+
53+
if (stat.isDirectory()) {
54+
if (DIRS_TO_SKIP.includes(file)) {
55+
continue;
56+
}
57+
routes = routes.concat(await getRoutes(fullPath, `${basePath}/${file}`));
58+
} else if (file.endsWith(".js") || (file.endsWith(".tsx") && !file.includes("_layout"))) {
59+
const route = file.replace(/(\.js|\.tsx)$/, "");
60+
if (route === "index") {
61+
routes.push(handleIndexRoute(basePath));
62+
} else if (route.startsWith("[") && route.endsWith("]")) {
63+
routes.push(handleParameterizedRoute(basePath, route));
64+
} else {
65+
routes.push(handleRegularRoute(basePath, route));
66+
}
67+
}
68+
}
69+
} catch (error) {
70+
console.error(`Error reading directory ${dir}:`, error);
71+
}
72+
return routes;
73+
}
74+
75+
export async function getAppRoutes() {
76+
const appRoot = await findAppRootFolder();
77+
if (!appRoot) {
78+
return [];
79+
}
80+
return getRoutes(path.join(appRoot, "app"));
81+
}

packages/vscode-extension/src/webview/components/UrlBar.tsx

+16-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState, useMemo } from "react";
1+
import { useEffect, useState, useMemo, useRef } from "react";
22
import { useProject } from "../providers/ProjectProvider";
33
import UrlSelect, { UrlItem } from "./UrlSelect";
44
import { IconButtonWithOptions } from "./IconButtonWithOptions";
@@ -39,13 +39,25 @@ function UrlBar({ disabled }: { disabled?: boolean }) {
3939
const [urlList, setUrlList] = useState<UrlItem[]>([]);
4040
const [recentUrlList, setRecentUrlList] = useState<UrlItem[]>([]);
4141
const [urlHistory, setUrlHistory] = useState<string[]>([]);
42-
const [urlSelectValue, setUrlSelectValue] = useState<string>(urlList[0]?.id);
42+
const [urlSelectValue, setUrlSelectValue] = useState<string>(urlList[0]?.id ?? "/{}");
4343

4444
useEffect(() => {
4545
function moveAsMostRecent(urls: UrlItem[], newUrl: UrlItem) {
4646
return [newUrl, ...urls.filter((record) => record.id !== newUrl.id)];
4747
}
4848

49+
function handleNavigationInit(navigationData: { displayName: string; id: string }[]) {
50+
const entries: Record<string, UrlItem> = {};
51+
urlList.forEach((item) => {
52+
entries[item.id] = item;
53+
});
54+
navigationData.forEach((item) => {
55+
entries[item.id] = { ...item, name: item.displayName };
56+
});
57+
const merged = Object.values(entries);
58+
setUrlList(merged);
59+
}
60+
4961
function handleNavigationChanged(navigationData: { displayName: string; id: string }) {
5062
if (backNavigationPath && backNavigationPath !== navigationData.id) {
5163
return;
@@ -72,8 +84,10 @@ function UrlBar({ disabled }: { disabled?: boolean }) {
7284
setBackNavigationPath("");
7385
}
7486

87+
project.addListener("navigationInit", handleNavigationInit);
7588
project.addListener("navigationChanged", handleNavigationChanged);
7689
return () => {
90+
project.removeListener("navigationInit", handleNavigationInit);
7791
project.removeListener("navigationChanged", handleNavigationChanged);
7892
};
7993
}, [recentUrlList, urlHistory, backNavigationPath]);

packages/vscode-extension/src/webview/components/UrlSelect.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function UrlSelect({ onValueChange, recentItems, items, value, disabled }: UrlSe
5858
</Select.Group>
5959
<Select.Separator className="url-select-separator" />
6060
<Select.Group>
61-
<Select.Label className="url-select-label">All visited paths:</Select.Label>
61+
<Select.Label className="url-select-label">All paths:</Select.Label>
6262
{items.map(
6363
(item) =>
6464
item.name && (

0 commit comments

Comments
 (0)