From bb66666ced7fb4cd28c07823abd6db33c31a78e8 Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Mon, 13 Oct 2025 17:10:15 +0530 Subject: [PATCH 1/5] feat(metadata): Enhance metadata generation for repository browsing feat(utils): Add parseRepoPath function to extract repository name and revision from URL path --- .../app/[domain]/browse/[...path]/page.tsx | 30 +++++++++++ packages/web/src/app/layout.tsx | 12 +++-- packages/web/src/lib/utils.ts | 52 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 84c87912..bf16cc6a 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -3,6 +3,36 @@ import { getBrowseParamsFromPathParam } from "../hooks/utils"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { Loader2 } from "lucide-react"; import { TreePreviewPanel } from "./components/treePreviewPanel"; +import { Metadata } from "next"; +import { parseRepoPath } from "@/lib/utils"; + +type Props = { + params: { + domain: string; + path: string[]; + }; +}; + +export async function generateMetadata({ params }: Props): Promise { + let title = 'Browse'; // Current Default + + try { + const parsedInfo = parseRepoPath(params.path); + + if (parsedInfo) { + const { fullRepoName, revision } = parsedInfo; + title = `${fullRepoName}${revision ? ` @ ${revision}` : ''}`; + } + } catch (error) { + // Log the error for debugging, but don't crash the page render. + console.error("Failed to generate metadata title from path:", params.path, error); + } + + return { + title, // e.g., "sourcebot-dev/sourcebot @ HEAD" + }; +} + interface BrowsePageProps { params: Promise<{ diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index dc0d4b8c..441e5e58 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -11,9 +11,15 @@ import { PlanProvider } from "@/features/entitlements/planProvider"; import { getEntitlements } from "@sourcebot/shared"; export const metadata: Metadata = { - title: "Sourcebot", - description: "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.", - manifest: "/manifest.json", + // Using the title.template will allow child pages to set the title + // while keeping a consistent suffix. + title: { + default: "Sourcebot", + template: "%s | Sourcebot", + }, + description: + "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.", + manifest: "/manifest.json", }; export default function RootLayout({ diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 38b52cfe..e13077bc 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -486,4 +486,56 @@ export const isHttpError = (error: unknown, status: number): boolean => { && typeof error === 'object' && 'status' in error && error.status === status; +} + + +/** + * Parses a URL path array to extract the full repository name and revision. + * This function assumes a URL structure like: + * `.../[hostname]/[owner]/[repo@revision]/-/tree/...` + * Or for nested groups (like GitLab): + * `.../[hostname]/[group]/[subgroup]/[repo@revision]/-/tree/...` + * + * @param path The array of path segments from Next.js params. + * @returns An object with fullRepoName and revision, or null if parsing fails. + */ +export const parseRepoPath = (path: string[]): { fullRepoName: string; revision: string } | null => { + if (path.length < 2) { + return null; // Not enough path segments to parse. + } + + // Find the index of the `-` delimiter which separates the repo info from the file tree info. + const delimiterIndex = path.indexOf('-'); + + // If no delimiter is found, we can't reliably parse the path. + if (delimiterIndex === -1) { + return null; + } + + // The repository parts are between the hostname (index 0) and the delimiter. + // e.g., ["github.com", "sourcebot-dev", "sourcebot"] -> slice will be ["sourcebot-dev", "sourcebot"] + const repoParts = path.slice(1, delimiterIndex); + + if (repoParts.length === 0) { + return null; + } + + // The last part of the repo segment potentially contains the revision. + const lastPart = repoParts[repoParts.length - 1]; + + // URL segments are encoded. Decode it to handle characters like '@' (%40). + const decodedLastPart = decodeURIComponent(lastPart); + + const [repoNamePart, revision = ''] = decodedLastPart.split('@'); + + // The preceding parts form the owner/group path. + // e.g., ["sourcebot"] or ["my-group", "my-subgroup"] + const ownerParts = repoParts.slice(0, repoParts.length - 1); + + // Reconstruct the full repository name. + // e.g., "sourcebot-dev" + "/" + "sourcebot" + // e.g., "my-group/my-subgroup" + "/" + "my-repo" + const fullRepoName = [...ownerParts, repoNamePart].join('/'); + + return { fullRepoName, revision }; } \ No newline at end of file From c4d4dda130a98f78513feabcfe7c9971e1f211b4 Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Mon, 13 Oct 2025 21:22:47 +0530 Subject: [PATCH 2/5] feat(metadata): update tab title with appropriate file name, path or repository name. --- .../app/[domain]/browse/[...path]/page.tsx | 17 ++-- packages/web/src/lib/utils.ts | 81 ++++++++++--------- 2 files changed, 51 insertions(+), 47 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index bf16cc6a..83ab5d2d 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -4,7 +4,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel"; import { Loader2 } from "lucide-react"; import { TreePreviewPanel } from "./components/treePreviewPanel"; import { Metadata } from "next"; -import { parseRepoPath } from "@/lib/utils"; +import { parsePathForTitle} from "@/lib/utils"; type Props = { params: { @@ -14,26 +14,22 @@ type Props = { }; export async function generateMetadata({ params }: Props): Promise { - let title = 'Browse'; // Current Default + let title = 'Browse'; // Default Fallback try { - const parsedInfo = parseRepoPath(params.path); + title = parsePathForTitle(params.path); - if (parsedInfo) { - const { fullRepoName, revision } = parsedInfo; - title = `${fullRepoName}${revision ? ` @ ${revision}` : ''}`; - } } catch (error) { - // Log the error for debugging, but don't crash the page render. + // TODO: Maybe I need to look into a better way of handling this error. + // for now, it is just a log, fallback tab title and prevents the app from crashing. console.error("Failed to generate metadata title from path:", params.path, error); } return { - title, // e.g., "sourcebot-dev/sourcebot @ HEAD" + title, }; } - interface BrowsePageProps { params: Promise<{ path: string[]; @@ -48,6 +44,7 @@ export default async function BrowsePage(props: BrowsePageProps) { } = params; const rawPath = _rawPath.join('/'); + console.log("rawPath:", rawPath); const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath); return ( diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index e13077bc..2afe8ef6 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -488,54 +488,61 @@ export const isHttpError = (error: unknown, status: number): boolean => { && error.status === status; } - /** - * Parses a URL path array to extract the full repository name and revision. - * This function assumes a URL structure like: - * `.../[hostname]/[owner]/[repo@revision]/-/tree/...` - * Or for nested groups (like GitLab): - * `.../[hostname]/[group]/[subgroup]/[repo@revision]/-/tree/...` + * Parses the URL path to generate a descriptive title. + * It handles three cases: + * 1. File view (`blob`): "filename.ts - owner/repo" + * 2. Directory view (`tree`): "directory/ - owner/repo" + * 3. Repository root: "owner/repo" * * @param path The array of path segments from Next.js params. - * @returns An object with fullRepoName and revision, or null if parsing fails. + * @returns A formatted title string. */ -export const parseRepoPath = (path: string[]): { fullRepoName: string; revision: string } | null => { - if (path.length < 2) { - return null; // Not enough path segments to parse. - } - - // Find the index of the `-` delimiter which separates the repo info from the file tree info. +export const parsePathForTitle = (path: string[]): string => { const delimiterIndex = path.indexOf('-'); - - // If no delimiter is found, we can't reliably parse the path. - if (delimiterIndex === -1) { - return null; + if (delimiterIndex === -1 || delimiterIndex === 0) { + return 'Browse'; } - // The repository parts are between the hostname (index 0) and the delimiter. - // e.g., ["github.com", "sourcebot-dev", "sourcebot"] -> slice will be ["sourcebot-dev", "sourcebot"] const repoParts = path.slice(1, delimiterIndex); + if (repoParts.length === 0) return 'Browse'; - if (repoParts.length === 0) { - return null; + const lastPart = decodeURIComponent(repoParts.pop()!); + const [repoNamePart, revision = ''] = lastPart.split('@'); + const ownerParts = repoParts; + const fullRepoName = [...ownerParts, repoNamePart].join('/'); + const repoAndRevision = `${fullRepoName}${revision ? ` @ ${revision}` : ''}`; + + // Check for file (`blob`) or directory (`tree`) view + const blobIndex = path.indexOf('blob'); + const treeIndex = path.indexOf('tree'); + + // Case 1: Viewing a file + if (blobIndex !== -1 && path.length > blobIndex + 1) { + const encodedFilePath = path[blobIndex + 1]; + const filePath = decodeURIComponent(encodedFilePath); + + const fileName = filePath.split('/').pop() || filePath; + + // Return a title like: "agents.ts - sourcebot-dev/sourcebot @ HEAD" + return `${fileName} - ${repoAndRevision}`; } - // The last part of the repo segment potentially contains the revision. - const lastPart = repoParts[repoParts.length - 1]; + // Case 2: Viewing a directory + if (treeIndex !== -1 && path.length > treeIndex + 1) { + const encodedDirPath = path[treeIndex + 1]; + const dirPath = decodeURIComponent(encodedDirPath); + + // If we're at the root of the tree, just show the repo name + if (dirPath === '/' || dirPath === '') { + return repoAndRevision; + } - // URL segments are encoded. Decode it to handle characters like '@' (%40). - const decodedLastPart = decodeURIComponent(lastPart); + // Otherwise, show the directory path + // Return a title like: "client/src/store/ - sourcebot-dev/sourcebot @ HEAD" + return `${dirPath.endsWith('/') ? dirPath : dirPath + '/'} - ${repoAndRevision}`; + } - const [repoNamePart, revision = ''] = decodedLastPart.split('@'); - - // The preceding parts form the owner/group path. - // e.g., ["sourcebot"] or ["my-group", "my-subgroup"] - const ownerParts = repoParts.slice(0, repoParts.length - 1); - - // Reconstruct the full repository name. - // e.g., "sourcebot-dev" + "/" + "sourcebot" - // e.g., "my-group/my-subgroup" + "/" + "my-repo" - const fullRepoName = [...ownerParts, repoNamePart].join('/'); - - return { fullRepoName, revision }; + // Case 3: Fallback to the repository root + return repoAndRevision; } \ No newline at end of file From af915f7eaeff255de28f70c15ae5089508163f1b Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Mon, 13 Oct 2025 22:21:53 +0530 Subject: [PATCH 3/5] fix: remove left-over console logs and Async Params resolution. --- .../web/src/app/[domain]/browse/[...path]/page.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 83ab5d2d..9c9b19e7 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -7,22 +7,23 @@ import { Metadata } from "next"; import { parsePathForTitle} from "@/lib/utils"; type Props = { - params: { + params: Promise<{ domain: string; path: string[]; - }; + }>; }; -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata({ params: paramsPromise }: Props): Promise { let title = 'Browse'; // Default Fallback try { + const params = await paramsPromise; title = parsePathForTitle(params.path); } catch (error) { // TODO: Maybe I need to look into a better way of handling this error. // for now, it is just a log, fallback tab title and prevents the app from crashing. - console.error("Failed to generate metadata title from path:", params.path, error); + console.error("Failed to generate metadata title from path:", error); } return { @@ -44,7 +45,6 @@ export default async function BrowsePage(props: BrowsePageProps) { } = params; const rawPath = _rawPath.join('/'); - console.log("rawPath:", rawPath); const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath); return ( From 785a077eff56e51c48a96baf0a90cecec32deb7b Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Mon, 13 Oct 2025 22:33:01 +0530 Subject: [PATCH 4/5] feat: refactor parsePathForTitle to utilize getBrowseParamsFromPathParam for cleaner code. --- packages/web/src/lib/utils.ts | 64 +++++++++++++---------------------- 1 file changed, 23 insertions(+), 41 deletions(-) diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 2afe8ef6..261c9270 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -17,6 +17,7 @@ import { ErrorCode } from "./errorCodes"; import { NextRequest } from "next/server"; import { Org } from "@sourcebot/db"; import { OrgMetadata, orgMetadataSchema } from "@/types"; +import { getBrowseParamsFromPathParam } from "@/app/[domain]/browse/hooks/utils"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -499,50 +500,31 @@ export const isHttpError = (error: unknown, status: number): boolean => { * @returns A formatted title string. */ export const parsePathForTitle = (path: string[]): string => { - const delimiterIndex = path.indexOf('-'); - if (delimiterIndex === -1 || delimiterIndex === 0) { - return 'Browse'; - } + const pathParam = path.join('/'); - const repoParts = path.slice(1, delimiterIndex); - if (repoParts.length === 0) return 'Browse'; + const { repoName, revisionName, path: filePath, pathType } = getBrowseParamsFromPathParam(pathParam); - const lastPart = decodeURIComponent(repoParts.pop()!); - const [repoNamePart, revision = ''] = lastPart.split('@'); - const ownerParts = repoParts; - const fullRepoName = [...ownerParts, repoNamePart].join('/'); - const repoAndRevision = `${fullRepoName}${revision ? ` @ ${revision}` : ''}`; + // Build the base repository and revision string. + const cleanRepoName = repoName.split('/').slice(1).join('/'); // Remove the version control system prefix + const repoAndRevision = `${cleanRepoName}${revisionName ? ` @ ${revisionName}` : ''}`; - // Check for file (`blob`) or directory (`tree`) view - const blobIndex = path.indexOf('blob'); - const treeIndex = path.indexOf('tree'); - - // Case 1: Viewing a file - if (blobIndex !== -1 && path.length > blobIndex + 1) { - const encodedFilePath = path[blobIndex + 1]; - const filePath = decodeURIComponent(encodedFilePath); - - const fileName = filePath.split('/').pop() || filePath; - - // Return a title like: "agents.ts - sourcebot-dev/sourcebot @ HEAD" - return `${fileName} - ${repoAndRevision}`; - } - - // Case 2: Viewing a directory - if (treeIndex !== -1 && path.length > treeIndex + 1) { - const encodedDirPath = path[treeIndex + 1]; - const dirPath = decodeURIComponent(encodedDirPath); - - // If we're at the root of the tree, just show the repo name - if (dirPath === '/' || dirPath === '') { - return repoAndRevision; + switch (pathType) { + case 'blob': { + // For blobs, get the filename from the end of the path. + const fileName = filePath.split('/').pop() || filePath; + return `${fileName} - ${repoAndRevision}`; } - - // Otherwise, show the directory path - // Return a title like: "client/src/store/ - sourcebot-dev/sourcebot @ HEAD" - return `${dirPath.endsWith('/') ? dirPath : dirPath + '/'} - ${repoAndRevision}`; + case 'tree': { + // If the path is empty, it's the repo root. + if (filePath === '' || filePath === '/') { + return repoAndRevision; + } + // Otherwise, show the directory path. + const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`; + return `${directoryPath} - ${repoAndRevision}`; + } + default: + // Fallback to just the repository name. + return repoAndRevision; } - - // Case 3: Fallback to the repository root - return repoAndRevision; } \ No newline at end of file From 0173774f2a1e32f900c803be938f65255162d373 Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Tue, 14 Oct 2025 11:19:24 +0530 Subject: [PATCH 5/5] minor refactoring and adding changelog. --- CHANGELOG.md | 3 ++ .../app/[domain]/browse/[...path]/page.tsx | 40 +++++++++++++++++-- packages/web/src/lib/utils.ts | 40 ------------------- 3 files changed, 40 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b854fd..db02263f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Added +- Implement dynamic tab titles for files and folders in browse tab. [#560](https://github.com/sourcebot-dev/sourcebot/pull/560) + ### Fixed - Fixed "dubious ownership" errors when cloning / fetching repos. [#553](https://github.com/sourcebot-dev/sourcebot/pull/553) diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 9c9b19e7..42b80730 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -4,7 +4,43 @@ import { CodePreviewPanel } from "./components/codePreviewPanel"; import { Loader2 } from "lucide-react"; import { TreePreviewPanel } from "./components/treePreviewPanel"; import { Metadata } from "next"; -import { parsePathForTitle} from "@/lib/utils"; + +/** + * Parses the URL path to generate a descriptive title. + * It handles three cases: + * 1. File view (`blob`): "filename.ts - owner/repo" + * 2. Directory view (`tree`): "directory/ - owner/repo" + * 3. Repository root: "owner/repo" + * + * @param path The array of path segments from Next.js params. + * @returns A formatted title string. + */ +export const parsePathForTitle = (path: string[]): string => { + const pathParam = path.join('/'); + + const { repoName, revisionName, path: filePath, pathType } = getBrowseParamsFromPathParam(pathParam); + + // Build the base repository and revision string. + const cleanRepoName = repoName.split('/').slice(1).join('/'); // Remove the version control system prefix + const repoAndRevision = `${cleanRepoName}${revisionName ? ` @ ${revisionName}` : ''}`; + + switch (pathType) { + case 'blob': { + // For blobs, get the filename from the end of the path. + const fileName = filePath.split('/').pop() || filePath; + return `${fileName} - ${repoAndRevision}`; + } + case 'tree': { + // If the path is empty, it's the repo root. + if (filePath === '' || filePath === '/') { + return repoAndRevision; + } + // Otherwise, show the directory path. + const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`; + return `${directoryPath} - ${repoAndRevision}`; + } + } +} type Props = { params: Promise<{ @@ -21,8 +57,6 @@ export async function generateMetadata({ params: paramsPromise }: Props): Promis title = parsePathForTitle(params.path); } catch (error) { - // TODO: Maybe I need to look into a better way of handling this error. - // for now, it is just a log, fallback tab title and prevents the app from crashing. console.error("Failed to generate metadata title from path:", error); } diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 261c9270..a7669e6c 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -487,44 +487,4 @@ export const isHttpError = (error: unknown, status: number): boolean => { && typeof error === 'object' && 'status' in error && error.status === status; -} - -/** - * Parses the URL path to generate a descriptive title. - * It handles three cases: - * 1. File view (`blob`): "filename.ts - owner/repo" - * 2. Directory view (`tree`): "directory/ - owner/repo" - * 3. Repository root: "owner/repo" - * - * @param path The array of path segments from Next.js params. - * @returns A formatted title string. - */ -export const parsePathForTitle = (path: string[]): string => { - const pathParam = path.join('/'); - - const { repoName, revisionName, path: filePath, pathType } = getBrowseParamsFromPathParam(pathParam); - - // Build the base repository and revision string. - const cleanRepoName = repoName.split('/').slice(1).join('/'); // Remove the version control system prefix - const repoAndRevision = `${cleanRepoName}${revisionName ? ` @ ${revisionName}` : ''}`; - - switch (pathType) { - case 'blob': { - // For blobs, get the filename from the end of the path. - const fileName = filePath.split('/').pop() || filePath; - return `${fileName} - ${repoAndRevision}`; - } - case 'tree': { - // If the path is empty, it's the repo root. - if (filePath === '' || filePath === '/') { - return repoAndRevision; - } - // Otherwise, show the directory path. - const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`; - return `${directoryPath} - ${repoAndRevision}`; - } - default: - // Fallback to just the repository name. - return repoAndRevision; - } } \ No newline at end of file