diff --git a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx index 2b3253f2f..ba0e208df 100644 --- a/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx +++ b/apps/geolibre-desktop/src/components/processing/model-builder/ModelBuilderPanel.tsx @@ -32,6 +32,7 @@ import { Button, Input, Label, ScrollArea, Select, cn } from "@geolibre/ui"; import { ChevronDown, ChevronUp, + Copy, Download, GripVertical, LayoutGrid, @@ -64,6 +65,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { clamp } from "../../../lib/clamp"; import { createDuckDbCapability } from "../../../lib/duckdb-processing"; +import { modelGraphToPython } from "../../../lib/model-python-script"; import { buildModelToolCatalog, groupModelTools, @@ -296,6 +298,8 @@ export function ModelBuilderPanel({ const [search, setSearch] = useState(""); const [log, setLog] = useState([]); const [running, setRunning] = useState(false); + /** Script for the last graph that completed successfully, not later canvas edits. */ + const [pythonScript, setPythonScript] = useState(null); const [nodeStatus, setNodeStatus] = useState>({}); /** * The output port a keyboard/click user has armed, waiting for an input port @@ -442,6 +446,7 @@ export function ModelBuilderPanel({ // abortRun() clears the ref, so the in-flight run's `finally` no longer // matches its own controller and will not clear this itself. setRunning(false); + setPythonScript(null); setNodeStatus({}); setLog([]); }, [abortRun]); @@ -886,6 +891,7 @@ export function ModelBuilderPanel({ const controller = new AbortController(); abortRef.current = controller; setRunning(true); + setPythonScript(null); setNodeStatus({}); const duckdb = createDuckDbCapability(); // Outputs the host refused after the graph itself finished, so the summary @@ -946,6 +952,7 @@ export function ModelBuilderPanel({ // runModelGraph returns, so counting failures first would always read 0. await Promise.allSettled(pendingAdds); if (!controller.signal.aborted) { + if (!result.error) setPythonScript(modelGraphToPython(graph, resolveDescriptor)); appendLog( result.error ? `${t("processing.modelBuilder.runFailed")}: ${result.error.message}` @@ -964,6 +971,20 @@ export function ModelBuilderPanel({ } }, [issues.length, graph, resolveDescriptor, layers, addGeoJsonLayer, onAddRaster, appendLog, t]); + const handleCopyPython = useCallback(async () => { + if (!pythonScript) return; + try { + await navigator.clipboard.writeText(pythonScript); + appendLog(t("processing.modelBuilder.pythonCopied")); + } catch (error) { + appendLog( + `${t("processing.modelBuilder.pythonCopyFailed")}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }, [pythonScript, appendLog, t]); + // --- Panel chrome ------------------------------------------------------- const handleDragStart = (event: ReactPointerEvent) => { @@ -1314,6 +1335,18 @@ export function ModelBuilderPanel({ {" "} {!compact && t("processing.modelBuilder.exportModel")} + {running ? (