diff --git a/bun.lock b/bun.lock index 441ac53a35..7b0fed123a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mux", @@ -63,6 +62,7 @@ "posthog-node": "^5.17.0", "quickjs-emscripten": "^0.31.0", "quickjs-emscripten-core": "^0.31.0", + "react-router-dom": "^7.11.0", "rehype-harden": "^1.1.5", "rehype-sanitize": "^6.0.0", "shescape": "^2.1.6", @@ -3136,6 +3136,10 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-router": ["react-router@7.11.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="], + + "react-router-dom": ["react-router-dom@7.11.0", "", { "dependencies": { "react-router": "7.11.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], @@ -3276,6 +3280,8 @@ "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], diff --git a/package.json b/package.json index 305c41303d..842cdbafb3 100644 --- a/package.json +++ b/package.json @@ -102,8 +102,8 @@ "parse-duration": "^2.1.4", "posthog-node": "^5.17.0", "quickjs-emscripten": "^0.31.0", - "typescript": "^5.1.3", "quickjs-emscripten-core": "^0.31.0", + "react-router-dom": "^7.11.0", "rehype-harden": "^1.1.5", "rehype-sanitize": "^6.0.0", "shescape": "^2.1.6", @@ -111,6 +111,7 @@ "streamdown": "1.6.10", "trpc-cli": "^0.12.1", "turndown": "^7.2.2", + "typescript": "^5.1.3", "undici": "^7.16.0", "write-file-atomic": "^6.0.0", "ws": "^8.18.3", diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 3267245f95..fa40f402d6 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -57,10 +57,6 @@ import { getWorkspaceSidebarKey } from "./utils/workspace"; const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh"]; -function isStorybookIframe(): boolean { - return typeof window !== "undefined" && window.location.pathname.endsWith("iframe.html"); -} - function AppInner() { // Get workspace state from context const { @@ -139,21 +135,10 @@ function AppInner() { // Auto-resume interrupted streams on app startup and when failures occur useResumeManager(); - // Sync selectedWorkspace with URL hash + // Update window title based on selected workspace + // URL syncing is now handled by RouterContext useEffect(() => { - // Storybook's test runner treats hash updates as navigations and will retry play tests. - // The hash deep-linking isn't needed in Storybook, so skip it there. - const shouldSyncHash = !isStorybookIframe(); - if (selectedWorkspace) { - // Update URL with workspace ID - if (shouldSyncHash) { - const newHash = `#workspace=${encodeURIComponent(selectedWorkspace.workspaceId)}`; - if (window.location.hash !== newHash) { - window.history.replaceState(null, "", newHash); - } - } - // Update window title with workspace title (or name for legacy workspaces) const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId); const workspaceTitle = metadata?.title ?? metadata?.name ?? selectedWorkspace.workspaceId; @@ -162,29 +147,26 @@ function AppInner() { document.title = title; void api?.window.setTitle({ title }); } else { - // Clear hash when no workspace selected - if (shouldSyncHash && window.location.hash) { - window.history.replaceState(null, "", window.location.pathname); - } // Set document.title locally for browser mode, call backend for Electron document.title = "mux"; void api?.window.setTitle({ title: "mux" }); } }, [selectedWorkspace, workspaceMetadata, api]); + // Validate selected workspace exists and has all required fields + // Note: workspace validity is now primarily handled by RouterContext deriving + // selectedWorkspace from URL + metadata. This effect handles edge cases like + // stale localStorage or missing fields in legacy workspaces. useEffect(() => { if (selectedWorkspace) { const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId); if (!metadata) { - // Workspace was deleted + // Workspace was deleted - navigate home (clears selection) console.warn( `Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection` ); setSelectedWorkspace(null); - if (!isStorybookIframe() && window.location.hash) { - window.history.replaceState(null, "", window.location.pathname); - } } else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) { // Old localStorage entry missing namedWorkspacePath - update it once console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`); diff --git a/src/browser/components/AppLoader.tsx b/src/browser/components/AppLoader.tsx index 89e20306b2..6acdce395d 100644 --- a/src/browser/components/AppLoader.tsx +++ b/src/browser/components/AppLoader.tsx @@ -6,6 +6,7 @@ import { useGitStatusStoreRaw } from "../stores/GitStatusStore"; import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext"; import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API"; import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext"; +import { RouterProvider } from "../contexts/RouterContext"; interface AppLoaderProps { /** Optional pre-created ORPC api?. If provided, skips internal connection setup. */ @@ -18,7 +19,8 @@ interface AppLoaderProps { * 2. Sync stores with loaded data * 3. Only render App when everything is ready * - * WorkspaceContext handles workspace selection restoration (localStorage, URL hash, launch project). + * WorkspaceContext handles workspace selection restoration from URL. + * RouterProvider must wrap WorkspaceProvider since workspace state is derived from URL. * WorkspaceProvider must be nested inside ProjectProvider so it can call useProjectContext(). * This ensures App.tsx can assume stores are always synced and removes * the need for conditional guards in effects. @@ -26,11 +28,13 @@ interface AppLoaderProps { export function AppLoader(props: AppLoaderProps) { return ( - - - - - + + + + + + + ); } diff --git a/src/browser/contexts/RouterContext.tsx b/src/browser/contexts/RouterContext.tsx new file mode 100644 index 0000000000..bbf3a5398f --- /dev/null +++ b/src/browser/contexts/RouterContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { MemoryRouter, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { parseInitialUrl, useRouterUrlSync } from "../hooks/useRouterUrlSync"; + +// Navigation context value +export interface RouterContextValue { + // Navigation functions + navigateToWorkspace: (workspaceId: string) => void; + navigateToProject: (projectPath: string) => void; + navigateToHome: () => void; + // Current route state (derived from URL) + currentWorkspaceId: string | null; + currentProjectPath: string | null; +} + +const RouterContext = createContext(null); + +export function useRouter(): RouterContextValue { + const ctx = useContext(RouterContext); + if (!ctx) { + throw new Error("useRouter must be used within RouterProvider"); + } + return ctx; +} + +/** + * Inner provider that has access to router hooks. + * Parses URL to derive current workspace/project state. + */ +function RouterContextInner(props: { children: ReactNode }) { + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + + // Sync router state to browser URL + useRouterUrlSync(); + + // Parse workspace ID from pathname: /workspace/:workspaceId + let currentWorkspaceId: string | null = null; + const workspaceMatch = /^\/workspace\/(.+)$/.exec(location.pathname); + if (workspaceMatch) { + currentWorkspaceId = decodeURIComponent(workspaceMatch[1]); + } + + // Parse project path from query: /project?path=... + const currentProjectPath = location.pathname === "/project" ? searchParams.get("path") : null; + + const value: RouterContextValue = { + navigateToWorkspace: (workspaceId: string) => { + void navigate(`/workspace/${encodeURIComponent(workspaceId)}`, { replace: true }); + }, + navigateToProject: (projectPath: string) => { + void navigate(`/project?path=${encodeURIComponent(projectPath)}`, { replace: true }); + }, + navigateToHome: () => { + void navigate("/", { replace: true }); + }, + currentWorkspaceId, + currentProjectPath, + }; + + return {props.children}; +} + +/** + * Main router provider that wraps app with MemoryRouter and context. + * Handles backward compatibility with legacy hash URLs. + */ +export function RouterProvider(props: { children: ReactNode }) { + // Parse initial URL for MemoryRouter (handles legacy hash URLs) + const initialEntry = parseInitialUrl(); + + return ( + + {props.children} + + ); +} diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index d41c5aa77d..e6ebc9035c 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -5,6 +5,7 @@ import { GlobalWindow } from "happy-dom"; import type { WorkspaceContext } from "./WorkspaceContext"; import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext"; import { ProjectProvider } from "@/browser/contexts/ProjectContext"; +import { RouterProvider } from "@/browser/contexts/RouterContext"; import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { SELECTED_WORKSPACE_KEY, @@ -409,6 +410,18 @@ describe("WorkspaceContext", () => { test("beginWorkspaceCreation clears selection and tracks pending state", async () => { createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: "ws-existing", + projectPath: "/existing", + projectName: "existing", + name: "main", + namedWorkspacePath: "/existing-main", + }), + ]), + }, localStorage: { selectedWorkspace: JSON.stringify({ workspaceId: "ws-existing", @@ -464,6 +477,18 @@ describe("WorkspaceContext", () => { test("selectedWorkspace restores from localStorage on mount", async () => { createMockAPI({ + workspace: { + list: () => + Promise.resolve([ + createWorkspaceMetadata({ + id: "ws-restore", + projectPath: "/restore", + projectName: "restore", + name: "main", + namedWorkspacePath: "/restore-main", + }), + ]), + }, localStorage: { selectedWorkspace: JSON.stringify({ workspaceId: "ws-restore", @@ -479,18 +504,13 @@ describe("WorkspaceContext", () => { await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-restore")); }); - test("launch project takes precedence over localStorage selection", async () => { + test("launch project auto-selects workspace when no URL hash", async () => { + // With the new router, URL takes precedence. When there's no URL hash, + // and localStorage has no saved workspace, the launch project kicks in. createMockAPI({ workspace: { list: () => Promise.resolve([ - createWorkspaceMetadata({ - id: "ws-existing", - projectPath: "/existing", - projectName: "existing", - name: "main", - namedWorkspacePath: "/existing-main", - }), createWorkspaceMetadata({ id: "ws-launch", projectPath: "/launch-project", @@ -503,18 +523,10 @@ describe("WorkspaceContext", () => { projects: { list: () => Promise.resolve([]), }, - localStorage: { - selectedWorkspace: JSON.stringify({ - workspaceId: "ws-existing", - projectPath: "/existing", - projectName: "existing", - namedWorkspacePath: "/existing-main", - }), - }, server: { getLaunchProject: () => Promise.resolve("/launch-project"), }, - locationHash: "#/launch-project", // Simulate launch project via URL hash + // No locationHash, no localStorage - so launch project should kick in }); const ctx = await setup(); @@ -685,13 +697,15 @@ async function setup() { return null; } - // WorkspaceProvider needs ProjectProvider to call useProjectContext + // WorkspaceProvider needs RouterProvider and ProjectProvider render( - - - - - + + + + + + + ); // Inject client immediately to handle race conditions where effects run before store update diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index f32fd45fe5..12d27ef312 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -20,16 +20,13 @@ import { SELECTED_WORKSPACE_KEY, } from "@/common/constants/storage"; import { useAPI } from "@/browser/contexts/API"; -import { - readPersistedState, - updatePersistedState, - usePersistedState, -} from "@/browser/hooks/usePersistedState"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; import { isExperimentEnabled } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { isWorkspaceArchived } from "@/common/utils/archive"; +import { useRouter } from "@/browser/contexts/RouterContext"; /** * Seed per-workspace localStorage from backend workspace metadata. @@ -139,6 +136,14 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const { api } = useAPI(); // Get project refresh function from ProjectContext const { refreshProjects } = useProjectContext(); + // Get router navigation functions and current route state + const { + navigateToWorkspace, + navigateToProject, + navigateToHome, + currentWorkspaceId, + currentProjectPath, + } = useRouter(); const workspaceStore = useWorkspaceStoreRaw(); const [workspaceMetadata, setWorkspaceMetadataState] = useState< @@ -160,12 +165,33 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [workspaceStore] ); const [loading, setLoading] = useState(true); - const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null); - // Manage selected workspace internally with localStorage persistence - const [selectedWorkspace, setSelectedWorkspace] = usePersistedState( - SELECTED_WORKSPACE_KEY, - null + // pendingNewWorkspaceProject is derived from currentProjectPath in URL + const pendingNewWorkspaceProject = currentProjectPath; + + // selectedWorkspace is derived from currentWorkspaceId in URL + workspaceMetadata + const selectedWorkspace = useMemo(() => { + if (!currentWorkspaceId) return null; + const metadata = workspaceMetadata.get(currentWorkspaceId); + if (!metadata) return null; + return toWorkspaceSelection(metadata); + }, [currentWorkspaceId, workspaceMetadata]); + + // setSelectedWorkspace navigates to the workspace URL (or clears if null) + const setSelectedWorkspace = useCallback( + (update: SetStateAction) => { + // Handle functional updates by resolving against current value + const newValue = typeof update === "function" ? update(selectedWorkspace) : update; + if (newValue) { + navigateToWorkspace(newValue.workspaceId); + // Persist to localStorage for next session + updatePersistedState(SELECTED_WORKSPACE_KEY, newValue); + } else { + navigateToHome(); + updatePersistedState(SELECTED_WORKSPACE_KEY, null); + } + }, + [selectedWorkspace, navigateToWorkspace, navigateToHome] ); // Used by async subscription handlers to safely access the most recent metadata map @@ -217,40 +243,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { })(); }, [loadWorkspaceMetadata, refreshProjects]); - // Restore workspace from URL hash (overrides localStorage) - // Runs once after metadata is loaded - useEffect(() => { - if (loading) return; - - const hash = window.location.hash; - if (hash.startsWith("#workspace=")) { - const workspaceId = decodeURIComponent(hash.substring("#workspace=".length)); - - // Find workspace in metadata - const metadata = workspaceMetadata.get(workspaceId); - - if (metadata) { - // Restore from hash (overrides localStorage) - setSelectedWorkspace(toWorkspaceSelection(metadata)); - } - } else if (hash.length > 1) { - // Try to interpret hash as project path (for direct deep linking) - // e.g. #/Users/me/project or #/launch-project - const projectPath = decodeURIComponent(hash.substring(1)); - - // Find first workspace with this project path - const projectWorkspaces = Array.from(workspaceMetadata.values()).filter( - (meta) => meta.projectPath === projectPath - ); - - if (projectWorkspaces.length > 0) { - const metadata = projectWorkspaces[0]; - setSelectedWorkspace(toWorkspaceSelection(metadata)); - } - } - // Only run once when loading finishes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading]); + // URL restoration is now handled by RouterContext which parses the URL on load + // and provides currentWorkspaceId/currentProjectPath that we derive state from. // Check for launch project from server (for --add-project flag) // This only applies in server mode, runs after metadata loads @@ -594,15 +588,14 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const beginWorkspaceCreation = useCallback( (projectPath: string) => { - setPendingNewWorkspaceProject(projectPath); - setSelectedWorkspace(null); + navigateToProject(projectPath); }, - [setSelectedWorkspace] + [navigateToProject] ); const clearPendingWorkspaceCreation = useCallback(() => { - setPendingNewWorkspaceProject(null); - }, []); + navigateToHome(); + }, [navigateToHome]); const value = useMemo( () => ({ diff --git a/src/browser/hooks/useRouterUrlSync.ts b/src/browser/hooks/useRouterUrlSync.ts new file mode 100644 index 0000000000..f23be9062c --- /dev/null +++ b/src/browser/hooks/useRouterUrlSync.ts @@ -0,0 +1,81 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { readPersistedState } from "./usePersistedState"; +import { SELECTED_WORKSPACE_KEY } from "@/common/constants/storage"; +import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; + +/** + * Parses the current browser URL into a router-compatible location. + * Handles backward compatibility with legacy hash URLs and localStorage. + */ +export function parseInitialUrl(): string { + const { pathname, search, hash } = window.location; + + // Legacy hash URL: #workspace=abc123 → /workspace/abc123 + if (hash.startsWith("#workspace=")) { + const workspaceId = decodeURIComponent(hash.substring("#workspace=".length)); + return `/workspace/${encodeURIComponent(workspaceId)}`; + } + + // Legacy hash URL: #/path/to/project → /project?path=/path/to/project + if (hash.length > 1 && !hash.startsWith("#/workspace") && !hash.startsWith("#/project")) { + const projectPath = decodeURIComponent(hash.substring(1)); + return `/project?path=${encodeURIComponent(projectPath)}`; + } + + // If URL is at root (dev server), about:blank (tests), file:// path (packaged Electron), + // or iframe.html (Storybook), check localStorage for saved workspace from previous session + const isRootOrFileUrl = + pathname === "/" || + pathname === "" || + pathname === "blank" || + pathname.endsWith("index.html") || + pathname.endsWith("iframe.html"); + // For iframe.html (Storybook), ignore search params when checking for root URL + const effectiveSearch = pathname.endsWith("iframe.html") ? "" : search; + if (isRootOrFileUrl && !effectiveSearch) { + const savedWorkspace = readPersistedState( + SELECTED_WORKSPACE_KEY, + null + ); + if (savedWorkspace?.workspaceId) { + return `/workspace/${encodeURIComponent(savedWorkspace.workspaceId)}`; + } + } + + // Standard URL: use pathname + search + return pathname + search; +} + +/** + * Syncs React Router's MemoryRouter state with the browser URL. + * Uses replaceState to update URL without adding to history. + * + * In browser/server mode, this enables proper URLs that survive refresh. + * In Electron (file:// protocol), we skip URL sync since it would break page reload. + */ +export function useRouterUrlSync(): void { + const location = useLocation(); + + useEffect(() => { + // Skip sync in Storybook iframe to avoid test runner issues + if (typeof window !== "undefined" && window.location.pathname.endsWith("iframe.html")) { + return; + } + + // Skip sync in Electron (file:// protocol) - updating URL would break page reload + // since Electron would try to load /workspace/abc as a file instead of index.html + if (window.location.protocol === "file:") { + return; + } + + // Build the URL from router location + const url = location.pathname + location.search; + + // Avoid unnecessary URL updates + const currentUrl = window.location.pathname + window.location.search; + if (url !== currentUrl) { + window.history.replaceState(null, "", url); + } + }, [location.pathname, location.search]); +}