1- import { useCallback , useEffect , useRef , useState } from 'react' ;
2- import { ButtonGroup , Form , InputGroup , Modal } from 'react-bootstrap' ;
31import './SQLTerminal.scss' ;
2+ import { useCallback , useEffect , useRef , useState } from 'react' ;
3+ import { ButtonGroup , Form , InputGroup , Modal , Table } from 'react-bootstrap' ;
44import PerfectScrollbar from 'react-perfect-scrollbar' ;
55import { CloseSVG } from '../../../svgs/Close' ;
66import logger from '../../../services/logger.service' ;
@@ -11,15 +11,63 @@ import { RootService } from '../../../services/http.service';
1111import { setShowModals , setShowToast } from '../../../store/rootSlice' ;
1212import { useDispatch , useSelector } from 'react-redux' ;
1313import { selectShowModals } from '../../../store/rootSelectors' ;
14+ import ToggleSwitch from '../../shared/ToggleSwitch/ToggleSwitch' ;
15+
16+ const parseTableName = ( sql : string ) : string | null => {
17+ const match = sql . replace ( / \s + / g, ' ' ) . trim ( ) . match ( / \b F R O M \s + ( [ ` " \[ ] ? [ \w ] + [ ` " \] ] ? ) / i) ;
18+ if ( ! match ) return null ;
19+ return match [ 1 ] . replace ( / [ ` " [ \] ] / g, '' ) ;
20+ } ;
21+
22+ const parseColumnNames = ( sql : string ) : string [ ] | null => {
23+ const trimmed = sql . replace ( / \s + / g, ' ' ) . trim ( ) ;
24+ const match = trimmed . match ( / ^ S E L E C T \s + ( [ \s \S ] + ?) \s + F R O M \s + / i) ;
25+ if ( ! match ) return null ;
26+
27+ const colsPart = match [ 1 ] . trim ( ) ;
28+ if ( colsPart === '*' ) return null ;
29+
30+ const cols : string [ ] = [ ] ;
31+ let depth = 0 ;
32+ let current = '' ;
33+ for ( const ch of colsPart ) {
34+ if ( ch === '(' ) depth ++ ;
35+ else if ( ch === ')' ) depth -- ;
36+ else if ( ch === ',' && depth === 0 ) {
37+ cols . push ( current . trim ( ) ) ;
38+ current = '' ;
39+ continue ;
40+ }
41+ current += ch ;
42+ }
43+ if ( current . trim ( ) ) cols . push ( current . trim ( ) ) ;
44+
45+ return cols . map ( col => {
46+ const asMatch = col . match ( / \b A S \s + ( [ ` " \[ ] ? [ \w ] + [ ` " \] ] ? ) \s * $ / i) ;
47+ if ( asMatch ) return asMatch [ 1 ] . replace ( / [ ` " [ \] ] / g, '' ) ;
48+ const tokens = col . split ( / \s + / ) ;
49+ const last = tokens [ tokens . length - 1 ] . replace ( / [ ` " [ \] ] / g, '' ) ;
50+ if ( / ^ [ \w ] + $ / . test ( last ) ) return last ;
51+ return col ;
52+ } ) ;
53+ } ;
54+
55+ type ViewMode = 'Raw' | 'Table' ;
56+
57+ type OutputState =
58+ | { type : 'empty' }
59+ | { type : 'error' ; message : string }
60+ | { type : 'result' ; columns : string [ ] | null ; rows : any [ ] [ ] } ;
1461
1562const SQLTerminal = ( ) => {
1663 const containerRef = useRef < HTMLDivElement > ( null ) ;
1764 const showModals = useSelector ( selectShowModals ) ;
1865 const dispatch = useDispatch ( ) ;
19- const outputRef = useRef < HTMLPreElement | null > ( null ) ;
66+ const outputRef = useRef < HTMLDivElement | null > ( null ) ;
2067 const [ executed , setExecuted ] = useState ( false ) ;
2168 const [ query , setQuery ] = useState ( '' ) ;
22- const [ output , setOutput ] = useState ( '' ) ;
69+ const [ viewMode , setViewMode ] = useState < ViewMode > ( 'Raw' ) ;
70+ const [ outputState , setOutputState ] = useState < OutputState > ( { type : 'empty' } ) ;
2371
2472 const scrollToBottom = ( ) => {
2573 if ( outputRef . current ) {
@@ -56,14 +104,35 @@ const SQLTerminal = () => {
56104 const formattedQuery = query . replace ( / \n / g, ' ' ) . replace ( / \s + / g, ' ' ) . trim ( ) ;
57105 try {
58106 const result : any = await RootService . executeSql ( formattedQuery ) ;
59- setOutput ( JSON . stringify ( result . rows , null , 2 ) + '\n\n' ) ;
60- setOutput ( formattedQuery + '\n' + JSON . stringify ( result . rows , null , 2 ) + '\n\n' ) ;
61- } catch ( error : any ) {
62- if ( error && error . message ) {
63- setOutput ( formattedQuery + '\nError: ' + error . message ) ;
64- } else {
65- setOutput ( formattedQuery + '\nError: ' + error ) ;
107+ const rows : any [ ] [ ] = result . rows ;
108+
109+ if ( ! Array . isArray ( rows ) || rows . length === 0 ) {
110+ setOutputState ( { type : 'result' , columns : null , rows : [ ] } ) ;
111+ return ;
112+ }
113+
114+ let columns = parseColumnNames ( formattedQuery ) ;
115+
116+ if ( ! columns ) {
117+ const tableName = parseTableName ( formattedQuery ) ;
118+ if ( tableName ) {
119+ try {
120+ const schema : any = await RootService . listSqlSchemas ( tableName ) ;
121+ const tableSchema = schema ?. schemas ?. [ 0 ] ;
122+ if ( tableSchema ?. columns && Array . isArray ( tableSchema . columns ) ) {
123+ columns = tableSchema . columns . map ( ( col : any ) => col . name ?? String ( col ) ) ;
124+ }
125+ } catch ( schemaError : any ) {
126+ logger . error ( 'Failed to fetch SQL schema for table: ' + tableName , schemaError ) ;
127+ }
128+ }
66129 }
130+ setOutputState ( { type : 'result' , columns, rows } ) ;
131+ } catch ( error : any ) {
132+ setOutputState ( {
133+ type : 'error' ,
134+ message : error ?. message ? error . message : String ( error ) ,
135+ } ) ;
67136 }
68137 } , [ query ] ) ;
69138
@@ -73,11 +142,11 @@ const SQLTerminal = () => {
73142
74143 const handleClear = ( ) => {
75144 setQuery ( '' ) ;
76- setOutput ( '' ) ;
145+ setOutputState ( { type : 'empty' } ) ;
146+ setViewMode ( 'Raw' ) ;
77147 } ;
78148
79149 useEffect ( ( ) => {
80- // Check if the last character is a newline and the character before it is a semicolon
81150 if ( ! executed && query . endsWith ( '\n' ) && query . trimEnd ( ) . endsWith ( ';' ) ) {
82151 setExecuted ( true ) ;
83152 handleExecute ( ) ;
@@ -86,11 +155,101 @@ const SQLTerminal = () => {
86155
87156 useEffect ( ( ) => {
88157 scrollToBottom ( ) ;
89- } , [ output ] ) ;
158+ } , [ outputState ] ) ;
90159
91160 const closeHandler = ( ) => {
161+ handleClear ( ) ;
92162 dispatch ( setShowModals ( { ...showModals , sqlTerminalModal : false } ) ) ;
93- }
163+ } ;
164+
165+ const getHeaders = ( columns : string [ ] | null , firstRow : any [ ] ) : string [ ] => {
166+ if ( columns && columns . length === firstRow . length ) return columns ;
167+ return firstRow . map ( ( _ , i ) => `Col ${ i } ` ) ;
168+ } ;
169+
170+ const renderOutput = ( ) => {
171+ switch ( outputState . type ) {
172+ case 'empty' :
173+ return (
174+ < div className = 'terminal-output text-muted' >
175+ < span > Results will appear here…</ span >
176+ </ div >
177+ ) ;
178+
179+ case 'error' :
180+ return (
181+ < pre className = 'terminal-output text-invalid' >
182+ < code > Error: { outputState . message } </ code >
183+ </ pre >
184+ ) ;
185+
186+ case 'result' : {
187+ const { columns, rows } = outputState ;
188+
189+ if ( rows . length === 0 ) {
190+ return (
191+ < div className = 'terminal-output text-muted' >
192+ < span > Query returned no rows.</ span >
193+ </ div >
194+ ) ;
195+ }
196+
197+ const headers = getHeaders ( columns , rows [ 0 ] ) ;
198+
199+ return (
200+ < div >
201+ { viewMode === 'Table' ? (
202+ < div className = 'terminal-output terminal-table-wrapper text-valid' ref = { outputRef } >
203+ < div className = 'terminal-table-header text-muted' >
204+ { rows . length } row{ rows . length !== 1 ? 's' : '' }
205+ </ div >
206+ < PerfectScrollbar options = { { suppressScrollX : false } } >
207+ < Table striped bordered hover size = 'sm' className = 'sql-result-table mb-0' >
208+ < thead >
209+ < tr >
210+ { headers . map ( ( h , i ) => (
211+ < th key = { i } > { h } </ th >
212+ ) ) }
213+ </ tr >
214+ </ thead >
215+ < tbody >
216+ { rows . map ( ( row , ri ) => (
217+ < tr key = { ri } >
218+ { ( Array . isArray ( row ) ? row : [ row ] ) . map ( ( cell , ci ) => (
219+ < td key = { ci } >
220+ { cell === null || cell === undefined
221+ ? < span className = 'text-muted fst-italic' > null</ span >
222+ : String ( cell ) }
223+ </ td >
224+ ) ) }
225+ </ tr >
226+ ) ) }
227+ </ tbody >
228+ </ Table >
229+ </ PerfectScrollbar >
230+ </ div >
231+ ) : (
232+ < div className = 'terminal-output text-valid' >
233+ < div className = 'd-flex justify-content-between align-items-start' >
234+ < div className = 'text-muted' >
235+ { rows . length } record{ rows . length !== 1 ? 's' : '' }
236+ </ div >
237+ < button tabIndex = { 3 } onClick = { handleCopy } className = 'btn-copy-output' >
238+ < CopySVG id = 'Output' showTooltip = { true } />
239+ </ button >
240+ </ div >
241+ < pre className = 'terminal-output-scroll-container' ref = { outputRef as any } >
242+ < PerfectScrollbar >
243+ < code > { JSON . stringify ( rows , null , 2 ) } </ code >
244+ </ PerfectScrollbar >
245+ </ pre >
246+ </ div >
247+ ) }
248+ </ div >
249+ ) ;
250+ }
251+ }
252+ } ;
94253
95254 return (
96255 < Modal ref = { containerRef } show = { showModals . sqlTerminalModal } onHide = { closeHandler } centered className = 'modal-xl' data-testid = 'sql-terminal' >
@@ -128,44 +287,20 @@ const SQLTerminal = () => {
128287 < CopySVG id = 'SQL Query' showTooltip = { true } />
129288 </ InputGroup . Text >
130289 </ InputGroup >
131- < div style = { { position : 'relative' } } >
132- < button tabIndex = { 3 } onClick = { handleCopy } className = 'btn-copy-output' >
133- < CopySVG id = 'Output' showTooltip = { true } />
134- </ button >
135- < pre
136- className = {
137- 'terminal-output ' + ( output . includes ( 'Error: ' ) ? 'text-invalid' : 'text-valid' )
138- }
139- ref = { outputRef }
140- >
141- < PerfectScrollbar >
142- < code > { output } </ code >
143- </ PerfectScrollbar >
144- </ pre >
290+ < div className = 'd-flex justify-content-end' >
291+ < ToggleSwitch className = 'mb-4' onChange = { ( changedIndex ) => setViewMode ( changedIndex === 0 ? 'Raw' : 'Table' ) } values = { [ 'Raw' , 'Table' ] } selValue = { viewMode } />
292+ </ div >
293+ < div className = 'terminal-output-container' >
294+ { renderOutput ( ) }
145295 </ div >
146296 < ButtonGroup className = 'btn-group-action mb-3' >
147- < button
148- tabIndex = { 4 }
149- type = 'button'
150- className = 'btn-rounded bg-primary fs-6 me-4'
151- onClick = { handleExecute }
152- >
297+ < button tabIndex = { 4 } type = 'button' className = 'btn-rounded bg-primary fs-6 me-4' onClick = { handleExecute } >
153298 Execute
154299 </ button >
155- < button
156- tabIndex = { 5 }
157- type = 'button'
158- className = 'btn-rounded bg-primary fs-6 me-4'
159- onClick = { handleClear }
160- >
300+ < button tabIndex = { 5 } type = 'button' className = 'btn-rounded bg-primary fs-6 me-4' onClick = { handleClear } >
161301 Clear
162302 </ button >
163- < button
164- tabIndex = { 6 }
165- type = 'button'
166- className = 'btn-rounded bg-primary fs-6 me-4'
167- onClick = { handleHelp }
168- >
303+ < button tabIndex = { 6 } type = 'button' className = 'btn-rounded bg-primary fs-6 me-4' onClick = { handleHelp } >
169304 Help
170305 </ button >
171306 </ ButtonGroup >
0 commit comments