@@ -30,6 +30,8 @@ import {
3030 isBinaryRendererFile ,
3131} from "@/components/renderers/file-renderer" ;
3232import { fetcher } from "@/lib/api" ;
33+ import { sameFilePath } from "@/lib/file-path" ;
34+ import type { LineRange } from "@/lib/line-range" ;
3335
3436// Truncate previews of files larger than 100KB so we don't blow up the
3537// renderer pane on huge artifacts (matches TaskFilesPanel).
@@ -205,9 +207,32 @@ function collectFiles(nodes: TreeNode[]): TreeNode[] {
205207
206208interface ArtifactsViewerProps {
207209 filesUrl : string ;
210+ /**
211+ * Deep-linked file to select once the listing loads (``?file=`` while
212+ * ``tab=artifacts``). Accepts the tree path shown in the browser, the
213+ * original storage path, or a suffix of either (bare file name).
214+ */
215+ initialFilePath ?: string | null ;
216+ /** Line range to highlight in the selected file (``?lines=``). */
217+ selectedLines ?: LineRange | null ;
218+ onSelectLinesChange ?: ( range : LineRange | null ) => void ;
219+ /**
220+ * Reports the selected file's tree path (and its original storage path,
221+ * when known) whenever a file is selected, for URL sync. The storage path
222+ * lets the parent recognize a deep link that addressed the file by
223+ * storage path — the two forms differ for multi-step artifacts. Never
224+ * called with null — transient resets are not reported.
225+ */
226+ onSelectedFileChange ?: ( path : string , fullPath ?: string ) => void ;
208227}
209228
210- export function ArtifactsViewer ( { filesUrl } : ArtifactsViewerProps ) {
229+ export function ArtifactsViewer ( {
230+ filesUrl,
231+ initialFilePath,
232+ selectedLines,
233+ onSelectLinesChange,
234+ onSelectedFileChange,
235+ } : ArtifactsViewerProps ) {
211236 const { data, isLoading, error } = useSWR < ArtifactsListing > (
212237 `${ filesUrl } ?recursive=1` ,
213238 fetcher ,
@@ -227,7 +252,15 @@ export function ArtifactsViewer({ filesUrl }: ArtifactsViewerProps) {
227252 const [ expandedDirs , setExpandedDirs ] = useState < Set < string > > ( new Set ( ) ) ;
228253 const [ viewMode , setViewMode ] = useState < "rendered" | "raw" > ( "rendered" ) ;
229254
230- // First load: expand every dir and select the first file. We also re-run
255+ // A deep-linked path owns the first selection; read through a ref so the
256+ // load effect doesn't re-run when the parent echoes selections back.
257+ const initialFilePathRef = useRef ( initialFilePath ) ;
258+ useEffect ( ( ) => {
259+ initialFilePathRef . current = initialFilePath ;
260+ } ) ;
261+
262+ // First load: expand every dir and select the deep-linked file if one is
263+ // addressed (exact path or suffix), else the first file. We also re-run
231264 // this if the file set changes (e.g. trial finishes producing artifacts
232265 // while the drawer is open) but only fall back to a fresh selection when
233266 // the previously selected path no longer exists.
@@ -240,11 +273,45 @@ export function ArtifactsViewer({ filesUrl }: ArtifactsViewerProps) {
240273 setExpandedDirs ( new Set ( collectDirPaths ( tree ) ) ) ;
241274 setSelectedPath ( ( prev ) => {
242275 if ( prev && allFiles . some ( ( f ) => f . path === prev ) ) return prev ;
276+ const wanted = initialFilePathRef . current ;
277+ if ( wanted ) {
278+ // Match against the relativized tree path and the original storage
279+ // path: multi-step artifacts insert the step segment into the tree
280+ // path (steps/setup/artifacts/x → setup/x), so a storage path from
281+ // the files API is not a suffix of it and only fullPath can match.
282+ const match =
283+ allFiles . find ( ( f ) => f . path === wanted ) ??
284+ allFiles . find ( ( f ) => f . fullPath === wanted ) ??
285+ allFiles . find ( ( f ) => sameFilePath ( f . path , wanted ) ) ??
286+ allFiles . find (
287+ ( f ) => f . fullPath != null && sameFilePath ( f . fullPath , wanted ) ,
288+ ) ;
289+ // An unresolved deep link keeps the selection empty instead of
290+ // falling through to the first file: reporting that fallback
291+ // would wipe the ?file= / ?lines= address it couldn't resolve.
292+ // The effect re-runs as the listing grows, so a late-arriving
293+ // artifact still resolves.
294+ return match ?. path ?? null ;
295+ }
243296 const first = findFirstFile ( tree ) ;
244297 return first ?. path ?? null ;
245298 } ) ;
246299 } , [ tree , allFiles ] ) ;
247300
301+ // Report file selections upward for URL sync. Nulls (transient resets)
302+ // are never reported — they would wipe a live ?file= anchor.
303+ const onSelectedFileChangeRef = useRef ( onSelectedFileChange ) ;
304+ useEffect ( ( ) => {
305+ onSelectedFileChangeRef . current = onSelectedFileChange ;
306+ } ) ;
307+ useEffect ( ( ) => {
308+ if ( selectedPath === null ) return ;
309+ const file = allFiles . find ( ( f ) => f . path === selectedPath ) ;
310+ onSelectedFileChangeRef . current ?.( selectedPath , file ?. fullPath ) ;
311+ // allFiles is a dependency only to read the fullPath; a listing refresh
312+ // re-reports the same selection, which the parent treats as a no-op.
313+ } , [ selectedPath , allFiles ] ) ;
314+
248315 const selectedFile = useMemo (
249316 ( ) => allFiles . find ( ( f ) => f . path === selectedPath ) ?? null ,
250317 [ allFiles , selectedPath ] ,
@@ -365,6 +432,8 @@ export function ArtifactsViewer({ filesUrl }: ArtifactsViewerProps) {
365432 selectedFile = { selectedFile }
366433 viewMode = { viewMode }
367434 onViewModeChange = { setViewMode }
435+ selectedLines = { selectedLines }
436+ onSelectLinesChange = { onSelectLinesChange }
368437 />
369438 </ div >
370439 ) ;
@@ -375,13 +444,17 @@ interface ArtifactContentPaneProps {
375444 selectedFile : TreeNode | null ;
376445 viewMode : "rendered" | "raw" ;
377446 onViewModeChange : ( mode : "rendered" | "raw" ) => void ;
447+ selectedLines ?: LineRange | null ;
448+ onSelectLinesChange ?: ( range : LineRange | null ) => void ;
378449}
379450
380451function ArtifactContentPane ( {
381452 filesUrl,
382453 selectedFile,
383454 viewMode,
384455 onViewModeChange,
456+ selectedLines,
457+ onSelectLinesChange,
385458} : ArtifactContentPaneProps ) {
386459 const contentRef = useRef < HTMLDivElement > ( null ) ;
387460 const [ content , setContent ] = useState < string | null > ( null ) ;
@@ -573,6 +646,8 @@ function ArtifactContentPane({
573646 content = { isBinary ? null : content }
574647 fileSize = { fileSize }
575648 viewMode = { viewMode }
649+ selectedLines = { selectedLines }
650+ onSelectLines = { onSelectLinesChange }
576651 />
577652 ) }
578653 </ div >
0 commit comments