diff --git a/web/src/components/DiffToolbar.tsx b/web/src/components/DiffToolbar.tsx index 70ec5db14..e457b66b2 100644 --- a/web/src/components/DiffToolbar.tsx +++ b/web/src/components/DiffToolbar.tsx @@ -96,10 +96,10 @@ export function DiffToolbar({ type="button" onClick={onToggleTreeDesktop} aria-label={desktopTreeOpen ? t("repo.hide_file_tree") : t("repo.show_file_tree")} - aria-pressed={desktopTreeOpen} + aria-pressed={!!desktopTreeOpen} // `pl-1` nudges the icon right so it visually aligns with // the sidebar's collapsed-rail edge on desktop. - className="hidden size-6 cursor-pointer place-items-center rounded text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground) lg:grid pl-1" + className="hidden size-6 cursor-pointer place-items-center rounded text-(--color-muted-foreground) hover:bg-(--color-surface) hover:text-(--color-foreground) lg:grid lg:pl-1" > {desktopTreeOpen ? ( diff --git a/web/src/router.tsx b/web/src/router.tsx index 8d083e80b..0b984a38c 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,34 +1,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - Outlet, - RouterProvider, - createRootRouteWithContext, - createRoute, - createRouter, - notFound, -} from "@tanstack/react-router"; +import { Outlet, RouterProvider, createRootRouteWithContext, createRoute, createRouter } from "@tanstack/react-router"; import { Footer } from "@/components/Footer"; import { Navbar } from "@/components/Navbar"; import { TooltipProvider } from "@/components/ui/tooltip"; import { webContext } from "@/lib/context"; -import { LoaderResponseError, loaderResponseError } from "@/lib/loader-error"; -import { repoHeaderQuery } from "@/lib/queries/repo"; -import { subUrl } from "@/lib/url"; import type { UserInfo } from "@/lib/user-info"; import { Landing } from "@/pages/Landing"; import { NotFound } from "@/pages/NotFound"; import { ServerError } from "@/pages/ServerError"; -import { RepoCommit, type RepoCommitPage } from "@/pages/repo/Commit"; -import { validateRepoCommitSearch } from "@/pages/repo/Commit.search"; +import { createRepoRoutes } from "@/routes/repo"; import { createUserRoutes } from "@/routes/user"; -// Match the legacy server-side route constraint (see `web.go` near the -// `/commit/:sha([a-f0-9]{7,40})$` declaration). The server enforces the same -// shape for SEO and to skip the SPA shell for malformed paths; this client -// check short-circuits the loader so we render 404 without a wasted fetch. -const SHA_RE = /^[a-f0-9]{7,40}$/; - interface RouterContext { user: UserInfo | null; queryClient: QueryClient; @@ -55,57 +38,7 @@ const landingRoute = createRoute({ component: Landing, }); -const repoCommitRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/$owner/$repo/commit/$sha", - validateSearch: validateRepoCommitSearch, - // Reject malformed SHA at parse time so the route doesn't match for paths - // like `/owner/repo/commit/garbage`. The thrown `notFound()` bubbles to the - // root route's NotFound component. - params: { - parse: (raw: { owner: string; repo: string; sha: string }) => { - if (!SHA_RE.test(raw.sha)) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- `notFound()` is the documented TanStack Router signal for 404, not an Error subclass. - throw notFound(); - } - return raw; - }, - stringify: (params: { owner: string; repo: string; sha: string }) => params, - }, - loaderDeps: ({ search }) => ({ whitespace: search.whitespace }), - loader: async ({ params, deps, context }): Promise => { - const metaURL = subUrl(`/api/web/${params.owner}/${params.repo}/commit/${params.sha}`); - const rawBase = subUrl(`/${params.owner}/${params.repo}/commit/${params.sha}.diff`); - const rawURL = deps.whitespace ? `${rawBase}?whitespace=${encodeURIComponent(deps.whitespace)}` : rawBase; - // Three requests in parallel: repo header (via Query cache for cross-page - // reuse), commit metadata JSON, raw patch text. Splitting the patch out - // skips JSON-string escaping and lets the browser cache the (often large) - // patch separately from the metadata. - try { - const [, meta, patch] = await Promise.all([ - context.queryClient.ensureQueryData(repoHeaderQuery(params.owner, params.repo)), - fetch(metaURL, { credentials: "same-origin" }).then(async (res) => { - if (!res.ok) throw await loaderResponseError(res); - return (await res.json()) as Omit; - }), - fetch(rawURL, { credentials: "same-origin" }).then(async (res) => { - if (!res.ok) throw await loaderResponseError(res); - return res.text(); - }), - ]); - return { ...meta, patch }; - } catch (err) { - if (err instanceof LoaderResponseError && err.status === 404) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- `notFound()` is the documented TanStack Router signal for 404, not an Error subclass. - throw notFound(); - } - throw err; - } - }, - component: RepoCommit, -}); - -const routeTree = rootRoute.addChildren([landingRoute, ...createUserRoutes(rootRoute), repoCommitRoute]); +const routeTree = rootRoute.addChildren([landingRoute, ...createUserRoutes(rootRoute), ...createRepoRoutes(rootRoute)]); function makeRouter(context: RouterContext) { return createRouter({ diff --git a/web/src/routes/repo.tsx b/web/src/routes/repo.tsx new file mode 100644 index 000000000..d0ccc7bf6 --- /dev/null +++ b/web/src/routes/repo.tsx @@ -0,0 +1,73 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { type AnyRoute, createRoute, notFound } from "@tanstack/react-router"; + +import { LoaderResponseError, loaderResponseError } from "@/lib/loader-error"; +import { repoHeaderQuery } from "@/lib/queries/repo"; +import { subUrl } from "@/lib/url"; +import { RepoCommit, type RepoCommitPage } from "@/pages/repo/Commit"; +import { type RepoCommitSearch, validateRepoCommitSearch } from "@/pages/repo/Commit.search"; + +// Match the legacy server-side route constraint (see `web.go` near the +// `/commit/:sha([a-f0-9]{7,40})$` declaration). The server enforces the same +// shape for SEO and to skip the SPA shell for malformed paths; this client +// check short-circuits the loader so we render 404 without a wasted fetch. +const SHA_RE = /^[a-f0-9]{7,40}$/; + +interface RouterContext { + queryClient: QueryClient; +} + +export function createRepoRoutes(rootRoute: AnyRoute) { + const repoCommitRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/$owner/$repo/commit/$sha", + validateSearch: validateRepoCommitSearch, + // Reject malformed SHA at parse time so the route doesn't match for paths + // like `/owner/repo/commit/garbage`. The thrown `notFound()` bubbles to the + // root route's NotFound component. + params: { + parse: (raw: { owner: string; repo: string; sha: string }) => { + if (!SHA_RE.test(raw.sha)) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- `notFound()` is the documented TanStack Router signal for 404, not an Error subclass. + throw notFound(); + } + return raw; + }, + stringify: (params: { owner: string; repo: string; sha: string }) => params, + }, + loaderDeps: ({ search }: { search: RepoCommitSearch }) => ({ whitespace: search.whitespace }), + loader: async ({ params, deps, context }): Promise => { + const metaURL = subUrl(`/api/web/${params.owner}/${params.repo}/commit/${params.sha}`); + const rawBase = subUrl(`/${params.owner}/${params.repo}/commit/${params.sha}.diff`); + const rawURL = deps.whitespace ? `${rawBase}?whitespace=${encodeURIComponent(deps.whitespace)}` : rawBase; + const routerContext = context as RouterContext; + // Three requests in parallel: repo header (via Query cache for cross-page + // reuse), commit metadata JSON, raw patch text. Splitting the patch out + // skips JSON-string escaping and lets the browser cache the (often large) + // patch separately from the metadata. + try { + const [, meta, patch] = await Promise.all([ + routerContext.queryClient.ensureQueryData(repoHeaderQuery(params.owner, params.repo)), + fetch(metaURL, { credentials: "same-origin" }).then(async (res) => { + if (!res.ok) throw await loaderResponseError(res); + return (await res.json()) as Omit; + }), + fetch(rawURL, { credentials: "same-origin" }).then(async (res) => { + if (!res.ok) throw await loaderResponseError(res); + return res.text(); + }), + ]); + return { ...meta, patch }; + } catch (err) { + if (err instanceof LoaderResponseError && err.status === 404) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- `notFound()` is the documented TanStack Router signal for 404, not an Error subclass. + throw notFound(); + } + throw err; + } + }, + component: RepoCommit, + }); + + return [repoCommitRoute]; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 478e1bf82..09acc59de 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -13,10 +13,10 @@ export default defineConfig({ }, server: { port: 5173, - // The dev page is served by the gogs Go server (e.g. https://gogs.localhost) - // which reverse-proxies HTTP to this Vite dev server. That proxy is HTTP-only, - // so the HMR client's WebSocket can't tunnel through it. Point HMR's WS - // directly at the Vite dev port instead, bypassing gogs entirely. + // The dev page is served by the Go server (e.g., https://gogs.localhost) + // which reverse-proxies HTTP to this Vite dev server. That proxy is + // HTTP-only, so the HMR client's WebSocket can't tunnel through it. Point + // HMR's WS directly at the Vite dev port instead, bypassing gogs entirely. hmr: { protocol: "ws", host: "localhost",