Skip to content

Commit 98aaaa9

Browse files
committed
Support artifact file and line URL links (#1038)
From PR #1038.
1 parent 9a6ea32 commit 98aaaa9

2 files changed

Lines changed: 157 additions & 12 deletions

File tree

frontend/src/components/artifacts-viewer.tsx

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
isBinaryRendererFile,
3131
} from "@/components/renderers/file-renderer";
3232
import { 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

206208
interface 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

380451
function 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>

frontend/src/components/trial-detail-panel.tsx

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -856,12 +856,25 @@ export function TrialDetailPanel({
856856
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
857857
const [deleting, setDeleting] = useState(false);
858858
const [deleteError, setDeleteError] = useState<string | null>(null);
859+
// ``?file=`` / ``?lines=`` are scoped by ``?tab=``: on the artifacts tab
860+
// they address the artifact browser, otherwise the files tab. Each tab
861+
// keeps its own state; the URL carries the active tab's pair.
859862
const [filesTargetPath, setFilesTargetPath] = useState<string | null>(() =>
860-
getLiveParam("file"),
863+
getLiveParam("tab") === "artifacts" ? null : getLiveParam("file"),
861864
);
862865
// Line-anchor range within the selected file (``?lines=L12-L20``).
863866
const [selectedLines, setSelectedLines] = useState<LineRange | null>(() =>
864-
parseLineRange(getLiveParam("lines")),
867+
getLiveParam("tab") === "artifacts"
868+
? null
869+
: parseLineRange(getLiveParam("lines")),
870+
);
871+
const [artifactsTargetPath, setArtifactsTargetPath] = useState<
872+
string | null
873+
>(() => (getLiveParam("tab") === "artifacts" ? getLiveParam("file") : null));
874+
const [artifactsLines, setArtifactsLines] = useState<LineRange | null>(() =>
875+
getLiveParam("tab") === "artifacts"
876+
? parseLineRange(getLiveParam("lines"))
877+
: null,
865878
);
866879

867880
const hydratedFromUrl = useRef(false);
@@ -878,6 +891,15 @@ export function TrialDetailPanel({
878891
const urlFile = getLiveParam("file");
879892
const urlLines = parseLineRange(getLiveParam("lines"));
880893
if (urlTab && validTabs.has(urlTab)) setActiveTab(urlTab);
894+
if (urlTab === "artifacts") {
895+
// ?file=/?lines= address the artifact browser while tab=artifacts.
896+
if (urlFile) {
897+
setArtifactsTargetPath(urlFile);
898+
artifactsTargetPathRef.current = urlFile;
899+
}
900+
if (urlLines) setArtifactsLines(urlLines);
901+
return;
902+
}
881903
if (urlFile) {
882904
setFilesTargetPath(urlFile);
883905
filesTargetPathRef.current = urlFile;
@@ -898,15 +920,35 @@ export function TrialDetailPanel({
898920
setFilesTargetPath(path);
899921
}, []);
900922

901-
// Navigating to a different trial keeps the file path (attempts share
923+
// Same shape for the artifacts tab: its browser reports selections the
924+
// same way, and a different file drops the artifact line anchor. The
925+
// comparison also accepts the file's storage path — a deep link can
926+
// address a multi-step artifact by storage path, and the browser echoes
927+
// back the relativized tree path, which is not a suffix match of it.
928+
const artifactsTargetPathRef = useRef<string | null>(artifactsTargetPath);
929+
const handleArtifactsFileChange = useCallback(
930+
(path: string | null, fullPath?: string) => {
931+
const prev = artifactsTargetPathRef.current;
932+
const same =
933+
sameFilePath(prev, path) ||
934+
(fullPath !== undefined && sameFilePath(prev, fullPath));
935+
if (!same) setArtifactsLines(null);
936+
artifactsTargetPathRef.current = path;
937+
setArtifactsTargetPath(path);
938+
},
939+
[]
940+
);
941+
942+
// Navigating to a different trial keeps the file paths (attempts share
902943
// layouts, and comparing the same file across attempts is the point) but
903-
// drops the line anchorit addressed the previous trial's content and
904-
// would highlight arbitrary lines here.
944+
// drops the line anchorsthey addressed the previous trial's content
945+
// and would highlight arbitrary lines here.
905946
const lastTrialIdRef = useRef<string | null>(trial?.id ?? null);
906947
useEffect(() => {
907948
const id = trial?.id ?? null;
908949
if (id && lastTrialIdRef.current && id !== lastTrialIdRef.current) {
909950
setSelectedLines(null);
951+
setArtifactsLines(null);
910952
}
911953
lastTrialIdRef.current = id;
912954
}, [trial?.id]);
@@ -929,14 +971,31 @@ export function TrialDetailPanel({
929971
next.delete("tab");
930972
}
931973

932-
if (filesTargetPath) {
933-
next.set("file", filesTargetPath);
974+
// ?file=/?lines= describe the active tab's view: the files tab's pair
975+
// or the artifacts tab's pair. On tabs with no file view (summary,
976+
// live, trajectory) they drop out of the URL — the tab states stay in
977+
// React, so flipping back restores and re-writes them.
978+
const paneFile =
979+
activeTab === "artifacts"
980+
? artifactsTargetPath
981+
: activeTab === "files"
982+
? filesTargetPath
983+
: null;
984+
const paneLines =
985+
activeTab === "artifacts"
986+
? artifactsLines
987+
: activeTab === "files"
988+
? selectedLines
989+
: null;
990+
991+
if (paneFile) {
992+
next.set("file", paneFile);
934993
} else {
935994
next.delete("file");
936995
}
937996

938-
if (selectedLines) {
939-
next.set("lines", formatLineRange(selectedLines));
997+
if (paneLines) {
998+
next.set("lines", formatLineRange(paneLines));
940999
} else {
9411000
next.delete("lines");
9421001
}
@@ -945,7 +1004,14 @@ export function TrialDetailPanel({
9451004
const url = `${window.location.pathname}${next.toString() ? `?${next.toString()}` : ""}`;
9461005
window.history.replaceState(window.history.state, "", url);
9471006
}
948-
}, [isOpen, activeTab, filesTargetPath, selectedLines]);
1007+
}, [
1008+
isOpen,
1009+
activeTab,
1010+
filesTargetPath,
1011+
selectedLines,
1012+
artifactsTargetPath,
1013+
artifactsLines,
1014+
]);
9491015

9501016
const canRetry =
9511017
allowRetry && (trial?.status === "failed" || trial?.status === "success");
@@ -1692,6 +1758,10 @@ export function TrialDetailPanel({
16921758
<TabsContent value="artifacts" className="m-0 h-full p-0">
16931759
<ArtifactsViewer
16941760
filesUrl={`${apiBaseUrl}/trials/${trial.id}/files`}
1761+
initialFilePath={artifactsTargetPath}
1762+
selectedLines={artifactsLines}
1763+
onSelectLinesChange={setArtifactsLines}
1764+
onSelectedFileChange={handleArtifactsFileChange}
16951765
/>
16961766
</TabsContent>
16971767

0 commit comments

Comments
 (0)