diff --git a/src/main/services/ai/agents/projectAgent.ts b/src/main/services/ai/agents/projectAgent.ts index 332f5060..664583b7 100644 --- a/src/main/services/ai/agents/projectAgent.ts +++ b/src/main/services/ai/agents/projectAgent.ts @@ -137,12 +137,12 @@ If a database connection is failing or dbt cannot connect: 1. First diagnose; do not repair by editing connection files. 2. Use \`studio_connections_test\` when available to test the configured connection. -3. If needed, run \`studio_cli_run_dbt\` with \`dbt debug\` to verify the failure mode. +3. If needed, run \`studio_cli_run_dbt\` with \`command: "debug"\` to verify the failure mode. Do not pass \`"dbt debug"\` as the command; the tool adds the dbt executable. 4. Use \`getDbtLogs\` and read-only file inspection to explain the likely cause. 5. Ask the user to fix the connection in the DBT Studio Connections UI, secure keytar-backed credentials, or the intended connection-management workflow. 6. Wait for user confirmation that the connection has been corrected before continuing with dbt execution. -If the failure appears to be caused by invalid credentials, unreachable host, wrong port, missing network access, expired token, missing env vars, or bad connection metadata, do not rewrite \`profiles.yml\`. Report the issue clearly and direct the user to fix the connection configuration. +If the failure appears to be caused by invalid credentials, unreachable host, wrong port, missing network access, expired token, missing env vars, or bad connection metadata, run \`studio_cli_run_dbt\` with \`command: "debug"\` when it can add evidence, then do not rewrite \`profiles.yml\`. Report the exact missing env var or failed connection field clearly and direct the user to fix the connection configuration. ## Available Tools diff --git a/src/main/services/ai/tools/dbt.tools.ts b/src/main/services/ai/tools/dbt.tools.ts index a7e75e6c..80f316cc 100644 --- a/src/main/services/ai/tools/dbt.tools.ts +++ b/src/main/services/ai/tools/dbt.tools.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import SettingsService from '../../settings.service'; import AgentService from '../../agent.service'; +import SecureStorageService from '../../secureStorage.service'; import { TerminalConfirmGate } from './terminalConfirmGate'; // Security constraints @@ -57,6 +58,42 @@ function isAllowedCommand(command: string, allowedCommands: string[]): boolean { }); } +async function buildDbtProcessEnv( + projectPath: string, +): Promise> { + const env = { ...process.env }; + const profilesPath = path.join(projectPath, 'profiles.yml'); + + if (!fs.existsSync(profilesPath)) { + return env; + } + + const profilesContent = await fs.promises.readFile(profilesPath, 'utf8'); + const envVarRegex = /env_var\(\s*["']([^"']+)["']/g; + const envVarNames = new Set(); + + for ( + let match = envVarRegex.exec(profilesContent); + match !== null; + match = envVarRegex.exec(profilesContent) + ) { + envVarNames.add(match[1]); + } + + await Promise.all( + Array.from(envVarNames).map(async (envVarName) => { + if (envVarName in env) return; + + const storedValue = await SecureStorageService.getCredential(envVarName); + if (storedValue) { + env[envVarName] = storedValue; + } + }), + ); + + return env; +} + export async function executeDbtCommand(opts: { command: string; select?: string; @@ -146,9 +183,12 @@ export async function executeDbtCommand(opts: { mainWindow.webContents.send('cli:output', `> ${displayCmd}\n`); } + const env = await buildDbtProcessEnv(projectPath); + return await new Promise((resolve) => { const child = spawn(dbtExe, args, { cwd: projectPath, + env, shell: false, }); @@ -435,6 +475,7 @@ export const runDbtCommand = tool({ // Execute with timeout const output = execFileSync(dbtExe, args, { cwd: projectPath, + env: await buildDbtProcessEnv(projectPath), encoding: 'utf-8', timeout: COMMAND_TIMEOUT, maxBuffer: 1024 * 1024 * 10, // 10 MB buffer diff --git a/src/main/services/ai/tools/studio/cli.tools.ts b/src/main/services/ai/tools/studio/cli.tools.ts index 5eda6e2e..7fa0936e 100644 --- a/src/main/services/ai/tools/studio/cli.tools.ts +++ b/src/main/services/ai/tools/studio/cli.tools.ts @@ -10,15 +10,41 @@ const PHASE_19_ALLOWED_COMMANDS = [ 'run', 'test', 'compile', + 'deps', + 'seed', + 'debug', 'docs generate', 'source freshness', ]; +const ALLOWED_COMMAND_DESCRIPTION = + 'Allowed dbt command: run, test, compile, deps, seed, debug, docs generate, or source freshness'; + +const studioCliRunDbtInputSchema = z.object({ + command: z + .string() + .refine((command) => PHASE_19_ALLOWED_COMMANDS.includes(command), { + message: ALLOWED_COMMAND_DESCRIPTION, + }) + .describe(ALLOWED_COMMAND_DESCRIPTION), + select: z + .string() + .optional() + .describe( + 'Optional dbt selector (e.g., "my_model", "tag:daily", "my_model+")', + ), + extraArgs: z + .string() + .optional() + .describe('Optional additional dbt arguments (for advanced use cases)'), +}); + +type StudioCliRunDbtInput = z.infer; export function createStudioCliTools(options: { projectPath?: string; conversationId?: number; mainWindow?: BrowserWindow; -}) { +}): Record { const { projectPath, conversationId, mainWindow } = options; const cliEnabled = isToolEnabled(STUDIO_CLI_RUN_DBT_FLAG); @@ -30,26 +56,8 @@ export function createStudioCliTools(options: { studio_cli_run_dbt: tool({ description: 'Run an approved dbt CLI command for the active project. Always requires explicit user approval before execution.', - inputSchema: z.object({ - command: z - .enum(['run', 'test', 'compile', 'docs generate', 'source freshness']) - .describe( - 'Allowed dbt command: run, test, compile, docs generate, or source freshness', - ), - select: z - .string() - .optional() - .describe( - 'Optional dbt selector (e.g., "my_model", "tag:daily", "my_model+")', - ), - extraArgs: z - .string() - .optional() - .describe( - 'Optional additional dbt arguments (for advanced use cases)', - ), - }), - execute: async ({ command, select, extraArgs }) => { + inputSchema: studioCliRunDbtInputSchema as any, + execute: async ({ command, select, extraArgs }: StudioCliRunDbtInput) => { const startedAt = Date.now(); try { diff --git a/src/renderer/App.css b/src/renderer/App.css index 7e53336e..788ad737 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -81,3 +81,9 @@ a:hover { background-size: 14px 14px; cursor: pointer; } + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/renderer/components/chat/MessageRenderer.tsx b/src/renderer/components/chat/MessageRenderer.tsx index 36e68a33..b5dd2abb 100644 --- a/src/renderer/components/chat/MessageRenderer.tsx +++ b/src/renderer/components/chat/MessageRenderer.tsx @@ -64,6 +64,49 @@ interface MessageRendererProps { orderedParts?: StreamContentPart[]; } +type RenderToolStatus = ToolCallState['status']; + +function normalizePersistedToolStatus( + status: string | undefined, + output?: unknown, + error?: unknown, + isStreaming = false, +): RenderToolStatus { + const normalizedStatus = String(status ?? '').toLowerCase(); + + if ( + ['done', 'completed', 'success', 'succeeded'].includes(normalizedStatus) + ) { + return 'done'; + } + + if ( + ['error', 'failed', 'failure', 'cancelled', 'canceled'].includes( + normalizedStatus, + ) + ) { + return 'error'; + } + + if ( + isStreaming && + ['running', 'pending', 'queued'].includes(normalizedStatus) + ) { + return 'running'; + } + + if (error) return 'error'; + if (output !== undefined && output !== null) return 'done'; + + // Persisted history is never actively running. A historical running/pending + // tool without output most likely failed or was interrupted before result. + if (['running', 'pending', 'queued'].includes(normalizedStatus)) { + return 'error'; + } + + return 'done'; +} + /** * Converts persisted DB tool calls into AgentStep[] for rendering with AgentStepBlock. * Tool calls are grouped by _stepNumber stored in toolInput. @@ -90,18 +133,17 @@ function buildStepsFromToolCalls( ); } - const statusMap: Record = { - completed: 'done', - failed: 'error', - }; - const toolCallState: ToolCallState = { id: toolCallId, toolName: tc.toolName, args: displayArgs as Record, result: tc.toolOutput, error: tc.errorMessage ?? undefined, - status: statusMap[tc.status] ?? 'done', + status: normalizePersistedToolStatus( + tc.status, + tc.toolOutput, + tc.errorMessage, + ), }; if (!stepMap.has(stepNumber)) { @@ -594,6 +636,22 @@ export const MessageRenderer: React.FC = ({ return buildStepsFromToolCalls(toolCalls); }, [toolCalls]); + const persistedToolStatusById = React.useMemo(() => { + const statusById = new Map(); + if (!toolCalls) return statusById; + + toolCalls.forEach((tc) => { + const toolCallId: string = + (tc.toolInput?.tcId as string) ?? String(tc.id); + statusById.set( + toolCallId, + normalizePersistedToolStatus(tc.status, tc.toolOutput, tc.errorMessage), + ); + }); + + return statusById; + }, [toolCalls]); + // Fallback: if content looks like HTML (legacy TipTap), strip tags for display const displayContent = React.useMemo(() => { const looksLikeHtml = /<\/?[a-z][\s\S]*>/i.test(content || ''); @@ -687,6 +745,15 @@ export const MessageRenderer: React.FC = ({ } // tool-call part const tc = part as ToolCallContentPart; + const persistedStatus = persistedToolStatusById.get(tc.toolCallId); + const status = + persistedStatus ?? + normalizePersistedToolStatus( + tc.status, + tc.result, + tc.error, + isStreaming, + ); return ( = ({ args: tc.args, result: tc.result, error: tc.error, - status: tc.status, + status, durationMs: tc.durationMs, }} /> diff --git a/src/renderer/components/chat/ToolCallRow.tsx b/src/renderer/components/chat/ToolCallRow.tsx index 19bb54a8..8eb378dd 100644 --- a/src/renderer/components/chat/ToolCallRow.tsx +++ b/src/renderer/components/chat/ToolCallRow.tsx @@ -41,6 +41,21 @@ export const ToolCallRow: React.FC = ({ let suffix: React.ReactNode = null; const { toolName, args, result, status } = toolCall; + const isDone = status === 'done'; + const isError = status === 'error'; + const labelForStatus = ({ + done, + error, + running, + }: { + done: string; + error: string; + running: string; + }) => { + if (isError) return error; + if (isDone) return done; + return running; + }; switch (toolName) { case 'readDbtModel': @@ -94,10 +109,11 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = - status === 'done' - ? `Analyzed database schema` - : `Analyzing database schema`; + label = labelForStatus({ + done: `Analyzed database schema`, + error: `Failed database schema analysis`, + running: `Analyzing database schema`, + }); category = 'read'; break; } @@ -108,8 +124,11 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = - status === 'done' ? `Updated SQL editor` : `Updating SQL editor`; + label = labelForStatus({ + done: `Updated SQL editor`, + error: `Failed SQL editor update`, + running: `Updating SQL editor`, + }); category = 'write'; break; } @@ -121,8 +140,11 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = - status === 'done' ? `Executed SQL query` : `Executing SQL query`; + label = labelForStatus({ + done: `Executed SQL query`, + error: `Failed SQL query`, + running: `Executing SQL query`, + }); category = 'run'; break; } @@ -134,7 +156,7 @@ export const ToolCallRow: React.FC = ({ label = `Edited ${filename}`; category = 'write'; // Check for diff stats in result - if (status === 'done' && result && typeof result === 'object') { + if (isDone && result && typeof result === 'object') { const r = result as any; if (r.linesAdded !== undefined && r.linesRemoved !== undefined) { suffix = ( @@ -189,12 +211,13 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = - status === 'done' - ? `Ran ${fullCmd}`.substring(0, 50) + - (fullCmd.length > 50 ? '...' : '') - : `Running ${fullCmd}`.substring(0, 50) + - (fullCmd.length > 50 ? '...' : ''); + const truncatedCommand = + fullCmd.substring(0, 50) + (fullCmd.length > 50 ? '...' : ''); + label = labelForStatus({ + done: `Ran ${truncatedCommand}`, + error: `Failed ${truncatedCommand}`, + running: `Running ${truncatedCommand}`, + }); category = 'run'; break; } @@ -204,10 +227,11 @@ export const ToolCallRow: React.FC = ({ sx={{ fontSize: '0.9rem', color: theme.palette.text.secondary }} /> ); - label = - status === 'done' - ? `Loaded skill: ${args.name}` - : `Loading skill: ${args.name}`; + label = labelForStatus({ + done: `Loaded skill: ${args.name}`, + error: `Failed loading skill: ${args.name}`, + running: `Loading skill: ${args.name}`, + }); category = 'read'; break; } @@ -229,8 +253,11 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = - status === 'done' ? `Listed connections` : `Listing connections`; + label = labelForStatus({ + done: `Listed connections`, + error: `Failed listing connections`, + running: `Listing connections`, + }); category = 'read'; break; } @@ -242,7 +269,11 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = status === 'done' ? `Tested connection` : `Testing connection`; + label = labelForStatus({ + done: `Tested connection`, + error: `Failed connection test`, + running: `Testing connection`, + }); category = 'run'; break; } @@ -253,8 +284,11 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = - status === 'done' ? `Listed cloud objects` : `Listing cloud objects`; + label = labelForStatus({ + done: `Listed cloud objects`, + error: `Failed listing cloud objects`, + running: `Listing cloud objects`, + }); category = 'read'; break; } @@ -265,8 +299,11 @@ export const ToolCallRow: React.FC = ({ sx={{ color: theme.palette.text.secondary }} /> ); - label = - status === 'done' ? `Previewed cloud data` : `Previewing cloud data`; + label = labelForStatus({ + done: `Previewed cloud data`, + error: `Failed cloud data preview`, + running: `Previewing cloud data`, + }); category = 'read'; break; } diff --git a/src/renderer/components/customTable/index.tsx b/src/renderer/components/customTable/index.tsx index d5cc93e2..1630258e 100644 --- a/src/renderer/components/customTable/index.tsx +++ b/src/renderer/components/customTable/index.tsx @@ -22,6 +22,8 @@ const CustomTable = ({ toolbarContent, dataTestId, showSearch, + hideToolbar, + paginationLeftContent, }: CustomTableType) => { const [page, setPage] = React.useState(0); const [perPage, setPerPage] = useLocalStorage(id, '10'); @@ -69,18 +71,20 @@ const CustomTable = ({ )} - { - if (customPagination) { - customPagination.setKeyword(value); - return; - } - setKeyword(value); - }} - showSearch={showSearch} - /> + {!hideToolbar && ( + { + if (customPagination) { + customPagination.setKeyword(value); + return; + } + setKeyword(value); + }} + showSearch={showSearch} + /> + )} ({ - { - if (customPagination?.setPerPage) { - customPagination?.setPerPage(value); - return; - } - setPerPage(String(value)); - }} - total={customPagination?.count ?? rows.length} - /> +
+ {paginationLeftContent && ( +
+ {paginationLeftContent} +
+ )} +
+ { + if (customPagination?.setPerPage) { + customPagination?.setPerPage(value); + return; + } + setPerPage(String(value)); + }} + total={customPagination?.count ?? rows.length} + /> +
+
); }; diff --git a/src/renderer/components/customTable/types.ts b/src/renderer/components/customTable/types.ts index d145d830..9d4a2a8a 100644 --- a/src/renderer/components/customTable/types.ts +++ b/src/renderer/components/customTable/types.ts @@ -40,4 +40,6 @@ export type CustomTableType = { toolbarContent?: ReactNode; dataTestId?: string; showSearch?: boolean; + hideToolbar?: boolean; + paginationLeftContent?: ReactNode; }; diff --git a/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx b/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx index e99a0421..e364be9d 100644 --- a/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx +++ b/src/renderer/components/dbtModelButtons/ModelSplitButton.tsx @@ -17,6 +17,7 @@ import { } from '../../services/connectors.service'; import type { PreviewResult } from '../../../types/frontend'; import type { DbtCommandType } from '../../../types/backend'; +import type { ProjectQueryPreviewPayload } from '../projectQueryResults'; interface ModelSplitButtonProps { modelPath: string; @@ -26,6 +27,10 @@ interface ModelSplitButtonProps { isRunningDbt: boolean; isRunningRosettaDbt: boolean; environment?: 'local' | 'cloud'; + onBeforeExecute?: () => void; + onQueryPreviewStart?: (payload: Partial) => void; + onQueryPreviewSuccess?: (payload: ProjectQueryPreviewPayload) => void; + onQueryPreviewError?: (payload: ProjectQueryPreviewPayload) => void; } export const ModelSplitButton: React.FC = ({ @@ -36,6 +41,10 @@ export const ModelSplitButton: React.FC = ({ isRunningDbt, isRunningRosettaDbt, environment = 'local', + onBeforeExecute, + onQueryPreviewStart, + onQueryPreviewSuccess, + onQueryPreviewError, }) => { const [isCompiling, setIsCompiling] = useState(false); const [showCompileModal, setShowCompileModal] = useState(false); @@ -71,6 +80,7 @@ export const ModelSplitButton: React.FC = ({ localHandler: () => Promise, dbtArgs?: string, ) => { + onBeforeExecute?.(); if (environment === 'cloud') { setCloudDbtArguments(dbtArgs || ''); setRunInCloudModal(command); @@ -151,12 +161,28 @@ export const ModelSplitButton: React.FC = ({ setIsPreviewing(true); setPreviewError(undefined); setPreviewResult(null); + const startedAt = Date.now(); + onQueryPreviewStart?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + rawSql: fileContent, + }); try { // Extract model name from path for compilation const modelName = extractModelNameFromPath(modelPath); if (!modelName) { - toast.error('Could not extract model name from path'); + const errorMessage = 'Could not extract model name from path'; + toast.error(errorMessage); + onQueryPreviewError?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + rawSql: fileContent, + durationMs: Date.now() - startedAt, + errorMessage, + }); return; } @@ -164,22 +190,55 @@ export const ModelSplitButton: React.FC = ({ const modelCompiledSql = await dbtCompileModel(project, modelName); if (!modelCompiledSql || modelCompiledSql.trim() === '') { - toast.error( - 'No compiled SQL returned. The model might not exist or be disabled.', - ); + const errorMessage = + 'No compiled SQL returned. The model might not exist or be disabled.'; + toast.error(errorMessage); + onQueryPreviewError?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + modelName, + rawSql: fileContent, + compiledSql: modelCompiledSql, + durationMs: Date.now() - startedAt, + errorMessage, + }); return; } // Check if project has a connection configured if (!project.connectionId) { - toast.error('No database connection configured for this project'); + const errorMessage = + 'No database connection configured for this project'; + toast.error(errorMessage); + onQueryPreviewError?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + modelName, + rawSql: fileContent, + compiledSql: modelCompiledSql, + durationMs: Date.now() - startedAt, + errorMessage, + }); return; } // Get the connection details const connection = await getConnectionById(project.connectionId); if (!connection) { - toast.error('Database connection not found'); + const errorMessage = 'Database connection not found'; + toast.error(errorMessage); + onQueryPreviewError?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + modelName, + rawSql: fileContent, + compiledSql: modelCompiledSql, + durationMs: Date.now() - startedAt, + errorMessage, + }); return; } @@ -195,6 +254,17 @@ export const ModelSplitButton: React.FC = ({ toast.error(`Preview failed: ${errorMessage}`); setPreviewError(errorMessage); setIsPreviewing(false); + onQueryPreviewError?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + modelName, + rawSql: fileContent, + compiledSql: modelCompiledSql, + result: queryResult, + durationMs: Date.now() - startedAt, + errorMessage, + }); return; } @@ -214,11 +284,32 @@ export const ModelSplitButton: React.FC = ({ setPreviewResult(modelPreviewResult); setShowPreviewModal(true); + onQueryPreviewSuccess?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + modelName, + rawSql: fileContent, + compiledSql: modelCompiledSql, + result: { + ...queryResult, + duration: Date.now() - startedAt, + }, + durationMs: Date.now() - startedAt, + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; setPreviewError(errorMessage); toast.error(`Preview failed: ${errorMessage}`); + onQueryPreviewError?.({ + projectId: project.id, + projectName: project.name, + filePath: modelPath, + rawSql: fileContent, + durationMs: Date.now() - startedAt, + errorMessage, + }); } finally { setIsPreviewing(false); } diff --git a/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx b/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx index 5ac807b9..6dd9bd81 100644 --- a/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx +++ b/src/renderer/components/dbtModelButtons/ProjectDbtSplitButton.tsx @@ -33,6 +33,7 @@ interface ProjectDbtSplitButtonProps { isRunningRosettaDbt: boolean; connection?: any; environment?: 'local' | 'cloud'; + onBeforeExecute?: () => void; // Function handlers that are used elsewhere in ProjectDetails rosettaDbt: (project: Project, command: Command) => Promise; } @@ -46,6 +47,7 @@ export const ProjectDbtSplitButton: React.FC = ({ isRunningRosettaDbt, connection, environment = 'local', + onBeforeExecute, rosettaDbt, }) => { // Functions that are only used in this component - moved inside @@ -190,6 +192,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtRun(project); }, leftIcon: , @@ -203,6 +206,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtTest(project); }, leftIcon: , @@ -216,6 +220,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtBuild(project); }, leftIcon: , @@ -229,6 +234,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtCompileProject(project); }, leftIcon: , @@ -242,6 +248,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtDebug(project); }, leftIcon: , @@ -255,6 +262,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtDocsGenerate(project); }, leftIcon: , @@ -279,6 +287,7 @@ export const ProjectDbtSplitButton: React.FC = ({ stop(); return; } + onBeforeExecute?.(); start( `cd "${project.path}" && "${dbtPath}" docs serve`, connection?.connection?.name ?? '', @@ -295,6 +304,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtClean(project); }, leftIcon: , @@ -308,6 +318,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtDeps(project); }, leftIcon: , @@ -321,6 +332,7 @@ export const ProjectDbtSplitButton: React.FC = ({ toast.info('Please configure dbt path in settings'); return; } + onBeforeExecute?.(); dbtSeed(project); }, leftIcon: , @@ -375,6 +387,7 @@ export const ProjectDbtSplitButton: React.FC = ({ path={rawPath} project={project} processCallback={async (updatedPath) => { + onBeforeExecute?.(); await rosettaDbt(project, { command: 'extract', commandType: CommandType.DBTNext, @@ -399,6 +412,7 @@ export const ProjectDbtSplitButton: React.FC = ({ }); args.set(' ', command); } + onBeforeExecute?.(); await rosettaDbt(project, { command: 'staging', commandType: CommandType.DBTNext, @@ -423,6 +437,7 @@ export const ProjectDbtSplitButton: React.FC = ({ }); args.set(' ', command); } + onBeforeExecute?.(); await rosettaDbt(project, { commandType: CommandType.DBTNext, command: 'incremental', diff --git a/src/renderer/components/dbtRunHistory/DbtRunHistoryPanel.tsx b/src/renderer/components/dbtRunHistory/DbtRunHistoryPanel.tsx new file mode 100644 index 00000000..a98cfadb --- /dev/null +++ b/src/renderer/components/dbtRunHistory/DbtRunHistoryPanel.tsx @@ -0,0 +1,141 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; +import { useDbtRunHistory } from '../../hooks/useDbtRunHistory'; +import { DbtRunHistoryToolbar } from './DbtRunHistoryToolbar'; +import { DbtRunHistoryRunRow } from './DbtRunHistoryRunRow'; +import { DbtRunHistoryFilterState } from './types'; + +interface Props { + projectId: string; + onFixWithAI?: (prompt: string) => void; +} + +export const DbtRunHistoryPanel: React.FC = ({ + projectId, + onFixWithAI, +}) => { + const { history, clear } = useDbtRunHistory(projectId); + const [filters, setFilters] = useState({}); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isClearDialogOpen, setIsClearDialogOpen] = useState(false); + + const filteredHistory = useMemo(() => { + return history.filter((entry) => { + if (filters.status && entry.status !== filters.status) return false; + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + const matchesCommand = entry.fullCommand + .toLowerCase() + .includes(searchLower); + const matchesResults = entry.results?.some((r) => + r.name.toLowerCase().includes(searchLower), + ); + if (!matchesCommand && !matchesResults) return false; + } + return true; + }); + }, [history, filters]); + + const handleClear = () => { + setIsClearDialogOpen(true); + }; + + const handleConfirmClear = () => { + clear(); + setIsClearDialogOpen(false); + }; + + // Mock refresh since localStorage updates automatically + const handleRefresh = () => { + // A real refresh would re-read from disk if we wanted, but our hook auto-syncs. + }; + + return ( + + setIsFullscreen((v) => !v)} + /> + + + {filteredHistory.length === 0 ? ( + + No run history found. + + ) : ( + filteredHistory.map((entry) => ( + + )) + )} + + + setIsClearDialogOpen(false)} + aria-labelledby="clear-run-history-dialog-title" + aria-describedby="clear-run-history-dialog-description" + > + + Clear Run History + + + + Are you sure you want to clear the run history for this project? + This action cannot be undone. + + + + + + + + + ); +}; diff --git a/src/renderer/components/dbtRunHistory/DbtRunHistoryResultRow.tsx b/src/renderer/components/dbtRunHistory/DbtRunHistoryResultRow.tsx new file mode 100644 index 00000000..d8a0528d --- /dev/null +++ b/src/renderer/components/dbtRunHistory/DbtRunHistoryResultRow.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { Box, Typography, Tooltip, IconButton } from '@mui/material'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import WarningIcon from '@mui/icons-material/Warning'; +import InfoIcon from '@mui/icons-material/Info'; +import { + DbtRunHistoryEntry, + DbtRunHistoryResult, +} from '../../../types/dbtRunHistory'; +import { buildResultFailurePrompt } from './dbtRunHistoryPromptBuilders'; + +interface Props { + entry: DbtRunHistoryEntry; + result: DbtRunHistoryResult; + onExplainFailure?: (prompt: string) => void; +} + +const getStatusIcon = (status: string) => { + switch (status) { + case 'success': + case 'pass': + return ; + case 'error': + case 'fail': + case 'runtime error': + return ; + case 'warn': + return ; + default: + return ; + } +}; + +export const DbtRunHistoryResultRow: React.FC = ({ + entry, + result, + onExplainFailure, +}) => { + const isFailedOrWarn = ['error', 'fail', 'warn', 'runtime error'].includes( + result.status, + ); + + return ( + + + {getStatusIcon(result.status)} + + + + {result.name} + + + {result.resourceType && ( + + {result.resourceType} + + )} + + {result.executionTime !== undefined && ( + + {result.executionTime.toFixed(2)}s + + )} + + {result.message && ( + + + {result.message} + + + )} + + + {isFailedOrWarn && onExplainFailure && ( + + { + e.stopPropagation(); + onExplainFailure(buildResultFailurePrompt(entry, result)); + }} + > + + + + )} + + + ); +}; diff --git a/src/renderer/components/dbtRunHistory/DbtRunHistoryRunRow.tsx b/src/renderer/components/dbtRunHistory/DbtRunHistoryRunRow.tsx new file mode 100644 index 00000000..361c2d27 --- /dev/null +++ b/src/renderer/components/dbtRunHistory/DbtRunHistoryRunRow.tsx @@ -0,0 +1,182 @@ +import React, { useState } from 'react'; +import { Box, Typography, IconButton, Tooltip, Collapse } from '@mui/material'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import WarningIcon from '@mui/icons-material/Warning'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; + +import { DbtRunHistoryEntry } from '../../../types/dbtRunHistory'; +import { DbtRunHistoryResultRow } from './DbtRunHistoryResultRow'; +import { buildRunFailurePrompt } from './dbtRunHistoryPromptBuilders'; + +interface Props { + entry: DbtRunHistoryEntry; + onFixWithAI?: (prompt: string) => void; +} + +const getStatusIcon = (status: string) => { + switch (status) { + case 'success': + return ; + case 'error': + return ; + case 'warn': + return ; + case 'running': + return ( + + ); + default: + return ; + } +}; + +export const DbtRunHistoryRunRow: React.FC = ({ + entry, + onFixWithAI, +}) => { + const [expanded, setExpanded] = useState(false); + + const hasResults = entry.results && entry.results.length > 0; + const isFailed = entry.status === 'error'; + + return ( + <> + hasResults && setExpanded(!expanded)} + > + + {hasResults && ( + + {expanded ? ( + + ) : ( + + )} + + )} + + + + {getStatusIcon(entry.status)} + + + + + {entry.fullCommand} + + + + {entry.summary && entry.summary.total > 0 && ( + + {entry.summary.success}/{entry.summary.total} passed + {entry.summary.error > 0 && ` (${entry.summary.error} failed)`} + + )} + + + {entry.elapsedTime !== undefined + ? `${entry.elapsedTime.toFixed(2)}s` + : '-'} + + + + {new Date(entry.startedAt).toLocaleString()} + + + + + { + e.stopPropagation(); + navigator.clipboard.writeText( + entry.shellCommand || entry.fullCommand, + ); + }} + > + + + + {isFailed && onFixWithAI && ( + + { + e.stopPropagation(); + onFixWithAI(buildRunFailurePrompt(entry)); + }} + > + + + + )} + + + + {hasResults && ( + + + {entry.results!.map((result) => ( + + ))} + + + )} + + ); +}; diff --git a/src/renderer/components/dbtRunHistory/DbtRunHistoryToolbar.tsx b/src/renderer/components/dbtRunHistory/DbtRunHistoryToolbar.tsx new file mode 100644 index 00000000..0ef08692 --- /dev/null +++ b/src/renderer/components/dbtRunHistory/DbtRunHistoryToolbar.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { + Box, + IconButton, + Tooltip, + TextField, + MenuItem, + Select, +} from '@mui/material'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; +import FullscreenIcon from '@mui/icons-material/Fullscreen'; +import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; +import { DbtRunHistoryFilterState } from './types'; + +interface Props { + filters: DbtRunHistoryFilterState; + onFilterChange: (filters: DbtRunHistoryFilterState) => void; + onRefresh: () => void; + onClear: () => void; + isFullscreen?: boolean; + onToggleFullscreen?: () => void; +} + +export const DbtRunHistoryToolbar: React.FC = ({ + filters, + onFilterChange, + onRefresh, + onClear, + isFullscreen, + onToggleFullscreen, +}) => { + return ( + + + + + + + + + + + + + + + + onFilterChange({ ...filters, search: e.target.value })} + sx={{ + flexGrow: 1, + maxWidth: 300, + '& .MuiInputBase-root': { height: 28, fontSize: '0.8rem' }, + }} + /> + + {onToggleFullscreen && ( + + + {isFullscreen ? ( + + ) : ( + + )} + + + )} + + ); +}; diff --git a/src/renderer/components/dbtRunHistory/dbtRunHistoryPromptBuilders.ts b/src/renderer/components/dbtRunHistory/dbtRunHistoryPromptBuilders.ts new file mode 100644 index 00000000..ba0f62ed --- /dev/null +++ b/src/renderer/components/dbtRunHistory/dbtRunHistoryPromptBuilders.ts @@ -0,0 +1,33 @@ +import { + DbtRunHistoryEntry, + DbtRunHistoryResult, +} from '../../../types/dbtRunHistory'; + +export const buildRunFailurePrompt = (entry: DbtRunHistoryEntry): string => { + return `The following dbt command failed during execution: +**Command:** \`${entry.fullCommand}\` +**Status:** \`${entry.status}\` + +${entry.errorMessage ? `**Error Message:**\n\`\`\`\n${entry.errorMessage}\n\`\`\`\n` : ''} +${entry.rawOutputExcerpt ? `**Output Excerpt:**\n\`\`\`\n${entry.rawOutputExcerpt}\n\`\`\`\n` : ''} + +Please analyze this failure and suggest how to fix it. If the issue is related to a specific model or test, let me know. +`; +}; + +export const buildResultFailurePrompt = ( + entry: DbtRunHistoryEntry, + result: DbtRunHistoryResult, +): string => { + return `A specific dbt resource failed during a run. +**Command Run:** \`${entry.fullCommand}\` +**Resource Name:** \`${result.name}\` +**Resource Type:** \`${result.resourceType || 'unknown'}\` +**Status:** \`${result.status}\` + +${result.message ? `**Message:**\n\`\`\`\n${result.message}\n\`\`\`\n` : ''} +${result.compiledSql ? `**Compiled SQL:**\n\`\`\`sql\n${result.compiledSql}\n\`\`\`\n` : ''} + +Please explain why this resource failed and how I can fix it. +`; +}; diff --git a/src/renderer/components/dbtRunHistory/index.ts b/src/renderer/components/dbtRunHistory/index.ts new file mode 100644 index 00000000..d47ef538 --- /dev/null +++ b/src/renderer/components/dbtRunHistory/index.ts @@ -0,0 +1,2 @@ +export * from './DbtRunHistoryPanel'; +export * from './types'; diff --git a/src/renderer/components/dbtRunHistory/types.ts b/src/renderer/components/dbtRunHistory/types.ts new file mode 100644 index 00000000..95fb6a5a --- /dev/null +++ b/src/renderer/components/dbtRunHistory/types.ts @@ -0,0 +1,5 @@ +export interface DbtRunHistoryFilterState { + command?: string; + status?: string; + search?: string; +} diff --git a/src/renderer/components/editor/index.tsx b/src/renderer/components/editor/index.tsx index c7858cdc..93b702d8 100644 --- a/src/renderer/components/editor/index.tsx +++ b/src/renderer/components/editor/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useTheme } from '@mui/material'; +import { GlobalStyles, useTheme } from '@mui/material'; import * as monaco from 'monaco-editor'; import { useGetFileHeadContent, @@ -17,8 +17,10 @@ import { getLanguageFromExtension, normalizeEol, } from './helpers'; +import { extractModelNameFromPath } from '../../helpers/utils'; import { Container, EditorViewport } from './styles'; import { getOrCreateModel } from '../../lib/monaco/modelStore'; +import { buildCteQuery, detectCtes } from '../../utils/sql/cteDetection'; import type { EditorTabId, EditorTabState, @@ -55,6 +57,19 @@ type EditorProps = { onOpenFile?: (filePath: string) => void; /** Called when the user clicks the Preview button: toggles a preview tab. */ onTogglePreviewTab?: (currentPath: string, content: string) => void; + onExecuteQuery?: (payload: { + sql: string; + filePath: string; + modelName?: string; + compileModel?: boolean; + }) => void | Promise; + onExecuteCte?: (payload: { + sql: string; + filePath: string; + cteName: string; + modelName?: string; + compileModel?: boolean; + }) => void | Promise; extraActions?: React.ReactNode; }; @@ -89,6 +104,8 @@ export const Editor: React.FC = ({ onGitStatusRefresh, onOpenFile, onTogglePreviewTab, + onExecuteQuery, + onExecuteCte, extraActions, }) => { const theme = useTheme(); @@ -226,6 +243,128 @@ export const Editor: React.FC = ({ [], ); + React.useEffect(() => { + if (!onExecuteQuery && !onExecuteCte) { + return undefined; + } + + const runQueryCommandId = `dbtStudio.executeQuery.${projectId ?? 'project'}`; + const runCteCommandId = `dbtStudio.executeCte.${projectId ?? 'project'}`; + + const runQueryCommand = monaco.editor.registerCommand( + runQueryCommandId, + (_accessor, args?: { uri?: string }) => { + const model = editorInstance?.getModel(); + if ( + !model || + model.uri.toString() !== args?.uri || + !activeTab || + !activeTab.path.endsWith('.sql') + ) { + return; + } + + const selection = editorInstance?.getSelection(); + const selectedSql = + selection && !selection.isEmpty() + ? model.getValueInRange(selection) + : model.getValue(); + + onExecuteQuery?.({ + sql: selectedSql, + filePath: activeTab.path, + modelName: extractModelNameFromPath(activeTab.path), + compileModel: selection?.isEmpty() ?? true, + }); + }, + ); + + const runCteCommand = monaco.editor.registerCommand( + runCteCommandId, + (_accessor, args?: { uri?: string; cteIndex?: number }) => { + const model = editorInstance?.getModel(); + if ( + !model || + model.uri.toString() !== args?.uri || + args?.cteIndex === undefined || + !activeTab || + !activeTab.path.endsWith('.sql') + ) { + return; + } + + const ctes = detectCtes(model, monaco); + const builtQuery = buildCteQuery(model, ctes, args.cteIndex); + if (!builtQuery) { + return; + } + + onExecuteCte?.({ + sql: builtQuery.query, + filePath: activeTab.path, + cteName: builtQuery.targetCte.name, + modelName: extractModelNameFromPath(activeTab.path), + compileModel: true, + }); + }, + ); + + const provider = monaco.languages.registerCodeLensProvider('jinja-sql', { + provideCodeLenses(model) { + if ( + !activeTab || + !activeTab.path.endsWith('.sql') || + model.uri.toString() !== activeModel?.uri.toString() + ) { + return { lenses: [], dispose: () => {} }; + } + + const lenses: monaco.languages.CodeLens[] = [ + { + range: new monaco.Range(1, 1, 1, 1), + command: { + id: runQueryCommandId, + title: '$(play) Execute Query', + arguments: [{ uri: model.uri.toString() }], + }, + }, + ]; + + const ctes = detectCtes(model, monaco); + ctes.forEach((cte, index) => { + lenses.push({ + range: new monaco.Range( + cte.range.startLineNumber, + 1, + cte.range.startLineNumber, + 1, + ), + command: { + id: runCteCommandId, + title: `$(play) Execute CTE: ${cte.name}`, + arguments: [{ uri: model.uri.toString(), cteIndex: index }], + }, + }); + }); + + return { lenses, dispose: () => {} }; + }, + }); + + return () => { + provider.dispose(); + runQueryCommand.dispose(); + runCteCommand.dispose(); + }; + }, [ + activeModel, + activeTab, + editorInstance, + onExecuteCte, + onExecuteQuery, + projectId, + ]); + React.useEffect(() => { if (!editorInstance || !activeModel || decorationMode === 'clean') { decorationsRef.current?.clear(); @@ -374,6 +513,23 @@ export const Editor: React.FC = ({ /> + a': { + position: 'relative', + left: -10, + }, + '.monaco-editor .codelens-decoration .codicon': { + fontSize: 11, + marginRight: 3, + }, + }} + /> {showDiffView ? ( = ({ modelKey={activeTabId} theme={monacoTheme} readOnly={!isFileEditable} + options={{ codeLens: true, codeLensFontSize: 11 }} onMount={handleEditorMount} /> )} diff --git a/src/renderer/components/lineage/LineageView.tsx b/src/renderer/components/lineage/LineageView.tsx index d72d447f..98083863 100644 --- a/src/renderer/components/lineage/LineageView.tsx +++ b/src/renderer/components/lineage/LineageView.tsx @@ -9,7 +9,7 @@ import { Tooltip, } from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh'; -import OpenInFullIcon from '@mui/icons-material/OpenInFull'; +import FullscreenIcon from '@mui/icons-material/Fullscreen'; import { LineageToolbar } from './LineageToolbar'; import { LineageGraph } from './LineageGraph'; import { NodeDetailsPanel } from './NodeDetailsPanel'; @@ -334,7 +334,7 @@ export const LineageView: React.FC = ({ extraActions={ - + } diff --git a/src/renderer/components/projectQueryResults/ProjectQueryResultsPanel.tsx b/src/renderer/components/projectQueryResults/ProjectQueryResultsPanel.tsx new file mode 100644 index 00000000..11efa44f --- /dev/null +++ b/src/renderer/components/projectQueryResults/ProjectQueryResultsPanel.tsx @@ -0,0 +1,800 @@ +import React from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + Autocomplete, + Chip, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + Tab, + Tabs, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import { + BookmarkBorder as BookmarkBorderIcon, + CleaningServices as CleaningServicesIcon, + Code as CodeIcon, + ContentCopy as CopyIcon, + DeleteOutline as DeleteOutlineIcon, + Error as ErrorIcon, + Fullscreen as FullscreenIcon, + FullscreenExit as FullscreenExitIcon, + PlayArrow as PlayArrowIcon, +} from '@mui/icons-material'; +import { toast } from 'react-toastify'; +import { QueryResult } from '../queryResult'; +import type { + ProjectQueryBookmark, + ProjectQueryHistoryItem, + ProjectQueryPanelState, + ProjectQueryResultsTab, +} from './types'; + +type Props = { + state: ProjectQueryPanelState; + onTabChange: (tab: ProjectQueryResultsTab) => void; + onLimitChange: (limit: number) => void; + onClear: () => void; + onRun?: () => void; + onOpenSqlInEditor?: (sql: string) => void; + isFullscreenView?: boolean; + onCloseFullscreen?: () => void; + onAddBookmark?: ( + bookmark: Omit, + ) => void; + onDeleteBookmark?: (id: string) => void; + onRunHistoryItem?: (item: { + rawSql: string; + compiledSql?: string; + filePath?: string; + modelName?: string; + }) => void; +}; + +const formatDuration = (durationMs?: number) => { + if (durationMs === undefined) return undefined; + return durationMs > 1000 + ? `${(durationMs / 1000).toFixed(2)}s` + : `${durationMs}ms`; +}; + +const codeBlockSx = { + m: 0, + p: 1.5, + height: '100%', + overflow: 'auto', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: 12, + lineHeight: 1.5, + bgcolor: 'background.default', + border: 1, + borderColor: 'divider', + borderRadius: 1, + whiteSpace: 'pre-wrap', +}; + +const ProjectQuerySqlTab: React.FC<{ + rawSql?: string; + compiledSql?: string; +}> = ({ rawSql, compiledSql }) => { + const [selected, setSelected] = React.useState<'compiled' | 'raw'>( + compiledSql ? 'compiled' : 'raw', + ); + const visibleSql = selected === 'compiled' ? compiledSql : rawSql; + + React.useEffect(() => { + setSelected(compiledSql ? 'compiled' : 'raw'); + }, [compiledSql]); + + if (!rawSql && !compiledSql) { + return ( + + + Run a model preview to inspect SQL here. + + + ); + } + + return ( + + + setSelected(value)} + sx={{ minHeight: 28, '& .MuiTab-root': { minHeight: 28, py: 0 } }} + > + + + + + + + { + if (visibleSql) { + navigator.clipboard.writeText(visibleSql); + toast.success('SQL copied'); + } + }} + > + + + + + + + + + {visibleSql} + + + + ); +}; + +const AddBookmarkModal: React.FC<{ + open: boolean; + onClose: () => void; + onSave: (name: string, tags: string[]) => void; + defaultName?: string; +}> = ({ open, onClose, onSave, defaultName }) => { + const [name, setName] = React.useState(defaultName || 'Bookmark name'); + const [tags, setTags] = React.useState([]); + + React.useEffect(() => { + if (open) { + setName(defaultName || 'Bookmark name'); + setTags([]); + } + }, [open, defaultName]); + + return ( + + Add bookmark + + setName(e.target.value)} + sx={{ mt: 1 }} + /> + setTags(newValue)} + renderTags={(value: readonly string[], getTagProps) => + value.map((option: string, index: number) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + + + + + + ); +}; + +const ProjectQueryHistoryTab: React.FC<{ + history: ProjectQueryHistoryItem[]; + onBookmark?: (item: ProjectQueryHistoryItem) => void; + onRunHistoryItem?: (item: { + rawSql: string; + compiledSql?: string; + filePath?: string; + modelName?: string; + }) => void; +}> = ({ history, onBookmark, onRunHistoryItem }) => { + if (history.length === 0) { + return ( + + + Execute a query to view history. + + + ); + } + + return ( + + + {history.map((item) => ( + + {item.status === 'error' ? ( + + ) : ( + + )} + + + {item.modelName || + item.rawSql.replace(/\n/g, ' ').substring(0, 100)} + + + + + {new Date(item.executedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })}{' '} + {new Date(item.executedAt).toLocaleDateString([], { + month: 'short', + day: 'numeric', + year: '2-digit', + })} + + + + + { + navigator.clipboard.writeText(item.rawSql); + toast.success('Query copied to clipboard'); + }} + sx={{ p: 0.5 }} + > + + + + {onRunHistoryItem && ( + + onRunHistoryItem(item)} + sx={{ p: 0.5 }} + > + + + + )} + {onBookmark && ( + + onBookmark(item)} + sx={{ p: 0.5 }} + > + + + + )} + + + ))} + + + ); +}; + +const ProjectQueryBookmarksTab: React.FC<{ + bookmarks: ProjectQueryBookmark[]; + onDeleteBookmark?: (id: string) => void; + onRunHistoryItem?: (item: { + rawSql: string; + compiledSql?: string; + filePath?: string; + modelName?: string; + }) => void; +}> = ({ bookmarks, onDeleteBookmark, onRunHistoryItem }) => { + if (bookmarks.length === 0) { + return ( + + + No bookmarks saved. Hover over a history item to bookmark it. + + + ); + } + + return ( + + + {bookmarks.map((item) => ( + + + + + + {item.name} + + + + {item.modelName || + item.rawSql.replace(/\n/g, ' ').substring(0, 100)} + + + + + {item.tags.length > 0 && ( + + {item.tags.map((tag) => ( + + ))} + + )} + + + {new Date(item.createdAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })}{' '} + {new Date(item.createdAt).toLocaleDateString([], { + month: 'short', + day: 'numeric', + year: '2-digit', + })} + + + + + { + navigator.clipboard.writeText(item.rawSql); + toast.success('Query copied to clipboard'); + }} + sx={{ p: 0.5 }} + > + + + + {onRunHistoryItem && ( + + onRunHistoryItem(item)} + sx={{ p: 0.5 }} + > + + + + )} + {onDeleteBookmark && ( + + onDeleteBookmark(item.id)} + sx={{ p: 0.5 }} + > + + + + )} + + + ))} + + + ); +}; + +export const ProjectQueryResultsPanel: React.FC = ({ + state, + onTabChange, + onLimitChange, + onClear, + onRun, + onOpenSqlInEditor, + isFullscreenView, + onCloseFullscreen, + onAddBookmark, + onDeleteBookmark, + onRunHistoryItem, +}) => { + const theme = useTheme(); + const [isFullscreen, setIsFullscreen] = React.useState(false); + + const [addBookmarkModalOpen, setAddBookmarkModalOpen] = React.useState(false); + const [bookmarkingItem, setBookmarkingItem] = + React.useState(null); + + let previewLabel = 'Preview'; + if (state.result) { + let durationText = ''; + if (state.lastDurationMs !== undefined) { + durationText = ` in ${formatDuration(state.lastDurationMs)}`; + } + previewLabel = `Preview ${state.limit} rows${durationText}`; + } + + let previewContent: React.ReactNode = null; + if (state.result) { + previewContent = ; + } else if (!state.isRunning) { + previewContent = ( + + No query result yet. + + ); + } + + return ( + + + + onTabChange(newValue as ProjectQueryResultsTab) + } + sx={{ + minHeight: 32, + '& .MuiTab-root': { + minHeight: 32, + py: 0, + fontSize: 11, + fontWeight: 500, + textTransform: 'uppercase', + }, + }} + > + + + + + + + onLimitChange(Number(event.target.value))} + inputProps={{ min: 1, max: 5000 }} + sx={{ + width: 86, + '& .MuiInputLabel-root': { + fontSize: 10, + top: -4, + }, + '& .MuiOutlinedInput-root': { + height: 28, + borderRadius: 0, + fontSize: 12, + }, + '& input': { + py: 0.5, + }, + }} + /> + + + + + + + + + + + + + + + setIsFullscreen(true) + } + sx={{ + width: 28, + height: 28, + border: 1, + borderColor: 'divider', + borderRadius: 0, + color: 'text.secondary', + '&:hover': { + color: 'text.primary', + bgcolor: 'action.hover', + borderColor: 'text.secondary', + }, + }} + > + {isFullscreenView ? ( + + ) : ( + + )} + + + + + + + {state.activeTab === 'preview' && ( + + {state.isRunning && ( + + + Running preview... + + )} + {state.error && ( + + {state.error} + + )} + {previewContent} + + )} + {state.activeTab === 'sql' && ( + + )} + {state.activeTab === 'history' && ( + { + setBookmarkingItem(item); + setAddBookmarkModalOpen(true); + }} + /> + )} + {state.activeTab === 'bookmarks' && ( + + )} + + + setAddBookmarkModalOpen(false)} + defaultName={bookmarkingItem?.modelName ?? 'Bookmark name'} + onSave={(name, tags) => { + if (bookmarkingItem && onAddBookmark) { + onAddBookmark({ + name, + tags, + projectId: bookmarkingItem.projectId, + projectName: bookmarkingItem.projectName, + filePath: bookmarkingItem.filePath, + modelName: bookmarkingItem.modelName, + rawSql: bookmarkingItem.rawSql, + compiledSql: bookmarkingItem.compiledSql, + }); + } + setAddBookmarkModalOpen(false); + }} + /> + + {/* Full screen modal */} + {!isFullscreenView && ( + setIsFullscreen(false)} + > + setIsFullscreen(false)} + /> + + )} + + ); +}; + +export default ProjectQueryResultsPanel; diff --git a/src/renderer/components/projectQueryResults/index.ts b/src/renderer/components/projectQueryResults/index.ts new file mode 100644 index 00000000..94639d25 --- /dev/null +++ b/src/renderer/components/projectQueryResults/index.ts @@ -0,0 +1,7 @@ +export { ProjectQueryResultsPanel } from './ProjectQueryResultsPanel'; +export type { + ProjectQueryHistoryItem, + ProjectQueryPanelState, + ProjectQueryPreviewPayload, + ProjectQueryResultsTab, +} from './types'; diff --git a/src/renderer/components/projectQueryResults/types.ts b/src/renderer/components/projectQueryResults/types.ts new file mode 100644 index 00000000..ed5bc2a4 --- /dev/null +++ b/src/renderer/components/projectQueryResults/types.ts @@ -0,0 +1,64 @@ +import type { QueryResponseType } from '../../../types/backend'; + +export type ProjectQueryResultsTab = + | 'preview' + | 'sql' + | 'history' + | 'bookmarks'; + +export interface ProjectQueryHistoryItem { + id: string; + projectId: string; + projectName: string; + filePath?: string; + modelName?: string; + rawSql: string; + compiledSql?: string; + executedAt: string; + durationMs?: number; + limit: number; + resultsPreview?: QueryResponseType; + rowCount?: number; + status: 'success' | 'error'; + errorMessage?: string; +} + +export interface ProjectQueryBookmark { + id: string; + name: string; + tags: string[]; + projectId: string; + projectName: string; + filePath?: string; + modelName?: string; + rawSql: string; + compiledSql?: string; + createdAt: string; +} + +export interface ProjectQueryPreviewPayload { + projectId: string; + projectName: string; + filePath?: string; + modelName?: string; + rawSql: string; + compiledSql?: string; + result?: QueryResponseType; + durationMs?: number; + errorMessage?: string; +} + +export interface ProjectQueryPanelState { + activeTab: ProjectQueryResultsTab; + limit: number; + isRunning: boolean; + result?: QueryResponseType; + error?: string; + rawSql?: string; + compiledSql?: string; + filePath?: string; + modelName?: string; + lastDurationMs?: number; + history: ProjectQueryHistoryItem[]; + bookmarks: ProjectQueryBookmark[]; +} diff --git a/src/renderer/components/queryResult/index.tsx b/src/renderer/components/queryResult/index.tsx index 85b2367f..efab03c9 100644 --- a/src/renderer/components/queryResult/index.tsx +++ b/src/renderer/components/queryResult/index.tsx @@ -61,13 +61,6 @@ type Props = { }; export const QueryResult: React.FC = ({ results, exportContext }) => { - const formatNumber = React.useCallback((n: number) => { - try { - return new Intl.NumberFormat('de-DE').format(n); - } catch { - return String(n); - } - }, []); const originalSql = (results as any).originalSql ?? exportContext?.originalSql; @@ -184,14 +177,6 @@ export const QueryResult: React.FC = ({ results, exportContext }) => { ]); const hasRows = rows.length > 0 && columns.length > 0; - const showingInfo = - isDuckLake && totalCount > 0 - ? `Showing ${formatNumber( - Math.min(page * perPage + 1, totalCount), - )}–${formatNumber(Math.min((page + 1) * perPage, totalCount))} of ${formatNumber( - totalCount, - )}` - : undefined; const [exportAnchorEl, setExportAnchorEl] = React.useState(null); @@ -488,28 +473,58 @@ export const QueryResult: React.FC = ({ results, exportContext }) => { id="query-result" dataTestId="sql-results-table" name="" - toolbarContent={ - - {results.duration !== undefined && ( - - {results.duration > 1000 - ? `${(results.duration / 1000).toFixed(2)}s` - : `${results.duration}ms`} - - )} - {showingInfo && ( - ({ + id: column, + label: underscoreToTitleCase(column), + render: (value) => { + const cellValue = value[column]; + // Handle null and undefined + if (cellValue === null || cellValue === undefined) { + return ( +
+ NULL +
+ ); + } + let stringValue: string; + if (typeof cellValue === 'object') { + try { + stringValue = JSON.stringify(cellValue); + } catch { + stringValue = String(cellValue); + } + } else { + stringValue = String(cellValue); + } + return ( +
- {showingInfo} - - )} + {stringValue} +
+ ); + }, + }))} + customPagination={customPagination as any} + loading={loading} + paginationLeftContent={ + <> @@ -535,12 +551,12 @@ export const QueryResult: React.FC = ({ results, exportContext }) => { open={exportMenuOpen} onClose={handleExportMenuClose} anchorOrigin={{ - vertical: 'bottom', - horizontal: 'right', + vertical: 'top', + horizontal: 'left', }} transformOrigin={{ - vertical: 'top', - horizontal: 'right', + vertical: 'bottom', + horizontal: 'left', }} > = ({ results, exportContext }) => { )} -
+ } - rows={rows as any} - columns={columns.map((column) => ({ - id: column, - label: underscoreToTitleCase(column), - render: (value) => { - const cellValue = value[column]; - // Handle null and undefined - if (cellValue === null || cellValue === undefined) { - return ( -
- NULL -
- ); - } - let stringValue: string; - if (typeof cellValue === 'object') { - try { - stringValue = JSON.stringify(cellValue); - } catch { - stringValue = String(cellValue); - } - } else { - stringValue = String(cellValue); - } - return ( -
- {stringValue} -
- ); - }, - }))} - customPagination={customPagination as any} - loading={loading} /> React.useContext(TerminalMinimizeContext); +const MINIMIZED_TERMINAL_HEIGHT = 32; + +export type TerminalPanelTab = + | 'terminal' + | 'process' + | 'lineage' + | 'queryResults' + | 'runHistory'; + +export interface TerminalLayoutRef { + switchTab: (tab: TerminalPanelTab) => void; +} + type Props = { project: Project; children: React.ReactNode; + queryResultsPanel?: React.ReactNode; + showQueryResultsTab?: boolean; + queryResultsRevision?: number; + runHistoryPanel?: React.ReactNode; + showRunHistoryTab?: boolean; }; -export const TerminalLayout: React.FC = ({ children, project }) => { - const { mode } = useColorScheme(); - const theme = useTheme(); - const { isRunning, stop, forceStop, pid, duration, status, command } = - useProcess(); - - const { selectedFilePath } = useSelectedFileContext(); - - // Only query for lineage if the file is a SQL model (not .yml/.yaml) - const isExecutableModel = React.useMemo(() => { - if (!selectedFilePath) return false; - const ext = selectedFilePath.toLowerCase().split('.').pop(); - return ext === 'sql'; - }, [selectedFilePath]); - - const { - data: currentModelData, - isLoading: isLoadingCurrentModel, - isError: isErrorCurrentModel, - } = useCurrentModelId( +export const TerminalLayout = React.forwardRef( + ( { - projectId: project.id, - filePath: selectedFilePath, + children, + project, + queryResultsPanel, + showQueryResultsTab = false, + queryResultsRevision = 0, + runHistoryPanel, + showRunHistoryTab = false, }, - { enabled: !!project.id && !!selectedFilePath && isExecutableModel }, - ); - - const showLineageTab = - isExecutableModel && - (Boolean(currentModelData?.modelId) || - isLoadingCurrentModel || - isErrorCurrentModel); - - const [selectedTab, setSelectedTab] = React.useState(0); - - const [lock, setLock] = React.useState(false); - const [sizes, setSizes] = React.useState([ - window.innerHeight - 300, - 300, - ]); - const [isMinimized, setIsMinimized] = React.useState(false); - const [menuAnchor, setMenuAnchor] = React.useState(null); - const [openLineageModal, setOpenLineageModal] = React.useState(false); - const [hasStartedProcess, setHasStartedProcess] = - React.useState(false); - const lastTerminalHeight = React.useRef(300); - const sizesRef = React.useRef(sizes); - sizesRef.current = sizes; - - const handleMinimize = React.useCallback(() => { - setIsMinimized(true); - [, lastTerminalHeight.current] = sizesRef.current; - setSizes([window.innerHeight, 0]); - }, []); - - const handleRestore = React.useCallback(() => { - setIsMinimized(false); - setSizes([ - window.innerHeight - lastTerminalHeight.current, - lastTerminalHeight.current, + ref, + ) => { + const { mode } = useColorScheme(); + const theme = useTheme(); + const { isRunning, stop, forceStop, pid, duration, status, command } = + useProcess(); + + const { selectedFilePath } = useSelectedFileContext(); + + // Only query for lineage if the file is a SQL model (not .yml/.yaml) + const isExecutableModel = React.useMemo(() => { + if (!selectedFilePath) return false; + const ext = selectedFilePath.toLowerCase().split('.').pop(); + return ext === 'sql'; + }, [selectedFilePath]); + + const { + data: currentModelData, + isLoading: isLoadingCurrentModel, + isError: isErrorCurrentModel, + } = useCurrentModelId( + { + projectId: project.id, + filePath: selectedFilePath, + }, + { enabled: !!project.id && !!selectedFilePath && isExecutableModel }, + ); + + const showLineageTab = + isExecutableModel && + (Boolean(currentModelData?.modelId) || + isLoadingCurrentModel || + isErrorCurrentModel); + + const [selectedTab, setSelectedTab] = + React.useState('terminal'); + + const [lock, setLock] = React.useState(false); + const [sizes, setSizes] = React.useState([ + window.innerHeight - 300, + 300, ]); - }, []); - - const terminalContextValue = React.useMemo( - () => ({ isMinimized, minimize: handleMinimize, restore: handleRestore }), - [isMinimized, handleMinimize, handleRestore], - ); - - const handleMenuOpen = (event: React.MouseEvent) => { - setMenuAnchor(event.currentTarget); - }; - - const handleMenuClose = () => { - setMenuAnchor(null); - }; - - const handleGracefulStop = async () => { - handleMenuClose(); - await stop(); - setSelectedTab(0); - }; - - const handleForceStop = async () => { - handleMenuClose(); - await forceStop(); - setSelectedTab(0); - }; - - const handleQuickStop = async () => { - await stop(); - setSelectedTab(0); - }; - - const renderSash = (_: number, active: boolean) => { - if (isMinimized) return null; - return ( -
+ const [isMinimized, setIsMinimized] = React.useState(false); + const [menuAnchor, setMenuAnchor] = React.useState( + null, + ); + const [openLineageModal, setOpenLineageModal] = React.useState(false); + const [hasStartedProcess, setHasStartedProcess] = + React.useState(false); + const rootRef = React.useRef(null); + const lastTerminalHeight = React.useRef(300); + const sizesRef = React.useRef(sizes); + sizesRef.current = sizes; + const getLayoutHeight = React.useCallback( + () => rootRef.current?.clientHeight || window.innerHeight, + [], + ); + + const handleMinimize = React.useCallback(() => { + setIsMinimized(true); + const layoutHeight = getLayoutHeight(); + const [, currentTerminalHeight] = sizesRef.current; + if ( + typeof currentTerminalHeight === 'number' && + currentTerminalHeight > MINIMIZED_TERMINAL_HEIGHT + ) { + lastTerminalHeight.current = currentTerminalHeight; + } + setSizes([ + layoutHeight - MINIMIZED_TERMINAL_HEIGHT, + MINIMIZED_TERMINAL_HEIGHT, + ]); + }, [getLayoutHeight]); + + const handleRestore = React.useCallback(() => { + setIsMinimized(false); + const layoutHeight = getLayoutHeight(); + setSizes([ + layoutHeight - lastTerminalHeight.current, + lastTerminalHeight.current, + ]); + }, [getLayoutHeight]); + + const terminalContextValue = React.useMemo( + () => ({ isMinimized, minimize: handleMinimize, restore: handleRestore }), + [isMinimized, handleMinimize, handleRestore], + ); + + React.useImperativeHandle( + ref, + () => ({ + switchTab: (tab: TerminalPanelTab) => { + setSelectedTab((prev) => { + if (prev !== tab) { + return tab; + } + return prev; + }); + if (isMinimized) { + handleRestore(); + } + }, + }), + [isMinimized, handleRestore], + ); + + const handleMenuOpen = (event: React.MouseEvent) => { + setMenuAnchor(event.currentTarget); + }; + + const handleMenuClose = () => { + setMenuAnchor(null); + }; + + const handleGracefulStop = async () => { + handleMenuClose(); + await stop(); + setSelectedTab('terminal'); + }; + + const handleForceStop = async () => { + handleMenuClose(); + await forceStop(); + setSelectedTab('terminal'); + }; + + const handleQuickStop = async () => { + await stop(); + setSelectedTab('terminal'); + }; + + const handleCloseProcessTab = React.useCallback(() => { + setHasStartedProcess(false); + setSelectedTab((prev) => (prev === 'process' ? 'terminal' : prev)); + }, []); + + const renderSash = (_: number, active: boolean) => { + if (isMinimized) return null; + return (
-
- ); - }; - - // Auto-switch to process tab when a process starts - React.useEffect(() => { - if (isRunning) { - setHasStartedProcess(true); - } - if (isRunning && selectedTab !== 1) { - setSelectedTab(1); - } - }, [isRunning]); - - // // Reset stopping state when process stops - // React.useEffect(() => { - // if (!isRunning) { - // setIsStoppingGracefully(false); - // } - // }, [isRunning]); - - // If Lineage tab is hidden but selected, switch back to terminal - // Only auto-switch when the query is settled (not loading) - React.useEffect(() => { - if (!showLineageTab && selectedTab === 2 && !isLoadingCurrentModel) { - setSelectedTab(0); - } - }, [showLineageTab, selectedTab, isLoadingCurrentModel]); - - const getTextColor = (themeMode: string | undefined) => { - switch (themeMode) { - case 'dark': - return theme.palette.common.white; - case 'light': - return theme.palette.common.black; - case 'system': - return theme.palette.text.primary; - default: - return theme.palette.text.primary; - } - }; - - const tabButtonSx = (isSelected: boolean) => ({ - minHeight: 22, - height: 22, - padding: '0 10px', - borderRadius: '4px', - marginRight: '6px', - backgroundColor: isSelected ? theme.palette.action.selected : 'transparent', - transition: 'background-color 0.15s, border-color 0.15s', - border: `1px solid ${isSelected ? theme.palette.divider : 'transparent'}`, - color: isSelected - ? theme.palette.text.primary - : theme.palette.text.secondary, - textTransform: 'none', - letterSpacing: 0.2, - '&:hover': { + > +
+
+ ); + }; + + // Auto-switch to process tab when a process starts + React.useEffect(() => { + if (isRunning) { + setHasStartedProcess(true); + } + if (isRunning && selectedTab !== 'process') { + setSelectedTab('process'); + } + }, [isRunning]); + + // // Reset stopping state when process stops + // React.useEffect(() => { + // if (!isRunning) { + // setIsStoppingGracefully(false); + // } + // }, [isRunning]); + + // If Lineage tab is hidden but selected, switch back to terminal + // Only auto-switch when the query is settled (not loading) + React.useEffect(() => { + if ( + !showLineageTab && + selectedTab === 'lineage' && + !isLoadingCurrentModel + ) { + setSelectedTab('terminal'); + } + }, [showLineageTab, selectedTab, isLoadingCurrentModel]); + + React.useEffect(() => { + if (showQueryResultsTab && queryResultsRevision > 0) { + setSelectedTab('queryResults'); + if (isMinimized) { + handleRestore(); + } + } + // handleRestore intentionally reads refs/state and should not retrigger this. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showQueryResultsTab, queryResultsRevision]); + + const getTextColor = (themeMode: string | undefined) => { + switch (themeMode) { + case 'dark': + return theme.palette.common.white; + case 'light': + return theme.palette.common.black; + case 'system': + return theme.palette.text.primary; + default: + return theme.palette.text.primary; + } + }; + + const tabButtonSx = (isSelected: boolean) => ({ + minHeight: 22, + height: 22, + padding: '0 10px', + borderRadius: '4px', + marginRight: '6px', backgroundColor: isSelected ? theme.palette.action.selected - : theme.palette.action.hover, - borderColor: isSelected ? theme.palette.divider : 'transparent', - }, - }); - - const formatDuration = (ms: number | null) => { - if (!ms) return '0s'; - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) return `${hours}h ${minutes % 60}m`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s`; - return `${seconds}s`; - }; - - // eslint-disable-next-line @typescript-eslint/no-shadow - const getStatusColor = (status: string) => { - switch (status) { - case 'running': - return 'success'; - case 'starting': - return 'info'; - case 'stopping': - return 'warning'; - case 'stopped': - return 'default'; - default: - return 'default'; - } - }; - - return ( - - { - if (!isMinimized) { - setSizes(newSizes); - } - }} - onDragStart={() => setLock(true)} - onDragEnd={() => setLock(false)} - sashRender={renderSash} - > - - - {children} - - - - {!isMinimized && ( - <> - - {/* CLI Terminal Tab */} - {/* CLI Terminal Tab */} - - {/* Lineage Terminal Tab */} - {showLineageTab && ( + + {isRunning && ( + + )} + + + {/* Quick stop when minimized */} + {isRunning && ( + + + + + + )} + + ) : ( + <> + + {/* CLI Terminal Tab */} + {/* CLI Terminal Tab */} - )} - {/* Process Tab - Only show when running */} - {hasStartedProcess && ( - - setSelectedTab(1)} - sx={{ - ...tabButtonSx(selectedTab === 1), - display: 'flex', - alignItems: 'center', - gap: 0.75, - padding: '0 6px 0 10px', - cursor: 'pointer', - outline: 'none', - }} + {/* Lineage Terminal Tab */} + {showLineageTab && ( + + )} + {/* Process Tab - Only show when running */} + {hasStartedProcess && ( + setSelectedTab('process')} sx={{ + ...tabButtonSx(selectedTab === 'process'), display: 'flex', alignItems: 'center', - gap: 0.5, + gap: 0.75, + padding: '0 6px 0 10px', + cursor: 'pointer', + outline: 'none', }} > - {pid && ( - - )} - {duration && ( - - )} - - - {/* Stop Options Menu */} - - { - e.stopPropagation(); - handleMenuOpen(e); - }} - size="small" - disabled={status === 'stopping'} + - - - - - {/* Quick Stop */} - - { - e.stopPropagation(); - handleQuickStop(); - setHasStartedProcess(false); - }} - size="small" - disabled={status === 'stopping'} + PID SERVER + + + - - - + {pid && ( + + )} + {duration && ( + + )} + + + {/* Stop Options Menu */} + + { + e.stopPropagation(); + handleMenuOpen(e); + }} + size="small" + sx={{ + padding: 0.25, + color: 'inherit', + minWidth: 'auto', + }} + > + + + + + {/* Close Tab */} + + { + e.stopPropagation(); + handleCloseProcessTab(); + }} + size="small" + sx={{ + padding: 0.25, + color: 'inherit', + minWidth: 'auto', + }} + > + + + + + )} + {/* Query Results Tab */} + {showQueryResultsTab && ( + + )} + {/* Run History Tab */} + {showRunHistoryTab && ( + + )} + {/* Minimize Button */} + +
+ +
+
+
+ + {/* Tab Content */} + + + + + + + {selectedTab === 'lineage' && showLineageTab && ( + setOpenLineageModal(true)} + /> + )} + {selectedTab === 'queryResults' && showQueryResultsTab && ( + + {queryResultsPanel} )} - {/* Minimize Button */} - -
- -
-
-
- - {/* Tab Content */} - {selectedTab === 0 && } - {selectedTab === 1 && } - {selectedTab === 2 && showLineageTab && ( - setOpenLineageModal(true)} - /> - )} - - )} -
-
- - {/* Minimized Taskbar */} - {isMinimized && ( - - - - Terminal - - - {isRunning && ( - + {selectedTab === 'runHistory' && showRunHistoryTab && ( + + {runHistoryPanel} + + )} + )} - - - {/* Quick stop when minimized */} - {isRunning && ( - - - - - - )} - - )} - - {/* Stop Options Menu */} - - - - - - - - - - - - - - + + + + {/* Stop Options Menu */} + + + + + + + - {command && ( - + - + - - 30 - ? `${command.substring(0, 30)}...` - : command - } - /> - + - )} - - setOpenLineageModal(false)} - projectId={project.id} - filePath={selectedFilePath} - /> - - ); -}; + + {command && ( + + + + + + 30 + ? `${command.substring(0, 30)}...` + : command + } + /> + + + )} + + setOpenLineageModal(false)} + projectId={project.id} + filePath={selectedFilePath} + /> +
+ ); + }, +); diff --git a/src/renderer/components/terminal/styles.ts b/src/renderer/components/terminal/styles.ts index 2974b3df..f92ff3e2 100644 --- a/src/renderer/components/terminal/styles.ts +++ b/src/renderer/components/terminal/styles.ts @@ -51,9 +51,11 @@ export const InputLine = styled('div')(({ theme }) => ({ })); export const Root = styled(Box)(() => ({ - height: '100vh', + height: '100%', + minHeight: 0, display: 'flex', flexDirection: 'column', + overflow: 'hidden', })); export const Sash = styled(Box)(({ theme }) => ({ @@ -86,7 +88,8 @@ export const TerminalHeader = styled(Box)(({ theme }) => ({ })); export const Taskbar = styled(Box)(({ theme }) => ({ - height: 40, + height: '100%', + minHeight: 32, backgroundColor: theme.palette.background.paper, display: 'flex', alignItems: 'center', @@ -98,7 +101,7 @@ export const Taskbar = styled(Box)(({ theme }) => ({ export const TaskbarItem = styled(Box)(({ theme }) => ({ color: theme.palette.primary.main, - height: 30, + height: 24, cursor: 'pointer', display: 'flex', alignItems: 'center', diff --git a/src/renderer/components/terminal/terminal.tsx b/src/renderer/components/terminal/terminal.tsx index 42a3f71d..0f2b2fe7 100644 --- a/src/renderer/components/terminal/terminal.tsx +++ b/src/renderer/components/terminal/terminal.tsx @@ -35,6 +35,11 @@ export const Terminal: React.FC = ({ project }) => { const { data: settings } = useGetSettings(); const { record, getPrev, getNext, resetPointer } = useCommandHistory(); + // Clear output when mounting for a new project (project.id changes → key changes → remount) + React.useEffect(() => { + clearOutput(); + }, [project.id]); + const [contextMenu, setContextMenu] = React.useState<{ mouseX: number; mouseY: number; diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index afbfde1d..84bf1491 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -17,6 +17,8 @@ import { useSchemaForConnection } from './useSchemaForConnection'; import { useNotebookConnectionState } from './useNotebookConnectionState'; import { useNotebookSidebarState } from './useNotebookSidebarState'; import { useToolMode } from './useToolMode'; +import { useProjectQueryResultsPanel } from './useProjectQueryResultsPanel'; +import { useProjectSqlExecution } from './useProjectSqlExecution'; import { useAgentStream } from './useAgentStream'; @@ -40,5 +42,7 @@ export { useNotebookConnectionState, useNotebookSidebarState, useToolMode, + useProjectQueryResultsPanel, + useProjectSqlExecution, useAgentStream, }; diff --git a/src/renderer/hooks/useDbt.ts b/src/renderer/hooks/useDbt.ts index 5b682850..c0ed43aa 100644 --- a/src/renderer/hooks/useDbt.ts +++ b/src/renderer/hooks/useDbt.ts @@ -10,6 +10,7 @@ import { } from '../controllers'; import { Project, DbtCommandType, ConnectionInput } from '../../types/backend'; import { useAppContext } from './index'; +import { useDbtRunHistory } from './useDbtRunHistory'; interface UseDbtReturn { run: (project: Project, path?: string) => Promise; @@ -88,6 +89,8 @@ const useDbt = ( const { data: apiKey } = useApiKey(); const { env: environment } = useAppContext(); + const { recordCommandStart, recordCommandFinished, recordCommandFailed } = + useDbtRunHistory(); const { runCommand, stopCommand, isRunning } = useCli(); const { data: connections = [] } = useGetConnections(true); @@ -218,6 +221,25 @@ const useDbt = ( ], ); + // Build human-readable command string for display in run history + const buildDisplayCommand = useCallback( + (command: DbtCommandType, args: string = ''): string => { + let displayCmd: string; + switch (command) { + case 'docs:generate': + displayCmd = 'dbt docs generate'; + break; + case 'docs:serve': + displayCmd = 'dbt docs serve'; + break; + default: + displayCmd = `dbt ${command}`; + } + return args ? `${displayCmd} ${args}` : displayCmd; + }, + [], + ); + // Build command string const buildCommand = useCallback( (command: DbtCommandType, project: Project, args: string = '') => { @@ -260,6 +282,8 @@ const useDbt = ( return; } + let runHistoryId: string | undefined; + try { // Check if DBT path is configured if (!settings?.dbtPath) { @@ -299,6 +323,18 @@ const useDbt = ( return; } + runHistoryId = recordCommandStart({ + projectId: project.id, + projectName: project.name, + projectPath: project.path, + command, + args, + // Short human-readable command for display + fullCommand: buildDisplayCommand(command, args), + // Full shell command for copy-to-clipboard + shellCommand: cmdString, + }); + // Execute command const result = await runCommand(cmdString); const aggregatedError = extractCliErrorDetails( @@ -311,11 +347,35 @@ const useDbt = ( if (options.showToast) { toast.success(`dbt ${command} completed successfully`); } + // Only attach run_results.json for commands that actually produce it + const commandsWithRunResults = ['run', 'test', 'seed', 'snapshot']; + if (commandsWithRunResults.includes(command)) { + await recordCommandFinished( + runHistoryId, + project.id, + `${project.path}/target/run_results.json`, + ); + } successCallback?.(); - } else if (options.showToast) { - toast.error(`Command failed: ${aggregatedError.join('\n')}`); + } else { + if (options.showToast) { + toast.error(`Command failed: ${aggregatedError.join('\n')}`); + } + recordCommandFailed( + runHistoryId, + project.id, + aggregatedError.join('\n'), + result.output.slice(-20).join('\n'), // last 20 lines of output + ); } } catch (error) { + if (runHistoryId) { + recordCommandFailed( + runHistoryId, + project.id, + error instanceof Error ? error.message : 'Unknown error', + ); + } if (options.showToast) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -331,9 +391,13 @@ const useDbt = ( connections, setupConnectionEnv, buildCommand, + buildDisplayCommand, runCommand, successCallback, settings?.dbtPath, + recordCommandStart, + recordCommandFinished, + recordCommandFailed, ], ); diff --git a/src/renderer/hooks/useDbtRunHistory.ts b/src/renderer/hooks/useDbtRunHistory.ts new file mode 100644 index 00000000..dfc5f167 --- /dev/null +++ b/src/renderer/hooks/useDbtRunHistory.ts @@ -0,0 +1,271 @@ +import { useState, useCallback, useEffect } from 'react'; +import { + DbtRunHistoryEntry, + DbtRunHistoryResult, + DbtRunHistoryStatus, +} from '../../types/dbtRunHistory'; + +const MAX_HISTORY = 50; +const EVENT_NAME = 'dbt-run-history-changed'; + +function dispatchChangeEvent() { + window.dispatchEvent(new Event(EVENT_NAME)); +} + +function mapDbtStatus(rawStatus: string): DbtRunHistoryStatus { + switch (rawStatus?.toLowerCase()) { + case 'success': + case 'pass': + return 'success'; + case 'error': + case 'fail': + case 'runtime error': + return 'error'; + case 'warn': + return 'warn'; + case 'skipped': + return 'skipped'; + case 'no_matches': + return 'no_matches'; + default: + return 'error'; + } +} + +function parseRunResults(rawJson: string) { + try { + const parsed = JSON.parse(rawJson); + const results = Array.isArray(parsed.results) ? parsed.results : []; + + const mappedResults: DbtRunHistoryResult[] = results.map( + (r: any, i: number) => ({ + id: `${parsed.metadata?.invocation_id || 'unknown'}-${i}`, + runId: parsed.metadata?.invocation_id || 'unknown', + uniqueId: r.unique_id, + name: r.unique_id?.split('.').pop() || 'unknown', + resourceType: r.unique_id?.split('.')[0] || 'unknown', + status: mapDbtStatus(r.status), + executionTime: r.execution_time, + message: r.message, + adapterResponse: r.adapter_response, + compiledSql: r.compiled_code || r.compiled_sql, + relationName: r.relation_name, + }), + ); + + const summary = { + total: results.length, + success: results.filter((r: any) => + ['success', 'pass'].includes(r.status), + ).length, + error: results.filter((r: any) => + ['error', 'fail', 'runtime error'].includes(r.status), + ).length, + warn: results.filter((r: any) => r.status === 'warn').length, + skipped: results.filter((r: any) => r.status === 'skipped').length, + }; + + return { + invocationId: parsed.metadata?.invocation_id, + elapsedTime: parsed.elapsed_time, + results: mappedResults, + summary, + }; + } catch { + return null; + } +} + +export const useDbtRunHistory = (projectId?: string) => { + const getStorageKey = useCallback( + (overrideProjectId?: string) => { + const id = overrideProjectId || projectId; + return id ? `dbt-studio:run-history:${id}` : null; + }, + [projectId], + ); + + const list = useCallback( + (overrideProjectId?: string): DbtRunHistoryEntry[] => { + const key = getStorageKey(overrideProjectId); + if (!key) return []; + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : []; + } catch { + return []; + } + }, + [getStorageKey], + ); + + const [history, setHistory] = useState(list()); + + useEffect(() => { + setHistory(list()); + const handleStorage = () => setHistory(list()); + window.addEventListener(EVENT_NAME, handleStorage); + return () => window.removeEventListener(EVENT_NAME, handleStorage); + }, [list]); + + const saveList = useCallback( + (newList: DbtRunHistoryEntry[], overrideProjectId?: string) => { + const key = getStorageKey(overrideProjectId); + if (!key) return; + // Cap at MAX_HISTORY + const capped = newList.slice(0, MAX_HISTORY); + localStorage.setItem(key, JSON.stringify(capped)); + dispatchChangeEvent(); + }, + [getStorageKey], + ); + + const recordCommandStart = useCallback( + ( + request: Omit< + DbtRunHistoryEntry, + 'id' | 'status' | 'summary' | 'startedAt' + >, + ) => { + const currentList = list(request.projectId); + const newEntry: DbtRunHistoryEntry = { + ...request, + id: `run-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + status: 'running', + startedAt: new Date().toISOString(), + summary: { total: 0, success: 0, error: 0, warn: 0, skipped: 0 }, + }; + saveList([newEntry, ...currentList], request.projectId); + return newEntry.id; + }, + [list, saveList], + ); + + const recordCommandFinished = useCallback( + async (id: string, projectIdForRun: string, artifactPath?: string) => { + const currentList = list(projectIdForRun); + const idx = currentList.findIndex((r) => r.id === id); + if (idx === -1) return; + + const entry = { ...currentList[idx] }; + entry.status = 'success'; + entry.completedAt = new Date().toISOString(); + + // Calculate basic elapsed time based on timestamps + if (entry.startedAt) { + entry.elapsedTime = + (new Date(entry.completedAt).getTime() - + new Date(entry.startedAt).getTime()) / + 1000; + } + + if (artifactPath) { + entry.artifactPath = artifactPath; + try { + // Dynamic import to avoid circular dependency at module init time + const { getFileContent } = await import( + '../services/projects.service' + ); + const rawContent = await getFileContent({ path: artifactPath }); + if (rawContent) { + const parsed = parseRunResults(rawContent); + if (parsed) { + entry.invocationId = parsed.invocationId || entry.invocationId; + entry.elapsedTime = parsed.elapsedTime || entry.elapsedTime; + entry.results = parsed.results; + entry.summary = parsed.summary; + + // If any child failed, the parent run should probably be marked as error + if (parsed.summary.error > 0) { + entry.status = 'error'; + } + } + } + } catch { + // Missing or malformed artifacts should not block run history updates. + } + } + + const newList = [...currentList]; + + // Deduplicate by invocation_id if it exists + if (entry.invocationId) { + const dupIdx = newList.findIndex( + (r, i) => i !== idx && r.invocationId === entry.invocationId, + ); + if (dupIdx !== -1) { + newList.splice(dupIdx, 1); + // if idx shifted because dup was before it + if (dupIdx < idx) { + newList[idx - 1] = entry; + } else { + newList[idx] = entry; + } + } else { + newList[idx] = entry; + } + } else { + newList[idx] = entry; + } + + saveList(newList, projectIdForRun); + }, + [list, saveList], + ); + + const recordCommandFailed = useCallback( + ( + id: string, + projectIdForRun: string, + errorMessage?: string, + rawOutputExcerpt?: string, + ) => { + const currentList = list(projectIdForRun); + const idx = currentList.findIndex((r) => r.id === id); + if (idx === -1) return; + + const entry = { ...currentList[idx] }; + entry.status = 'error'; + entry.completedAt = new Date().toISOString(); + entry.errorMessage = errorMessage; + entry.rawOutputExcerpt = rawOutputExcerpt; + + if (entry.startedAt) { + entry.elapsedTime = + (new Date(entry.completedAt).getTime() - + new Date(entry.startedAt).getTime()) / + 1000; + } + + const newList = [...currentList]; + newList[idx] = entry; + saveList(newList, projectIdForRun); + }, + [list, saveList], + ); + + const get = useCallback( + (id: string) => { + return list().find((r) => r.id === id) || null; + }, + [list], + ); + + const clear = useCallback(() => { + const key = getStorageKey(); + if (key) { + localStorage.removeItem(key); + dispatchChangeEvent(); + } + }, [getStorageKey]); + + return { + history, + recordCommandStart, + recordCommandFinished, + recordCommandFailed, + list, + get, + clear, + }; +}; diff --git a/src/renderer/hooks/useProjectQueryResultsPanel.ts b/src/renderer/hooks/useProjectQueryResultsPanel.ts new file mode 100644 index 00000000..fba1bc22 --- /dev/null +++ b/src/renderer/hooks/useProjectQueryResultsPanel.ts @@ -0,0 +1,265 @@ +import React from 'react'; +import type { + ProjectQueryBookmark, + ProjectQueryHistoryItem, + ProjectQueryPanelState, + ProjectQueryPreviewPayload, + ProjectQueryResultsTab, +} from '../components/projectQueryResults/types'; + +const DEFAULT_LIMIT = 500; +const HISTORY_LIMIT = 50; +const BOOKMARK_LIMIT = 50; + +const trimResultForHistory = (result?: ProjectQueryPreviewPayload['result']) => + result + ? { + ...result, + data: result.data?.slice(0, 10), + } + : undefined; + +const getHistoryKey = (projectId: string) => + `dbt_studio_query_history_${projectId}`; +const getBookmarkKey = (projectId: string) => + `dbt_studio_bookmarks_${projectId}`; + +export const useProjectQueryResultsPanel = (projectId?: string) => { + const [state, setState] = React.useState({ + activeTab: 'preview', + limit: DEFAULT_LIMIT, + isRunning: false, + history: [], + bookmarks: [], + }); + const [revision, setRevision] = React.useState(0); + + // Load history and bookmarks when projectId changes + React.useEffect(() => { + if (!projectId) return; + try { + const storedHistory = localStorage.getItem(getHistoryKey(projectId)); + const storedBookmarks = localStorage.getItem(getBookmarkKey(projectId)); + + const savedHistory: ProjectQueryHistoryItem[] = storedHistory + ? JSON.parse(storedHistory) + : []; + const savedBookmarks: ProjectQueryBookmark[] = storedBookmarks + ? JSON.parse(storedBookmarks) + : []; + + setState((current) => ({ + ...current, + history: savedHistory, + bookmarks: savedBookmarks, + // Clear transient preview state when switching projects + result: undefined, + error: undefined, + rawSql: undefined, + compiledSql: undefined, + filePath: undefined, + modelName: undefined, + lastDurationMs: undefined, + })); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Failed to parse query state from localStorage', e); + setState((current) => ({ + ...current, + history: [], + bookmarks: [], + // Clear transient preview state on error as well + result: undefined, + error: undefined, + rawSql: undefined, + compiledSql: undefined, + filePath: undefined, + modelName: undefined, + lastDurationMs: undefined, + })); + } + }, [projectId]); + + // Save history and bookmarks when they change + React.useEffect(() => { + if (!projectId) return; + try { + const historyToSave = state.history.map((item) => ({ + ...item, + resultsPreview: undefined, // strip large data payload + })); + localStorage.setItem( + getHistoryKey(projectId), + JSON.stringify(historyToSave), + ); + localStorage.setItem( + getBookmarkKey(projectId), + JSON.stringify(state.bookmarks), + ); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Failed to save query state to localStorage', e); + } + }, [state.history, state.bookmarks, projectId]); + + const setActiveTab = React.useCallback( + (activeTab: ProjectQueryResultsTab) => { + setState((current) => ({ ...current, activeTab })); + }, + [], + ); + + const setLimit = React.useCallback((value: number) => { + const next = Number.isFinite(value) + ? Math.min(Math.max(Math.trunc(value), 1), 5000) + : DEFAULT_LIMIT; + setState((current) => ({ ...current, limit: next })); + }, []); + + const startPreview = React.useCallback( + (payload: Partial = {}) => { + setRevision((current) => current + 1); + setState((current) => ({ + ...current, + activeTab: 'preview', + isRunning: true, + result: undefined, + error: undefined, + lastDurationMs: undefined, + rawSql: payload.rawSql ?? current.rawSql, + compiledSql: payload.compiledSql ?? current.compiledSql, + filePath: payload.filePath ?? current.filePath, + modelName: payload.modelName ?? current.modelName, + })); + }, + [], + ); + + const completePreview = React.useCallback( + (payload: ProjectQueryPreviewPayload) => { + setRevision((current) => current + 1); + setState((current) => { + const historyItem: ProjectQueryHistoryItem = { + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + projectId: payload.projectId, + projectName: payload.projectName, + filePath: payload.filePath, + modelName: payload.modelName, + rawSql: payload.rawSql, + compiledSql: payload.compiledSql, + executedAt: new Date().toISOString(), + durationMs: payload.durationMs, + limit: current.limit, + resultsPreview: trimResultForHistory(payload.result), + rowCount: + payload.result?.rowCount ?? + payload.result?.data?.length ?? + undefined, + status: 'success', + }; + + return { + ...current, + activeTab: 'preview', + isRunning: false, + result: payload.result, + error: undefined, + rawSql: payload.rawSql, + compiledSql: payload.compiledSql, + lastDurationMs: payload.durationMs, + history: [historyItem, ...current.history].slice(0, HISTORY_LIMIT), + }; + }); + }, + [], + ); + + const failPreview = React.useCallback( + (payload: ProjectQueryPreviewPayload) => { + setRevision((current) => current + 1); + setState((current) => { + const historyItem: ProjectQueryHistoryItem = { + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + projectId: payload.projectId, + projectName: payload.projectName, + filePath: payload.filePath, + modelName: payload.modelName, + rawSql: payload.rawSql, + compiledSql: payload.compiledSql, + executedAt: new Date().toISOString(), + durationMs: payload.durationMs, + limit: current.limit, + status: 'error', + errorMessage: payload.errorMessage, + }; + + return { + ...current, + activeTab: 'preview', + isRunning: false, + result: undefined, + error: payload.errorMessage, + rawSql: payload.rawSql, + compiledSql: payload.compiledSql, + lastDurationMs: payload.durationMs, + history: [historyItem, ...current.history].slice(0, HISTORY_LIMIT), + }; + }); + }, + [], + ); + + const clear = React.useCallback(() => { + setState((current) => ({ + ...current, + activeTab: 'preview', + isRunning: false, + result: undefined, + error: undefined, + rawSql: undefined, + compiledSql: undefined, + filePath: undefined, + modelName: undefined, + lastDurationMs: undefined, + })); + }, []); + + const addBookmark = React.useCallback( + (bookmark: Omit) => { + setState((current) => ({ + ...current, + bookmarks: [ + { + ...bookmark, + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + createdAt: new Date().toISOString(), + }, + ...current.bookmarks, + ].slice(0, BOOKMARK_LIMIT), + })); + }, + [], + ); + + const deleteBookmark = React.useCallback((id: string) => { + setState((current) => ({ + ...current, + bookmarks: current.bookmarks.filter((b) => b.id !== id), + })); + }, []); + + return { + state, + revision, + setActiveTab, + setLimit, + startPreview, + completePreview, + failPreview, + clear, + addBookmark, + deleteBookmark, + }; +}; + +export default useProjectQueryResultsPanel; diff --git a/src/renderer/hooks/useProjectSqlExecution.ts b/src/renderer/hooks/useProjectSqlExecution.ts new file mode 100644 index 00000000..1efc7e98 --- /dev/null +++ b/src/renderer/hooks/useProjectSqlExecution.ts @@ -0,0 +1,169 @@ +import React from 'react'; +import { toast } from 'react-toastify'; +import type { Project } from '../../types/backend'; +import { extractModelNameFromPath } from '../helpers/utils'; +import { getConnectionById, queryData } from '../services/connectors.service'; +import type { ProjectQueryPreviewPayload } from '../components/projectQueryResults'; +import useDbt from './useDbt'; +import { buildCteQueryFromSqlText } from '../utils/sql/cteDetection'; + +type LifecycleHandler = (payload: ProjectQueryPreviewPayload) => void; +type StartHandler = (payload: Partial) => void; + +type ExecuteProjectSqlParams = { + project: Project; + filePath?: string; + rawSql: string; + querySql?: string; + modelName?: string; + label?: string; + compileModel?: boolean; + cteName?: string; + limit?: number; +}; + +type UseProjectSqlExecutionParams = { + onStart?: StartHandler; + onSuccess?: LifecycleHandler; + onError?: LifecycleHandler; +}; + +export const useProjectSqlExecution = ({ + onStart, + onSuccess, + onError, +}: UseProjectSqlExecutionParams = {}) => { + const { compile: dbtCompileModel } = useDbt(); + + const executeProjectSql = React.useCallback( + async ({ + project, + filePath, + rawSql, + querySql, + modelName: explicitModelName, + label, + compileModel = false, + cteName, + limit, + }: ExecuteProjectSqlParams) => { + const startedAt = Date.now(); + const modelName = + explicitModelName || + (filePath ? extractModelNameFromPath(filePath) : undefined) || + label; + + onStart?.({ + projectId: project.id, + projectName: project.name, + filePath, + modelName, + rawSql, + }); + + let executableSql = querySql ?? rawSql; + let compiledSql: string | undefined = querySql; + + try { + if (compileModel) { + if (!modelName) { + throw new Error('Could not extract model name from path'); + } + + const modelCompiledSql = await dbtCompileModel(project, modelName); + if (!modelCompiledSql || modelCompiledSql.trim() === '') { + throw new Error( + 'No compiled SQL returned. The model might not exist or be disabled.', + ); + } + executableSql = modelCompiledSql; + compiledSql = modelCompiledSql; + + if (cteName) { + const compiledCteQuery = buildCteQueryFromSqlText( + modelCompiledSql, + cteName, + ); + if (!compiledCteQuery) { + throw new Error( + `Could not find CTE "${cteName}" in compiled SQL`, + ); + } + executableSql = compiledCteQuery; + compiledSql = compiledCteQuery; + } + } + + if (!project.connectionId) { + throw new Error('No database connection configured for this project'); + } + + const connection = await getConnectionById(project.connectionId); + if (!connection) { + throw new Error('Database connection not found'); + } + + const result = await queryData({ + connection: connection.connection, + query: executableSql, + projectName: project.name, + limit, + }); + const durationMs = Date.now() - startedAt; + + if (!result.success) { + const errorMessage = result.error || 'Query execution failed'; + onError?.({ + projectId: project.id, + projectName: project.name, + filePath, + modelName, + rawSql, + compiledSql, + result, + durationMs, + errorMessage, + }); + toast.error(`Query failed: ${errorMessage}`); + return result; + } + + onSuccess?.({ + projectId: project.id, + projectName: project.name, + filePath, + modelName, + rawSql, + compiledSql, + result: { + ...result, + duration: durationMs, + }, + durationMs, + }); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + onError?.({ + projectId: project.id, + projectName: project.name, + filePath, + modelName, + rawSql, + compiledSql, + durationMs: Date.now() - startedAt, + errorMessage, + }); + toast.error(`Query failed: ${errorMessage}`); + return undefined; + } + }, + [dbtCompileModel, onError, onStart, onSuccess], + ); + + return { executeProjectSql }; +}; + +export default useProjectSqlExecution; diff --git a/src/renderer/screens/projectDetails/index.tsx b/src/renderer/screens/projectDetails/index.tsx index 7450a517..bb02b350 100644 --- a/src/renderer/screens/projectDetails/index.tsx +++ b/src/renderer/screens/projectDetails/index.tsx @@ -41,6 +41,8 @@ import { PipelineSelectorModal, PushToCloudModal, } from '../../components'; +import { TerminalLayoutRef, TerminalPanelTab } from '../../components/terminal'; +import { ProjectQueryResultsPanel } from '../../components/projectQueryResults'; import { ProjectSidebar, SidebarTab, @@ -67,18 +69,15 @@ import { CloudLogViewer, isPipelineFile, } from '../../components/pipelineView'; +import { DbtRunHistoryPanel } from '../../components/dbtRunHistory'; import { Taskbar, TaskbarItem } from '../../components/terminal/styles'; import { projectsServices } from '../../services'; -import { - Container, - Content, - EditorContainer, - Header, - NoFileSelected, -} from './styles'; +import { Content, EditorContainer, Header, NoFileSelected } from './styles'; import { useAppContext, useDbt, + useProjectQueryResultsPanel, + useProjectSqlExecution, useRosettaDBT, useTabManager, } from '../../hooks'; @@ -178,7 +177,22 @@ const ProjectDetails: React.FC = () => { onDiscardAndClose, onCancelClose, } = useTabManager(project?.id); + const terminalLayoutRef = React.useRef(null); + + const handleTerminalTabSwitch = React.useCallback((tab: TerminalPanelTab) => { + terminalLayoutRef.current?.switchTab(tab); + }, []); + const fileContent = activeTab?.content; + const projectQueryResults = useProjectQueryResultsPanel(project?.id); + const { executeProjectSql } = useProjectSqlExecution({ + onStart: (payload) => { + handleTerminalTabSwitch('queryResults'); + projectQueryResults.startPreview(payload); + }, + onSuccess: projectQueryResults.completePreview, + onError: projectQueryResults.failPreview, + }); const previousProjectPathRef = React.useRef(); @@ -445,6 +459,98 @@ const ProjectDetails: React.FC = () => { await updateStatuses(); }, [tabs, markTabSavedByPath, setTabErrorByPath, updateStatuses]); + const handleOpenQuerySqlInEditor = React.useCallback( + (sql: string) => { + if (!activeTabId) { + toast.error('No active editor tab'); + return; + } + updateTabContent(activeTabId, sql); + }, + [activeTabId, updateTabContent], + ); + + const handleExecuteEditorQuery = React.useCallback( + async ({ + sql, + filePath, + modelName, + compileModel, + }: { + sql: string; + filePath: string; + modelName?: string; + compileModel?: boolean; + }) => { + if (!project) { + toast.error('No active project'); + return; + } + if (!settings?.dbtPath && compileModel) { + toast.info('Please configure dbt path in settings'); + return; + } + + await executeProjectSql({ + project, + filePath, + rawSql: sql, + modelName, + compileModel, + limit: projectQueryResults.state.limit, + }); + }, + [ + executeProjectSql, + project, + settings?.dbtPath, + projectQueryResults.state.limit, + ], + ); + + const handleExecuteEditorCte = React.useCallback( + async ({ + sql, + filePath, + cteName, + modelName, + compileModel, + }: { + sql: string; + filePath: string; + cteName: string; + modelName?: string; + compileModel?: boolean; + }) => { + if (!project) { + toast.error('No active project'); + return; + } + if (!settings?.dbtPath && compileModel) { + toast.info('Please configure dbt path in settings'); + return; + } + + await executeProjectSql({ + project, + filePath, + rawSql: sql, + querySql: sql, + modelName, + label: `CTE ${cteName}`, + compileModel, + cteName, + limit: projectQueryResults.state.limit, + }); + }, + [ + executeProjectSql, + project, + settings?.dbtPath, + projectQueryResults.state.limit, + ], + ); + const handleCloseAllTabs = React.useCallback(() => { const unmodifiedTabs = tabs.filter((tab) => !tab.isModified); const modifiedCount = tabs.length - unmodifiedTabs.length; @@ -935,15 +1041,16 @@ const ProjectDetails: React.FC = () => { handleTerminalTabSwitch('terminal')} /> } panelTitle="DBT Studio" @@ -1157,8 +1264,66 @@ const ProjectDetails: React.FC = () => { > - - +
+ 0 || + Boolean(projectQueryResults.state.result) || + projectQueryResults.state.isRunning + } + queryResultsRevision={projectQueryResults.revision} + queryResultsPanel={ + { + if (!project) return; + await executeProjectSql({ + project, + filePath: item.filePath, + modelName: item.modelName, + rawSql: item.rawSql, + querySql: item.compiledSql ?? item.rawSql, + compileModel: false, + limit: projectQueryResults.state.limit, + }); + }} + onRun={ + projectQueryResults.state.rawSql + ? async () => { + if (!project) return; + await executeProjectSql({ + project, + filePath: projectQueryResults.state.filePath, + modelName: projectQueryResults.state.modelName, + rawSql: projectQueryResults.state.rawSql!, + querySql: + projectQueryResults.state.compiledSql ?? + projectQueryResults.state.rawSql!, + compileModel: false, + limit: projectQueryResults.state.limit, + }); + } + : undefined + } + onOpenSqlInEditor={handleOpenQuerySqlInEditor} + /> + } + showRunHistoryTab + runHistoryPanel={ + openChatWithMessage(prompt)} + /> + } + >
@@ -1378,6 +1543,8 @@ const ProjectDetails: React.FC = () => { openTab(filePath); }} onTogglePreviewTab={handleTogglePreviewTab} + onExecuteQuery={handleExecuteEditorQuery} + onExecuteCte={handleExecuteEditorCte} extraActions={ <> {menuItems.length > 0 && ( @@ -1399,6 +1566,21 @@ const ProjectDetails: React.FC = () => { isRunningDbt={isRunningDbt} isRunningRosettaDbt={isRunningRosettaDbt} environment={env} + onBeforeExecute={() => + handleTerminalTabSwitch('terminal') + } + onQueryPreviewStart={(payload) => { + handleTerminalTabSwitch('queryResults'); + projectQueryResults.startPreview( + payload, + ); + }} + onQueryPreviewSuccess={ + projectQueryResults.completePreview + } + onQueryPreviewError={ + projectQueryResults.failPreview + } /> )} @@ -1493,7 +1675,7 @@ const ProjectDetails: React.FC = () => { initialDbtArguments={pipelineRunArgs} /> )} - +
diff --git a/src/renderer/services/connectors.service.ts b/src/renderer/services/connectors.service.ts index 211bfcee..e600f78a 100644 --- a/src/renderer/services/connectors.service.ts +++ b/src/renderer/services/connectors.service.ts @@ -73,6 +73,7 @@ export const queryData = async (body: { query: string; projectName: string; queryId?: string; + limit?: number; }): Promise => { const { data } = await client.post< { @@ -80,6 +81,7 @@ export const queryData = async (body: { query: string; projectName: string; queryId?: string; + limit?: number; }, QueryResponseType >('connector:query', body); diff --git a/src/renderer/utils/sql/cteDetection.ts b/src/renderer/utils/sql/cteDetection.ts new file mode 100644 index 00000000..f57e98e0 --- /dev/null +++ b/src/renderer/utils/sql/cteDetection.ts @@ -0,0 +1,385 @@ +import type * as monaco from 'monaco-editor'; + +/* eslint-disable no-continue, no-restricted-syntax, no-cond-assign */ + +export interface CteInfo { + name: string; + range: monaco.IRange; + queryRange: monaco.IRange; + index: number; + withClauseStart: number; + withClauseStartOffset?: number; +} + +export interface CteQueryBuildResult { + query: string; + targetCte: CteInfo; +} + +const IDENTIFIER_PATTERN = + '(?:[a-zA-Z_][a-zA-Z0-9_]*|"[^"]+"|`[^`]+`|\\[[^\\]]+\\])'; +const CTE_NAME_PATTERN = `(${IDENTIFIER_PATTERN}(?:\\.${IDENTIFIER_PATTERN})*(?:\\s*\\([^)]*\\))?)`; + +const isWordChar = (value: string | undefined): boolean => + Boolean(value && /[a-zA-Z0-9_]/.test(value)); + +const handleComment = (text: string, pos: number): number => { + const char = text[pos]; + const nextChar = pos < text.length - 1 ? text[pos + 1] : ''; + + if (char === '-' && nextChar === '-') { + let endPos = pos + 2; + while (endPos < text.length && !['\n', '\r'].includes(text[endPos])) { + endPos += 1; + } + return endPos; + } + + if (char === '/' && nextChar === '*') { + let endPos = pos + 2; + while (endPos < text.length - 1) { + if (text[endPos] === '*' && text[endPos + 1] === '/') { + return endPos + 2; + } + endPos += 1; + } + return text.length; + } + + if (char === '{' && nextChar === '#') { + let endPos = pos + 2; + while (endPos < text.length - 1) { + if (text[endPos] === '#' && text[endPos + 1] === '}') { + return endPos + 2; + } + endPos += 1; + } + return text.length; + } + + return pos; +}; + +const findMatchingClosingParen = (text: string, openPos: number): number => { + let depth = 0; + let inString = false; + let stringChar = ''; + let pos = openPos; + + while (pos < text.length) { + const commentEnd = handleComment(text, pos); + if (!inString && commentEnd !== pos) { + pos = commentEnd; + continue; + } + + const char = text[pos]; + const nextChar = pos < text.length - 1 ? text[pos + 1] : ''; + + if (!inString && (char === "'" || char === '"')) { + inString = true; + stringChar = char; + } else if (inString && char === stringChar) { + if (nextChar === stringChar) { + pos += 1; + } else { + inString = false; + stringChar = ''; + } + } else if (!inString && char === '(') { + depth += 1; + } else if (!inString && char === ')') { + depth -= 1; + if (depth === 0) { + return pos; + } + } + + pos += 1; + } + + return -1; +}; + +const findWithKeywords = (text: string): number[] => { + const positions: number[] = []; + let pos = 0; + let inString = false; + let stringChar = ''; + + while (pos < text.length) { + const commentEnd = handleComment(text, pos); + if (!inString && commentEnd !== pos) { + pos = commentEnd; + continue; + } + + const char = text[pos]; + const nextChar = pos < text.length - 1 ? text[pos + 1] : ''; + + if (!inString && (char === "'" || char === '"')) { + inString = true; + stringChar = char; + } else if (inString && char === stringChar) { + if (nextChar === stringChar) { + pos += 1; + } else { + inString = false; + stringChar = ''; + } + } + + if (!inString && /^with\b/i.test(text.slice(pos))) { + const charBefore = pos > 0 ? text[pos - 1] : ' '; + if (!isWordChar(charBefore)) { + positions.push(pos); + } + pos += 4; + continue; + } + + pos += 1; + } + + return positions; +}; + +const findWithClauseEnd = (text: string, withStartPos: number): number => { + let pos = withStartPos; + let depth = 0; + let inString = false; + let stringChar = ''; + + while (pos < text.length) { + const commentEnd = handleComment(text, pos); + if (!inString && commentEnd !== pos) { + pos = commentEnd; + continue; + } + + const char = text[pos]; + const nextChar = pos < text.length - 1 ? text[pos + 1] : ''; + + if (!inString && (char === "'" || char === '"')) { + inString = true; + stringChar = char; + } else if (inString && char === stringChar) { + if (nextChar === stringChar) { + pos += 1; + } else { + inString = false; + stringChar = ''; + } + } else if (!inString && char === '(') { + depth += 1; + } else if (!inString && char === ')') { + depth = Math.max(0, depth - 1); + } + + if (!inString && depth === 0 && /^select\b/i.test(text.slice(pos))) { + return pos; + } + + pos += 1; + } + + return -1; +}; + +const skipWhitespaceAndComments = (text: string, startPos: number): number => { + let pos = startPos; + while (pos < text.length) { + while (pos < text.length && /\s/.test(text[pos])) { + pos += 1; + } + const commentEnd = handleComment(text, pos); + if (commentEnd !== pos) { + pos = commentEnd; + continue; + } + break; + } + return pos; +}; + +const stripColumnList = (name: string): string => + name.replace(/\s*\([^)]*\)\s*$/, '').trim(); + +const quoteSqlIdentifier = (identifier: string): string => { + if (/^[["'`]/.test(identifier) || identifier.includes('.')) { + return identifier; + } + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) { + return `"${identifier}"`; + } + return identifier; +}; + +export const detectCtes = ( + model: monaco.editor.ITextModel, + monacoNs: typeof monaco, +): CteInfo[] => { + const text = model.getValue(); + const ctes: CteInfo[] = []; + const cteRegex = new RegExp(`${CTE_NAME_PATTERN}\\s+as\\s*\\(`, 'gi'); + + for (const withPos of findWithKeywords(text)) { + const withStartPos = skipWhitespaceAndComments(text, withPos + 4); + const withEndPos = findWithClauseEnd(text, withStartPos); + if (withEndPos === -1) { + continue; + } + + const withContent = text.slice(withStartPos, withEndPos); + cteRegex.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = cteRegex.exec(withContent)) !== null) { + const localCteStart = match.index; + const localOpenParen = cteRegex.lastIndex - 1; + const localCteEnd = findMatchingClosingParen(withContent, localOpenParen); + if (localCteEnd === -1) { + continue; + } + + const cteStart = withStartPos + localCteStart; + const queryStart = withStartPos + localOpenParen + 1; + const queryEnd = withStartPos + localCteEnd; + const name = stripColumnList(match[1]); + + const start = model.getPositionAt(cteStart); + const end = model.getPositionAt(cteStart + name.length); + const queryRangeStart = model.getPositionAt(queryStart); + const queryRangeEnd = model.getPositionAt(queryEnd); + + ctes.push({ + name, + range: new monacoNs.Range( + start.lineNumber, + start.column, + end.lineNumber, + end.column, + ), + queryRange: new monacoNs.Range( + queryRangeStart.lineNumber, + queryRangeStart.column, + queryRangeEnd.lineNumber, + queryRangeEnd.column, + ), + index: ctes.length, + withClauseStart: withPos, + }); + + cteRegex.lastIndex = localCteEnd + 1; + } + } + + return ctes; +}; + +export const buildCteQuery = ( + model: monaco.editor.ITextModel, + ctes: CteInfo[], + cteIndex: number, +): CteQueryBuildResult | undefined => { + const text = model.getValue(); + const targetCte = ctes[cteIndex]; + if (!targetCte) { + return undefined; + } + + const dependencyCtes = ctes.filter( + (cte) => + cte.withClauseStart === targetCte.withClauseStart && + cte.index <= targetCte.index, + ); + + const cteDefinitions = dependencyCtes.map((cte) => { + const query = model.getValueInRange(cte.queryRange); + return `${cte.name} AS (\n${query}\n)`; + }); + + if (cteDefinitions.length === 0) { + return undefined; + } + + const preamble = text.slice(0, targetCte.withClauseStart).trim(); + const query = [ + preamble, + `WITH ${cteDefinitions.join(',\n')}`, + `SELECT * FROM ${quoteSqlIdentifier(targetCte.name)}`, + ] + .filter(Boolean) + .join('\n\n'); + + return { query, targetCte }; +}; + +export const buildCteQueryFromSqlText = ( + sql: string, + cteName: string, +): string | undefined => { + const cteRegex = new RegExp(`${CTE_NAME_PATTERN}\\s+as\\s*\\(`, 'gi'); + + for (const withPos of findWithKeywords(sql)) { + const withStartPos = skipWhitespaceAndComments(sql, withPos + 4); + const withEndPos = findWithClauseEnd(sql, withStartPos); + if (withEndPos === -1) { + continue; + } + + const withContent = sql.slice(withStartPos, withEndPos); + const ctes: Array<{ + name: string; + query: string; + withClauseStart: number; + }> = []; + cteRegex.lastIndex = 0; + + let targetIndex = -1; + let match: RegExpExecArray | null; + while ((match = cteRegex.exec(withContent)) !== null) { + const localOpenParen = cteRegex.lastIndex - 1; + const localCteEnd = findMatchingClosingParen(withContent, localOpenParen); + if (localCteEnd === -1) { + continue; + } + + const name = stripColumnList(match[1]); + const queryStart = withStartPos + localOpenParen + 1; + const queryEnd = withStartPos + localCteEnd; + ctes.push({ + name, + query: sql.slice(queryStart, queryEnd), + withClauseStart: withPos, + }); + + if (name === cteName) { + targetIndex = ctes.length - 1; + break; + } + + cteRegex.lastIndex = localCteEnd + 1; + } + + if (targetIndex === -1) { + continue; + } + + const dependencyCtes = ctes + .filter( + (cte, index) => cte.withClauseStart === withPos && index <= targetIndex, + ) + .map((cte) => `${cte.name} AS (\n${cte.query}\n)`); + + return [ + sql.slice(0, withPos).trim(), + `WITH ${dependencyCtes.join(',\n')}`, + `SELECT * FROM ${quoteSqlIdentifier(cteName)}`, + ] + .filter(Boolean) + .join('\n\n'); + } + + return undefined; +}; diff --git a/src/types/dbtRunHistory.ts b/src/types/dbtRunHistory.ts new file mode 100644 index 00000000..723b88d4 --- /dev/null +++ b/src/types/dbtRunHistory.ts @@ -0,0 +1,49 @@ +export type DbtRunHistoryStatus = + | 'running' + | 'success' + | 'error' + | 'warn' + | 'skipped' + | 'no_matches' + | 'cancelled'; + +export interface DbtRunHistoryResult { + id: string; + runId: string; + uniqueId?: string; + name: string; + resourceType?: string; + status: DbtRunHistoryStatus; + executionTime?: number; + message?: string; + adapterResponse?: Record; + compiledSql?: string; + relationName?: string; +} + +export interface DbtRunHistoryEntry { + id: string; + invocationId?: string; + projectId?: string; + projectName: string; + projectPath: string; + command: string; + args?: string; + fullCommand: string; + shellCommand?: string; // full shell command for copy-to-clipboard + status: DbtRunHistoryStatus; + startedAt: string; + completedAt?: string; + elapsedTime?: number; + rawOutputExcerpt?: string; + errorMessage?: string; + artifactPath?: string; + summary: { + total: number; + success: number; + error: number; + warn: number; + skipped: number; + }; + results?: DbtRunHistoryResult[]; +}