/* Copyright 2026 Marimo. All rights reserved. */ import humanizeDuration from "humanize-duration"; import { Loader2Icon } from "lucide-react"; import React, { type JSX, type PropsWithChildren } from "react"; import { z } from "zod"; import { Progress } from "@/components/ui/progress"; import { clamp } from "@/utils/math"; import { renderHTML } from "../core/RenderHTML"; import type { IStatelessPlugin, IStatelessPluginProps, } from "../stateless-plugin"; interface Data { /** * The title of the progress bar. */ title?: string; /** * The subtitle of the progress bar. */ subtitle?: string; /** * The progress of the progress bar. * Number from 0 to 100, or `true` to indicate indeterminate progress if the count is unknown. */ progress: number | boolean; /** * The total value of the progress bar. */ total?: number; /** * The estimated time remaining in seconds. */ eta?: number; /** * The rate of progress in items per second. */ rate?: number; } export class ProgressPlugin implements IStatelessPlugin { tagName = "marimo-progress"; validator = z.object({ title: z.string().optional(), subtitle: z.string().optional(), progress: z.union([z.number(), z.boolean()]), total: z.number().optional(), eta: z.number().optional(), rate: z.number().optional(), }); render(props: IStatelessPluginProps): JSX.Element { return ; } } export const ProgressComponent = ({ title, subtitle, progress, total, eta, rate, }: PropsWithChildren): JSX.Element => { const alignment = typeof progress === "number" ? "items-start" : "items-center"; const renderProgress = () => { // With a known total, show a progress bar. if (typeof progress === "number" && total != null && total > 0) { return (
{progress} / {total}
); } // With an unknown total, show a spinner. return ( ); }; const renderMeta = () => { const hasCompleted = typeof progress === "number" && total != null && progress >= total; const elements: React.ReactNode[] = []; if (rate) { if (rate < 1) { elements.push({prettyTime(1 / rate)} per iter); } else { elements.push({rate} iter/s); } elements.push(·); } if (!hasCompleted && eta) { elements.push( ETA {prettyTime(eta)}, ·, ); } if (hasCompleted && rate) { const totalTime = progress / rate; elements.push( Total time {prettyTime(totalTime)}, ·, ); } // pop the last spacer elements.pop(); if (elements.length > 0) { return (
{elements}
); } }; return (
{title && (
{renderHTML({ html: title })}
)} {subtitle && (
{renderHTML({ html: subtitle })}
)}
{renderProgress()} {renderMeta()}
); }; function clampProgress(progress: number): number { return clamp(progress, 0, 100); } const shortDuration = humanizeDuration.humanizer({ language: "shortEn", languages: { shortEn: { y: () => "y", mo: () => "mo", w: () => "w", d: () => "d", h: () => "h", m: () => "m", s: () => "s", ms: () => "ms", }, }, }); export function prettyTime(seconds: number): string { return shortDuration(seconds * 1000, { language: "shortEn", largest: 2, spacer: "", maxDecimalPoints: seconds < 10 ? 2 : 0, }); }