diff --git a/frontend/src/components/Common/ArtifactCompareModal.tsx b/frontend/src/components/Common/ArtifactCompareModal.tsx index 887d654f..544f8238 100644 --- a/frontend/src/components/Common/ArtifactCompareModal.tsx +++ b/frontend/src/components/Common/ArtifactCompareModal.tsx @@ -1,3 +1,4 @@ +import { ExternalLinkIcon } from "@chakra-ui/icons" /** * Modal for viewing an artifact with version comparison support. * @@ -5,37 +6,41 @@ * select two commits to compare side-by-side. */ import { - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalCloseButton, - ModalBody, - Flex, - Box, - Text, - Heading, - Spinner, + Avatar, Badge, - Code, + Box, Button, - VStack, + Checkbox, + Code, Divider, - useColorModeValue, + Flex, + Heading, + Icon, IconButton, - Tooltip, Link, - Textarea, - Avatar, - Tabs, - TabList, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Spinner, + Switch, Tab, - TabPanels, + TabList, TabPanel, - Icon, - Checkbox, - Switch, + TabPanels, + Tabs, + Text, + Textarea, + Tooltip, + VStack, + useColorModeValue, } from "@chakra-ui/react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Link as RouterLink } from "@tanstack/react-router" +import { Suspense, lazy, useEffect, useState } from "react" +import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued" import { FaCheck, FaChevronLeft, @@ -43,26 +48,22 @@ import { FaCodeBranch, FaGithub, FaLink, - FaUndo, FaReply, + FaUndo, } from "react-icons/fa" -import { ExternalLinkIcon } from "@chakra-ui/icons" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { useState, useEffect, lazy, Suspense } from "react" -import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued" -import FigureView from "../Figures/FigureView" -import PdfCanvas from "./PdfCanvas" -import FileContent from "../Files/FileContent" import { + type ContentsItem, type Figure, type GitRef, - type Publication, type Notebook, - type ContentsItem, ProjectsService, + type Publication, } from "../../client" import useAuth from "../../hooks/useAuth" +import FigureView from "../Figures/FigureView" +import FileContent from "../Files/FileContent" +import PdfCanvas from "./PdfCanvas" const IpynbRenderer = lazy(() => import("react-ipynb-renderer").then(async (m) => { await import("react-ipynb-renderer/dist/styles/monokai.css") @@ -132,7 +133,7 @@ function ArtifactContent({ } return ( - + ) } @@ -288,6 +289,81 @@ function useArtifactAtRef( }) } +/** Info panel for a figure, mirroring the publications page layout. */ +function FigureInfo({ + figure, + ownerName, + projectName, +}: { + figure: Figure + ownerName: string + projectName: string +}) { + const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate") + // Typed as plain string so the router's typed `to` prop accepts them. + const filesTo: string = `/${ownerName}/${projectName}/files` + const pipelineTo: string = `/${ownerName}/${projectName}/pipeline` + return ( + + + Info + + {figure.title && ( + + + Title: + {" "} + + {figure.title} + + + )} + {figure.description && ( + + + Description: + {" "} + + {figure.description} + + + )} + + + Path: + {" "} + + {figure.path} + + + + + Pipeline stage: + {" "} + {figure.stage ? ( + + + {figure.stage} + + + ) : ( + + Not in pipeline + + )} + + + ) +} + function FigureComments({ ownerName, projectName, @@ -756,6 +832,14 @@ export function ArtifactCompareModal({ const isComparing = Boolean(ref2) + // Figure metadata (title/description/stage) for the info panel, sourced from + // the figure at the displayed ref, falling back to the one we opened with. + const figureInfo = + kind === "figure" + ? (displayData1 as Figure | undefined) ?? + (initialArtifact as Figure | undefined) + : undefined + const getShareUrl = () => { const url = new URL(window.location.href) if (ref1) url.searchParams.set("base_ref", ref1) @@ -1149,6 +1233,13 @@ export function ArtifactCompareModal({ pl={3} overflowY="auto" > + {figureInfo && ( + + )} { const zoomBehaviorRef = useRef | null>(null) + // Bumped each time the diagram finishes rendering so the zoom-to-stage + // effect can re-run against the freshly drawn SVG. + const [renderTick, setRenderTick] = useState(0) const handleResetZoom = () => { const svgSelection = select(".mermaid svg") @@ -54,6 +65,7 @@ const Mermaid = ({ svgSelection.call(zoomBehavior) zoomBehaviorRef.current = zoomBehavior + setRenderTick((t) => t + 1) } catch (error) { console.error("Error rendering Mermaid diagram:", error) } @@ -64,6 +76,52 @@ const Mermaid = ({ } }, [children]) + // Pan/zoom to the requested stage's node once the diagram is rendered. + useEffect(() => { + if (!zoomToStage || !zoomBehaviorRef.current) return + const svgEl = select(".mermaid svg").node() + const gEl = svgEl?.querySelector("g") + if (!svgEl || !gEl) return + const nodes = Array.from(svgEl.querySelectorAll(".node")) + const label = (n: SVGGElement) => (n.textContent ?? "").trim() + const match = + nodes.find((n) => label(n) === zoomToStage) ?? + nodes.find((n) => label(n).split("@")[0] === zoomToStage) + if (!match) return + const gCTM = gEl.getCTM() + const nCTM = match.getCTM() + if (!gCTM || !nCTM) return + // Node center in the coordinate space the zoom transform writes into + // (g's parent / SVG user space). g's own transform cancels out here. + const m = gCTM.inverse().multiply(nCTM) + const bbox = match.getBBox() + let pt = svgEl.createSVGPoint() + pt.x = bbox.x + bbox.width / 2 + pt.y = bbox.y + bbox.height / 2 + pt = pt.matrixTransform(m) + const vb = svgEl.viewBox.baseVal + const hasVb = vb != null && vb.width > 0 + const vbW = hasVb ? vb.width : svgEl.clientWidth + const vbH = hasVb ? vb.height : svgEl.clientHeight + const cx = (hasVb ? vb.x : 0) + vbW / 2 + const cy = (hasVb ? vb.y : 0) + vbH / 2 + // Scale so the node fills ~45% of the view, clamped to a sane range. + const k = Math.max( + 1, + Math.min( + 2.5, + (vbW * 0.45) / (bbox.width || 1), + (vbH * 0.45) / (bbox.height || 1), + ), + ) + const tx = cx - k * pt.x + const ty = cy - k * pt.y + select(svgEl).call( + zoomBehaviorRef.current.transform, + zoomIdentity.translate(tx, ty).scale(k), + ) + }, [zoomToStage, renderTick]) + return ( import("react-plotly.js")) import { useQuery } from "@tanstack/react-query" import { getRouteApi } from "@tanstack/react-router" -import { type Figure } from "../../client" +import type { Figure } from "../../client" import PdfCanvas from "../Common/PdfCanvas" interface FigureViewProps { figure: Figure width?: string + /** + * Fit the figure to the height of its (height-bounded) container instead of + * its intrinsic height. Used in the figure modal so tall Plotly figures + * resize down rather than overflowing vertically. + */ + fillHeight?: boolean } -function FigureView({ figure, width }: FigureViewProps) { +function FigureView({ figure, width, fillHeight }: FigureViewProps) { const routeApi = getRouteApi("/_layout/$accountName/$projectName") const { accountName, projectName } = routeApi.useParams() const boxWidth = width ? width : "100%" @@ -91,19 +97,59 @@ function FigureView({ figure, width }: FigureViewProps) { try { const figObject = JSON.parse(atob(String(figure.content))) if (figObject.data && figObject.layout) { - figView = ( - - Loading...}> - - - - ) + if (fillHeight) { + // Render at the figure's natural height (Plotly's default is 450px + // when none is set), but cap it to the container and center it + // vertically so short figures keep their proportions and only tall + // ones are squished down to fit. + const naturalHeight = + typeof figObject.layout.height === "number" + ? figObject.layout.height + : 450 + const layout = { + ...figObject.layout, + autosize: true, + height: undefined, + width: undefined, + } + figView = ( + + + Loading...}> + + + + + ) + } else { + figView = ( + + Loading...}> + + + + ) + } } else { figView = Cannot render this type of figure } diff --git a/frontend/src/components/Projects/ProjectShowcase.tsx b/frontend/src/components/Projects/ProjectShowcase.tsx index 4c418864..50012180 100644 --- a/frontend/src/components/Projects/ProjectShowcase.tsx +++ b/frontend/src/components/Projects/ProjectShowcase.tsx @@ -1,12 +1,11 @@ -import React from "react" -import { Text, Box, Code } from "@chakra-ui/react" +import { Box, Code, Text } from "@chakra-ui/react" -import LoadingSpinner from "../Common/LoadingSpinner" import useProject from "../../hooks/useProject" -import FigureView from "../Figures/FigureView" -import PublicationView from "../Publications/PublicationView" +import LoadingSpinner from "../Common/LoadingSpinner" import Markdown from "../Common/Markdown" +import FigureView from "../Figures/FigureView" import NotebookView from "../Notebooks/NotebookView" +import PublicationView from "../Publications/PublicationView" interface ProjectShowcaseProps { ownerName: string @@ -28,17 +27,15 @@ function ProjectShowcase({ <> {showcaseRequest.data.elements.map((item, index) => ( // eslint-disable-next-line react/no-array-index-key - + {"figure" in item ? ( - - - + ) : "publication" in item ? ( - + ) : "text" in item ? ( - {item.text} + {item.text} ) : "markdown" in item ? ( {item.markdown} ) : "yaml" in item ? ( @@ -46,13 +43,13 @@ function ProjectShowcase({ {item.yaml} ) : "notebook" in item ? ( - + ) : ( "" )} - + ))} ) : ( diff --git a/frontend/src/lib/pipelineYaml.test.ts b/frontend/src/lib/pipelineYaml.test.ts new file mode 100644 index 00000000..86c9c970 --- /dev/null +++ b/frontend/src/lib/pipelineYaml.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest" + +import { + extractEnvRefs, + extractFilePaths, + findStageLineRange, + looksLikePath, +} from "./pipelineYaml" + +const CALKIT_YAML = `pipeline: + stages: + collect-data: + kind: python-script + script_path: scripts/collect.py + environment: main + outputs: + - data/raw.csv + run-on-cluster: + kind: shell-command + command: "python run.py" + environment: slurm-env:py + process-data: + kind: python-script + script_path: scripts/process.py + environment: main +` + +const DVC_YAML = `stages: + collect-data: + cmd: python scripts/collect.py + deps: + - scripts/collect.py + outs: + - data/raw.csv +` + +describe("looksLikePath", () => { + it("treats paths with slashes and file extensions as paths", () => { + expect(looksLikePath("scripts/collect.py")).toBe(true) + expect(looksLikePath("data/raw.csv")).toBe(true) + expect(looksLikePath("Dockerfile.txt")).toBe(true) + }) + + it("rejects URLs, git remotes, names with spaces, and bare words", () => { + expect(looksLikePath("https://example.com/x")).toBe(false) + expect(looksLikePath("git@github.com:org/repo.git")).toBe(false) + expect(looksLikePath("some thing")).toBe(false) + expect(looksLikePath("main")).toBe(false) + expect(looksLikePath("")).toBe(false) + }) +}) + +describe("extractFilePaths", () => { + it("collects file-path-looking strings and keys", () => { + const paths = extractFilePaths(CALKIT_YAML) + expect(paths.has("scripts/collect.py")).toBe(true) + expect(paths.has("data/raw.csv")).toBe(true) + expect(paths.has("scripts/process.py")).toBe(true) + // Plain env names / words are not paths. + expect(paths.has("main")).toBe(false) + }) + + it("returns an empty set for invalid YAML", () => { + expect(extractFilePaths(":\n - [unbalanced").size).toBe(0) + }) +}) + +describe("extractEnvRefs", () => { + it("collects every environment value, keeping composites whole", () => { + const refs = extractEnvRefs(CALKIT_YAML) + expect(refs.has("main")).toBe(true) + expect(refs.has("slurm-env:py")).toBe(true) + expect(refs.size).toBe(2) + }) + + it("does not collect non-environment keys", () => { + const refs = extractEnvRefs(CALKIT_YAML) + expect(refs.has("python-script")).toBe(false) + expect(refs.has("scripts/collect.py")).toBe(false) + }) + + it("returns an empty set when there are no environment keys (dvc.yaml)", () => { + expect(extractEnvRefs(DVC_YAML).size).toBe(0) + }) + + it("returns an empty set for invalid YAML", () => { + expect(extractEnvRefs(":\n - [unbalanced").size).toBe(0) + }) +}) + +describe("findStageLineRange", () => { + it("returns the [start, end) line range covering the stage block", () => { + const range = findStageLineRange(CALKIT_YAML, "collect-data") + expect(range).not.toBeNull() + const [start, end] = range! + const lines = CALKIT_YAML.split("\n") + expect(lines[start].trim()).toBe("collect-data:") + // The block ends at the next sibling stage key. + expect(lines[end].trim()).toBe("run-on-cluster:") + // It contains the stage's environment line. + const block = lines.slice(start, end) + expect(block.some((l) => l.includes("environment: main"))).toBe(true) + }) + + it("works for dvc.yaml stages", () => { + const range = findStageLineRange(DVC_YAML, "collect-data") + expect(range).not.toBeNull() + const [start] = range! + expect(DVC_YAML.split("\n")[start].trim()).toBe("collect-data:") + }) + + it("returns null for an unknown stage", () => { + expect(findStageLineRange(CALKIT_YAML, "does-not-exist")).toBeNull() + }) + + it("does not match a stage name that is only a substring of another", () => { + // "data" should not match "collect-data" / "process-data". + expect(findStageLineRange(CALKIT_YAML, "data")).toBeNull() + }) +}) diff --git a/frontend/src/lib/pipelineYaml.ts b/frontend/src/lib/pipelineYaml.ts new file mode 100644 index 00000000..f4c7d5ec --- /dev/null +++ b/frontend/src/lib/pipelineYaml.ts @@ -0,0 +1,110 @@ +import jsYaml from "js-yaml" + +// Pure helpers for the pipeline page's linked/highlighted YAML view. Kept +// out of the route module so they can be unit tested without pulling in the +// route's side effects (createFileRoute, syntax-highlighter registration). + +/** + * Heuristic for whether a YAML string token looks like a file path (so it can + * be linked to the files page). Works for both dvc.yaml and calkit.yaml. + */ +export function looksLikePath(s: string): boolean { + return ( + s.length > 0 && + !s.startsWith("http") && + !s.startsWith("git@") && + !s.includes(" ") && + (s.includes("/") || /\.[a-zA-Z0-9]{1,6}$/.test(s)) + ) +} + +/** Collect every string in the YAML that looks like a file path. */ +export function extractFilePaths(yamlContent: string): Set { + try { + const doc = jsYaml.load(yamlContent) + const paths = new Set() + function walk(v: unknown) { + if (typeof v === "string") { + if (looksLikePath(v)) paths.add(v) + } else if (Array.isArray(v)) { + v.forEach(walk) + } else if (v !== null && typeof v === "object") { + for (const [k, child] of Object.entries(v as Record)) { + if (looksLikePath(k)) paths.add(k) + walk(child) + } + } + } + walk(doc) + return paths + } catch { + return new Set() + } +} + +/** + * Collect the string values of every `environment:` key in the pipeline YAML. + * These are the tokens we turn into links. A value may be composite + * ("outer:inner"), which is split and linked per-segment in the renderer. + */ +export function extractEnvRefs(yamlContent: string): Set { + try { + const doc = jsYaml.load(yamlContent) + const refs = new Set() + function walk(v: unknown) { + if (Array.isArray(v)) { + v.forEach(walk) + } else if (v !== null && typeof v === "object") { + for (const [k, child] of Object.entries(v as Record)) { + if (k === "environment" && typeof child === "string") { + refs.add(child) + } else { + walk(child) + } + } + } + } + walk(doc) + return refs + } catch { + return new Set() + } +} + +/** + * Find the [start, end) line range of a stage's block within the YAML so it + * can be highlighted. Works for both calkit.yaml (pipeline.stages.) and + * dvc.yaml (stages.) by matching the stage key at any indent and + * extending until the next line at the same or lower indentation. + */ +export function findStageLineRange( + yamlContent: string, + stage: string, +): [number, number] | null { + const lines = yamlContent.split("\n") + const keyRe = new RegExp( + `^(\\s*)(["']?)${stage.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\2:\\s*(#.*)?$`, + ) + let start = -1 + let indent = 0 + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(keyRe) + if (m) { + start = i + indent = m[1].length + break + } + } + if (start === -1) return null + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i] + if (line.trim() === "" || line.trim().startsWith("#")) continue + const curIndent = line.length - line.trimStart().length + if (curIndent <= indent) { + end = i + break + } + } + return [start, end] +} diff --git a/frontend/src/routes/_layout/$accountName/$projectName/_layout/environments.tsx b/frontend/src/routes/_layout/$accountName/$projectName/_layout/environments.tsx index 34dc31b4..6d3bf5e6 100644 --- a/frontend/src/routes/_layout/$accountName/$projectName/_layout/environments.tsx +++ b/frontend/src/routes/_layout/$accountName/$projectName/_layout/environments.tsx @@ -1,37 +1,43 @@ -import LoadingSpinner from "../../../../../components/Common/LoadingSpinner" import { - Box, - Heading, - Flex, - Text, - Code, + Alert, + AlertIcon, Badge, - SimpleGrid, + Box, + Button, Card, + Code, + Flex, + Heading, Icon, - Button, - useDisclosure, Link, - Alert, - AlertIcon, + SimpleGrid, + Text, } from "@chakra-ui/react" import { - createFileRoute, Link as RouterLink, - useSearch, + createFileRoute, + useNavigate, } from "@tanstack/react-router" -import { FaCube, FaDocker } from "react-icons/fa" import { AiOutlinePython } from "react-icons/ai" +import { FaCube, FaDocker } from "react-icons/fa" import { SiAnaconda } from "react-icons/si" +import { z } from "zod" +import LoadingSpinner from "../../../../../components/Common/LoadingSpinner" -import { useProjectEnvironments } from "../../../../../hooks/useProject" +import type { Environment } from "../../../../../client" import ViewEnvironment from "../../../../../components/Environments/ViewEnvironment" -import { Environment } from "../../../../../client" +import { useProjectEnvironments } from "../../../../../hooks/useProject" + +const environmentsSearchSchema = z.object({ + ref: z.string().optional(), + name: z.string().optional(), +}) export const Route = createFileRoute( "/_layout/$accountName/$projectName/_layout/environments", )({ component: ProjectEnvs, + validateSearch: (search) => environmentsSearchSchema.parse(search), }) const getIcon = (envType: string) => { @@ -49,11 +55,10 @@ const getIcon = (envType: string) => { interface EnvCardProps { environment: Environment + onView: (name: string) => void } -const EnvCard = ({ environment }: EnvCardProps) => { - const viewEnvModal = useDisclosure() - +const EnvCard = ({ environment, onView }: EnvCardProps) => { return ( <> @@ -98,7 +103,7 @@ const EnvCard = ({ environment }: EnvCardProps) => { {environment.all_attrs.image ? ( Image:{" "} - {environment.all_attrs.image as String} + {environment.all_attrs.image as string} ) : ( "" @@ -116,18 +121,13 @@ const EnvCard = ({ environment }: EnvCardProps) => { variant="primary" size="xs" mr={2} - onClick={viewEnvModal.onOpen} + onClick={() => onView(environment.name)} > View ) : ( "" )} - @@ -136,11 +136,8 @@ const EnvCard = ({ environment }: EnvCardProps) => { function ProjectEnvsView() { const { accountName, projectName } = Route.useParams() - const layoutSearch = useSearch({ - from: "/_layout/$accountName/$projectName/_layout" as any, - strict: false, - }) as any - const ref: string | undefined = layoutSearch?.ref + const { ref, name: selectedEnvName } = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) const { environmentsRequest } = useProjectEnvironments( accountName, projectName, @@ -149,6 +146,13 @@ function ProjectEnvsView() { const { isPending: environmentsPending, data: environments } = environmentsRequest + const openEnv = (name: string) => + navigate({ search: (prev) => ({ ...prev, name }) }) + const closeEnv = () => + navigate({ search: (prev) => ({ ...prev, name: undefined }) }) + + const selectedEnv = environments?.find((e) => e.name === selectedEnvName) + return ( <> @@ -160,7 +164,11 @@ function ProjectEnvsView() { {environments?.map((environment) => ( - + ))} @@ -170,6 +178,13 @@ function ProjectEnvsView() { This project has no environments defined. )} + {selectedEnv && ( + + )} ) } diff --git a/frontend/src/routes/_layout/$accountName/$projectName/_layout/pipeline.tsx b/frontend/src/routes/_layout/$accountName/$projectName/_layout/pipeline.tsx index ae0f1ebc..2ef67369 100644 --- a/frontend/src/routes/_layout/$accountName/$projectName/_layout/pipeline.tsx +++ b/frontend/src/routes/_layout/$accountName/$projectName/_layout/pipeline.tsx @@ -1,61 +1,35 @@ -import { - createFileRoute, - Link as RouterLink, - useSearch, -} from "@tanstack/react-router" -import { Box, Flex, Heading, Alert, AlertIcon, Link } from "@chakra-ui/react" +import { Alert, AlertIcon, Box, Flex, Heading, Link } from "@chakra-ui/react" import { useQuery } from "@tanstack/react-query" -import { useState, useMemo, type ReactNode } from "react" +import { Link as RouterLink, createFileRoute } from "@tanstack/react-router" +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react" +import React from "react" import { Light as SyntaxHighlighter } from "react-syntax-highlighter" import yaml from "react-syntax-highlighter/dist/esm/languages/hljs/yaml" import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs" -import jsYaml from "js-yaml" -import React from "react" +import { z } from "zod" +import { ProjectsService } from "../../../../../client" import LoadingSpinner from "../../../../../components/Common/LoadingSpinner" import Mermaid from "../../../../../components/Common/Mermaid" -import { ProjectsService } from "../../../../../client" +import { useProjectEnvironments } from "../../../../../hooks/useProject" +import { + extractEnvRefs, + extractFilePaths, + findStageLineRange, +} from "../../../../../lib/pipelineYaml" SyntaxHighlighter.registerLanguage("yaml", yaml) -// --------------------------------------------------------------------------- -// Extract file paths from YAML (works for both dvc.yaml and calkit.yaml) -// --------------------------------------------------------------------------- -function looksLikePath(s: string): boolean { - return ( - s.length > 0 && - !s.startsWith("http") && - !s.startsWith("git@") && - !s.includes(" ") && - (s.includes("/") || /\.[a-zA-Z0-9]{1,6}$/.test(s)) - ) -} - -function extractFilePaths(yamlContent: string): Set { - try { - const doc = jsYaml.load(yamlContent) - const paths = new Set() - function walk(v: unknown) { - if (typeof v === "string") { - if (looksLikePath(v)) paths.add(v) - } else if (Array.isArray(v)) { - v.forEach(walk) - } else if (v !== null && typeof v === "object") { - for (const [k, child] of Object.entries(v as Record)) { - if (looksLikePath(k)) paths.add(k) - walk(child) - } - } - } - walk(doc) - return paths - } catch { - return new Set() - } -} - -function makeRenderer(paths: Set, filesTo: string) { - return function ({ +function makeRenderer( + paths: Set, + filesTo: string, + envRefs: Set, + envNames: Set, + envTo: string, + highlightRange: [number, number] | null, + firstHighlightRef: React.RefObject, +) { + return ({ rows, stylesheet, useInlineStyles, @@ -63,7 +37,7 @@ function makeRenderer(paths: Set, filesTo: string) { rows: unknown[] stylesheet: Record useInlineStyles: boolean - }) { + }) => { function renderNode(node: unknown, key: string): ReactNode { const n = node as { type?: string @@ -82,6 +56,27 @@ function makeRenderer(paths: Set, filesTo: string) { ) } + // Environment reference, possibly composite ("outer:inner"). Link each + // segment that names a known environment; leave the rest as plain text. + if (envRefs.has(val)) { + const segments = val.split(":") + return ( + + {segments.map((seg, i) => ( + + {i > 0 ? ":" : null} + {envNames.has(seg) ? ( + + {seg} + + ) : ( + seg + )} + + ))} + + ) + } return val } @@ -120,9 +115,33 @@ function makeRenderer(paths: Set, filesTo: string) { return ( - {rows.map((row, i) => ( - {renderNode(row, `r${i}`)} - ))} + {rows.map((row, i) => { + const highlighted = + highlightRange != null && + i >= highlightRange[0] && + i < highlightRange[1] + if (highlighted) { + return ( + + {renderNode(row, `r${i}`)} + + ) + } + return ( + {renderNode(row, `r${i}`)} + ) + })} ) } @@ -134,12 +153,45 @@ function makeRenderer(paths: Set, filesTo: string) { function LinkedYaml({ content, filesTo, + envNames, + envTo, + highlightStage, }: { content: string filesTo: string + envNames: Set + envTo: string + highlightStage?: string }) { const paths = useMemo(() => extractFilePaths(content), [content]) - const renderer = useMemo(() => makeRenderer(paths, filesTo), [paths, filesTo]) + const envRefs = useMemo(() => extractEnvRefs(content), [content]) + const highlightRange = useMemo( + () => (highlightStage ? findStageLineRange(content, highlightStage) : null), + [content, highlightStage], + ) + const firstHighlightRef = useRef(null) + const renderer = useMemo( + () => + makeRenderer( + paths, + filesTo, + envRefs, + envNames, + envTo, + highlightRange, + firstHighlightRef, + ), + [paths, filesTo, envRefs, envNames, envTo, highlightRange], + ) + + useEffect(() => { + if (highlightRange && firstHighlightRef.current) { + firstHighlightRef.current.scrollIntoView({ + block: "center", + behavior: "smooth", + }) + } + }, [highlightRange]) return ( @@ -164,19 +216,21 @@ function LinkedYaml({ // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- +const pipelineSearchSchema = z.object({ + ref: z.string().optional(), + stage: z.string().optional(), +}) + export const Route = createFileRoute( "/_layout/$accountName/$projectName/_layout/pipeline", )({ component: ProjectPipeline, + validateSearch: (search) => pipelineSearchSchema.parse(search), }) function ProjectPipeline() { const { accountName, projectName } = Route.useParams() - const layoutSearch = useSearch({ - from: "/_layout/$accountName/$projectName/_layout" as any, - strict: false, - }) as any - const ref: string | undefined = layoutSearch?.ref + const { ref, stage } = Route.useSearch() const pipelineQuery = useQuery({ queryKey: ["projects", accountName, projectName, "pipeline", ref], queryFn: () => @@ -186,9 +240,19 @@ function ProjectPipeline() { ref, }), }) + const { environmentsRequest } = useProjectEnvironments( + accountName, + projectName, + ref, + ) + const envNames = useMemo( + () => new Set(environmentsRequest.data?.map((e) => e.name) ?? []), + [environmentsRequest.data], + ) const [isDiagramExpanded, setIsDiagramExpanded] = useState(false) const filesTo = `/${accountName}/${projectName}/files` + const envTo = `/${accountName}/${projectName}/environments` return ( <> @@ -202,6 +266,7 @@ function ProjectPipeline() { {String(pipelineQuery.data.mermaid)} @@ -215,6 +280,9 @@ function ProjectPipeline() { ) : ( @@ -225,6 +293,9 @@ function ProjectPipeline() { )} diff --git a/frontend/src/routes/_layout/$accountName/$projectName/_layout/publications.tsx b/frontend/src/routes/_layout/$accountName/$projectName/_layout/publications.tsx index ba6076d1..34bbdda5 100644 --- a/frontend/src/routes/_layout/$accountName/$projectName/_layout/publications.tsx +++ b/frontend/src/routes/_layout/$accountName/$projectName/_layout/publications.tsx @@ -1,58 +1,58 @@ +import { ExternalLinkIcon } from "@chakra-ui/icons" import { - Flex, + Badge, Box, + Button, + Code, + Flex, + HStack, Heading, Icon, - Text, Link, - useColorModeValue, Menu, MenuButton, - Button, - Portal, - MenuList, MenuItem, - useDisclosure, - Badge, - Code, - HStack, - VStack, + MenuList, + Portal, + Text, Tooltip, + VStack, + useColorModeValue, + useDisclosure, } from "@chakra-ui/react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { - createFileRoute, Link as RouterLink, + createFileRoute, useNavigate, useSearch, } from "@tanstack/react-router" +import { useRef, useState } from "react" +import { FaCodeBranch, FaPlus, FaSync } from "react-icons/fa" import { FiFile } from "react-icons/fi" -import { FaPlus, FaSync, FaCodeBranch } from "react-icons/fa" import { SiOverleaf } from "react-icons/si" -import { ExternalLinkIcon } from "@chakra-ui/icons" import { z } from "zod" -import { useRef, useState } from "react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import type { Publication } from "../../../../../client" +import { ProjectsService } from "../../../../../client" +import type { ApiError } from "../../../../../client/core/ApiError" +import { ArtifactCompareModal } from "../../../../../components/Common/ArtifactCompareModal" import LoadingSpinner from "../../../../../components/Common/LoadingSpinner" -import { type Publication } from "../../../../../client" -import NewPublication from "../../../../../components/Publications/NewPublication" -import ImportOverleaf from "../../../../../components/Publications/ImportOverleaf" import PageMenu from "../../../../../components/Common/PageMenu" -import useProject, { - useProjectPublications, -} from "../../../../../hooks/useProject" -import PublicationView from "../../../../../components/Publications/PublicationView" +import ImportOverleaf from "../../../../../components/Publications/ImportOverleaf" +import NewPublication from "../../../../../components/Publications/NewPublication" import PdfAnnotator, { CommentList, commentToHighlight, type AnnotationHighlight, } from "../../../../../components/Publications/PdfAnnotator" -import { ProjectsService } from "../../../../../client" -import type { ApiError } from "../../../../../client/core/ApiError" +import PublicationView from "../../../../../components/Publications/PublicationView" +import useAuth from "../../../../../hooks/useAuth" import useCustomToast from "../../../../../hooks/useCustomToast" +import useProject, { + useProjectPublications, +} from "../../../../../hooks/useProject" import { handleError } from "../../../../../lib/errors" -import { ArtifactCompareModal } from "../../../../../components/Common/ArtifactCompareModal" -import useAuth from "../../../../../hooks/useAuth" const pubSearchSchema = z.object({ path: z.string().optional(), @@ -164,7 +164,15 @@ function PubInfo({ Pipeline stage: {" "} {publication.stage ? ( - {publication.stage} + + + {publication.stage} + + ) : ( Not in pipeline