Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve url experience - get routes based on file based routing #978

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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