/* Copyright 2026 Marimo. All rights reserved. */ import { RefreshCwIcon } from "lucide-react"; import { type JSX, useEffect, useState } from "react"; import timestring from "timestring"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { NativeSelect } from "@/components/ui/native-select"; import type { IPlugin, IPluginProps } from "@/plugins/types"; import { cn } from "@/utils/cn"; import { useEvent } from "../../hooks/useEvent"; import { Labeled } from "./common/labeled"; type Value = string | number | undefined; interface Data { /** * The refresh interval in seconds. * * It may also be a human-readable string like "1m" or "1h" or "3h 30m". * These will be converted to seconds. */ options: (string | number)[]; /** * The initial value. */ defaultInterval?: string | number; label?: string | null; } const MIN_INTERVAL = 0.1; const zodTimestring = z.string().superRefine((s, ctx) => { try { const seconds = timestring(s); if (seconds < MIN_INTERVAL) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Must be greater than ${MIN_INTERVAL} seconds.`, }); } return; } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Must be a valid timestring. e.g. 1m, 30m, 1h.", }); return; } }); export class RefreshPlugin implements IPlugin { tagName = "marimo-refresh"; validator = z.object({ options: z .array(z.union([zodTimestring, z.number().min(MIN_INTERVAL)])) .default([]), defaultInterval: z .union([zodTimestring, z.number().min(MIN_INTERVAL)]) .optional(), label: z.string().nullable(), }); render(props: IPluginProps): JSX.Element { return ; } } const OFF = "off"; let count = 0; const RefreshComponent = ({ setValue, data }: IPluginProps) => { // internal selection const [selected, setSelected] = useState( data.defaultInterval ?? OFF, ); // reset selection when defaultInterval changes useEffect(() => { setSelected(data.defaultInterval ?? OFF); }, [data.defaultInterval]); const [spin, setSpin] = useState(false); const refresh = useEvent(() => { setSpin(true); setValue(() => `${selected} (${count++})`); setTimeout(() => setSpin(false), 500); // spin for 500ms }); useEffect(() => { if (selected === OFF) { return; } let asSeconds = typeof selected === "number" ? selected : /[a-z]/.test(selected) // check if has units ? timestring(selected) : timestring(`${selected}s`); // default to seconds if no units // Constrain to smallest interval asSeconds = Math.max(asSeconds, MIN_INTERVAL); const id = setInterval(refresh, asSeconds * 1000); return () => clearInterval(id); }, [selected, refresh]); const noShadow = "shadow-none! hover:shadow-none! focus:shadow-none! active:shadow-none!"; const hasOptions = data.options.length > 0; return ( { setSelected(e.target.value); }} value={selected} className={cn( noShadow, "border mb-0 bg-secondary rounded rounded-tl-none rounded-bl-none hover:bg-secondary/60", !hasOptions && "hidden", )} > {data.options.map((option) => ( ))} ); };