Skip to content

Commit

Permalink
improve url experience - get routes based on file based routing
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
kewinzaq1 committed Feb 20, 2025
1 parent 3b10fe2 commit e019b86
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 21 deletions.
14 changes: 14 additions & 0 deletions packages/vscode-extension/lib/wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/vscode-extension/src/common/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export interface ProjectEventMap {
needsNativeRebuild: void;
replayDataCreated: MultimediaData;
isRecording: boolean;
navigationInit: { displayName: string; id: string }[];
}

export interface ProjectEventListener<T> {
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ async function findAppRootCandidates(): Promise<string[]> {
return candidates;
}

async function findAppRootFolder() {
export async function findAppRootFolder() {
const launchConfiguration = getLaunchConfiguration();
const appRootFromLaunchConfig = launchConfiguration.appRoot;
if (appRootFromLaunchConfig) {
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode-extension/src/project/deviceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type AppEvent = {
navigationChanged: { displayName: string; id: string };
fastRefreshStarted: undefined;
fastRefreshComplete: undefined;
navigationInit: { displayName: string; id: string }[];
};

export type EventDelegate = {
Expand Down Expand Up @@ -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;
Expand Down
60 changes: 42 additions & 18 deletions packages/vscode-extension/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
}
});

this.fileWatcher = watchProjectFiles(() => {
this.checkIfNativeChanged();
Expand Down Expand Up @@ -138,6 +144,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;
Expand Down Expand Up @@ -267,6 +276,15 @@ export class Project
await this.utils.showToast("Copied from device clipboard", 2000);
}

private async initializeFileBasedRoutes() {
const routes = await getAppRoutes();
this.devtools.addListener((name) => {
if (name === "RNIDE_appReady") {
this.devtools.send("RNIDE_loadFileBasedRoutes", routes);
}
});
}

onBundleError(): void {
this.updateProjectState({ status: "bundleError" });
}
Expand Down Expand Up @@ -450,28 +468,34 @@ export class Project
}

public async reload(type: ReloadAction): Promise<boolean> {
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();
}
}
return success;
}

private async start(restart: boolean, resetMetroCache: boolean) {
Expand Down
86 changes: 86 additions & 0 deletions packages/vscode-extension/src/utilities/getFileBasedRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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<string, any>;
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<Route[]> {
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")) {
const route = file.replace(/(\.js|\.tsx)$/, "");
if (route === "index") {
routes.push(handleIndexRoute(basePath));
} else if (route.startsWith("[") && route.endsWith("]")) {
// todo: think about it, perahps we can display `[param]` as a route.
// but that option does not seem to bee much useful. I simply
// skip those for now. Idally we'd allow typing paths similarly to
// how we do it in the browser.
// routes.push(handleParameterizedRoute(basePath, route));
continue;
} 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"));
}
16 changes: 15 additions & 1 deletion packages/vscode-extension/src/webview/components/UrlBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,25 @@ function UrlBar({ disabled }: { disabled?: boolean }) {
const [urlList, setUrlList] = useState<UrlItem[]>([]);
const [recentUrlList, setRecentUrlList] = useState<UrlItem[]>([]);
const [urlHistory, setUrlHistory] = useState<string[]>([]);
const [urlSelectValue, setUrlSelectValue] = useState<string>(urlList[0]?.id);
const [urlSelectValue, setUrlSelectValue] = useState<string>(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<string, UrlItem> = {};
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;
Expand All @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function UrlSelect({ onValueChange, recentItems, items, value, disabled }: UrlSe
</Select.Group>
<Select.Separator className="url-select-separator" />
<Select.Group>
<Select.Label className="url-select-label">All visited paths:</Select.Label>
<Select.Label className="url-select-label">All paths:</Select.Label>
{items.map(
(item) =>
item.name && (
Expand Down

0 comments on commit e019b86

Please sign in to comment.