@@ -139,6 +139,9 @@ export default function DockerRunnerView({
139139 ] ) ;
140140 const [ composeModalOpen , setComposeModalOpen ] = useState ( false ) ;
141141 const [ composeYaml , setComposeYaml ] = useState ( "" ) ;
142+ const [ importModalOpen , setImportModalOpen ] = useState ( false ) ;
143+ const [ importYaml , setImportYaml ] = useState ( "" ) ;
144+ const [ importError , setImportError ] = useState ( "" ) ;
142145 const [ saveStatus , setSaveStatus ] = useState ( "" ) ;
143146 const [ saveError , setSaveError ] = useState ( false ) ;
144147
@@ -385,7 +388,184 @@ export default function DockerRunnerView({
385388 } ) ;
386389 return yaml ;
387390 } ;
391+ const handleImportCompose = ( ) => {
392+ setImportError ( "" ) ;
393+ try {
394+ const lines = importYaml . split ( "\n" ) ;
395+ const newConfigs : ContainerConfig [ ] = [ ] ;
396+ let currentService : Partial < ContainerConfig > | null = null ;
397+ let inServices = false ;
398+ let inPorts = false ;
399+ let inEnv = false ;
400+ let inVolumes = false ;
401+
402+ for ( const rawLine of lines ) {
403+ const line = rawLine ;
404+ const trimmed = line . trimEnd ( ) ;
405+ const indent = line . length - line . trimStart ( ) . length ;
406+
407+ if ( trimmed . trim ( ) === "services:" ) {
408+ inServices = true ;
409+ continue ;
410+ }
411+
412+ if (
413+ inServices &&
414+ indent === 2 &&
415+ trimmed . trim ( ) . endsWith ( ":" ) &&
416+ ! trimmed . trim ( ) . startsWith ( "-" )
417+ ) {
418+ if ( currentService ?. imageName ) {
419+ newConfigs . push ( {
420+ key : Math . random ( ) . toString ( 36 ) . slice ( 2 ) ,
421+ imageSearch : "" ,
422+ searchResults : [ ] ,
423+ isSearching : false ,
424+ showSearchDropdown : false ,
425+ tags : [ ] ,
426+ isLoadingTags : false ,
427+ imageName : currentService . imageName || "" ,
428+ selectedTag : "" ,
429+ containerName : currentService . containerName || "" ,
430+ ports : currentService . ports || [ ] ,
431+ volumes : currentService . volumes || [ ] ,
432+ env : currentService . env || [ ] ,
433+ command : currentService . command || "" ,
434+ tty : currentService . tty || false ,
435+ } ) ;
436+ }
437+ currentService = { ports : [ ] , env : [ ] , volumes : [ ] } ;
438+ inPorts = false ;
439+ inEnv = false ;
440+ inVolumes = false ;
441+ continue ;
442+ }
443+
444+ if ( ! currentService ) continue ;
445+
446+ const t = trimmed . trim ( ) ;
447+
448+ if ( indent === 4 ) {
449+ if ( t . startsWith ( "image:" ) ) {
450+ currentService . imageName = t . replace ( "image:" , "" ) . trim ( ) ;
451+ inPorts = false ;
452+ inEnv = false ;
453+ inVolumes = false ;
454+ } else if ( t . startsWith ( "container_name:" ) ) {
455+ currentService . containerName = t
456+ . replace ( "container_name:" , "" )
457+ . trim ( ) ;
458+ inPorts = false ;
459+ inEnv = false ;
460+ inVolumes = false ;
461+ } else if ( t . startsWith ( "command:" ) ) {
462+ currentService . command = t . replace ( "command:" , "" ) . trim ( ) ;
463+ inPorts = false ;
464+ inEnv = false ;
465+ inVolumes = false ;
466+ } else if ( t === "ports:" ) {
467+ inPorts = true ;
468+ inEnv = false ;
469+ inVolumes = false ;
470+ } else if ( t === "environment:" ) {
471+ inEnv = true ;
472+ inPorts = false ;
473+ inVolumes = false ;
474+ } else if ( t === "volumes:" ) {
475+ inVolumes = true ;
476+ inPorts = false ;
477+ inEnv = false ;
478+ } else {
479+ inPorts = false ;
480+ inEnv = false ;
481+ inVolumes = false ;
482+ }
483+ }
484+
485+ if ( indent === 6 && t . startsWith ( "-" ) ) {
486+ const val = t
487+ . slice ( 1 )
488+ . trim ( )
489+ . replace ( / ^ [ " ' ] | [ " ' ] $ / g, "" ) ;
490+ if ( inPorts ) {
491+ const parts = val . split ( ":" ) ;
492+ if ( parts . length >= 2 ) {
493+ const proto = val . includes ( "/udp" ) ? "udp" : "tcp" ;
494+ currentService . ports ! . push ( {
495+ host : parts [ 0 ] ,
496+ container : parts [ 1 ] . replace ( "/udp" , "" ) . replace ( "/tcp" , "" ) ,
497+ protocol : proto ,
498+ } ) ;
499+ }
500+ } else if ( inEnv ) {
501+ const eqIdx = val . indexOf ( "=" ) ;
502+ if ( eqIdx > - 1 ) {
503+ currentService . env ! . push ( {
504+ key : val . slice ( 0 , eqIdx ) ,
505+ value : val . slice ( eqIdx + 1 ) ,
506+ } ) ;
507+ }
508+ } else if ( inVolumes ) {
509+ const vParts = val . split ( ":" ) ;
510+ if ( vParts . length >= 2 ) {
511+ currentService . volumes ! . push ( {
512+ hostPath : vParts [ 0 ] ,
513+ containerPath : vParts [ 1 ] ,
514+ } ) ;
515+ }
516+ }
517+ }
518+ }
519+
520+ // Push last service
521+ if ( currentService ?. imageName ) {
522+ newConfigs . push ( {
523+ key : Math . random ( ) . toString ( 36 ) . slice ( 2 ) ,
524+ imageSearch : "" ,
525+ searchResults : [ ] ,
526+ isSearching : false ,
527+ showSearchDropdown : false ,
528+ tags : [ ] ,
529+ isLoadingTags : false ,
530+ imageName : currentService . imageName || "" ,
531+ selectedTag : "" ,
532+ containerName : currentService . containerName || "" ,
533+ ports : currentService . ports || [ ] ,
534+ volumes : currentService . volumes || [ ] ,
535+ env : currentService . env || [ ] ,
536+ command : currentService . command || "" ,
537+ tty : currentService . tty || false ,
538+ } ) ;
539+ }
540+
541+ if ( newConfigs . length === 0 ) {
542+ setImportError (
543+ "No valid services found in the YAML. Make sure it has a 'services:' section with at least one image." ,
544+ ) ;
545+ return ;
546+ }
388547
548+ setConfigs ( newConfigs ) ;
549+ setImportModalOpen ( false ) ;
550+ setImportYaml ( "" ) ;
551+ setImportError ( "" ) ;
552+ } catch ( err : any ) {
553+ setImportError (
554+ "Failed to parse YAML: " + ( err . message || "Unknown error" ) ,
555+ ) ;
556+ }
557+ } ;
558+
559+ const handleImportFileUpload = ( e : React . ChangeEvent < HTMLInputElement > ) => {
560+ const file = e . target . files ?. [ 0 ] ;
561+ if ( ! file ) return ;
562+ const reader = new FileReader ( ) ;
563+ reader . onload = ( ev ) => {
564+ setImportYaml ( ( ev . target ?. result as string ) || "" ) ;
565+ setImportError ( "" ) ;
566+ } ;
567+ reader . readAsText ( file ) ;
568+ } ;
389569 const handleComposeClick = ( ) => {
390570 const yaml = generateComposeYaml ( ) ;
391571 setComposeYaml ( yaml ) ;
@@ -1348,6 +1528,28 @@ export default function DockerRunnerView({
13481528 gap : "16px" ,
13491529 } }
13501530 >
1531+ < button
1532+ className = "control-btn"
1533+ type = "button"
1534+ onClick = { ( ) => {
1535+ setImportYaml ( "" ) ;
1536+ setImportError ( "" ) ;
1537+ setImportModalOpen ( true ) ;
1538+ } }
1539+ style = { {
1540+ height : "42px" ,
1541+ padding : "0 24px" ,
1542+ fontSize : "0.85rem" ,
1543+ fontWeight : 700 ,
1544+ border : "1px solid var(--line-strong)" ,
1545+ } }
1546+ >
1547+ < FileCodeIcon
1548+ size = { 14 }
1549+ style = { { marginRight : 7 , verticalAlign : "middle" } }
1550+ /> { " " }
1551+ Import Compose File
1552+ </ button >
13511553 < button
13521554 className = "control-btn restart-btn"
13531555 type = "button"
@@ -1382,7 +1584,170 @@ export default function DockerRunnerView({
13821584 < RocketIcon size = { 14 } style = { { marginRight : 8 } } /> Launch Containers
13831585 </ button >
13841586 </ div >
1385-
1587+ { /* DOCKER COMPOSE IMPORT MODAL */ }
1588+ { importModalOpen && (
1589+ < div
1590+ style = { {
1591+ position : "fixed" ,
1592+ top : 0 ,
1593+ left : 0 ,
1594+ right : 0 ,
1595+ bottom : 0 ,
1596+ background : "rgba(0,0,0,0.8)" ,
1597+ zIndex : 100 ,
1598+ display : "flex" ,
1599+ alignItems : "center" ,
1600+ justifyContent : "center" ,
1601+ padding : "24px" ,
1602+ } }
1603+ >
1604+ < div
1605+ className = "metric-card"
1606+ style = { {
1607+ width : "100%" ,
1608+ maxWidth : "680px" ,
1609+ padding : "24px" ,
1610+ display : "flex" ,
1611+ flexDirection : "column" ,
1612+ gap : "20px" ,
1613+ border : "1px solid var(--line-strong)" ,
1614+ background : "var(--surface)" ,
1615+ boxShadow : "var(--shadow-technical)" ,
1616+ borderRadius : "var(--radius-lg)" ,
1617+ } }
1618+ >
1619+ < div
1620+ style = { {
1621+ display : "flex" ,
1622+ justifyContent : "space-between" ,
1623+ alignItems : "center" ,
1624+ } }
1625+ >
1626+ < h3 style = { { margin : 0 , fontSize : "1.2rem" , fontWeight : 800 } } >
1627+ Import docker-compose.yml
1628+ </ h3 >
1629+ < button
1630+ type = "button"
1631+ onClick = { ( ) => setImportModalOpen ( false ) }
1632+ style = { {
1633+ background : "transparent" ,
1634+ color : "var(--text-muted)" ,
1635+ border : "none" ,
1636+ fontSize : "1.4rem" ,
1637+ cursor : "pointer" ,
1638+ } }
1639+ >
1640+ < CloseIcon size = { 16 } />
1641+ </ button >
1642+ </ div >
1643+ < p
1644+ style = { {
1645+ margin : 0 ,
1646+ fontSize : "0.85rem" ,
1647+ color : "var(--text-muted)" ,
1648+ } }
1649+ >
1650+ Paste your < code > docker-compose.yml</ code > content below or upload
1651+ a file. Services will be loaded into the launcher.
1652+ </ p >
1653+ < div style = { { display : "flex" , gap : "12px" , alignItems : "center" } } >
1654+ < label
1655+ style = { {
1656+ fontSize : "0.82rem" ,
1657+ fontWeight : 600 ,
1658+ color : "var(--accent)" ,
1659+ cursor : "pointer" ,
1660+ padding : "6px 14px" ,
1661+ border : "1px solid var(--accent)" ,
1662+ borderRadius : "var(--radius-sm)" ,
1663+ } }
1664+ >
1665+ Upload File
1666+ < input
1667+ type = "file"
1668+ accept = ".yml,.yaml"
1669+ onChange = { handleImportFileUpload }
1670+ style = { { display : "none" } }
1671+ />
1672+ </ label >
1673+ < span style = { { fontSize : "0.8rem" , color : "var(--text-muted)" } } >
1674+ or paste below
1675+ </ span >
1676+ </ div >
1677+ < textarea
1678+ value = { importYaml }
1679+ onChange = { ( e ) => {
1680+ setImportYaml ( e . target . value ) ;
1681+ setImportError ( "" ) ;
1682+ } }
1683+ placeholder = {
1684+ "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - \"80:80\""
1685+ }
1686+ style = { {
1687+ width : "100%" ,
1688+ minHeight : "240px" ,
1689+ fontFamily : "var(--font-mono)" ,
1690+ fontSize : "0.82rem" ,
1691+ background : "var(--bg)" ,
1692+ color : "var(--accent)" ,
1693+ border : "1px solid var(--line)" ,
1694+ borderRadius : "var(--radius-sm)" ,
1695+ padding : "12px" ,
1696+ resize : "vertical" ,
1697+ boxSizing : "border-box" ,
1698+ } }
1699+ />
1700+ { importError && (
1701+ < div
1702+ style = { {
1703+ fontSize : "0.82rem" ,
1704+ color : "var(--danger)" ,
1705+ padding : "10px 14px" ,
1706+ background : "rgba(255,69,58,0.05)" ,
1707+ border : "1px solid rgba(255,69,58,0.2)" ,
1708+ borderRadius : "var(--radius-sm)" ,
1709+ } }
1710+ >
1711+ { importError }
1712+ </ div >
1713+ ) }
1714+ < div
1715+ style = { {
1716+ display : "flex" ,
1717+ justifyContent : "flex-end" ,
1718+ gap : "12px" ,
1719+ } }
1720+ >
1721+ < button
1722+ type = "button"
1723+ className = "control-btn stop-btn"
1724+ onClick = { ( ) => setImportModalOpen ( false ) }
1725+ style = { {
1726+ padding : "0 16px" ,
1727+ height : "36px" ,
1728+ fontSize : "0.82rem" ,
1729+ } }
1730+ >
1731+ Cancel
1732+ </ button >
1733+ < button
1734+ type = "button"
1735+ className = "control-btn start-btn"
1736+ onClick = { handleImportCompose }
1737+ style = { {
1738+ padding : "0 20px" ,
1739+ height : "36px" ,
1740+ fontSize : "0.82rem" ,
1741+ fontWeight : 700 ,
1742+ } }
1743+ >
1744+ < RocketIcon size = { 13 } style = { { marginRight : 6 } } /> Load
1745+ Services
1746+ </ button >
1747+ </ div >
1748+ </ div >
1749+ </ div >
1750+ ) }
13861751 { /* DOCKER COMPOSE GENERATION MODAL */ }
13871752 { composeModalOpen && (
13881753 < div
0 commit comments