mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-25 14:02:22 +02:00
[eric] swarm: .swarm import flow (drag-drop + file picker, preflight/commit modal)
This commit is contained in:
@@ -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 = () => {
|
||||
<DefaultModelGuard>
|
||||
<UpdateListener>
|
||||
<CrashRecoveryChip />
|
||||
<ImportEntryPoint />
|
||||
<DeepLinkListener>
|
||||
<ErrorBoundary scope="routes">
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -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<HTMLInputElement | null>(null);
|
||||
const [pending, setPending] = useState<File | null>(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 (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
take(e.target.files?.[0] || null);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Fade in={dragging} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
bgcolor: `${c.bg.page}e6`,
|
||||
border: `2px dashed ${c.accent.primary}`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<FileDownloadIcon sx={{ fontSize: 40, color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>
|
||||
Drop to import into OpenSwarm
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
<ImportModal file={pending} open={!!pending} onClose={() => setPending(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEntryPoint;
|
||||
@@ -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, (id: string) => string> = {
|
||||
app: (id) => `/apps/${id}`,
|
||||
dashboard: (id) => `/dashboard/${id}`,
|
||||
skill: () => '/skills',
|
||||
};
|
||||
|
||||
const ImportModal: React.FC<Props> = ({ file, open, onClose }) => {
|
||||
const c = useClaudeTokens();
|
||||
const navigate = useNavigate();
|
||||
const [preflight, setPreflight] = useState<ImportPreflight | null>(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 (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: 440,
|
||||
maxWidth: '92vw',
|
||||
bgcolor: c.bg.page,
|
||||
borderRadius: `${c.radius.xl}px`,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
boxShadow: c.shadow.lg,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2.5, pb: 1 }}>
|
||||
<Typography sx={{ fontSize: '1.05rem', fontWeight: 700, color: c.text.primary }}>
|
||||
Import {preflight ? preflight.summary.root.name : ''}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 3, pb: 3 }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress size={20} sx={{ color: c.accent.primary }} />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Box sx={{ py: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, mb: 1 }}>{error}</Typography>
|
||||
<Button size="small" onClick={load} sx={{ textTransform: 'none', color: c.accent.primary }}>
|
||||
Try again
|
||||
</Button>
|
||||
</Box>
|
||||
) : preflight ? (
|
||||
<>
|
||||
<IncludesList summary={preflight.summary} />
|
||||
{preflight.conflicts.length > 0 && (
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 1.5 }}>
|
||||
Some items already exist and will be added as copies.
|
||||
</Typography>
|
||||
)}
|
||||
{preflight.warnings.map((w, i) => (
|
||||
<Typography key={`w-${i}`} sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 0.5 }}>
|
||||
{w}
|
||||
</Typography>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCommit}
|
||||
disabled={committing}
|
||||
startIcon={committing ? <CircularProgress size={14} sx={{ color: c.text.inverse }} /> : undefined}
|
||||
sx={{
|
||||
bgcolor: c.accent.primary,
|
||||
'&:hover': { bgcolor: c.accent.pressed },
|
||||
'&.Mui-disabled': { bgcolor: c.border.medium, color: c.text.muted },
|
||||
textTransform: 'none',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
px: 2.5,
|
||||
py: 0.6,
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
Add to OpenSwarm
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={!!error && !open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setError('')}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="error" variant="outlined" sx={{ bgcolor: c.bg.surface, color: c.text.primary }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportModal;
|
||||
@@ -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 = () => {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, pt: 2, pb: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.text.primary }}>Skills</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.25 }}>
|
||||
<Tooltip title="Import .swarm">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT))}
|
||||
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<UploadFileIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Search">
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
Reference in New Issue
Block a user