mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 21:42:22 +02:00
[eric] swarm: drop-to-import with GPU-safe pixel digest; modal only for code/action bundles
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// The "digest" flash that plays where you drop a .swarm: an expanding ring of
|
||||
// brand-tinted dithered pixels, evoking PixelBlast WITHOUT any WebGL. PixelBlast
|
||||
// is a single shared WebGL2 context (one canvas, reparented) and reusing it here
|
||||
// would fight an app's loading animation over that one canvas, plus rapid
|
||||
// WebGL-context churn is the exact thing that crashed the GPU process. So this is
|
||||
// plain Canvas2D on ONE pooled canvas, and play() refuses to start while a burst
|
||||
// is already running, so drop-spam can never pile up work.
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
export interface DigestHandle {
|
||||
// Returns false if a burst is already playing (caller should ignore the drop).
|
||||
play: (x: number, y: number) => boolean;
|
||||
}
|
||||
|
||||
const SIZE = 240;
|
||||
const CELL = 6;
|
||||
const DURATION = 680;
|
||||
const RADIUS_MAX = 132;
|
||||
|
||||
function dither(gx: number, gy: number): number {
|
||||
const v = Math.sin(gx * 12.9898 + gy * 78.233) * 43758.5453;
|
||||
return v - Math.floor(v);
|
||||
}
|
||||
|
||||
const ImportDigest = forwardRef<DigestHandle, { color?: string }>(({ color = '#c4633a' }, ref) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const busyRef = useRef(false);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play(x: number, y: number): boolean {
|
||||
if (busyRef.current) return false;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return false;
|
||||
|
||||
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
busyRef.current = true;
|
||||
canvas.style.left = `${x - SIZE / 2}px`;
|
||||
canvas.style.top = `${y - SIZE / 2}px`;
|
||||
canvas.style.opacity = '1';
|
||||
|
||||
const finish = () => {
|
||||
busyRef.current = false;
|
||||
canvas.style.opacity = '0';
|
||||
};
|
||||
if (reduce) {
|
||||
// Honor reduced-motion: no flashing pixels, just a brief, calm beat.
|
||||
window.setTimeout(finish, 200);
|
||||
return true;
|
||||
}
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = SIZE * dpr;
|
||||
canvas.height = SIZE * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
ctx.scale(dpr, dpr);
|
||||
const cells = Math.ceil(SIZE / CELL);
|
||||
const center = SIZE / 2;
|
||||
const start = performance.now();
|
||||
|
||||
const frame = () => {
|
||||
const t = Math.min(1, (performance.now() - start) / DURATION);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const ring = eased * RADIUS_MAX;
|
||||
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||
ctx.fillStyle = color;
|
||||
for (let gy = 0; gy < cells; gy++) {
|
||||
for (let gx = 0; gx < cells; gx++) {
|
||||
const px = gx * CELL + CELL / 2;
|
||||
const py = gy * CELL + CELL / 2;
|
||||
const dist = Math.hypot(px - center, py - center);
|
||||
const band = 1 - Math.abs(dist - ring) / 34; // bright at the expanding front
|
||||
if (band <= 0) continue;
|
||||
const a = band * (0.35 + 0.65 * dither(gx, gy)) * (1 - t * 0.25);
|
||||
if (a <= 0) continue;
|
||||
ctx.globalAlpha = a > 1 ? 1 : a;
|
||||
ctx.fillRect(gx * CELL, gy * CELL, CELL - 1, CELL - 1);
|
||||
}
|
||||
}
|
||||
if (t < 1) {
|
||||
rafRef.current = requestAnimationFrame(frame);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(frame);
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={SIZE}
|
||||
height={SIZE}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2100,
|
||||
opacity: 0,
|
||||
transition: 'opacity 160ms ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ImportDigest.displayName = 'ImportDigest';
|
||||
export default ImportDigest;
|
||||
@@ -1,36 +1,108 @@
|
||||
// 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).
|
||||
// The one global import affordance. Drop a .swarm anywhere (or pick it): a
|
||||
// GPU-safe pixel "digest" flash plays where you dropped it WHILE the preflight
|
||||
// runs underneath, then it resolves straight into the import for safe bundles or
|
||||
// a short confirm for ones that carry code/actions. Mount once near the app root.
|
||||
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 Snackbar from '@mui/material/Snackbar';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
import ImportDigest, { DigestHandle } from './ImportDigest';
|
||||
import ImportModal from './ImportModal';
|
||||
import { importCommit, importPreflight } from './shareApi';
|
||||
import { ImportPreflight } from './shareTypes';
|
||||
|
||||
export const IMPORT_OPEN_EVENT = 'openswarm:import-open';
|
||||
|
||||
const ACCEPT = '.swarm,.md,.zip';
|
||||
const DIGEST_MS = 700;
|
||||
|
||||
const DEST: Record<string, (id: string) => string | null> = {
|
||||
app: (id) => `/apps/${id}`,
|
||||
dashboard: (id) => `/dashboard/${id}`,
|
||||
};
|
||||
|
||||
function looksImportable(name: string): boolean {
|
||||
const n = name.toLowerCase();
|
||||
return n.endsWith('.swarm') || n.endsWith('.md') || n.endsWith('.zip');
|
||||
}
|
||||
|
||||
// A bundle needs a confirm only if it can run code (an app) or wants actions
|
||||
// connected; everything else is inert data and imports straight away.
|
||||
function needsConfirm(pf: ImportPreflight): boolean {
|
||||
const s = pf.summary;
|
||||
const hasApp = s.root.type === 'app' || s.includes.some((i) => i.type === 'app');
|
||||
const hasAction = s.requirements.some((r) => r.kind === 'mcp_action');
|
||||
const risky = !!pf.review && pf.review.verdict !== 'clean';
|
||||
return hasApp || hasAction || risky;
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
const ImportEntryPoint: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [pending, setPending] = useState<File | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const digestRef = useRef<DigestHandle | null>(null);
|
||||
const depth = useRef(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [confirm, setConfirm] = useState<ImportPreflight | null>(null);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [toast, setToast] = useState<{ msg: string; sev: 'success' | 'error' } | null>(null);
|
||||
const confirmRef = useRef(false); // ignore new drops while a confirm is up
|
||||
|
||||
const take = useCallback((f: File | null) => {
|
||||
if (f && looksImportable(f.name)) setPending(f);
|
||||
}, []);
|
||||
const finish = useCallback(
|
||||
(rootType: string, rootId: string, name: string) => {
|
||||
setToast({ msg: `Added ${name}`, sev: 'success' });
|
||||
const to = DEST[rootType]?.(rootId);
|
||||
if (to) navigate(to);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const commitAndFinish = useCallback(
|
||||
async (pf: ImportPreflight) => {
|
||||
setCommitting(true);
|
||||
try {
|
||||
const res = await importCommit(pf.staging_token);
|
||||
finish(res.root_type, res.root_id, pf.summary.root.name);
|
||||
setConfirm(null);
|
||||
confirmRef.current = false;
|
||||
} catch (e: any) {
|
||||
setToast({ msg: e?.message || "We couldn't finish the import.", sev: 'error' });
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
},
|
||||
[finish],
|
||||
);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File | null, x: number, y: number) => {
|
||||
if (!file || !looksImportable(file.name) || confirmRef.current) return;
|
||||
// The digest doubles as the spam guard: it refuses to start while busy.
|
||||
if (!digestRef.current?.play(x, y)) return;
|
||||
let pf: ImportPreflight;
|
||||
try {
|
||||
[, pf] = await Promise.all([delay(DIGEST_MS), importPreflight(file)]);
|
||||
} catch (e: any) {
|
||||
setToast({ msg: e?.message || "We couldn't read this file.", sev: 'error' });
|
||||
return;
|
||||
}
|
||||
if (needsConfirm(pf)) {
|
||||
confirmRef.current = true;
|
||||
setConfirm(pf);
|
||||
} else {
|
||||
commitAndFinish(pf);
|
||||
}
|
||||
},
|
||||
[commitAndFinish],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const openPicker = () => inputRef.current?.click();
|
||||
@@ -40,9 +112,7 @@ const ImportEntryPoint: React.FC = () => {
|
||||
|
||||
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;
|
||||
@@ -62,10 +132,9 @@ const ImportEntryPoint: React.FC = () => {
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
take(f);
|
||||
void handleFile(f, e.clientX, e.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dragenter', onEnter);
|
||||
window.addEventListener('dragleave', onLeave);
|
||||
window.addEventListener('dragover', onOver);
|
||||
@@ -76,7 +145,7 @@ const ImportEntryPoint: React.FC = () => {
|
||||
window.removeEventListener('dragover', onOver);
|
||||
window.removeEventListener('drop', onDrop);
|
||||
};
|
||||
}, [take]);
|
||||
}, [handleFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,10 +155,11 @@ const ImportEntryPoint: React.FC = () => {
|
||||
accept={ACCEPT}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
take(e.target.files?.[0] || null);
|
||||
void handleFile(e.target.files?.[0] || null, window.innerWidth / 2, window.innerHeight / 2);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<ImportDigest ref={digestRef} color={c.accent.primary} />
|
||||
<Fade in={dragging} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -108,11 +178,35 @@ const ImportEntryPoint: React.FC = () => {
|
||||
>
|
||||
<FileDownloadIcon sx={{ fontSize: 40, color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>
|
||||
Drop to import into OpenSwarm
|
||||
Drop to add to OpenSwarm
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
<ImportModal file={pending} open={!!pending} onClose={() => setPending(null)} />
|
||||
<ImportModal
|
||||
preflight={confirm}
|
||||
open={!!confirm}
|
||||
committing={committing}
|
||||
onConfirm={() => confirm && commitAndFinish(confirm)}
|
||||
onClose={() => {
|
||||
setConfirm(null);
|
||||
confirmRef.current = false;
|
||||
}}
|
||||
/>
|
||||
<Snackbar
|
||||
open={!!toast}
|
||||
autoHideDuration={3500}
|
||||
onClose={() => setToast(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={toast?.sev || 'success'}
|
||||
variant="outlined"
|
||||
onClose={() => setToast(null)}
|
||||
sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}` }}
|
||||
>
|
||||
{toast?.msg}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,187 +1,102 @@
|
||||
// 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';
|
||||
// Confirmation surface shown only for bundles that carry something with a
|
||||
// consequence (an app that runs code, or actions that must be connected). Safe
|
||||
// bundles never reach here; the entry point auto-imports them. This is purely
|
||||
// presentational: the entry point owns preflight, commit, and navigation.
|
||||
import React 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;
|
||||
preflight: ImportPreflight | null;
|
||||
open: boolean;
|
||||
committing: boolean;
|
||||
onConfirm: () => void;
|
||||
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 ImportModal: React.FC<Props> = ({ preflight, open, committing, onConfirm, 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
|
||||
<Dialog
|
||||
open={open && !!preflight}
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{preflight && (
|
||||
<>
|
||||
<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 }}>
|
||||
Add {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 }}>
|
||||
<IncludesList summary={preflight.summary} />
|
||||
{preflight.review && preflight.review.findings.length > 0 && (
|
||||
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.status.warning}55`, bgcolor: c.status.warningBg }}>
|
||||
{preflight.review.findings.map((f, i) => (
|
||||
<Typography key={`rv-${i}`} sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
{f}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
|
||||
<Button onClick={onClose} sx={{ textTransform: 'none', color: c.text.secondary }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onConfirm}
|
||||
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>
|
||||
) : preflight ? (
|
||||
<>
|
||||
<IncludesList summary={preflight.summary} />
|
||||
{preflight.review && preflight.review.findings.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${c.status.warning}55`,
|
||||
bgcolor: c.status.warningBg,
|
||||
}}
|
||||
>
|
||||
{preflight.review.findings.map((f, i) => (
|
||||
<Typography key={`rv-${i}`} sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
{f}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user