diff --git a/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx b/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx index 4bdd1bfb233..2166eec5734 100644 --- a/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx +++ b/libs/remix-ui/tabs/src/lib/components/CompileDropdown.tsx @@ -13,37 +13,106 @@ interface CompileDropdownProps { plugin?: any disabled?: boolean compiledFileName?: string - onNotify?: (msg: string) => void onOpen?: () => void onRequestCompileAndPublish?: (type: string) => void setCompileState: (state: 'idle' | 'compiling' | 'compiled') => void } -export const CompileDropdown: React.FC = ({ tabPath, plugin, disabled, onNotify, onOpen, onRequestCompileAndPublish, compiledFileName, setCompileState }) => { +export const CompileDropdown: React.FC = ({ tabPath, plugin, disabled, onOpen, onRequestCompileAndPublish, compiledFileName, setCompileState }) => { const [scriptFiles, setScriptFiles] = useState([]) - const compileThen = async (nextAction: () => void) => { + const compileThen = async (nextAction: () => void, actionName: string) => { setCompileState('compiling') - setTimeout(async () => { - plugin.once('solidity', 'compilationFinished', (data) => { - const hasErrors = data.errors && data.errors.filter(e => e.severity === 'error').length > 0 - if (hasErrors) { - setCompileState('idle') - plugin.call('notification', 'toast', 'Compilation failed') - } else { - setCompileState('compiled') - nextAction() + try { + await plugin.call('fileManager', 'saveCurrentFile') + await plugin.call('manager', 'activatePlugin', 'solidity') + + const startedAt = Date.now() + const targetPath = tabPath || '' + + const waitForFreshCompilationResult = async ( + path: string, + startMs: number, + maxWaitMs = 1500, + intervalMs = 120 + ) => { + const norm = (p: string) => p.replace(/^\/+/, '') + const fileName = (norm(path).split('/').pop() || norm(path)).toLowerCase() + const hasFile = (res: any) => { + if (!res) return false + const inContracts = + res.contracts && typeof res.contracts === 'object' && + Object.keys(res.contracts).some(k => k.toLowerCase().endsWith(fileName) || norm(k).toLowerCase() === norm(path).toLowerCase()) + const inSources = + res.sources && typeof res.sources === 'object' && + Object.keys(res.sources).some(k => k.toLowerCase().endsWith(fileName) || norm(k).toLowerCase() === norm(path).toLowerCase()) + return inContracts || inSources } - }) - try { - await plugin.call('solidity', 'compile', tabPath) - } catch (e) { - console.error(e) + let last: any = null + const until = startMs + maxWaitMs + while (Date.now() < until) { + try { + const res = await plugin.call('solidity', 'getCompilationResult') + last = res + const ts = (res && (res.timestamp || res.timeStamp || res.time || res.generatedAt)) || null + const isFreshTime = typeof ts === 'number' ? ts >= startMs : true + if (res && hasFile(res) && isFreshTime) return res + } catch {} + await new Promise(r => setTimeout(r, intervalMs)) + } + return last + } + + let settled = false + let watchdog: NodeJS.Timeout | null = null + const cleanup = () => { + try { plugin.off('solidity', 'compilationFinished', onFinished) } catch {} + if (watchdog) { clearTimeout(watchdog); watchdog = null } + } + + const finishWithErrorUI = async () => { setCompileState('idle') + await plugin.call('manager', 'activatePlugin', 'solidity') + await plugin.call('menuicons', 'select', 'solidity') + plugin.call('notification', 'toast', `Compilation failed, skipping '${actionName}'.`) } - }, 0) + + const onFinished = async () => { + if (settled) return + settled = true + cleanup() + + const fresh = await waitForFreshCompilationResult(targetPath, startedAt).catch(() => null) + if (!fresh) { + await finishWithErrorUI() + return + } + + const errs = Array.isArray(fresh.errors) + ? fresh.errors.filter((e: any) => (e.severity || e.type) === 'error') + : [] + + if (errs.length > 0) { + await finishWithErrorUI() + return + } + + setCompileState('compiled') + nextAction() + } + + plugin.on('solidity', 'compilationFinished', onFinished) + + watchdog = setTimeout(() => { onFinished() }, 10000) + + await plugin.call('solidity', 'compile', targetPath) + + } catch (e) { + console.error(e) + setCompileState('idle') + } } const fetchScripts = async () => { @@ -55,10 +124,8 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi } const tsFiles = Object.keys(files).filter(f => f.endsWith('.ts')) setScriptFiles(tsFiles) - onNotify?.(`Loaded ${tsFiles.length} script files`) } catch (err) { console.error("Failed to read scripts directory:", err) - onNotify?.("Failed to read scripts directory") } } @@ -66,8 +133,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi await compileThen(async () => { const content = await plugin.call('fileManager', 'readFile', path) await plugin.call('scriptRunnerBridge', 'execute', content, path) - onNotify?.(`Executed script: ${path}`) - }) + }, 'Run Script') } const runRemixAnalysis = async () => { @@ -78,8 +144,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi await plugin.call('manager', 'activatePlugin', 'solidityStaticAnalysis') } plugin.call('menuicons', 'select', 'solidityStaticAnalysis') - onNotify?.("Ran Remix static analysis") - }) + }, 'Run Remix Analysis') } const handleScanContinue = async () => { @@ -87,7 +152,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi const firstSlashIndex = compiledFileName.indexOf('/') const finalPath = firstSlashIndex > 0 ? compiledFileName.substring(firstSlashIndex + 1) : compiledFileName await handleSolidityScan(plugin, finalPath) - }) + }, 'Run Solidity Scan') } const runSolidityScan = async () => { @@ -119,7 +184,6 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi await plugin.call('manager', 'activatePlugin', 'solidity') } plugin.call('menuicons', 'select', 'solidity') - onNotify?.("Ran Remix Solidity Compiler") } const items: MenuItem[] = [ @@ -139,7 +203,7 @@ export const CompileDropdown: React.FC = ({ tabPath, plugi icon: , dataId: 'compile-run-analysis-menu-item', submenu: [ - { label: 'Run Remix Analysis', icon: , onClick: runRemixAnalysis, dataId: 'run-remix-analysis-submenu-item' }, + { label: 'Run Remix Analysis', icon: , onClick: runRemixAnalysis, dataId: 'run-remix-analysis-submenu-item' }, { label: 'Run Solidity Scan', icon: , onClick: runSolidityScan, dataId: 'run-solidity-scan-submenu-item' } ] }, diff --git a/libs/remix-ui/tabs/src/lib/components/DropdownMenu.css b/libs/remix-ui/tabs/src/lib/components/DropdownMenu.css index d2ebc8d8704..8b004ff04e8 100644 --- a/libs/remix-ui/tabs/src/lib/components/DropdownMenu.css +++ b/libs/remix-ui/tabs/src/lib/components/DropdownMenu.css @@ -1,87 +1,89 @@ .custom-dropdown-wrapper { - position: relative; - display: inline-block; - z-index: 1000; + position: relative; + display: inline-block; + z-index: 1000; } .custom-dropdown-trigger { - /* color: var(--text); */ - /* background: var(--custom-select, #2d2f3b); */ - padding: 4px 8px; - border-left: 1px solid var(--bs-secondary); - border-radius: 0 4px 4px 0; - cursor: pointer; - height: 28px; - font-size: 11px; - font-weight: 700; + padding: 4px 8px; + border-left: 1px solid var(--bs-border-color, var(--bs-secondary)); + border-radius: 0 4px 4px 0; + cursor: pointer; + height: 28px; + font-size: 11px; + font-weight: 700; } -.custom-dropdown-panel { - background: var(--custom-select); - border: 1px solid var(--bs-secondary); - border-radius: 4px; - position: absolute; - top: calc(100% + 4px); - left: 0; - color: var(--text); - padding: 0; +.custom-dropdown-panel.dropdown-menu { + padding: 0; + min-width: auto; + z-index: 1310; + isolation: isolate; + background-color: var(--bs-dropdown-bg, var(--bs-body-bg)) !important; + color: var(--bs-dropdown-color, var(--bs-body-color)); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius, .375rem); + box-shadow: var(--bs-box-shadow, 0 .5rem 1rem rgba(0,0,0,.15)); } -.custom-dropdown-item { - display: flex; - align-items: center; - gap: 5px; - padding: 8px 16px; - color: var(--text); - cursor: pointer; - white-space: nowrap; - position: relative +.custom-dropdown-item.dropdown-item { + display: flex; + align-items: center; + gap: 5px; + color: var(--bs-dropdown-link-color, var(--bs-body-color)); + white-space: nowrap; + position: relative; + padding: 8px 16px; + background-color: transparent; } .custom-dropdown-item-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - display: block; - align-self: center; - line-height: 1; + width: 16px; + height: 16px; + flex-shrink: 0; + display: block; + line-height: 1; } - .custom-dropdown-item > span:not(.custom-dropdown-item-icon) { - flex-grow: 1; - display: flex; - align-items: center; - margin-right: 12px; + flex-grow: 1; + display: flex; + align-items: center; + margin-right: 12px; } - .custom-dropdown-item .custom-dropdown-item-icon:last-child { - margin-left: auto; + margin-left: auto; } -.custom-dropdown-item:hover { - background: var(--bs-secondary); +.custom-dropdown-item.dropdown-item:hover, +.custom-dropdown-submenu .custom-dropdown-item.dropdown-item:hover, +.custom-dropdown-item.dropdown-item:focus, +.custom-dropdown-submenu .custom-dropdown-item.dropdown-item:focus { + background-color: var(--bs-dropdown-link-hover-bg) !important; + color: var(--bs-dropdown-link-hover-color, var(--bs-body-color)) !important; + outline: none; + z-index: 1330; } -.custom-dropdown-submenu { - position: absolute; - top: 0; - left: 100%; - background: var(--custom-select); - border: 1px solid var(--bs-secondary); - border-radius: 4px; - min-width: 200px; - color: var(--text); -} +.custom-dropdown-item.disabled { opacity: 0.4; pointer-events: none; } +.custom-dropdown-item.border-top { border-top: 1px solid var(--bs-border-color); } +.custom-dropdown-item.border-bottom { border-bottom: 1px solid var(--bs-border-color); } -.custom-dropdown-item.disabled { - opacity: 0.4; - pointer-events: none; -} +.custom-dropdown-item.has-submenu { position: relative; } +.custom-dropdown-submenu.dropdown-menu { + position: absolute; + top: 0; + left: calc(100% - var(--bs-border-width, 1px)); + min-width: 200px; + padding: 0; + z-index: 1320; + transform: translateZ(0); + box-shadow: var(--bs-box-shadow); -.custom-dropdown-item.border-top { - border-top: 1px solid var(--bs-secondary); + background-color: var(--bs-dropdown-bg, var(--bs-body-bg)) !important; + color: var(--bs-dropdown-color, var(--bs-body-color)); + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius, .375rem); } - -.custom-dropdown-item.border-bottom { - border-bottom: 1px solid var(--bs-secondary); +.custom-dropdown-submenu .custom-dropdown-item.bg-light { + background-color: transparent !important; } diff --git a/libs/remix-ui/tabs/src/lib/components/DropdownMenu.tsx b/libs/remix-ui/tabs/src/lib/components/DropdownMenu.tsx index 0735b23b6cf..53ff83011fa 100644 --- a/libs/remix-ui/tabs/src/lib/components/DropdownMenu.tsx +++ b/libs/remix-ui/tabs/src/lib/components/DropdownMenu.tsx @@ -20,13 +20,15 @@ interface DropdownMenuProps { panelDataId?: string } -const DropdownMenu: React.FC = ({ items, disabled, onOpen, triggerDataId, panelDataId }) => { +const DropdownMenu: React.FC = ({ + items, disabled, onOpen, triggerDataId, panelDataId +}) => { const [open, setOpen] = useState(false) const [activeSubmenu, setActiveSubmenu] = useState(null) const ref = useRef(null) useEffect(() => { - function handleClickOutside(event: MouseEvent) { + const handleClickOutside = (event: MouseEvent) => { if (ref.current && !ref.current.contains(event.target as Node)) { setOpen(false) setActiveSubmenu(null) @@ -37,13 +39,17 @@ const DropdownMenu: React.FC = ({ items, disabled, onOpen, tr }, []) return ( -
+
- - {open && ( -
- {items.map((item, idx) => ( +
+ {items.map((item, idx) => { + const hasSub = !!item.submenu?.length + return (
!disabled && item.submenu && setActiveSubmenu(idx)} + className={[ + 'dropdown-item', + 'custom-dropdown-item', + disabled ? 'disabled' : '', + item.borderTop ? 'border-top' : '', + item.borderBottom ? 'border-bottom' : '', + hasSub ? 'has-submenu' : '' + ].join(' ')} + onMouseEnter={() => !disabled && hasSub && setActiveSubmenu(idx)} onMouseLeave={() => !disabled && setActiveSubmenu(null)} onClick={() => { if (!disabled && item.onClick) { @@ -67,25 +84,35 @@ const DropdownMenu: React.FC = ({ items, disabled, onOpen, tr } }} data-id={item.dataId} + role="menuitem" > {item.icon && {item.icon}} {item.label} - {item.submenu && } + {hasSub && ( + + + + )} - {activeSubmenu === idx && item.submenu && ( -
- {item.submenu.map((sub, subIdx) => ( + {activeSubmenu === idx && hasSub && ( +
+ {item.submenu!.map((sub, subIdx) => (
{ - if (!disabled && sub.onClick){ + if (!disabled && sub.onClick) { sub.onClick() setOpen(false) } - } - } + }} data-id={sub.dataId} + role="menuitem" > {sub.icon && {sub.icon}} {sub.label} @@ -94,11 +121,11 @@ const DropdownMenu: React.FC = ({ items, disabled, onOpen, tr
)}
- ))} -
- )} + ) + })} +
) } -export default DropdownMenu \ No newline at end of file +export default DropdownMenu diff --git a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx index 0ff12488e58..5b3d0598a9b 100644 --- a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx +++ b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx @@ -70,7 +70,7 @@ const tabsReducer = (state: ITabsState, action: ITabsAction) => { return state } } -const PlayExtList = ['js', 'ts', 'sol', 'circom', 'vy', 'nr'] +const PlayExtList = ['js', 'ts', 'sol', 'circom', 'vy', 'nr', 'yul'] export const TabsUI = (props: TabsUIProps) => { @@ -83,6 +83,10 @@ export const TabsUI = (props: TabsUIProps) => { tabs.current = props.tabs // we do this to pass the tabs list to the onReady callbacks const appContext = useContext(AppContext) + const compileSeq = useRef(0) + const compileWatchdog = useRef(null) + const settledSeqRef = useRef(0) + const [compileState, setCompileState] = useState<'idle' | 'compiling' | 'compiled'>('idle') useEffect(() => { @@ -193,7 +197,7 @@ export const TabsUI = (props: TabsUIProps) => { setFileDecorations }) return () => { - tabsElement.current.removeEventListener('wheel', transformScroll) + if (tabsElement.current) tabsElement.current.removeEventListener('wheel', transformScroll) } }, []) @@ -208,9 +212,27 @@ export const TabsUI = (props: TabsUIProps) => { setCompileState('idle') }, [tabsState.selectedIndex]) + useEffect(() => { + if (!props.plugin || tabsState.selectedIndex < 0) return + + const currentPath = props.tabs[tabsState.selectedIndex]?.name + if (!currentPath) return + + const listener = (path: string) => { + if (currentPath.endsWith(path)) { + setCompileState('idle') + } + } + + props.plugin.on('editor', 'contentChanged', listener) + + return () => { + props.plugin.off('editor', 'contentChanged') + } + }, [tabsState.selectedIndex, props.plugin, props.tabs]) + const handleCompileAndPublish = async (storageType: 'ipfs' | 'swarm') => { setCompileState('compiling') - await props.plugin.call('notification', 'toast', `Switching to Solidity Compiler to publish...`) await props.plugin.call('manager', 'activatePlugin', 'solidity') await props.plugin.call('menuicons', 'select', 'solidity') @@ -218,7 +240,7 @@ export const TabsUI = (props: TabsUIProps) => { await props.plugin.call('solidity', 'compile', active().substr(active().indexOf('/') + 1, active().length)) _paq.push(['trackEvent', 'editor', 'publishFromEditor', storageType]) - setTimeout(() => { + setTimeout(async () => { let buttonId if (storageType === 'ipfs') { buttonId = 'publishOnIpfs' @@ -231,13 +253,17 @@ export const TabsUI = (props: TabsUIProps) => { if (buttonToClick) { buttonToClick.click() } else { - props.plugin.call('notification', 'toast', 'Could not find the publish button.') + await props.plugin.call('notification', 'toast', `Compilation failed, skipping 'Publish'.`) + await props.plugin.call('manager', 'activatePlugin', 'solidity') + await props.plugin.call('menuicons', 'select', 'solidity') } }, 500) } catch (e) { console.error(e) - await props.plugin.call('notification', 'toast', `Error publishing: ${e.message}`) + await props.plugin.call('notification', 'toast', `Compilation failed, skipping 'Publish'.`) + await props.plugin.call('manager', 'activatePlugin', 'solidity') + await props.plugin.call('menuicons', 'select', 'solidity') } setCompileState('idle') @@ -307,17 +333,99 @@ export const TabsUI = (props: TabsUIProps) => { } } + const waitForFreshCompilationResult = async ( + mySeq: number, + targetPath: string, + startMs: number, + maxWaitMs = 1500, + intervalMs = 120 + ) => { + const norm = (p: string) => p.replace(/^\/+/, '') + const fileName = norm(targetPath).split('/').pop() || norm(targetPath) + + const hasFile = (res: any) => { + if (!res) return false + const byContracts = + res.contracts && typeof res.contracts === 'object' && + Object.keys(res.contracts).some(k => k.endsWith(fileName) || norm(k) === norm(targetPath)) + const bySources = + res.sources && typeof res.sources === 'object' && + Object.keys(res.sources).some(k => k.endsWith(fileName) || norm(k) === norm(targetPath)) + return byContracts || bySources + } + + let last: any = null + const until = startMs + maxWaitMs + while (Date.now() < until) { + if (mySeq !== compileSeq.current) return null + try { + const res = await props.plugin.call('solidity', 'getCompilationResult') + last = res + const ts = (res && (res.timestamp || res.timeStamp || res.time || res.generatedAt)) || null + const isFreshTime = typeof ts === 'number' ? ts >= startMs : true + if (res && hasFile(res) && isFreshTime) return res + } catch {} + await new Promise(r => setTimeout(r, intervalMs)) + } + return last + } + + const attachCompilationListener = (compilerName: string, mySeq: number, path: string, startedAt: number) => { + try { props.plugin.off(compilerName, 'compilationFinished') } catch {} + + const onFinished = async (_success: boolean) => { + if (mySeq !== compileSeq.current || settledSeqRef.current === mySeq) return + + if (compileWatchdog.current) { + clearTimeout(compileWatchdog.current) + compileWatchdog.current = null + } + + const fresh = await waitForFreshCompilationResult(mySeq, path, startedAt) + + if (!fresh) { + setCompileState('idle') + await props.plugin.call('manager', 'activatePlugin', 'solidity') + await props.plugin.call('menuicons', 'select', 'solidity') + } else { + const errs = Array.isArray(fresh.errors) ? fresh.errors.filter((e: any) => (e.severity || e.type) === 'error') : [] + if (errs.length > 0) { + setCompileState('idle') + await props.plugin.call('manager', 'activatePlugin', 'solidity') + await props.plugin.call('menuicons', 'select', 'solidity') + } else { + setCompileState('compiled') + } + } + settledSeqRef.current = mySeq + try { props.plugin.off(compilerName, 'compilationFinished') } catch {} + } + props.plugin.on(compilerName, 'compilationFinished', onFinished) + } + const handleCompileClick = async () => { setCompileState('compiling') _paq.push(['trackEvent', 'editor', 'clickRunFromEditor', tabsState.currentExt]) try { - const path = active().substr(active().indexOf('/') + 1, active().length) + const activePathRaw = active() + if (!activePathRaw || activePathRaw.indexOf('/') === -1) { + setCompileState('idle') + props.plugin.call('notification', 'toast', 'No file selected.') + return + } + const path = activePathRaw.substr(activePathRaw.indexOf('/') + 1) if (tabsState.currentExt === 'js' || tabsState.currentExt === 'ts') { - const content = await props.plugin.call('fileManager', 'readFile', path) - await props.plugin.call('scriptRunnerBridge', 'execute', content, path) - setCompileState('compiled') + try { + const content = await props.plugin.call('fileManager', 'readFile', path) + await props.plugin.call('scriptRunnerBridge', 'execute', content, path) + setCompileState('compiled') + } catch (e) { + console.error(e) + props.plugin.call('notification', 'toast', `Script error: ${e.message}`) + setCompileState('idle') + } return } @@ -334,21 +442,44 @@ export const TabsUI = (props: TabsUIProps) => { return } - props.plugin.once(compilerName, 'compilationFinished', (fileName, source, languageVersion, data) => { - const hasErrors = data.errors && data.errors.filter(e => e.severity === 'error').length > 0 - - if (hasErrors) { - setCompileState('idle') - } else { - setCompileState('compiled') + await props.plugin.call('fileManager', 'saveCurrentFile') + await props.plugin.call('manager', 'activatePlugin', compilerName) + + const mySeq = ++compileSeq.current + const startedAt = Date.now() + + attachCompilationListener(compilerName, mySeq, path, startedAt) + + if (compileWatchdog.current) clearTimeout(compileWatchdog.current) + compileWatchdog.current = window.setTimeout(async () => { + if (mySeq !== compileSeq.current || settledSeqRef.current === mySeq) return + const maybe = await props.plugin.call('solidity', 'getCompilationResult').catch(() => null) + if (maybe) { + const fresh = await waitForFreshCompilationResult(mySeq, path, startedAt, 400, 120) + if (fresh) { + const errs = Array.isArray(fresh.errors) ? fresh.errors.filter((e: any) => (e.severity || e.type) === 'error') : [] + setCompileState(errs.length ? 'idle' : 'compiled') + if (errs.length) { + await props.plugin.call('manager', 'activatePlugin', compilerName) + await props.plugin.call('menuicons', 'select', compilerName) + } + settledSeqRef.current = mySeq + return + } } - }) + setCompileState('idle') + await props.plugin.call('manager', 'activatePlugin', compilerName) + await props.plugin.call('menuicons', 'select', compilerName) + settledSeqRef.current = mySeq + try { props.plugin.off(compilerName, 'compilationFinished') } catch {} + }, 3000) if (tabsState.currentExt === 'vy') { await props.plugin.call(compilerName, 'vyperCompileCustomAction') } else { await props.plugin.call(compilerName, 'compile', path) } + } catch (e) { console.error(e) setCompileState('idle') @@ -415,7 +546,6 @@ export const TabsUI = (props: TabsUIProps) => { console.log(msg)} disabled={!(PlayExtList.includes(tabsState.currentExt)) || compileState === 'compiling'} /> ) : ( @@ -425,7 +555,6 @@ export const TabsUI = (props: TabsUIProps) => { compiledFileName={active()} plugin={props.plugin} disabled={!(PlayExtList.includes(tabsState.currentExt)) || compileState === 'compiling'} - onNotify={(msg) => console.log(msg)} onRequestCompileAndPublish={handleCompileAndPublish} setCompileState={setCompileState} />