From 1b8130b00e058acf3a9a024f4a4953f319795789 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 20 Jul 2026 16:26:31 -0400 Subject: [PATCH 1/3] fix(web): prevent file header overflow --- .../codePreviewPanel/codePreviewPanelClient.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx index 6368bacf1..36473617d 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx @@ -174,12 +174,14 @@ export const CodePreviewPanelClient = ({ path, repoName, revisionName, previewRe return ( <> -
- +
+
+ +
{fileWebUrl && ( From 603b31a888181eff56ba2ee1c48f48c5f84f5d55 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 20 Jul 2026 16:27:21 -0400 Subject: [PATCH 2/3] chore: add changelog entry for file header overflow --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5ee710c..7cab677b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Prevented focus rings in workspace connector dialogs from being clipped. [#1457](https://github.com/sourcebot-dev/sourcebot/pull/1457) +- Prevented long file paths from overflowing the browse file header. [#1465](https://github.com/sourcebot-dev/sourcebot/pull/1465) ## [5.1.2] - 2026-07-16 From 62761a4fdde3675820887c323c8e380012cd5809 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 20 Jul 2026 18:29:58 -0400 Subject: [PATCH 3/3] fix(web): prevent browse header overflow --- .../codePreviewPanelClient.tsx | 9 +- .../browse/components/pureFileTreePanel.tsx | 3 +- .../src/app/(app)/components/pathHeader.tsx | 83 ++++++++++++------- packages/web/src/app/(app)/layout.tsx | 2 +- 4 files changed, 62 insertions(+), 35 deletions(-) diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx index 36473617d..48a157967 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx @@ -174,7 +174,7 @@ export const CodePreviewPanelClient = ({ path, repoName, revisionName, previewRe return ( <> -
+
- {fileWebUrl && ( - + {isFileSourcePending ? ( + + ) : fileWebUrl ? ( Open in {codeHostInfo.codeHostName} - )} + ) : null}
{!previewRef && ( diff --git a/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx b/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx index dded766f5..da4a66ffb 100644 --- a/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx +++ b/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx @@ -86,7 +86,7 @@ export const PureFileTreePanel = ({ tree, openPaths, path, onTreeNodeClicked }: return ( {renderedTree} @@ -94,4 +94,3 @@ export const PureFileTreePanel = ({ tree, openPaths, path, onTreeNodeClicked }: ) } - diff --git a/packages/web/src/app/(app)/components/pathHeader.tsx b/packages/web/src/app/(app)/components/pathHeader.tsx index 1739fd724..924a35541 100644 --- a/packages/web/src/app/(app)/components/pathHeader.tsx +++ b/packages/web/src/app/(app)/components/pathHeader.tsx @@ -4,7 +4,7 @@ import { cn, getCodeHostInfoForRepo, truncateSha } from "@/lib/utils"; import Image from "next/image"; import { getBrowsePath } from "../browse/hooks/utils"; import { ChevronRight, MoreHorizontal } from "lucide-react"; -import { useCallback, useState, useMemo, useRef, useEffect } from "react"; +import { useCallback, useState, useMemo, useRef, useEffect, useLayoutEffect } from "react"; import { useToast } from "@/components/hooks/use-toast"; import { DropdownMenu, @@ -49,6 +49,8 @@ interface BreadcrumbSegment { }; } +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + export const PathHeader = ({ repo, path, @@ -110,12 +112,14 @@ export const PathHeader = ({ }, [path, pathHighlightRange]); // Calculate which segments should be visible based on available space - useEffect(() => { + useIsomorphicLayoutEffect(() => { const measureSegments = () => { if (!containerRef.current || !breadcrumbsRef.current) return; const containerWidth = containerRef.current.offsetWidth; const availableWidth = containerWidth - 40; // Reserve space for copy button and padding + const collapsedPrefixWidth = 40; // Ellipsis button + separator + const fileIconWidth = isFileIconVisible ? 20 : 0; // Icon + right margin // Create a temporary element to measure segment widths const tempElement = document.createElement('div'); @@ -125,27 +129,38 @@ export const PathHeader = ({ tempElement.className = 'font-mono text-sm'; document.body.appendChild(tempElement); - let totalWidth = 0; + const segmentWidths = breadcrumbSegments.map((segment) => { + tempElement.textContent = segment.name; + return tempElement.offsetWidth; + }); + + const fullBreadcrumbWidth = segmentWidths.reduce((width, segmentWidth) => width + segmentWidth, fileIconWidth) + + Math.max(0, breadcrumbSegments.length - 1) * 16; + let visibleCount = breadcrumbSegments.length; - // Start from the end (most important segments) and work backwards - for (let i = breadcrumbSegments.length - 1; i >= 0; i--) { - const segment = breadcrumbSegments[i]; - tempElement.textContent = segment.name; - const segmentWidth = tempElement.offsetWidth; - const separatorWidth = i < breadcrumbSegments.length - 1 ? 16 : 0; // ChevronRight width - - if (totalWidth + segmentWidth + separatorWidth > availableWidth && i > 0) { - // If adding this segment would overflow and it's not the last segment - visibleCount = breadcrumbSegments.length - i; - // Add width for ellipsis dropdown (approximately 24px) - if (visibleCount < breadcrumbSegments.length) { - totalWidth += 40; // Ellipsis button + separator + if (fullBreadcrumbWidth > availableWidth) { + let visibleWidth = fileIconWidth; + visibleCount = 0; + + // Keep the largest suffix that fits alongside the collapsed-prefix control. + for (let i = breadcrumbSegments.length - 1; i >= 0; i--) { + const separatorWidth = visibleCount > 0 ? 16 : 0; + const candidateWidth = visibleWidth + segmentWidths[i] + separatorWidth; + const prefixWidth = i > 0 ? collapsedPrefixWidth : 0; + + if (candidateWidth + prefixWidth > availableWidth) { + // The final segment is always visible. It may truncate when there + // is not enough room for it and the fixed controls by themselves. + if (visibleCount === 0) { + visibleCount = 1; + } + break; } - break; - } - totalWidth += segmentWidth + separatorWidth; + visibleWidth = candidateWidth; + visibleCount++; + } } document.body.removeChild(tempElement); @@ -160,7 +175,7 @@ export const PathHeader = ({ } return () => resizeObserver.disconnect(); - }, [breadcrumbSegments]); + }, [breadcrumbSegments, isFileIconVisible]); const hiddenSegments = useMemo(() => { if (visibleSegmentCount === null || visibleSegmentCount >= breadcrumbSegments.length) { @@ -249,14 +264,20 @@ export const PathHeader = ({ {breadcrumbSegments.length > 0 && ( ยท )} -
-
+
+
{hiddenSegments.length > 0 && ( <>