@@ -12,10 +12,10 @@ export function openDevTools(initialTab = 'actions') {
1212 if ( existing ) {
1313 const active = existing . querySelector ( '.bdt-tab-btn.active' ) ;
1414 if ( active && active . dataset . tab === initialTab ) {
15- existing . remove ( ) ;
15+ _destroyPanel ( existing ) ;
1616 return ; // toggle off
1717 }
18- existing . remove ( ) ;
18+ _destroyPanel ( existing ) ;
1919 }
2020 _buildPanel ( initialTab ) ;
2121}
@@ -24,7 +24,11 @@ export function openDevTools(initialTab = 'actions') {
2424
2525function _buildPanel ( activeTab ) {
2626 const panel = document . createElement ( 'div' ) ;
27+ const cleanupFns = [ ] ;
2728 panel . id = PANEL_ID ;
29+ panel . _bdtCleanup = ( ) => {
30+ while ( cleanupFns . length ) cleanupFns . pop ( ) ( ) ;
31+ } ;
2832 panel . innerHTML = `
2933 <div class="bdt-header">
3034 <span class="material-icons bdt-header-icon">construction</span>
@@ -51,15 +55,17 @@ function _buildPanel(activeTab) {
5155 panel . querySelectorAll ( '.bdt-tab-btn' ) . forEach ( btn => {
5256 btn . addEventListener ( 'click' , ( ) => _switchTab ( panel , btn . dataset . tab ) ) ;
5357 } ) ;
54- panel . querySelector ( '.bdt-close' ) . addEventListener ( 'click' , ( ) => panel . remove ( ) ) ;
58+ panel . querySelector ( '.bdt-close' ) . addEventListener ( 'click' , ( ) => _destroyPanel ( panel ) ) ;
5559
56- const onKey = e => { if ( e . key === 'Escape' ) { panel . remove ( ) ; document . removeEventListener ( 'keydown' , onKey ) ; } } ;
60+ const onKey = e => { if ( e . key === 'Escape' ) _destroyPanel ( panel ) ; } ;
5761 document . addEventListener ( 'keydown' , onKey ) ;
62+ cleanupFns . push ( ( ) => document . removeEventListener ( 'keydown' , onKey ) ) ;
5863
5964 _switchTab ( panel , activeTab ) ;
6065 _initActions ( panel ) ;
6166 _initTemplate ( panel ) ;
62- _initStates ( panel ) ;
67+ const cleanupStates = _initStates ( panel ) ;
68+ if ( cleanupStates ) cleanupFns . push ( cleanupStates ) ;
6369 _initConfig ( panel ) ;
6470}
6571
@@ -68,6 +74,12 @@ function _switchTab(panel, tab) {
6874 panel . querySelectorAll ( '.bdt-pane' ) . forEach ( p => p . classList . toggle ( 'active' , p . dataset . pane === tab ) ) ;
6975}
7076
77+ function _destroyPanel ( panel ) {
78+ if ( ! panel ) return ;
79+ if ( typeof panel . _bdtCleanup === 'function' ) panel . _bdtCleanup ( ) ;
80+ panel . remove ( ) ;
81+ }
82+
7183// ── Actions pane ──────────────────────────────────────────────────────────────
7284
7385function _actionsPane ( ) {
@@ -361,7 +373,7 @@ function _initActions(panel) {
361373 if ( entityId ) yaml += `target:\n entity_id: ${ entityId } \n` ;
362374 const dataFields = Object . entries ( formData ) . filter ( ( [ k ] ) => k !== 'entity_id' ) ;
363375 if ( dataFields . length ) {
364- yaml += `data:\n` + dataFields . map ( ( [ k , v ] ) => ` ${ k } : ${ v } ` ) . join ( '\n' ) + '\n' ;
376+ yaml += `data:\n` + dataFields . map ( ( [ k , v ] ) => ` ${ k } : ${ _formatYamlScalar ( v ) } ` ) . join ( '\n' ) + '\n' ;
365377 }
366378 yamlInput . value = yaml ;
367379 }
@@ -400,6 +412,7 @@ function _initActions(panel) {
400412 const { action, data = { } , target = { } } = parsed ;
401413 if ( ! action ) { _showResult ( yamlResult , false , 'Missing "action:" field' ) ; return ; }
402414 const [ domain , service ] = action . split ( '.' ) ;
415+ if ( ! domain || ! service ) { _showResult ( yamlResult , false , 'Action must be in domain.service format' ) ; return ; }
403416 await _callAction ( domain , service , data , target , yamlPerformBtn , yamlResult , 'Perform action' ) ;
404417 } ) ;
405418
@@ -545,7 +558,8 @@ function _initStates(panel) {
545558 e . entity_id . toLowerCase ( ) . includes ( q ) || ( e . friendly_name || '' ) . toLowerCase ( ) . includes ( q ) ) ;
546559 if ( ! filtered . length ) { tbody . innerHTML = '<tr><td colspan="3" class="bdt-states-loading">No entities match.</td></tr>' ; return ; }
547560
548- tbody . innerHTML = filtered . slice ( 0 , 200 ) . map ( e => {
561+ const visible = filtered . slice ( 0 , 200 ) ;
562+ tbody . innerHTML = visible . map ( e => {
549563 const cls = e . state === 'on' ? 'bdt-state-on' : e . state === 'off' ? 'bdt-state-off' : 'bdt-state-other' ;
550564 const attrs = e . attributes || { } ;
551565 // Show a short summary: up to 2 key attributes excluding friendly_name/icon
@@ -580,7 +594,7 @@ function _initStates(panel) {
580594 row . addEventListener ( 'click' , ( ) => {
581595 const next = row . nextElementSibling ;
582596 if ( next && next . classList . contains ( 'bdt-attr-detail-row' ) ) { next . remove ( ) ; return ; }
583- const entity = filtered [ i ] ;
597+ const entity = visible [ i ] ;
584598 const attrs = entity . attributes || { } ;
585599 const skip = new Set ( [ 'entity_picture' ] ) ;
586600 const rows = Object . entries ( attrs )
@@ -605,6 +619,8 @@ function _initStates(panel) {
605619 if ( pane . classList . contains ( 'active' ) && allEntities . length === 0 ) load ( ) ;
606620 } ) ;
607621 observer . observe ( pane , { attributes : true , attributeFilter : [ 'class' ] } ) ;
622+ if ( pane . classList . contains ( 'active' ) && allEntities . length === 0 ) load ( ) ;
623+ return ( ) => observer . disconnect ( ) ;
608624}
609625
610626// ── Config pane ───────────────────────────────────────────────────────────────
@@ -785,33 +801,114 @@ function _coerce(v) {
785801}
786802
787803/**
788- * Parse a simple HA action YAML block into { action, data, target }.
789- * Handles flat key:value and one level of nesting (data:, target:).
804+ * Parse the HA action YAML shape accepted by this panel into
805+ * { action, data, target }. It supports nested maps and simple lists under
806+ * data:/target: without trying to become a full YAML parser.
790807 */
791808function _parseActionYaml ( text ) {
792809 const result = { action : null , data : { } , target : { } } ;
793- let currentSection = null ;
794- for ( const raw of text . split ( '\n' ) ) {
795- const line = raw . trimEnd ( ) ;
796- if ( ! line . trim ( ) || line . trim ( ) . startsWith ( '#' ) ) continue ;
810+ const stack = [ { indent : - 1 , value : result } ] ;
811+
812+ const lines = text . split ( '\n' ) ;
813+ for ( let lineIndex = 0 ; lineIndex < lines . length ; lineIndex += 1 ) {
814+ const raw = lines [ lineIndex ] ;
815+ const withoutComment = _stripYamlComment ( raw ) ;
816+ const line = withoutComment . trimEnd ( ) ;
817+ if ( ! line . trim ( ) ) continue ;
818+
797819 const indent = line . match ( / ^ ( \s * ) / ) [ 1 ] . length ;
798820 const trimmed = line . trim ( ) ;
799- if ( indent === 0 ) {
800- currentSection = null ;
801- const colonIdx = trimmed . indexOf ( ':' ) ;
802- if ( colonIdx === - 1 ) continue ;
803- const key = trimmed . slice ( 0 , colonIdx ) . trim ( ) ;
804- const val = trimmed . slice ( colonIdx + 1 ) . trim ( ) ;
805- if ( key === 'action' || key === 'service' ) { result . action = val ; }
806- else if ( key === 'data' || key === 'target' ) { currentSection = key ; }
821+ while ( stack . length > 1 && indent <= stack [ stack . length - 1 ] . indent ) stack . pop ( ) ;
822+ const parent = stack [ stack . length - 1 ] . value ;
823+
824+ if ( trimmed . startsWith ( '- ' ) ) {
825+ if ( ! Array . isArray ( parent ) ) {
826+ throw new Error ( 'List items must belong to a key such as entity_id:' ) ;
827+ }
828+ parent . push ( _parseYamlValue ( trimmed . slice ( 2 ) . trim ( ) ) ) ;
829+ continue ;
830+ }
831+
832+ const colonIdx = trimmed . indexOf ( ':' ) ;
833+ if ( colonIdx === - 1 ) throw new Error ( `Invalid line: ${ trimmed } ` ) ;
834+
835+ const key = trimmed . slice ( 0 , colonIdx ) . trim ( ) ;
836+ const val = trimmed . slice ( colonIdx + 1 ) . trim ( ) ;
837+ if ( ! key ) throw new Error ( `Invalid line: ${ trimmed } ` ) ;
838+
839+ if ( indent === 0 && ( key === 'action' || key === 'service' ) ) {
840+ result . action = String ( _parseYamlValue ( val ) ) ;
841+ continue ;
842+ }
843+ if ( indent === 0 && ( key === 'data' || key === 'target' ) ) {
844+ stack . push ( { indent, value : result [ key ] } ) ;
845+ continue ;
846+ }
847+ if ( indent === 0 ) continue ;
848+
849+ if ( ! parent || typeof parent !== 'object' || Array . isArray ( parent ) ) {
850+ throw new Error ( `Cannot assign "${ key } " here` ) ;
851+ }
852+
853+ if ( val ) {
854+ parent [ key ] = _parseYamlValue ( val ) ;
807855 } else {
808- if ( ! currentSection ) continue ;
809- const colonIdx = trimmed . indexOf ( ':' ) ;
810- if ( colonIdx === - 1 ) continue ;
811- const key = trimmed . slice ( 0 , colonIdx ) . trim ( ) ;
812- const val = trimmed . slice ( colonIdx + 1 ) . trim ( ) ;
813- if ( val ) result [ currentSection ] [ key ] = _coerce ( val ) ;
856+ const nextLine = _nextContentLine ( lines , lineIndex ) ;
857+ parent [ key ] = nextLine && nextLine . trim ( ) . startsWith ( '- ' ) ? [ ] : { } ;
858+ stack . push ( { indent, value : parent [ key ] } ) ;
814859 }
815860 }
816861 return result ;
817862}
863+
864+ function _nextContentLine ( lines , currentIndex ) {
865+ for ( let i = currentIndex + 1 ; i < lines . length ; i += 1 ) {
866+ const line = _stripYamlComment ( lines [ i ] ) . trim ( ) ;
867+ if ( line ) return line ;
868+ }
869+ return '' ;
870+ }
871+
872+ function _stripYamlComment ( line ) {
873+ let inSingle = false ;
874+ let inDouble = false ;
875+ for ( let i = 0 ; i < line . length ; i += 1 ) {
876+ const char = line [ i ] ;
877+ const prev = line [ i - 1 ] ;
878+ if ( char === "'" && ! inDouble ) inSingle = ! inSingle ;
879+ else if ( char === '"' && ! inSingle && prev !== '\\' ) inDouble = ! inDouble ;
880+ else if ( char === '#' && ! inSingle && ! inDouble && ( i === 0 || / \s / . test ( prev ) ) ) {
881+ return line . slice ( 0 , i ) ;
882+ }
883+ }
884+ return line ;
885+ }
886+
887+ function _parseYamlValue ( value ) {
888+ if ( value === '' ) return '' ;
889+ if ( value . startsWith ( '[' ) && value . endsWith ( ']' ) ) {
890+ const inner = value . slice ( 1 , - 1 ) . trim ( ) ;
891+ if ( ! inner ) return [ ] ;
892+ return inner . split ( ',' ) . map ( item => _parseYamlValue ( item . trim ( ) ) ) ;
893+ }
894+ if ( value . startsWith ( '{' ) && value . endsWith ( '}' ) ) {
895+ const inner = value . slice ( 1 , - 1 ) . trim ( ) ;
896+ if ( ! inner ) return { } ;
897+ return inner . split ( ',' ) . reduce ( ( obj , pair ) => {
898+ const colonIdx = pair . indexOf ( ':' ) ;
899+ if ( colonIdx === - 1 ) throw new Error ( `Invalid inline map item: ${ pair . trim ( ) } ` ) ;
900+ const key = pair . slice ( 0 , colonIdx ) . trim ( ) ;
901+ obj [ key ] = _parseYamlValue ( pair . slice ( colonIdx + 1 ) . trim ( ) ) ;
902+ return obj ;
903+ } , { } ) ;
904+ }
905+ return _coerce ( value ) ;
906+ }
907+
908+ function _formatYamlScalar ( value ) {
909+ if ( Array . isArray ( value ) ) return `[${ value . map ( _formatYamlScalar ) . join ( ', ' ) } ]` ;
910+ if ( value && typeof value === 'object' ) {
911+ return `{${ Object . entries ( value ) . map ( ( [ k , v ] ) => `${ k } : ${ _formatYamlScalar ( v ) } ` ) . join ( ', ' ) } }` ;
912+ }
913+ return String ( value ) ;
914+ }
0 commit comments