Skip to content

Commit 6f7fd66

Browse files
committed
improve url experience - get routes based on file based routing
2nd point software-mansion#887 - Adds initial routes based on the file based routing that allows to use this integration immediately without the need to visit them at first.
1 parent 3b10fe2 commit 6f7fd66

File tree

8 files changed

+164
-21
lines changed

8 files changed

+164
-21
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

+42-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();
113+
}
114+
});
109115

110116
this.fileWatcher = watchProjectFiles(() => {
111117
this.checkIfNativeChanged();
@@ -138,6 +144,9 @@ export class Project
138144
case "navigationChanged":
139145
this.eventEmitter.emit("navigationChanged", payload);
140146
break;
147+
case "navigationInit":
148+
this.eventEmitter.emit("navigationInit", payload);
149+
break;
141150
case "fastRefreshStarted":
142151
this.updateProjectState({ status: "refreshing" });
143152
break;
@@ -267,6 +276,15 @@ export class Project
267276
await this.utils.showToast("Copied from device clipboard", 2000);
268277
}
269278

279+
private async initializeFileBasedRoutes() {
280+
const routes = await getAppRoutes();
281+
this.devtools.addListener((name) => {
282+
if (name === "RNIDE_appReady") {
283+
this.devtools.send("RNIDE_loadFileBasedRoutes", routes);
284+
}
285+
});
286+
}
287+
270288
onBundleError(): void {
271289
this.updateProjectState({ status: "bundleError" });
272290
}
@@ -450,28 +468,34 @@ export class Project
450468
}
451469

452470
public async reload(type: ReloadAction): Promise<boolean> {
453-
this.updateProjectState({ status: "starting", startupMessage: StartupMessage.Restarting });
471+
try {
472+
this.updateProjectState({ status: "starting", startupMessage: StartupMessage.Restarting });
454473

455-
getTelemetryReporter().sendTelemetryEvent("url-bar:reload-requested", {
456-
platform: this.projectState.selectedDevice?.platform,
457-
method: type,
458-
});
474+
getTelemetryReporter().sendTelemetryEvent("url-bar:reload-requested", {
475+
platform: this.projectState.selectedDevice?.platform,
476+
method: type,
477+
});
459478

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-
}
479+
// this action needs to be handled outside of device session as it resets the device session itself
480+
if (type === "reboot") {
481+
const deviceInfo = this.projectState.selectedDevice!;
482+
await this.start(true, false);
483+
await this.selectDevice(deviceInfo);
484+
return true;
485+
}
467486

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");
487+
const success = (await this.deviceSession?.perform(type)) ?? false;
488+
if (success) {
489+
this.updateProjectState({ status: "running" });
490+
} else {
491+
window.showErrorMessage("Failed to reload, you may try another reload option.", "Dismiss");
492+
}
493+
return success;
494+
} finally {
495+
if (await this.dependencyManager.checkProjectUsesExpoRouter()) {
496+
await this.initializeFileBasedRoutes();
497+
}
473498
}
474-
return success;
475499
}
476500

477501
private async start(restart: boolean, resetMetroCache: boolean) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
// todo: think about it, perahps we can display `[param]` as a route.
64+
// but that option does not seem to bee much useful. I simply
65+
// skip those for now. Idally we'd allow typing paths similarly to
66+
// how we do it in the browser.
67+
// routes.push(handleParameterizedRoute(basePath, route));
68+
continue;
69+
} else {
70+
routes.push(handleRegularRoute(basePath, route));
71+
}
72+
}
73+
}
74+
} catch (error) {
75+
console.error(`Error reading directory ${dir}:`, error);
76+
}
77+
return routes;
78+
}
79+
80+
export async function getAppRoutes() {
81+
const appRoot = await findAppRootFolder();
82+
if (!appRoot) {
83+
return [];
84+
}
85+
return getRoutes(path.join(appRoot, "app"));
86+
}

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

+15-1
Original file line numberDiff line numberDiff line change
@@ -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)