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,64 @@ 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+ // Match everything between SELECT and FROM
25+ const match = trimmed . match ( / ^ S E L E C T \s + ( [ \s \S ] + ?) \s + F R O M \s + / i) ;
26+ if ( ! match ) return null ;
27+
28+ const colsPart = match [ 1 ] . trim ( ) ;
29+ if ( colsPart === '*' ) return null ;
30+
31+ const cols : string [ ] = [ ] ;
32+ let depth = 0 ;
33+ let current = '' ;
34+ for ( const ch of colsPart ) {
35+ if ( ch === '(' ) depth ++ ;
36+ else if ( ch === ')' ) depth -- ;
37+ else if ( ch === ',' && depth === 0 ) {
38+ cols . push ( current . trim ( ) ) ;
39+ current = '' ;
40+ continue ;
41+ }
42+ current += ch ;
43+ }
44+ if ( current . trim ( ) ) cols . push ( current . trim ( ) ) ;
45+
46+ return cols . map ( col => {
47+ const asMatch = col . match ( / \b A S \s + ( [ ` " \[ ] ? [ \w ] + [ ` " \] ] ? ) \s * $ / i) ;
48+ if ( asMatch ) return asMatch [ 1 ] . replace ( / [ ` " [ \] ] / g, '' ) ;
49+ const tokens = col . split ( / \s + / ) ;
50+ const last = tokens [ tokens . length - 1 ] . replace ( / [ ` " [ \] ] / g, '' ) ;
51+ if ( / ^ [ \w ] + $ / . test ( last ) ) return last ;
52+ return col ;
53+ } ) ;
54+ } ;
55+
56+ type ViewMode = 'Raw' | 'Table' ;
57+
58+ type OutputState =
59+ | { type : 'empty' }
60+ | { type : 'error' ; message : string }
61+ | { type : 'result' ; columns : string [ ] | null ; rows : any [ ] [ ] } ;
1462
1563const SQLTerminal = ( ) => {
1664 const containerRef = useRef < HTMLDivElement > ( null ) ;
1765 const showModals = useSelector ( selectShowModals ) ;
1866 const dispatch = useDispatch ( ) ;
19- const outputRef = useRef < HTMLPreElement | null > ( null ) ;
67+ const outputRef = useRef < HTMLDivElement | null > ( null ) ;
2068 const [ executed , setExecuted ] = useState ( false ) ;
2169 const [ query , setQuery ] = useState ( '' ) ;
22- const [ output , setOutput ] = useState ( '' ) ;
70+ const [ viewMode , setViewMode ] = useState < ViewMode > ( 'Raw' ) ;
71+ const [ outputState , setOutputState ] = useState < OutputState > ( { type : 'empty' } ) ;
2372
2473 const scrollToBottom = ( ) => {
2574 if ( outputRef . current ) {
@@ -56,14 +105,35 @@ const SQLTerminal = () => {
56105 const formattedQuery = query . replace ( / \n / g, ' ' ) . replace ( / \s + / g, ' ' ) . trim ( ) ;
57106 try {
58107 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 ) ;
108+ const rows : any [ ] [ ] = result . rows ;
109+
110+ if ( ! Array . isArray ( rows ) || rows . length === 0 ) {
111+ setOutputState ( { type : 'result' , columns : null , rows : [ ] } ) ;
112+ return ;
113+ }
114+
115+ let columns = parseColumnNames ( formattedQuery ) ;
116+
117+ if ( ! columns ) {
118+ const tableName = parseTableName ( formattedQuery ) ;
119+ if ( tableName ) {
120+ try {
121+ const schema : any = await RootService . listSqlSchemas ( tableName ) ;
122+ const tableSchema = schema ?. schemas ?. [ 0 ] ;
123+ if ( tableSchema ?. columns && Array . isArray ( tableSchema . columns ) ) {
124+ columns = tableSchema . columns . map ( ( col : any ) => col . name ?? String ( col ) ) ;
125+ }
126+ } catch ( schemaError : any ) {
127+ logger . error ( 'Failed to fetch SQL schema for table: ' + tableName , schemaError ) ;
128+ }
129+ }
66130 }
131+ setOutputState ( { type : 'result' , columns, rows } ) ;
132+ } catch ( error : any ) {
133+ setOutputState ( {
134+ type : 'error' ,
135+ message : error ?. message ? error . message : String ( error ) ,
136+ } ) ;
67137 }
68138 } , [ query ] ) ;
69139
@@ -73,11 +143,11 @@ const SQLTerminal = () => {
73143
74144 const handleClear = ( ) => {
75145 setQuery ( '' ) ;
76- setOutput ( '' ) ;
146+ setOutputState ( { type : 'empty' } ) ;
147+ setViewMode ( 'Raw' ) ;
77148 } ;
78149
79150 useEffect ( ( ) => {
80- // Check if the last character is a newline and the character before it is a semicolon
81151 if ( ! executed && query . endsWith ( '\n' ) && query . trimEnd ( ) . endsWith ( ';' ) ) {
82152 setExecuted ( true ) ;
83153 handleExecute ( ) ;
@@ -86,11 +156,104 @@ const SQLTerminal = () => {
86156
87157 useEffect ( ( ) => {
88158 scrollToBottom ( ) ;
89- } , [ output ] ) ;
159+ } , [ outputState ] ) ;
90160
91161 const closeHandler = ( ) => {
162+ handleClear ( ) ;
92163 dispatch ( setShowModals ( { ...showModals , sqlTerminalModal : false } ) ) ;
93- }
164+ } ;
165+
166+ const getHeaders = ( columns : string [ ] | null , firstRow : any [ ] ) : string [ ] => {
167+ if ( columns && columns . length === firstRow . length ) return columns ;
168+ return firstRow . map ( ( _ , i ) => `Col ${ i } ` ) ;
169+ } ;
170+
171+ const renderOutput = ( ) => {
172+ switch ( outputState . type ) {
173+ case 'empty' :
174+ return (
175+ < div className = 'terminal-output text-muted' >
176+ < span > Results will appear here…</ span >
177+ </ div >
178+ ) ;
179+
180+ case 'error' :
181+ return (
182+ < pre className = 'terminal-output text-invalid' >
183+ < code > Error: { outputState . message } </ code >
184+ </ pre >
185+ ) ;
186+
187+ case 'result' : {
188+ const { columns, rows } = outputState ;
189+
190+ if ( rows . length === 0 ) {
191+ return (
192+ < div className = 'terminal-output text-muted' >
193+ < span > Query returned no rows.</ span >
194+ </ div >
195+ ) ;
196+ }
197+
198+ const headers = getHeaders ( columns , rows [ 0 ] ) ;
199+
200+ return (
201+ < div >
202+ < div className = 'd-flex justify-content-end' >
203+ < ToggleSwitch className = 'mb-4' onChange = { ( changedIndex ) => setViewMode ( changedIndex === 0 ? 'Raw' : 'Table' ) } values = { [ 'Raw' , 'Table' ] } selValue = { viewMode } />
204+ </ div >
205+ { viewMode === 'Table' ? (
206+ < div className = 'terminal-output terminal-table-wrapper text-valid' ref = { outputRef } >
207+ < div className = 'terminal-table-header text-muted' >
208+ { rows . length } row{ rows . length !== 1 ? 's' : '' }
209+ </ div >
210+ < PerfectScrollbar options = { { suppressScrollX : false } } >
211+ < Table striped bordered hover size = 'sm' className = 'sql-result-table mb-0' >
212+ < thead >
213+ < tr >
214+ { headers . map ( ( h , i ) => (
215+ < th key = { i } > { h } </ th >
216+ ) ) }
217+ </ tr >
218+ </ thead >
219+ < tbody >
220+ { rows . map ( ( row , ri ) => (
221+ < tr key = { ri } >
222+ { ( Array . isArray ( row ) ? row : [ row ] ) . map ( ( cell , ci ) => (
223+ < td key = { ci } >
224+ { cell === null || cell === undefined
225+ ? < span className = 'text-muted fst-italic' > null</ span >
226+ : String ( cell ) }
227+ </ td >
228+ ) ) }
229+ </ tr >
230+ ) ) }
231+ </ tbody >
232+ </ Table >
233+ </ PerfectScrollbar >
234+ </ div >
235+ ) : (
236+ < div className = 'terminal-output text-valid' >
237+ < div className = 'd-flex justify-content-between align-items-start' >
238+ < div className = 'text-muted' >
239+ { rows . length } record{ rows . length !== 1 ? 's' : '' }
240+ </ div >
241+ < button tabIndex = { 3 } onClick = { handleCopy } className = 'btn-copy-output' >
242+ < CopySVG id = 'Output' showTooltip = { true } />
243+ </ button >
244+ </ div >
245+ < pre className = 'terminal-output-scroll-container' ref = { outputRef as any } >
246+ < PerfectScrollbar >
247+ < code > { JSON . stringify ( rows , null , 2 ) } </ code >
248+ </ PerfectScrollbar >
249+ </ pre >
250+ </ div >
251+ ) }
252+ </ div >
253+ ) ;
254+ }
255+ }
256+ } ;
94257
95258 return (
96259 < Modal ref = { containerRef } show = { showModals . sqlTerminalModal } onHide = { closeHandler } centered className = 'modal-xl' data-testid = 'sql-terminal' >
@@ -128,44 +291,15 @@ const SQLTerminal = () => {
128291 < CopySVG id = 'SQL Query' showTooltip = { true } />
129292 </ InputGroup . Text >
130293 </ 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 >
145- </ div >
294+ { renderOutput ( ) }
146295 < 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- >
296+ < button tabIndex = { 4 } type = 'button' className = 'btn-rounded bg-primary fs-6 me-4' onClick = { handleExecute } >
153297 Execute
154298 </ button >
155- < button
156- tabIndex = { 5 }
157- type = 'button'
158- className = 'btn-rounded bg-primary fs-6 me-4'
159- onClick = { handleClear }
160- >
299+ < button tabIndex = { 5 } type = 'button' className = 'btn-rounded bg-primary fs-6 me-4' onClick = { handleClear } >
161300 Clear
162301 </ button >
163- < button
164- tabIndex = { 6 }
165- type = 'button'
166- className = 'btn-rounded bg-primary fs-6 me-4'
167- onClick = { handleHelp }
168- >
302+ < button tabIndex = { 6 } type = 'button' className = 'btn-rounded bg-primary fs-6 me-4' onClick = { handleHelp } >
169303 Help
170304 </ button >
171305 </ ButtonGroup >
0 commit comments