diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec667624..e736b4713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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 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..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 @@ -175,14 +175,17 @@ 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 && ( <>