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