diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index ab887c45..28e564f4 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -20,6 +20,7 @@ import { setUpdateError, } from '@/shared/state/updateSlice'; import AppShell from './components/Layout/AppShell'; +import ImportEntryPoint from './components/share/ImportEntryPoint'; import DashboardSelection from './pages/DashboardSelection/DashboardSelection'; import ErrorBoundary from './components/feedback/ErrorBoundary'; import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice'; @@ -468,6 +469,7 @@ const ThemedApp: React.FC = () => { + diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx new file mode 100644 index 00000000..d5b604f6 --- /dev/null +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -0,0 +1,120 @@ +// The one global import affordance: a hidden file picker plus a window-wide +// drag-and-drop overlay. Mount once near the app root. A sidebar/page button +// opens the picker by dispatching IMPORT_OPEN_EVENT, so there's a single owner +// of the ImportModal (no duplicate modals). +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Fade from '@mui/material/Fade'; +import Typography from '@mui/material/Typography'; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +import ImportModal from './ImportModal'; + +export const IMPORT_OPEN_EVENT = 'openswarm:import-open'; + +const ACCEPT = '.swarm,.md,.zip'; + +function looksImportable(name: string): boolean { + const n = name.toLowerCase(); + return n.endsWith('.swarm') || n.endsWith('.md') || n.endsWith('.zip'); +} + +const ImportEntryPoint: React.FC = () => { + const c = useClaudeTokens(); + const inputRef = useRef(null); + const [pending, setPending] = useState(null); + const [dragging, setDragging] = useState(false); + const depth = useRef(0); + + const take = useCallback((f: File | null) => { + if (f && looksImportable(f.name)) setPending(f); + }, []); + + useEffect(() => { + const openPicker = () => inputRef.current?.click(); + window.addEventListener(IMPORT_OPEN_EVENT, openPicker); + return () => window.removeEventListener(IMPORT_OPEN_EVENT, openPicker); + }, []); + + useEffect(() => { + const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files'); + // Webviews are a separate compositor layer; ignore drops landing on one. + const onWebview = (t: EventTarget | null) => (t as HTMLElement)?.tagName === 'WEBVIEW'; + + const onEnter = (e: DragEvent) => { + if (!hasFiles(e) || onWebview(e.target)) return; + depth.current += 1; + setDragging(true); + }; + const onLeave = () => { + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setDragging(false); + }; + const onOver = (e: DragEvent) => { + if (hasFiles(e)) e.preventDefault(); + }; + const onDrop = (e: DragEvent) => { + depth.current = 0; + setDragging(false); + if (onWebview(e.target)) return; + const f = e.dataTransfer?.files?.[0]; + if (f) { + e.preventDefault(); + take(f); + } + }; + + window.addEventListener('dragenter', onEnter); + window.addEventListener('dragleave', onLeave); + window.addEventListener('dragover', onOver); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onEnter); + window.removeEventListener('dragleave', onLeave); + window.removeEventListener('dragover', onOver); + window.removeEventListener('drop', onDrop); + }; + }, [take]); + + return ( + <> + { + take(e.target.files?.[0] || null); + e.target.value = ''; + }} + /> + + + + + Drop to import into OpenSwarm + + + + setPending(null)} /> + + ); +}; + +export default ImportEntryPoint; diff --git a/frontend/src/app/components/share/ImportModal.tsx b/frontend/src/app/components/share/ImportModal.tsx new file mode 100644 index 00000000..d5ea38d1 --- /dev/null +++ b/frontend/src/app/components/share/ImportModal.tsx @@ -0,0 +1,171 @@ +// Import side of .swarm: preflight shows what's inside (and any environment +// requirements as informational "Needs X" rows), then commit writes the +// entities with fresh ids and we navigate to the imported root. Requirements in +// v1 are informational only; the live "enable this Action" walkthrough lands +// with the app/dashboard slices, so we never imply a grant we don't perform. +import React, { useCallback, useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Dialog from '@mui/material/Dialog'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import CircularProgress from '@mui/material/CircularProgress'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import CloseIcon from '@mui/icons-material/Close'; +import { useNavigate } from 'react-router-dom'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +import IncludesList from './IncludesList'; +import { importCommit, importPreflight } from './shareApi'; +import { ImportPreflight } from './shareTypes'; + +interface Props { + file: File | null; + open: boolean; + onClose: () => void; +} + +const DEST: Record string> = { + app: (id) => `/apps/${id}`, + dashboard: (id) => `/dashboard/${id}`, + skill: () => '/skills', +}; + +const ImportModal: React.FC = ({ file, open, onClose }) => { + const c = useClaudeTokens(); + const navigate = useNavigate(); + const [preflight, setPreflight] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [committing, setCommitting] = useState(false); + + const load = useCallback(() => { + if (!file) return undefined; + setPreflight(null); + setError(''); + setLoading(true); + let alive = true; + importPreflight(file) + .then((pf) => alive && setPreflight(pf)) + .catch((e) => alive && setError(e?.message || "We couldn't read this file.")) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [file]); + + useEffect(() => { + if (!open) return; + return load(); + }, [open, load]); + + const handleCommit = async () => { + if (!preflight) return; + setCommitting(true); + try { + const result = await importCommit(preflight.staging_token); + const dest = (DEST[result.root_type] || (() => '/skills'))(result.root_id); + onClose(); + navigate(dest); + } catch (e: any) { + setError(e?.message || "We couldn't finish the import."); + } finally { + setCommitting(false); + } + }; + + return ( + <> + + + + Import {preflight ? preflight.summary.root.name : ''} + + + + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + + ) : preflight ? ( + <> + + {preflight.conflicts.length > 0 && ( + + Some items already exist and will be added as copies. + + )} + {preflight.warnings.map((w, i) => ( + + {w} + + ))} + + + + + ) : null} + + + + setError('')} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + {error} + + + + ); +}; + +export default ImportModal; diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 482208e8..c34be184 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -52,6 +52,8 @@ import { } from '@/shared/state/skillRegistrySlice'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; import ShareButton from '@/app/components/share/ShareButton'; +import { IMPORT_OPEN_EVENT } from '@/app/components/share/ImportEntryPoint'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat'; interface SkillForm { @@ -315,6 +317,15 @@ const Skills: React.FC = () => { Skills + + window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT))} + sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} + > + + +