diff --git a/src/main/index.ts b/src/main/index.ts index 4d48e1d..aa0d6a0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -119,6 +119,9 @@ async function startBackendServices(): Promise { } else if (platform === 'darwin') { apiPath = path.join(basePath, 'mac', 'qgenie-api') aiPath = path.join(basePath, 'mac', 'qgenie-ai') + } else if (platform === 'linux') { + apiPath = path.join(basePath, 'linux', 'qgenie-api') + aiPath = path.join(basePath, 'linux', 'qgenie-ai') } else { throw new Error(`Unsupported platform: ${platform}`) } @@ -126,7 +129,7 @@ async function startBackendServices(): Promise { if (!fs.existsSync(apiPath)) throw new Error(`API executable not found: ${apiPath}`) if (!fs.existsSync(aiPath)) throw new Error(`AI executable not found: ${aiPath}`) - if (platform === 'darwin') { + if (platform === 'darwin' || platform === 'linux') { fs.chmodSync(apiPath, 0o755) fs.chmodSync(aiPath, 0o755) } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index edb54c4..155bec0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, ipcMain, shell } from 'electron' +import { BrowserWindow, ipcMain, shell, dialog } from 'electron' +import fs from 'fs' import { createSubWindow } from '../windows/subWindow' import axios from 'axios' @@ -34,6 +35,50 @@ export function registerIpcHandlers(mainWindow?: BrowserWindow): void { win?.close() }) + // 파일 저장 핸들러: SQL + ipcMain.on('save-sql', async (_event, sqlContent: string) => { + const window = BrowserWindow.getFocusedWindow() + if (!window) return + + const { filePath } = await dialog.showSaveDialog(window, { + title: 'Save SQL File', + defaultPath: `query-${Date.now()}.sql`, + filters: [{ name: 'SQL Files', extensions: ['sql'] }] + }) + + if (filePath) { + try { + fs.writeFileSync(filePath, sqlContent, 'utf-8') + console.log('SQL file saved to:', filePath) + } catch (err) { + console.error('Failed to save SQL file:', err) + } + } + }) + + // 파일 저장 핸들러: CSV + ipcMain.on('save-csv', async (_event, csvContent: string) => { + const window = BrowserWindow.getFocusedWindow() + if (!window) return + + const { filePath } = await dialog.showSaveDialog(window, { + title: 'Save CSV File', + defaultPath: `results-${Date.now()}.csv`, + filters: [{ name: 'CSV Files', extensions: ['csv'] }] + }) + + if (filePath) { + try { + // CSV 파일에 BOM을 추가하여 Excel에서 한글이 깨지지 않도록 함 + const BOM = '\uFEFF' + fs.writeFileSync(filePath, BOM + csvContent, 'utf-8') + console.log('CSV file saved to:', filePath) + } catch (err) { + console.error('Failed to save CSV file:', err) + } + } + }) + // Vercel AI SDK 'useChat' 훅을 위한 핸들러 (스트리밍 시뮬레이션) ipcMain.handle('chat:completion', async (event, { messages, chatTabId }) => { if (!chatTabId) { @@ -58,6 +103,8 @@ export function registerIpcHandlers(mainWindow?: BrowserWindow): void { requestBody ) + console.log('Backend response data:', JSON.stringify(response.data, null, 2)) + if (response.data && response.data.data) { const aiMessage = response.data.data.message as string diff --git a/src/preload/index.ts b/src/preload/index.ts index 7f85325..9993765 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,7 +6,7 @@ import type { CustomAPI } from './index.d' const api: CustomAPI = { versions: process.versions, send: (channel: string, data?: T) => { - const validChannels = ['open-sub-window', 'ping', 'open-external'] + const validChannels = ['open-sub-window', 'ping', 'open-external', 'save-sql', 'save-csv'] if (validChannels.includes(channel)) { ipcRenderer.send(channel, data) } diff --git a/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx b/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx index b477382..b723787 100644 --- a/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx +++ b/src/renderer/src/components/workspace/ai-chat-panel/chat-message.tsx @@ -108,13 +108,13 @@ export default function ChatMessage({ isUser ? 'items-end' : 'items-start' )} > - {(isAi || isUser) && ( + {(isAi || isUser) && mainContent && (
{ @@ -11,12 +12,6 @@ interface ApiResponse { data: T } -interface DbProfile { - id: string - view_name?: string - name?: string -} - interface ColumnInfo { name: string } @@ -47,28 +42,34 @@ const initializeExpandedState = (nodes: SchemaNode[]): Record = return state } +interface DbSchemaPanelProps { + profiles: ConnectionProfile[] + isLoading: boolean +} + /** * @author nahyeongjin1 * @summary DB 스키마 정보를 보여주는 패널 * @returns JSX.Element */ -export default function DbSchemaPanel(): React.JSX.Element { +export default function DbSchemaPanel({ + profiles, + isLoading: isLoadingProfiles +}: DbSchemaPanelProps): React.JSX.Element { const [schemaData, setSchemaData] = useState([]) - const [isLoading, setIsLoading] = useState(true) + const [isSchemaLoading, setIsSchemaLoading] = useState(true) const [expandedNodes, setExpandedNodes] = useState>({}) useEffect(() => { + // profiles가 비어있거나 로딩 중이면 아무것도 하지 않음 + if (isLoadingProfiles || profiles.length === 0) { + if (!isLoadingProfiles) setIsSchemaLoading(false) + return + } + const fetchSchemaData = async (): Promise => { try { - const profilesRes = (await api.get('/api/user/db/find/all')) as unknown as ApiResponse< - DbProfile[] - > - const profiles = profilesRes.data - - if (!profiles || !Array.isArray(profiles)) { - throw new Error('Invalid profile data received') - } - + console.log('[DbSchemaPanel] 받은 profiles로 스키마 정보 조회를 시작합니다:', profiles) const allSchemasPromises = profiles.map((profile) => api.get(`/api/user/db/find/hierarchical-schema/${profile.id}`) ) @@ -87,7 +88,7 @@ export default function DbSchemaPanel(): React.JSX.Element { response.data.length === 0 ) { console.warn( - `Could not fetch schema for ${profile.view_name}: ${response.message || 'Empty data'}` + `[DbSchemaPanel] 스키마 조회 실패 ${profile.view_name}: ${response.message || 'Empty data'}` ) return null } @@ -115,19 +116,19 @@ export default function DbSchemaPanel(): React.JSX.Element { } }) .filter(Boolean) as SchemaNode[] - + console.log('[DbSchemaPanel] 스키마 정보 변환 완료:', transformedData) setSchemaData(transformedData) setExpandedNodes(initializeExpandedState(transformedData)) } catch (error) { toast.error('데이터베이스 스키마 정보를 불러오는 데 실패했습니다.') - console.error(error) + console.error('[DbSchemaPanel] 스키마 정보 조회 실패:', error) } finally { - setIsLoading(false) + setIsSchemaLoading(false) } } fetchSchemaData() - }, []) + }, [profiles, isLoadingProfiles]) const handleToggle = (nodeId: string): void => { setExpandedNodes((prev) => ({ @@ -136,7 +137,7 @@ export default function DbSchemaPanel(): React.JSX.Element { })) } - if (isLoading) { + if (isLoadingProfiles || isSchemaLoading) { return (

로딩 중...

diff --git a/src/renderer/src/components/workspace/query-panel/connection-selector.tsx b/src/renderer/src/components/workspace/query-panel/connection-selector.tsx new file mode 100644 index 0000000..8c52add --- /dev/null +++ b/src/renderer/src/components/workspace/query-panel/connection-selector.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { ConnectionProfile } from '../../../types/database' +import { ChevronDown } from 'lucide-react' + +interface ConnectionSelectorProps { + connections: ConnectionProfile[] + activeConnection: ConnectionProfile | null + setActiveConnection: (connection: ConnectionProfile | null) => void +} + +const ConnectionSelector: React.FC = ({ + connections, + activeConnection, + setActiveConnection +}) => { + const handleSelectionChange = (event: React.ChangeEvent): void => { + const selectedId = event.target.value + const selectedConnection = connections.find((c) => c.id === selectedId) || null + console.log('[ConnectionSelector] DB 연결 변경:', selectedConnection) + setActiveConnection(selectedConnection) + } + + return ( +
+ + +
+ ) +} + +export default ConnectionSelector diff --git a/src/renderer/src/components/workspace/query-panel/query-panel.tsx b/src/renderer/src/components/workspace/query-panel/query-panel.tsx index c7a5c40..dd22612 100644 --- a/src/renderer/src/components/workspace/query-panel/query-panel.tsx +++ b/src/renderer/src/components/workspace/query-panel/query-panel.tsx @@ -3,30 +3,25 @@ import { Code2, ChartColumn, Download, Play } from 'lucide-react' import { cn } from '@/lib/utils' import QueryEditor from './query-editor' import QueryResults, { type QueryResultData } from './query-results' +import { ConnectionProfile } from '../../../types/database' +import ConnectionSelector from './connection-selector' +import { api } from '@renderer/utils/api' +import { toast } from 'sonner' type ActiveTab = 'editor' | 'results' -// Mock API function -const executeQuery = async (query: string): Promise => { - console.log('Executing query:', query) - return new Promise((resolve, reject) => { - setTimeout(() => { - // Simulate success/error randomly - if (Math.random() > 0.3) { - resolve({ - columns: ['id', 'name', 'email', 'age'], - rows: [ - [1, 'Alice', 'alice@example.com', 30], - [2, 'Bob', 'bob@example.com', 25], - [3, 'Charlie', 'charlie@example.com', 35], - [4, 'David', 'david@example.com', 28] - ] - }) - } else { - reject(new Error("Syntax error near 'FROM'. Check your SQL syntax.")) - } - }, 1500) - }) +interface QueryPanelProps { + connections: ConnectionProfile[] + activeConnection: ConnectionProfile | null + setActiveConnection: (connection: ConnectionProfile | null) => void +} + +// API가 반환하는 실제 데이터 구조에 대한 타입 +interface QueryApiResponse { + data: { + columns: string[] + data: Record[] // 객체의 배열 + } } /** @@ -34,7 +29,11 @@ const executeQuery = async (query: string): Promise => { * @summary 쿼리 편집기 및 결과 탭 패널 * @returns JSX.Element */ -export default function QueryPanel(): React.JSX.Element { +export default function QueryPanel({ + connections, + activeConnection, + setActiveConnection +}: QueryPanelProps): React.JSX.Element { const [activeTab, setActiveTab] = useState('editor') const [query, setQuery] = useState( 'SELECT p.ProductName, SUM(sod.sales_quantity) as total_quantity_sold, SUM(sod.sales_quantity * sod.UnitPrice) as total_revenue FROM Products p JOIN SalesOrderDetails sod ON p.ProductID = sod.ProductID GROUP BY p.ProductID, p.ProductName ORDER BY total_revenue DESC LIMIT 5;' @@ -44,14 +43,45 @@ export default function QueryPanel(): React.JSX.Element { const [error, setError] = useState(null) const handleExecuteQuery = async (): Promise => { + if (!activeConnection) { + toast.error('쿼리를 실행할 데이터베이스를 선택해주세요.') + return + } + setIsLoading(true) setError(null) + + const requestBody = { + user_db_id: activeConnection.id, + database: activeConnection.name, + query_text: query + // chat_message_id는 현재 컨텍스트에 없으므로 생략하거나 임시 값을 사용합니다. + // chat_message_id: 'temp-id' + } + try { - const queryResult = await executeQuery(query) + console.log('[QueryPanel] 쿼리 실행 요청:', requestBody) + const response = (await api.post('/api/query/execute/test', requestBody)) as QueryApiResponse + console.log('[QueryPanel] 쿼리 실행 응답:', response) + + const { columns, data: rowsAsObjects } = response.data + + // API 응답(객체의 배열)을 컴포넌트가 사용할 형태(값 배열의 배열)로 변환합니다. + const rowsAsArrays = rowsAsObjects.map((rowObject) => + columns.map((columnName) => rowObject[columnName]) + ) + + const queryResult: QueryResultData = { + columns: columns, + rows: rowsAsArrays + } + setResult(queryResult) setActiveTab('results') } catch (err) { - setError(err instanceof Error ? err.message : 'An unknown error occurred.') + const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.' + console.error('[QueryPanel] 쿼리 실행 오류:', errorMessage) + setError(errorMessage) setResult(null) setActiveTab('results') } finally { @@ -59,6 +89,49 @@ export default function QueryPanel(): React.JSX.Element { } } + const handleTabChange = (tabName: ActiveTab): void => { + // '실행 결과' 탭에서 '쿼리 편집기' 탭으로 돌아올 때 에러 상태를 초기화합니다. + if (tabName === 'editor') { + setError(null) + } + setActiveTab(tabName) + } + + const handleExport = (): void => { + if (activeTab === 'editor') { + if (!query.trim()) { + toast.info('내보낼 쿼리가 없습니다.') + return + } + window.api.send('save-sql', query) + } else if (activeTab === 'results') { + if (!result || result.rows.length === 0) { + toast.info('내보낼 결과가 없습니다.') + return + } + + const { columns, rows } = result + const header = columns.join(',') + const body = rows + .map((row) => + row + .map((cell) => { + const cellStr = String(cell ?? '') + // 셀에 쉼표나 큰따옴표가 포함된 경우 큰따옴표로 감싸고, 내부 큰따옴표는 두 번 씁니다. + if (cellStr.includes(',') || cellStr.includes('"')) { + return `"${cellStr.replace(/"/g, '""')}"` + } + return cellStr + }) + .join(',') + ) + .join('\n') + + const csvContent = `${header}\n${body}` + window.api.send('save-csv', csvContent) + } + } + const TabButton = ({ tabName, Icon, @@ -69,7 +142,7 @@ export default function QueryPanel(): React.JSX.Element { label: string }): React.JSX.Element => (
setActiveTab(tabName)} + onClick={() => handleTabChange(tabName)} className={cn( 'group flex items-center gap-2 py-[16.5px] cursor-pointer border-b-3 -mb-px', activeTab === tabName @@ -83,7 +156,8 @@ export default function QueryPanel(): React.JSX.Element { ) return ( -
+
+ {/* Header */}
@@ -93,10 +167,10 @@ export default function QueryPanel(): React.JSX.Element { {activeTab == 'editor' ? (
-
- {activeTab === 'editor' && } + {/* Content */} +
+ {activeTab === 'editor' && ( +
+
+ +
+ +
+ )} {activeTab === 'results' && ( )} diff --git a/src/renderer/src/components/workspace/query-panel/query-results.tsx b/src/renderer/src/components/workspace/query-panel/query-results.tsx index b83d242..cc7b4e7 100644 --- a/src/renderer/src/components/workspace/query-panel/query-results.tsx +++ b/src/renderer/src/components/workspace/query-panel/query-results.tsx @@ -14,80 +14,80 @@ export default function QueryResults({ isLoading, error }: QueryResultsProps): React.JSX.Element { - const hasResults = result && result.columns.length > 0 + const hasResults = result && result.columns.length > 0 && result.rows - const renderContent = (): React.ReactNode => { - if (isLoading) { - return ( -
- 쿼리를 실행 중입니다... -
- ) - } - - if (error) { - return ( -
오류: {error}
- ) - } + if (isLoading) { + return ( +
+ 쿼리를 실행 중입니다... +
+ ) + } - if (hasResults) { - return ( -
- - - - {result.columns.map((column) => ( - - ))} - - - - {result.rows.length > 0 ? ( - result.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - )) - ) : ( - - - - )} - -
- {column} -
- {String(cell)} -
- 쿼리는 성공했지만 반환된 행이 없습니다. -
+ if (error) { + return ( +
+
+
오류: {error}
- ) - } +
+ ) + } + if (hasResults) { return ( -
- 쿼리를 실행하여 결과를 확인하세요. +
+ + + + {result.columns.map((column) => ( + + ))} + + + + {result.rows.length > 0 ? ( + result.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {column} +
+ {cell === null ? ( + NULL + ) : ( + String(cell) + )} +
+ 쿼리는 성공했지만 반환된 행이 없습니다. +
) } return ( -
-
{renderContent()}
+
+ 쿼리를 실행하여 결과를 확인하세요.
) } diff --git a/src/renderer/src/components/workspace/workspace.tsx b/src/renderer/src/components/workspace/workspace.tsx index 5cd9f71..c513b5f 100644 --- a/src/renderer/src/components/workspace/workspace.tsx +++ b/src/renderer/src/components/workspace/workspace.tsx @@ -1,20 +1,69 @@ +import { useEffect, useState } from 'react' import { DbSchemaPanel } from './db-schema-panel' import { AiChatPanel } from './ai-chat-panel' import { QueryPanel } from './query-panel' +import { api } from '@renderer/utils/api' +import { toast } from 'sonner' +import { ConnectionProfile } from '@renderer/types/database' +import { ApiResponse } from '@renderer/types' const WorkSpace = (): React.JSX.Element => { + const [connections, setConnections] = useState([]) + const [activeConnection, setActiveConnection] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const fetchConnections = async (): Promise => { + try { + console.log('[Workspace] DB 연결 목록 조회를 시작합니다.') + const res = await api.get>('/api/user/db/find/all') + console.log('[Workspace] Raw API response:', res) + + if (res && Array.isArray(res.data)) { + const fetchedConnections = res.data + console.log('[Workspace] DB 연결 목록 조회 성공:', fetchedConnections) + setConnections(fetchedConnections) + + if (fetchedConnections.length > 0) { + console.log('[Workspace] 첫 번째 연결을 활성 DB로 설정합니다:', fetchedConnections[0]) + setActiveConnection(fetchedConnections[0]) + } else { + console.log('[Workspace] 저장된 DB 연결이 없습니다.') + } + } else { + throw new Error('Invalid API response format') + } + } catch (error) { + toast.error('DB 연결 목록을 불러오는 데 실패했습니다.') + console.error('[Workspace] DB 연결 목록 조회 실패:', error) + } finally { + setIsLoading(false) + } + } + + fetchConnections() + }, []) + return (
{/* DB Schema Panel (Left) */} - + {/* Main Content (Center & Right) */} -
+
{/* AI Chat Panel (Center) */} - +
+ +
{/* Query & Results Panel (Right) */} - +
+ +
) diff --git a/src/renderer/src/types/database.ts b/src/renderer/src/types/database.ts new file mode 100644 index 0000000..869d296 --- /dev/null +++ b/src/renderer/src/types/database.ts @@ -0,0 +1,11 @@ +export interface ConnectionProfile { + id: string + type: 'mysql' | 'mariadb' | 'postgresql' | 'oracle' | 'sqlite' + host: string | null + port: number | null + name: string | null + username: string | null + view_name: string | null + created_at: string + updated_at: string +}