diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
index 2d7562f..f271f0b 100644
--- a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
@@ -115,7 +115,7 @@ export function PullDetailHeader({
- {!pr.isMerged && !isReviewRequested && (
+ {!isReviewRequested && (
void;
}) {
+ const [copied, setCopied] = useState(false);
+
return (
-
+
-
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: span stops propagation so text selection doesn't trigger collapse */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard users use the collapse button; this only prevents mouse click bubbling */}
+ e.stopPropagation()}
+ >
{file.previousFilename && file.previousFilename !== file.filename ? (
<>
@@ -308,6 +316,25 @@ function FileHeader({
)}
+
+
{file.additions > 0 && (
+{file.additions}
diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts
index dab5d37..622df77 100644
--- a/apps/dashboard/src/lib/tab-store.ts
+++ b/apps/dashboard/src/lib/tab-store.ts
@@ -16,12 +16,30 @@ export interface Tab {
export const TABS_STORAGE_KEY = "diffkit:tabs";
+const VALID_TAB_TYPES = new Set(["pull", "issue", "review"]);
+
+function isValidTab(t: unknown): t is Tab {
+ return (
+ t !== null &&
+ typeof t === "object" &&
+ typeof (t as Tab).id === "string" &&
+ VALID_TAB_TYPES.has((t as Tab).type)
+ );
+}
+
export function readStoredTabs(): Tab[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(TABS_STORAGE_KEY);
- return raw ? JSON.parse(raw) : [];
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed) || !parsed.every(isValidTab)) {
+ localStorage.removeItem(TABS_STORAGE_KEY);
+ return [];
+ }
+ return parsed;
} catch {
+ localStorage.removeItem(TABS_STORAGE_KEY);
return [];
}
}
diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts
index e43cbbc..99155a2 100644
--- a/apps/dashboard/src/routeTree.gen.ts
+++ b/apps/dashboard/src/routeTree.gen.ts
@@ -9,9 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
import { Route as SetupRouteImport } from './routes/setup'
-import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ProtectedRouteImport } from './routes/_protected'
import { Route as ProtectedIndexRouteImport } from './routes/_protected/index'
@@ -30,21 +28,11 @@ import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_pr
import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId'
import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId'
-const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({
- id: '/sitemap.xml',
- path: '/sitemap.xml',
- getParentRoute: () => rootRouteImport,
-} as any)
const SetupRoute = SetupRouteImport.update({
id: '/setup',
path: '/setup',
getParentRoute: () => rootRouteImport,
} as any)
-const RobotsDottxtRoute = RobotsDottxtRouteImport.update({
- id: '/robots.txt',
- path: '/robots.txt',
- getParentRoute: () => rootRouteImport,
-} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -137,9 +125,7 @@ const ProtectedOwnerRepoIssuesIssueIdRoute =
export interface FileRoutesByFullPath {
'/': typeof ProtectedIndexRoute
'/login': typeof LoginRoute
- '/robots.txt': typeof RobotsDottxtRoute
'/setup': typeof SetupRoute
- '/sitemap.xml': typeof SitemapDotxmlRoute
'/issues': typeof ProtectedIssuesRoute
'/pulls': typeof ProtectedPullsRoute
'/reviews': typeof ProtectedReviewsRoute
@@ -157,9 +143,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
- '/robots.txt': typeof RobotsDottxtRoute
'/setup': typeof SetupRoute
- '/sitemap.xml': typeof SitemapDotxmlRoute
'/issues': typeof ProtectedIssuesRoute
'/pulls': typeof ProtectedPullsRoute
'/reviews': typeof ProtectedReviewsRoute
@@ -179,9 +163,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_protected': typeof ProtectedRouteWithChildren
'/login': typeof LoginRoute
- '/robots.txt': typeof RobotsDottxtRoute
'/setup': typeof SetupRoute
- '/sitemap.xml': typeof SitemapDotxmlRoute
'/_protected/issues': typeof ProtectedIssuesRoute
'/_protected/pulls': typeof ProtectedPullsRoute
'/_protected/reviews': typeof ProtectedReviewsRoute
@@ -203,9 +185,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/login'
- | '/robots.txt'
| '/setup'
- | '/sitemap.xml'
| '/issues'
| '/pulls'
| '/reviews'
@@ -223,9 +203,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
- | '/robots.txt'
| '/setup'
- | '/sitemap.xml'
| '/issues'
| '/pulls'
| '/reviews'
@@ -244,9 +222,7 @@ export interface FileRouteTypes {
| '__root__'
| '/_protected'
| '/login'
- | '/robots.txt'
| '/setup'
- | '/sitemap.xml'
| '/_protected/issues'
| '/_protected/pulls'
| '/_protected/reviews'
@@ -267,9 +243,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
ProtectedRoute: typeof ProtectedRouteWithChildren
LoginRoute: typeof LoginRoute
- RobotsDottxtRoute: typeof RobotsDottxtRoute
SetupRoute: typeof SetupRoute
- SitemapDotxmlRoute: typeof SitemapDotxmlRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute
ApiGithubAppAuthorizeRoute: typeof ApiGithubAppAuthorizeRoute
@@ -278,13 +252,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
- '/sitemap.xml': {
- id: '/sitemap.xml'
- path: '/sitemap.xml'
- fullPath: '/sitemap.xml'
- preLoaderRoute: typeof SitemapDotxmlRouteImport
- parentRoute: typeof rootRouteImport
- }
'/setup': {
id: '/setup'
path: '/setup'
@@ -292,13 +259,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SetupRouteImport
parentRoute: typeof rootRouteImport
}
- '/robots.txt': {
- id: '/robots.txt'
- path: '/robots.txt'
- fullPath: '/robots.txt'
- preLoaderRoute: typeof RobotsDottxtRouteImport
- parentRoute: typeof rootRouteImport
- }
'/login': {
id: '/login'
path: '/login'
@@ -465,9 +425,7 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
ProtectedRoute: ProtectedRouteWithChildren,
LoginRoute: LoginRoute,
- RobotsDottxtRoute: RobotsDottxtRoute,
SetupRoute: SetupRoute,
- SitemapDotxmlRoute: SitemapDotxmlRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiWebhooksGithubRoute: ApiWebhooksGithubRoute,
ApiGithubAppAuthorizeRoute: ApiGithubAppAuthorizeRoute,
diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx
index 997ff0e..d928854 100644
--- a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx
+++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx
@@ -28,11 +28,15 @@ export const Route = createFileRoute(
// Never block navigation — fire prefetch and let the component
// show cached data instantly or a skeleton while loading.
void context.queryClient.prefetchQuery(pageOptions);
+
+ return { issueTitle: cachedData?.detail?.title ?? null };
},
head: ({ match, params }) =>
buildSeo({
path: match.pathname,
- title: formatPageTitle(`Issue #${params.issueId}`),
+ title: formatPageTitle(
+ match.loaderData?.issueTitle ?? `Issue #${params.issueId}`,
+ ),
description: `Private GitHub issue #${params.issueId} in ${params.owner}/${params.repo}.`,
robots: "noindex",
}),
diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx
index 07bbdc2..479ada4 100644
--- a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx
+++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx
@@ -19,7 +19,8 @@ export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({
// Clean up broken cache entries (no detail)
const cachedData = context.queryClient.getQueryData(pageOptions.queryKey);
- if (cachedData !== undefined && !cachedData?.detail) {
+ const isBrokenEntry = cachedData !== undefined && !cachedData?.detail;
+ if (isBrokenEntry) {
context.queryClient.removeQueries({
queryKey: pageOptions.queryKey,
exact: true,
@@ -30,11 +31,17 @@ export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({
// show cached data instantly or a skeleton while loading.
void context.queryClient.prefetchQuery(pageOptions);
void context.queryClient.prefetchQuery(githubViewerQueryOptions(scope));
+
+ return {
+ prTitle: isBrokenEntry ? null : (cachedData?.detail?.title ?? null),
+ };
},
head: ({ match, params }) =>
buildSeo({
path: match.pathname,
- title: formatPageTitle(`PR #${params.pullId}`),
+ title: formatPageTitle(
+ match.loaderData?.prTitle ?? `PR #${params.pullId}`,
+ ),
description: `Private pull request #${params.pullId} in ${params.owner}/${params.repo}.`,
robots: "noindex",
}),
diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx
index 1a5e77c..f6e8e8d 100644
--- a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx
+++ b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx
@@ -27,7 +27,9 @@ export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")(
const cachedPageData = context.queryClient.getQueryData(
pageOptions.queryKey,
);
- if (cachedPageData !== undefined && !cachedPageData?.detail) {
+ const isBrokenEntry =
+ cachedPageData !== undefined && !cachedPageData?.detail;
+ if (isBrokenEntry) {
context.queryClient.removeQueries({
queryKey: pageOptions.queryKey,
exact: true,
@@ -46,11 +48,19 @@ export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")(
data: { ...input, page: 1, perPage: PULL_FILES_PAGE_SIZE },
});
}
+
+ return {
+ prTitle: isBrokenEntry ? null : (cachedPageData?.detail?.title ?? null),
+ };
},
head: ({ match, params }) =>
buildSeo({
path: match.pathname,
- title: formatPageTitle(`Review PR #${params.pullId}`),
+ title: formatPageTitle(
+ match.loaderData?.prTitle
+ ? `Review: ${match.loaderData.prTitle}`
+ : `Review PR #${params.pullId}`,
+ ),
description: `Private code review workspace for pull request #${params.pullId} in ${params.owner}/${params.repo}.`,
robots: "noindex",
}),
diff --git a/apps/dashboard/src/routes/robots[.]txt.ts b/apps/dashboard/src/routes/robots[.]txt.ts
deleted file mode 100644
index 4d70bec..0000000
--- a/apps/dashboard/src/routes/robots[.]txt.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { createFileRoute } from "@tanstack/react-router";
-import { siteConfig } from "#/lib/site-config";
-
-export const Route = createFileRoute("/robots.txt")({
- server: {
- handlers: {
- GET: async () => {
- const lines = [
- "User-agent: *",
- "Allow: /",
- "Disallow: /api/",
- `Sitemap: ${siteConfig.url}/sitemap.xml`,
- ];
-
- return new Response(lines.join("\n"), {
- headers: {
- "Cache-Control": "public, max-age=0, s-maxage=3600",
- "Content-Type": "text/plain; charset=utf-8",
- },
- });
- },
- },
- },
-});
diff --git a/apps/dashboard/src/routes/sitemap[.]xml.ts b/apps/dashboard/src/routes/sitemap[.]xml.ts
deleted file mode 100644
index e686ace..0000000
--- a/apps/dashboard/src/routes/sitemap[.]xml.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { createFileRoute } from "@tanstack/react-router";
-import { siteConfig } from "#/lib/site-config";
-
-export const Route = createFileRoute("/sitemap.xml")({
- server: {
- handlers: {
- GET: async () => {
- const lastModified = new Date().toISOString();
- const pages = [
- {
- loc: `${siteConfig.url}/login`,
- changefreq: "weekly",
- priority: "0.8",
- },
- ];
-
- const xml = `
-
-${pages
- .map(
- (page) => `
- ${page.loc}
- ${lastModified}
- ${page.changefreq}
- ${page.priority}
- `,
- )
- .join("\n")}
-`;
-
- return new Response(xml, {
- headers: {
- "Cache-Control": "public, max-age=0, s-maxage=3600",
- "Content-Type": "application/xml; charset=utf-8",
- },
- });
- },
- },
- },
-});
diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts
index 499fe84..8b2c158 100644
--- a/packages/icons/src/index.ts
+++ b/packages/icons/src/index.ts
@@ -65,3 +65,4 @@ export {
} from "@hugeicons/react";
export { GitHubLogo, GitHubWordmarkLogo } from "./brand-logos";
export { PenIcon } from "./pen-icon";
+export { SeparatorHorizontalIcon } from "./separator-horizontal-icon";
diff --git a/packages/icons/src/separator-horizontal-icon.tsx b/packages/icons/src/separator-horizontal-icon.tsx
new file mode 100644
index 0000000..57dbe9f
--- /dev/null
+++ b/packages/icons/src/separator-horizontal-icon.tsx
@@ -0,0 +1,28 @@
+import type { SVGProps } from "react";
+
+export function SeparatorHorizontalIcon(
+ props: SVGProps & { size?: number }
+) {
+ const { size = 24, width, height, ...rest } = props;
+ return (
+
+ );
+}