diff --git a/CHANGELOG.md b/CHANGELOG.md index ddae873..e646bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the PostgreSQL Explorer extension will be documented in t The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.2] - 2026-04-28 + +### Added +- **Sliding-window result streaming**: Optional PostgreSQL `SCROLL` cursor execution for eligible parameter-free `SELECT` queries (`postgresExplorer.performance.slidingWindowSelects`, default on). Keeps a bounded row buffer in the grid and extension host; scrolling fetches the next/previous window. Configurable cap via `postgresExplorer.performance.slidingWindowRowCap` (10–500 rows). Dismissible streaming hint banner with optional mute for the session/workspace. +- **`bytea` display modes**: Setting `postgresExplorer.query.byteaDisplayFormat` β€” `hex0x` (default), PostgreSQL `\x` hex text, or JSON-oriented debug shape β€” applied consistently in result grids and history. +- **SQL Assistant turn controls**: Regenerate the latest assistant reply without duplicating the user turn; resend / branch from a chosen user message (truncate history after that turn and rerun). Attach-to-assistant flows can prefill the composer when a message string is supplied (toast copy updated accordingly). +- **Result grid toolbar & editing workflow**: Result identity bar, consolidated toolbar/footer, view selector, inline banners, and a commit-confirmation step for pending cell edits. Source notebook cell index is surfaced for returning focus to the executing cell. + +### Changed +- **Export vs Auto-LIMIT**: Query results carry `exportQuery` (original SQL before Auto-LIMIT) so full CSV/JSON/Excel exports can rerun the unrestricted statement when the grid was limited for display. +- **Renderer & executor integration**: Server-side cursor sessions coordinate with the webview for windowed fetches; grid-derived queries and edit-commit preferences are handled in the extension host so UI actions stay consistent with execution policy. + ## [1.2.0] - 2026-04-19 ### Added diff --git a/MARKETPLACE.md b/MARKETPLACE.md index 06aadcc..d335997 100644 --- a/MARKETPLACE.md +++ b/MARKETPLACE.md @@ -58,6 +58,9 @@ | πŸ€– **AI-Powered** | GitHub Copilot, GitHub Models, OpenAI, Anthropic, and Google Gemini integration | | ⌨️ **Developer Tools** | IntelliSense, keyboard shortcuts, PSQL terminal access | | πŸ“€ **Export Data** | Export query results to CSV, JSON, or Excel formats | +| πŸ“‰ **Large result streaming** | Optional sliding-window cursor mode for big `SELECT`s β€” bounded memory, scroll to fetch | +| πŸ”’ **Binary columns** | Configurable `bytea` display (hex / PostgreSQL / JSON debug) | +| πŸ€– **SQL Assistant** | Regenerate last reply or resend from an earlier user message; prefill when attaching context | --- @@ -123,7 +126,7 @@ ## πŸ“‹ Feature Matrix -| Area | PgStudio v1.0.0 | Notes | +| Area | PgStudio v1.2.1 | Notes | |---|---|---| | Core PostgreSQL object operations | βœ… | Tables, views, mat views, functions, roles, extensions, FDWs, and more | | AI-assisted SQL workflows | βœ… | Generate, optimize, explain, analyze, and notebook handoff | @@ -136,7 +139,7 @@ --- -## ⚠️ Known Limitations (v1.0.0) +## ⚠️ Known Limitations (v1.2.1) - In-grid editing is currently more limited than full desktop DB IDEs. - ERD/schema visualization is available but not yet feature-complete. diff --git a/README.md b/README.md index 7b6b241..623c2d3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Version](https://img.shields.io/visual-studio-marketplace/v/ric-v.postgres-explorer?style=for-the-badge&logo=visual-studio-code&logoColor=white&color=0066CC)](https://marketplace.visualstudio.com/items?itemName=ric-v.postgres-explorer) [![Downloads](https://img.shields.io/visual-studio-marketplace/d/ric-v.postgres-explorer?style=for-the-badge&logo=visual-studio-code&logoColor=white&color=2ECC71)](https://marketplace.visualstudio.com/items?itemName=ric-v.postgres-explorer) [![Rating](https://img.shields.io/visual-studio-marketplace/r/ric-v.postgres-explorer?style=for-the-badge&logo=visual-studio-code&logoColor=white&color=F39C12)](https://marketplace.visualstudio.com/items?itemName=ric-v.postgres-explorer) -[![Status](https://img.shields.io/badge/status-stable%20v1.0.0%20%2B%20nightly-blue?style=for-the-badge&logo=git&logoColor=white)](https://github.com/dev-asterix/PgStudio/releases) +[![Status](https://img.shields.io/badge/status-stable%20v1.2.1%20%2B%20nightly-blue?style=for-the-badge&logo=git&logoColor=white)](https://github.com/dev-asterix/PgStudio/releases) **PgStudio** (formerly YAPE) is a comprehensive PostgreSQL database management extension featuring interactive SQL notebooks, real-time monitoring dashboard, AI-powered assistance, and advanced database operationsβ€”all within VS Code. @@ -17,13 +17,6 @@ --- -## πŸ†• Since v1.0.0 - -- 🧩 **SQL Assistant in Editor Tabs** β€” Open SQL Assistant directly in the main editor area using `SQL Assistant: Open in Editor Tab`. -- πŸ—‚οΈ **Multiple SQL Assistant Tabs** β€” Run parallel assistant conversations in separate tabs and switch context faster. - ---- - ## πŸ“Ί Video Guides ### 1. Setup @@ -71,10 +64,10 @@ - πŸ“‹ **Smart Paste** β€” Context-aware clipboard actions (SQL/CSV/JSON) - πŸ“Š **Table Intelligence** β€” Profile, activity monitor, index usage, definition viewer - πŸ” **EXPLAIN CodeLens** β€” One-click query analysis directly in notebooks -- πŸŽ›οΈ **Advanced Result UX** β€” Column stats, transpose view, enhanced filtering, and improved in-grid editing controls +- πŸŽ›οΈ **Advanced Result UX** β€” Column stats, transpose view, enhanced filtering, sliding-window streaming for large `SELECT`s, configurable `bytea` display, and structured in-grid editing with explicit commit confirmation - πŸ›‘οΈ **Auto-LIMIT** β€” Intelligent query protection (configurable, default 1000 rows) - 🌍 **Foreign Data Wrappers** β€” Manage foreign servers, user mappings & tables -- πŸ€– **AI-Powered** β€” Generate, Optimize, Explain & Analyze with guided follow-ups and next-step suggestions (GitHub Models, OpenAI, Anthropic, Gemini, VS Code LM) +- πŸ€– **AI-Powered** β€” Generate, Optimize, Explain & Analyze with guided follow-ups; regenerate or branch the conversation from a prior user message (GitHub Models, OpenAI, Anthropic, Gemini, VS Code LM) - 🧩 **Flexible SQL Assistant Layout** β€” Open SQL Assistant in editor tabs and keep multiple assistant tabs open simultaneously - πŸ–ΌοΈ **Vision AI** β€” Paste or upload images directly in the SQL Assistant; sent to vision-capable AI providers - πŸ“Ž **File Preview** β€” Click attached file chips to open them as preview tabs in the editor @@ -135,27 +128,19 @@ ## πŸ“‹ Feature Matrix -| Area | PgStudio v1.0.0 | Notes | +| Area | PgStudio v1.2.1 | Notes | |---|---|---| | Core PostgreSQL object operations | βœ… | Tables, views, mat views, functions, roles, extensions, FDWs, and more | | AI-assisted SQL workflows | βœ… | Generate, optimize, explain, and analyze with notebook-first execution | | Production safety controls | βœ… | Read-only mode, risk scoring, confirmation prompts, Auto-LIMIT | | Real-time monitoring dashboard | βœ… | Activity and health views in VS Code | | Interactive SQL notebooks | βœ… | Native `.pgsql` notebook execution with completions | -| In-grid result editing parity with desktop IDEs | ⚠️ Partial | Planned improvements post-v1.0.0 | +| In-grid result editing parity with desktop IDEs | ⚠️ Partial | Stronger commit flow and tooling in v1.2.x; full parity still evolving | | ERD/schema visualization parity | ⚠️ Partial | Schema designer exists; ERD depth still evolving | | Advanced replication administration | ⚠️ Partial | Additional publication/subscription depth planned | --- -## ⚠️ Known Limitations (v1.0.0) - -- In-grid editing is limited compared to full desktop DB IDEs. -- ERD/schema visualization is still maturing. -- Some advanced PostgreSQL administration areas are partial and will be expanded in v1.x. - ---- - ## πŸš€ Quick Start ```bash @@ -184,7 +169,7 @@ Then: **PostgreSQL icon** β†’ **Add Connection** β†’ Enter details β†’ **Connect - `SECURITY.md` - Security policy and vulnerability reporting guidance - `CHANGELOG.md` - Release notes and what changed across versions -**Stable: v1.0.0 | Nightly: v1.0.0-nightly+ β€”** Production-ready stable release plus active nightly improvements (dashboard telemetry UX and SQL Preview toggle workflows). See [Release Notes](docs/RELEASE_NOTES_v1.0.0.md), [Migration Guide](docs/MIGRATION_GUIDE_0.x_to_1.0.0.md), and [CHANGELOG.md](CHANGELOG.md) for details. +**Stable: v1.2.1 | Nightly: v1.0.0-nightly+ β€”** Latest stable adds cursor-based result streaming, `bytea` formatting controls, richer result-grid tooling, export correctness with Auto-LIMIT, and SQL Assistant regenerate/resend. See [CHANGELOG.md](CHANGELOG.md); v1.0 launch materials remain in [Release Notes](docs/RELEASE_NOTES_v1.0.0.md) and [Migration Guide](docs/MIGRATION_GUIDE_0.x_to_1.0.0.md). --- diff --git a/docs/assets/01-setup.gif b/docs/assets/01-setup.gif index 41aee9a..0ef8e46 100644 Binary files a/docs/assets/01-setup.gif and b/docs/assets/01-setup.gif differ diff --git a/docs/assets/02-more-settings.gif b/docs/assets/02-more-settings.gif old mode 100755 new mode 100644 index 855ba0d..24da659 Binary files a/docs/assets/02-more-settings.gif and b/docs/assets/02-more-settings.gif differ diff --git a/docs/assets/03-ai-assist.gif b/docs/assets/03-ai-assist.gif index e51c396..4fb2942 100755 Binary files a/docs/assets/03-ai-assist.gif and b/docs/assets/03-ai-assist.gif differ diff --git a/docs/assets/04-ai-copilot.gif b/docs/assets/04-ai-copilot.gif index 9c1a856..ecab041 100755 Binary files a/docs/assets/04-ai-copilot.gif and b/docs/assets/04-ai-copilot.gif differ diff --git a/docs/assets/05-dashboard.gif b/docs/assets/05-dashboard.gif index 6be17d5..2c76946 100755 Binary files a/docs/assets/05-dashboard.gif and b/docs/assets/05-dashboard.gif differ diff --git a/docs/assets/07-power-editor.gif b/docs/assets/07-power-editor.gif index 27111b0..85c6a18 100755 Binary files a/docs/assets/07-power-editor.gif and b/docs/assets/07-power-editor.gif differ diff --git a/docs/assets/08-more-features.gif b/docs/assets/08-more-features.gif index d54e6c1..2e4860c 100755 Binary files a/docs/assets/08-more-features.gif and b/docs/assets/08-more-features.gif differ diff --git a/package.json b/package.json index 75da258..e25b055 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.2.1", + "version": "1.2.2", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false, @@ -1751,11 +1751,33 @@ "default": 1000, "description": "Default row limit for SELECT queries when auto-LIMIT is enabled." }, + "postgresExplorer.performance.slidingWindowSelects": { + "type": "boolean", + "default": true, + "description": "Use a PostgreSQL SCROLL cursor for eligible SELECT queries (no SQL parameters): stream one window of rows into the cell and fetch more on scrollβ€”bounded RAM on host and webview." + }, + "postgresExplorer.performance.slidingWindowRowCap": { + "type": "number", + "default": 100, + "minimum": 10, + "maximum": 500, + "description": "Maximum rows kept in the result grid at once when sliding-window streaming is active." + }, "postgresExplorer.query.autoLimitEnabled": { "type": "boolean", "default": true, "description": "Automatically append LIMIT clause to SELECT queries that don't have one. Always enabled in read-only mode." }, + "postgresExplorer.query.byteaDisplayFormat": { + "type": "string", + "enum": [ + "hex0x", + "postgresql", + "json" + ], + "default": "hex0x", + "description": "Display format for binary (bytea) columns in query results: 0x-prefixed hex, PostgreSQL \\x hex, or JSON Buffer shape (debug)." + }, "postgresExplorer.parameters.cacheLastValues": { "type": "boolean", "default": true, diff --git a/src/common/types.ts b/src/common/types.ts index e266098..1a18b62 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -90,6 +90,11 @@ export interface BreadcrumbContext { }; } +/** How decoded `bytea` / binary values are shown in query result grids */ +export type ByteaDisplayFormat = 'hex0x' | 'postgresql' | 'json'; + +export const BYTEA_DISPLAY_DEFAULT: ByteaDisplayFormat = 'hex0x'; + /** PostgreSQL notice with client receive time (log-style UI) */ export interface NoticeLogEntry { message: string; @@ -103,6 +108,8 @@ export interface QueryResults { rowCount?: number | null; command?: string; query?: string; + /** Raw query before auto-LIMIT; used by full export reruns. */ + exportQuery?: string; notices?: NoticeLogEntry[]; executionTime?: number; tableInfo?: TableInfo; @@ -124,6 +131,20 @@ export interface QueryResults { autoLimitApplied?: boolean; /** Effective LIMIT value when autoLimitApplied is true. */ autoLimitValue?: number; + /** Notebook setting: binary column display mode (embedded for renderer). */ + byteaDisplayFormat?: ByteaDisplayFormat; + /** Server-side cursor session: UI loads pages on scroll; keeps small row buffer client-side. */ + slidingWindow?: { + sessionId: string; + windowStartRow: number; + windowSize: number; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + }; + /** When true with slidingWindow, show the streaming hint banner (policy-gated). */ + showSlidingCursorBanner?: boolean; + /** Notebook cell index that produced this output (hover toolbar / focus source cell). */ + sourceCellIndex?: number; } export interface TableRenderOptions { @@ -144,6 +165,9 @@ export interface TableRenderOptions { onFkLookup?: (req: FkLookupRequest) => void; onFilterChange?: (filterState: FilterState) => void; onSortChange?: (sortState: SortState) => void; + byteaDisplayFormat?: ByteaDisplayFormat; + /** 1-based absolute row label for first visible row (sliding-window results). Defaults to 1. */ + rowNumberBaseline?: number; } export interface ChartRenderOptions { @@ -296,4 +320,5 @@ export interface ResultHistoryEntry { /** PostgreSQL notices (RAISE NOTICE, etc.) for this result */ notices?: NoticeLogEntry[]; timestamp: number; + byteaDisplayFormat?: ByteaDisplayFormat; } diff --git a/src/extension.ts b/src/extension.ts index b01ac70..f516938 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -69,10 +69,12 @@ async function ensureRendererMessageHandlers( explainHandlersModule, coreHandlersModule, queryHandlersModule, + cursorBannerModule, ] = await Promise.all([ import('./services/handlers/ExplainHandlers'), import('./services/handlers/CoreHandlers'), import('./services/handlers/QueryHandlers'), + import('./services/handlers/CursorStreamBannerHandler'), ]); // Explain & Chat Handlers @@ -89,8 +91,12 @@ async function ensureRendererMessageHandlers( registry.register('showDatabaseSwitcher', new coreHandlersModule.ShowDatabaseSwitcherHandler(statusBarInstance)); registry.register('showErrorMessage', new coreHandlersModule.ShowErrorMessageHandler()); registry.register('export_request', new coreHandlersModule.ExportRequestHandler()); + registry.register('runDerivedQuery', new coreHandlersModule.RunDerivedQueryHandler()); registry.register('retryCell', new coreHandlersModule.RetryCellHandler()); registry.register('showConnectionInfo', new coreHandlersModule.ShowConnectionInfoHandler()); + registry.register('gridCommitPreference', new coreHandlersModule.GridCommitPreferenceHandler(context)); + registry.register('cursorStreamBannerDismiss', new cursorBannerModule.CursorStreamBannerDismissHandler(context)); + registry.register('cursorStreamBannerMute', new cursorBannerModule.CursorStreamBannerMuteHandler(context)); // Query Execution Handlers registry.register('execute_update_background', new queryHandlersModule.ExecuteUpdateBackgroundHandler()); diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 5ef453a..5242ec7 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -129,6 +129,17 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { case 'sendMessage': await this._handleUserMessage(data.message, data.attachments, data.mentions); break; + case 'regenerateAssistant': + await this._regenerateAssistantReply(); + break; + case 'resendUserMessage': { + const idx = + typeof data.userIndex === 'number' && Number.isInteger(data.userIndex) + ? data.userIndex + : -1; + await this._resendUserMessageAtIndex(idx); + break; + } case 'clearChat': this._messages = []; this._sessionService.clearCurrentSession(); @@ -371,10 +382,21 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { if (data.notices?.length) { attached.push('notices'); } + if (data.message?.trim()) { + targetWebview.postMessage({ + type: 'prefillInput', + message: data.message, + autoSend: false, + }); + } const summary = attached.length ? attached.join(' & ') : 'Content'; - vscode.window.showInformationMessage( - `${summary} attached to SQL Assistant. Add your question and send!`, - ); + const toast = + data.message?.trim() + ? attached.length > 0 + ? `${summary} attached to SQL Assistant. Review the prefilled prompt and press Send.` + : 'Review the prefilled prompt in SQL Assistant and press Send.' + : `${summary} attached to SQL Assistant. Add your question and send!`; + vscode.window.showInformationMessage(toast); } catch (error) { console.error('[ChatViewProvider] Failed to create temp files:', error); @@ -404,23 +426,28 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // ==================== Message Handling ==================== - private async _handleUserMessage(message: string, attachments?: FileAttachment[], mentions?: DbMention[]) { - if (this._isProcessing) { - return; + /** Plain prompt text without attachment display suffixes (matches webview copy behavior). */ + private _plainPromptFromUserMessage(user: ChatMessage): string { + if (user.role !== 'user') { + return ''; } - - this._isProcessing = true; - - console.log('[ChatView] ========== HANDLING USER MESSAGE =========='); - console.log('[ChatView] Message:', message); - console.log('[ChatView] Attachments:', attachments?.length || 0); - console.log('[ChatView] Mentions:', mentions?.length || 0); - if (mentions && mentions.length > 0) { - console.log('[ChatView] Mention details:', JSON.stringify(mentions, null, 2)); + let c = user.content || ''; + const idxFile = c.indexOf('\n\nπŸ“Ž'); + const idxImg = c.indexOf('\n\nπŸ–ΌοΈ'); + const candidates = [idxFile, idxImg].filter(i => i >= 0); + if (candidates.length > 0) { + c = c.slice(0, Math.min(...candidates)).trim(); + } else { + c = c.trim(); } + return c; + } - // Build message with attachments - // For display (history), we only show links/names to keep UI clean + private async _composeUserTurnPayload( + message: string, + attachments?: FileAttachment[], + mentions?: DbMention[] + ): Promise<{ fullMessage: string; aiMessage: string }> { let fullMessage = message; if (attachments && attachments.length > 0) { const attachmentLinks = attachments.map(att => { @@ -436,7 +463,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { fullMessage = message + attachmentLinks; } - // For AI (current turn), we need the full content let aiMessage = message; if (attachments && attachments.length > 0) { const attachmentContent = attachments.map(att => { @@ -448,18 +474,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { aiMessage = message + attachmentContent; } - // Process @ mentions - add schema context for AI - // aiMessage already has attachments, now add schema context if (mentions && mentions.length > 0) { console.log('[ChatView] Processing mentions for schema context...'); - - // Phase C: Capture connection context from first mention + if (mentions[0]) { this._currentDatabase = mentions[0].database; - // Note: connectionName might not be populated in DbMention, so we use connectionId as fallback this._currentConnectionName = mentions[0].breadcrumb?.split('.')[0] || mentions[0].connectionId; - // B1: Look up environment and read-only mode from connection config if (mentions[0].connectionId) { const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; const conn = connections.find(c => c.id === mentions[0].connectionId); @@ -469,7 +490,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } } - // Pass safety context to AI service so system prompt is tailored this._aiService.setConnectionContext({ environment: this._currentEnvironment, readOnlyMode: this._currentReadOnlyMode, @@ -478,7 +498,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this._sendContextUpdate(); } - + let schemaContext = '\n\n=== DATABASE SCHEMA CONTEXT (Use this information to answer the question) ===\n'; for (const mention of mentions) { @@ -505,14 +525,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const errorMsg = e instanceof Error ? e.message : String(e); console.error('[ChatView] Failed to get schema for mention:', mention.name, e); - // Notify user about the error this._getTargetWebview()?.postMessage({ type: 'schemaError', object: `${mention.schema}.${mention.name}`, error: errorMsg }); - // Still add a note in context so AI knows there was an issue schemaContext += `\n### ${mention.type.toUpperCase()}: ${mention.schema}.${mention.name}\n`; schemaContext += `[Schema could not be retrieved: ${errorMsg}]\n`; } @@ -520,7 +538,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { schemaContext += '\n=== END DATABASE SCHEMA CONTEXT ===\n\n'; - // Prepend schema context to the message so AI sees it first aiMessage = schemaContext + fullMessage; console.log('[ChatView] AI message with schema context length:', aiMessage.length); console.log('[ChatView] ========== FULL AI MESSAGE =========='); @@ -528,23 +545,18 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { console.log('[ChatView] ========== END FULL AI MESSAGE =========='); } - // Add user message to history - this._messages.push({ role: 'user', content: fullMessage, attachments, mentions }); - this._updateChatHistory(); - - // Show typing indicator - this._setTypingIndicator(true); + return { fullMessage, aiMessage }; + } + private async _runAiRequest(aiMessage: string): Promise { try { const config = vscode.workspace.getConfiguration('postgresExplorer'); const provider = config.get('aiProvider') || 'vscode-lm'; const modelInfo = await this._aiService.getModelInfo(provider, config); console.log('[ChatView] Using AI provider:', provider, 'Model:', modelInfo); - // Update model info in UI this._updateModelInfo(); - // Show model info to user vscode.window.setStatusBarMessage(`$(sparkle) AI: ${modelInfo}`, 3000); this._aiService.setMessages(this._messages); @@ -573,8 +585,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { console.log('[ChatView] AI response received, length:', responseText.length); - // Sanitize response - remove any HTML-like patterns that shouldn't be there - // This prevents the model from learning bad patterns from previous responses responseText = this._sanitizeResponse(responseText); this._messages.push({ role: 'assistant', content: responseText, usage: usageInfo }); @@ -586,9 +596,112 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { role: 'assistant', content: `❌ Error: ${errorMessage}\n\nPlease check your AI provider settings in the extension configuration.` }); + } + } + + /** Replace the last assistant reply without appending a duplicate user turn. */ + private async _regenerateAssistantReply(): Promise { + if (this._isProcessing) { + return; + } + if (this._messages.length === 0) { + return; + } + + this._isProcessing = true; + try { + const last = this._messages[this._messages.length - 1]!; + if (last.role === 'assistant') { + this._messages.pop(); + } + + const user = this._messages[this._messages.length - 1]; + if (!user || user.role !== 'user') { + return; + } + + const plain = this._plainPromptFromUserMessage(user); + const { aiMessage } = await this._composeUserTurnPayload(plain, user.attachments, user.mentions); + + this._updateChatHistory(); + + this._setTypingIndicator(true); + try { + await this._runAiRequest(aiMessage); + } finally { + this._setTypingIndicator(false); + this._updateChatHistory(); + } + } finally { + this._isProcessing = false; + } + } + + /** Truncate at `userIndex` and re-run AI for that user message (drops later turns in-place). */ + private async _resendUserMessageAtIndex(userIndex: number): Promise { + if (this._isProcessing) { + return; + } + if (!Number.isFinite(userIndex) || userIndex < 0 || userIndex >= this._messages.length) { + return; + } + + const turn = this._messages[userIndex]; + if (!turn || turn.role !== 'user') { + return; + } + + this._isProcessing = true; + try { + this._messages = this._messages.slice(0, userIndex); + this._messages.push(turn); + + const plain = this._plainPromptFromUserMessage(turn); + const { aiMessage } = await this._composeUserTurnPayload(plain, turn.attachments, turn.mentions); + + this._updateChatHistory(); + + this._setTypingIndicator(true); + try { + await this._runAiRequest(aiMessage); + } finally { + this._setTypingIndicator(false); + this._updateChatHistory(); + } } finally { - this._setTypingIndicator(false); + this._isProcessing = false; + } + } + + private async _handleUserMessage(message: string, attachments?: FileAttachment[], mentions?: DbMention[]) { + if (this._isProcessing) { + return; + } + + this._isProcessing = true; + + console.log('[ChatView] ========== HANDLING USER MESSAGE =========='); + console.log('[ChatView] Message:', message); + console.log('[ChatView] Attachments:', attachments?.length || 0); + console.log('[ChatView] Mentions:', mentions?.length || 0); + if (mentions && mentions.length > 0) { + console.log('[ChatView] Mention details:', JSON.stringify(mentions, null, 2)); + } + + try { + const { fullMessage, aiMessage } = await this._composeUserTurnPayload(message, attachments, mentions); + + this._messages.push({ role: 'user', content: fullMessage, attachments, mentions }); this._updateChatHistory(); + + this._setTypingIndicator(true); + try { + await this._runAiRequest(aiMessage); + } finally { + this._setTypingIndicator(false); + this._updateChatHistory(); + } + } finally { this._isProcessing = false; } } diff --git a/src/providers/NotebookKernel.ts b/src/providers/NotebookKernel.ts index bb964ee..1f66f27 100644 --- a/src/providers/NotebookKernel.ts +++ b/src/providers/NotebookKernel.ts @@ -11,9 +11,19 @@ import { ExecuteUpdateBackgroundHandler, ScriptDeleteHandler, ExecuteUpdateHandler, CancelQueryHandler, DeleteRowsHandler, SaveChangesHandler } from '../services/handlers/QueryHandlers'; -import { ExportRequestHandler, ShowErrorMessageHandler, ImportRequestHandler, ImportPickFileHandler, OpenImportDataHandler } from '../services/handlers/CoreHandlers'; +import { + ExportRequestHandler, + ShowErrorMessageHandler, + ImportRequestHandler, + ImportPickFileHandler, + OpenImportDataHandler, + GridCommitPreferenceHandler, + RunDerivedQueryHandler, + NotebookOutputToolbarHandler, +} from '../services/handlers/CoreHandlers'; import { SendToChatHandler } from '../services/handlers/ExplainHandlers'; import { FkLookupHandler } from '../services/handlers/FkLookupHandler'; +import { CursorWindowHandler } from '../services/handlers/CursorWindowHandler'; import { InsertRowHandler } from '../services/handlers/InsertRowHandler'; export class PostgresKernel implements vscode.Disposable { @@ -81,8 +91,12 @@ export class PostgresKernel implements vscode.Disposable { registry.register('saveChanges', new SaveChangesHandler()); registry.register('showErrorMessage', new ShowErrorMessageHandler()); + registry.register('runDerivedQuery', new RunDerivedQueryHandler()); registry.register('fkLookup', new FkLookupHandler()); + registry.register('resultCursorFetch', new CursorWindowHandler()); registry.register('insertRow', new InsertRowHandler()); + registry.register('gridCommitPreference', new GridCommitPreferenceHandler(context)); + registry.register('notebookOutputToolbar', new NotebookOutputToolbarHandler()); (this._controller as any).onDidReceiveMessage(async (event: any) => { // console.log('[NotebookKernel] onDidReceiveMessage', event.message.type); diff --git a/src/providers/kernel/SqlExecutor.ts b/src/providers/kernel/SqlExecutor.ts index 4323083..6f57add 100644 --- a/src/providers/kernel/SqlExecutor.ts +++ b/src/providers/kernel/SqlExecutor.ts @@ -3,7 +3,13 @@ import * as vscode from 'vscode'; import { NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; import { ConnectionManager } from '../../services/ConnectionManager'; import { TelemetryService, SpanNames } from '../../services/TelemetryService'; -import { NoticeLogEntry, PostgresMetadata, QueryResults } from '../../common/types'; +import { + NoticeLogEntry, + PostgresMetadata, + QueryResults, + BYTEA_DISPLAY_DEFAULT, + ByteaDisplayFormat, +} from '../../common/types'; import { getPgDataTypeName } from '../../common/pgDataTypeNames'; import { SqlParser } from './SqlParser'; import { SecretStorageService } from '../../services/SecretStorageService'; @@ -15,6 +21,8 @@ import { QueryPerformanceService } from '../../services/QueryPerformanceService' import { extensionContext } from '../../extension'; import { QueryCodeLensProvider } from '../QueryCodeLensProvider'; import { updateNotebookTitle } from '../../utils/notebookTitle'; +import { ResultCursorService } from '../../services/ResultCursorService'; +import { CursorStreamBannerPolicy } from '../../services/CursorStreamBannerPolicy'; /** Streaming NOTICE feed during a single-statement cell run (replaced by final result output). */ const MIME_NOTICES_LIVE = 'application/vnd.postgres-notebook.notices-live'; @@ -251,6 +259,28 @@ export class SqlExecutor { return limitedQuery; } + /** + * Optional execution directives embedded as SQL comments at top-level. + * - pgstudio:full-dataset => disable streaming + disable auto-limit for this statement. + * - pgstudio:no-stream => disable streaming + disable auto-limit for this statement. + */ + private consumeExecutionDirectives(query: string): { + query: string; + disableStreaming: boolean; + disableAutoLimit: boolean; + } { + const hasFullDataset = /\bpgstudio:(?:full-dataset|no-stream)\b/i.test(query); + const stripped = query + .replace(/^\s*--\s*pgstudio:(?:full-dataset|no-stream)\s*$/gim, '') + .replace(/\/\*\s*pgstudio:(?:full-dataset|no-stream)\s*\*\//gim, '') + .trim(); + return { + query: stripped, + disableStreaming: hasFullDataset, + disableAutoLimit: hasFullDataset, + }; + } + public async executeCell(cell: vscode.NotebookCell) { console.log(`SqlExecutor: Starting cell execution. Controller ID: ${this._controller.id}`); const execution = this._controller.createNotebookCellExecution(cell); @@ -391,6 +421,7 @@ export class SqlExecutor { // Execute each statement for (let stmtIndex = 0; stmtIndex < statements.length; stmtIndex++) { + ResultCursorService.closeSessionsForCellUri(cell.document.uri.toString()); liveNoticesActive = false; let query = statements[stmtIndex]; const stmtStartTime = Date.now(); @@ -440,12 +471,48 @@ export class SqlExecutor { pgParamValues = vals; } - // Apply auto-LIMIT if applicable (pass notebook metadata and profile context for settings) + const directives = this.consumeExecutionDirectives(query); + query = directives.query; + if (!query.trim()) { + continue; + } + const originalQuery = query; - query = this.applyAutoLimit(query, connection, metadata, activeProfileContext); - const autoLimitApplied = query !== originalQuery; + let queryForExecution = query; + let autoLimitApplied = false; + let usedSlidingWindow = false; + let slidingPayload: QueryResults['slidingWindow']; + let openedSession: Awaited> = null; + + const windowSize = ResultCursorService.getWindowSizeCap(); + const trySliding = + !directives.disableStreaming && + ResultCursorService.isGloballyEnabled() && + pgParamValues === undefined && + ResultCursorService.isEligibleQuery(query); + + if (trySliding) { + const txInfo = getTransactionManager().getTransactionInfo(cell.notebook.uri.toString()); + openedSession = await ResultCursorService.tryOpenSession({ + client, + notebookUri: cell.notebook.uri.toString(), + cellUri: cell.document.uri.toString(), + sql: query, + inTransaction: !!txInfo?.isActive, + windowSize, + }); + if (openedSession) { + usedSlidingWindow = true; + slidingPayload = openedSession.payload; + } + } + + if (!usedSlidingWindow && !directives.disableAutoLimit) { + queryForExecution = this.applyAutoLimit(query, connection, metadata, activeProfileContext); + autoLimitApplied = queryForExecution !== originalQuery; + } - console.log(`SqlExecutor: Executing statement ${stmtIndex + 1}/${statements.length}:`, query.substring(0, 100)); + console.log(`SqlExecutor: Executing statement ${stmtIndex + 1}/${statements.length}:`, queryForExecution.substring(0, 100)); let result; try { @@ -455,9 +522,19 @@ export class SqlExecutor { statementCount: statements.length }); - result = - pgParamValues !== undefined ? await client.query(query, pgParamValues) : await client.query(query); - + if (usedSlidingWindow && openedSession) { + result = { + rows: openedSession.rows, + fields: openedSession.fields as any, + rowCount: null, + command: 'SELECT', + }; + } else { + result = + pgParamValues !== undefined + ? await client.query(queryForExecution, pgParamValues) + : await client.query(queryForExecution); + } const stmtEndTime = Date.now(); const executionTime = (stmtEndTime - stmtStartTime) / 1000; @@ -471,7 +548,7 @@ export class SqlExecutor { // Performance Tracking const queryAnalyzer = QueryAnalyzer.getInstance(); - const queryHash = queryAnalyzer.getQueryHash(query); + const queryHash = queryAnalyzer.getQueryHash(queryForExecution); const performanceService = QueryPerformanceService.getInstance(); // Record this execution @@ -526,10 +603,10 @@ export class SqlExecutor { console.log('[Performance] Analysis:', JSON.stringify(performanceAnalysis)); // Build output data - const tableInfo = await this.getTableInfo(client, result, query); + const tableInfo = await this.getTableInfo(client, result, queryForExecution); let autoLimitValue: number | undefined; if (autoLimitApplied) { - const lim = query.match(/\bLIMIT\s+(\d+)/i); + const lim = queryForExecution.match(/\bLIMIT\s+(\d+)/i); autoLimitValue = lim ? parseInt(lim[1], 10) : undefined; } @@ -550,6 +627,26 @@ export class SqlExecutor { } } + const rawByteaFmt = vscode.workspace + .getConfiguration('postgresExplorer') + .get('query.byteaDisplayFormat'); + const byteaDisplayFormat: ByteaDisplayFormat = + rawByteaFmt === 'hex0x' || rawByteaFmt === 'postgresql' || rawByteaFmt === 'json' + ? rawByteaFmt + : BYTEA_DISPLAY_DEFAULT; + + let showSlidingCursorBanner = false; + if (slidingPayload && extensionContext) { + const slidingCount = CursorStreamBannerPolicy.incrementSlidingExecCount( + extensionContext.workspaceState, + ); + showSlidingCursorBanner = CursorStreamBannerPolicy.shouldShowBanner( + extensionContext.globalState, + extensionContext.workspaceState, + slidingCount, + ); + } + const outputData: QueryResults = { success, rowCount: result.rowCount, @@ -557,7 +654,9 @@ export class SqlExecutor { columns, columnTypes, command: result.command, - query: query, + query: queryForExecution, + exportQuery: originalQuery, + byteaDisplayFormat, notices: [...notices], // Copy current notices executionTime, backendPid, @@ -567,16 +666,23 @@ export class SqlExecutor { slowQuery: isSlow, autoLimitApplied, autoLimitValue, + ...(slidingPayload + ? { + slidingWindow: slidingPayload, + ...(showSlidingCursorBanner ? { showSlidingCursorBanner: true } : {}), + } + : {}), breadcrumb: { connectionId: connection.id, connectionName: connection.name || connection.host, database: metadata.databaseName || connection.database, schema: tableInfo?.schema, object: tableInfo?.table ? { name: tableInfo.table, type: 'table' } : undefined - } + }, + sourceCellIndex: cell.index, }; - telemetry.endSpan(spanId, { success: 'true', rowCount: result.rowCount ?? 0 }); + telemetry.endSpan(spanId, { success: 'true', rowCount: result.rowCount ?? rows.length }); // Clear notices for next statement notices.length = 0; @@ -597,17 +703,17 @@ export class SqlExecutor { QueryCodeLensProvider.getInstance()?.updatePill(cell.document.uri.toString(), { success: true, elapsedSeconds: executionTime, - rowCount: result.rowCount ?? 0 + rowCount: result.rowCount ?? rows.length }); // Log to history QueryHistoryService.getInstance().add({ - query: query, + query: queryForExecution, success: true, duration: executionTime, durationMs, slow: isSlow, - rowCount: result.rowCount || 0, + rowCount: result.rowCount ?? rows.length, connectionName: connection.name }); @@ -641,7 +747,8 @@ export class SqlExecutor { slowQuery: isSlow, canExplain: true, errorCode: pgErrorCode, - errorExplanation: pgErrorCode ? getErrorExplanation(pgErrorCode) : undefined + errorExplanation: pgErrorCode ? getErrorExplanation(pgErrorCode) : undefined, + sourceCellIndex: cell.index, }; const errorOutput = new NotebookCellOutput([ diff --git a/src/renderer/components/ActionBar.ts b/src/renderer/components/ActionBar.ts index a8919ff..b2b7312 100644 --- a/src/renderer/components/ActionBar.ts +++ b/src/renderer/components/ActionBar.ts @@ -1,10 +1,15 @@ -import { createButton } from './ui'; - -/** - * ActionBar component for the Result Panel table data view. - * Renders a split bar with data actions on the left and AI actions on the right, - * separated by a visible vertical divider. - */ +import { prefersReducedMotion } from '../../ui/theme/motion'; +import { EXPORT_MENU_Z_INDEX, positionExportDropdown } from '../features/export'; +import { + RESULT_TOOLBAR_ICON_CLASS, + RESULT_TOOLBAR_SPARKLE_PX, + applyResultRowToolStyle, + attachResultRowToolInteractions, + fillRowToolButton, + fillToolbarButtonContent, + type ResultToolbarGlyph, + resultToolbarSvg, +} from './ResultToolbarUi'; export interface ActionBarOptions { onSelectAll: () => void; @@ -16,66 +21,271 @@ export interface ActionBarOptions { onOptimize: () => void; } +export interface AiMenuOptions { + onSendToChat: () => void; + onAnalyzeWithAI: () => void; + onOptimize: () => void; +} + +export interface ActionBarParts { + container: HTMLElement; + primaryTools: HTMLElement; + rightGroup: HTMLElement; +} + +const AI_MENU_STYLE_ID = 'pg-ai-menu-btn-styles'; + +const ROW_TOOL_COPY_FEEDBACK_MS = 2500; + +function ensureAiMenuButtonStyles(): void { + if (typeof document === 'undefined') return; + if (document.getElementById(AI_MENU_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = AI_MENU_STYLE_ID; + style.textContent = ` + @keyframes pg-ai-ring-shift { + 0%, 100% { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--vscode-terminal-ansiCyan) 42%, transparent), + 0 1px 0 color-mix(in srgb, var(--vscode-editor-background) 80%, transparent); + } + 33% { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--vscode-terminal-ansiYellow) 48%, transparent), + 0 2px 10px color-mix(in srgb, var(--vscode-terminal-ansiMagenta) 18%, transparent); + } + 66% { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--vscode-textLink-foreground) 38%, transparent), + 0 1px 0 color-mix(in srgb, var(--vscode-editor-background) 80%, transparent); + } + } + .pg-ai-menu-btn--animated button { + font-weight: 600; + letter-spacing: 0.03em; + background: transparent !important; + box-shadow: none !important; + } + .pg-ai-menu-btn--animated { + animation: pg-ai-ring-shift 14s ease-in-out infinite; + border-radius: 999px; + padding: 1px; + } + `; + document.head.appendChild(style); +} + +export type RowToolsOptions = Pick & { + /** True when every row in the current result is selected. */ + allRowsSelected?: boolean; +}; + +function flashCopyRowToolSuccess(btn: HTMLButtonElement): void { + type Btn = HTMLButtonElement & { _pgCopyFlashT?: ReturnType }; + const b = btn as Btn; + if (b._pgCopyFlashT) clearTimeout(b._pgCopyFlashT); + btn.dataset.pgRowToolFlash = 'copy'; + fillToolbarButtonContent(btn, 'copySuccess', 'Copied'); + btn.style.color = 'var(--vscode-testing-iconPassed, #3fb950)'; + btn.style.borderColor = + 'color-mix(in srgb, var(--vscode-testing-iconPassed, #3fb950) 45%, var(--vscode-widget-border))'; + b._pgCopyFlashT = setTimeout(() => { + delete btn.dataset.pgRowToolFlash; + b._pgCopyFlashT = undefined; + fillToolbarButtonContent(btn, 'copy', 'Copy'); + applyResultRowToolStyle(btn); + }, ROW_TOOL_COPY_FEEDBACK_MS); +} + +/** All / Copy / Import / Export β€” used in the results footer (left). */ +export function createRowTools(options: RowToolsOptions): HTMLElement { + const primaryTools = document.createElement('div'); + primaryTools.style.cssText = + 'display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;'; + + const selBtn = document.createElement('button'); + selBtn.type = 'button'; + const allOn = Boolean(options.allRowsSelected); + fillRowToolButton(selBtn, allOn ? 'checkboxChecked' : 'checkboxEmpty', 'All'); + selBtn.title = 'Select all rows or clear selection'; + selBtn.onclick = () => options.onSelectAll(); + primaryTools.appendChild(selBtn); + + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + fillRowToolButton(copyBtn, 'copy', 'Copy'); + copyBtn.title = 'Copy results as CSV'; + copyBtn.onclick = () => { + options.onCopy(); + flashCopyRowToolSuccess(copyBtn); + }; + primaryTools.appendChild(copyBtn); + + const importBtn = document.createElement('button'); + importBtn.type = 'button'; + fillRowToolButton(importBtn, 'import', 'Import'); + importBtn.title = 'Import into table'; + importBtn.onclick = () => options.onImport(); + primaryTools.appendChild(importBtn); + + const exportBtn = document.createElement('button'); + exportBtn.type = 'button'; + fillRowToolButton(exportBtn, 'export', 'Export'); + exportBtn.style.position = 'relative'; + exportBtn.onclick = () => options.onExport(exportBtn); + primaryTools.appendChild(exportBtn); + + return primaryTools; +} + +/** Prominent AI control with optional subtle gradient animation (respects reduced motion). */ +export function createAiMenuButton(options: AiMenuOptions): HTMLElement { + ensureAiMenuButtonStyles(); + + const wrap = document.createElement('span'); + wrap.style.cssText = 'display:inline-flex;align-items:center;position:relative;'; + if (!prefersReducedMotion()) { + wrap.classList.add('pg-ai-menu-btn--animated'); + } + + const aiBtn = document.createElement('button'); + aiBtn.type = 'button'; + fillToolbarButtonContent(aiBtn, 'sparkles', 'Ask AI'); + const aiIc = aiBtn.querySelector(`.${RESULT_TOOLBAR_ICON_CLASS}`); + if (aiIc) { + aiIc.innerHTML = resultToolbarSvg('sparkles', RESULT_TOOLBAR_SPARKLE_PX); + } + aiBtn.style.cssText = ` + display:inline-flex; + align-items:center; + gap:8px; + padding:6px 14px; + font-size:11px; + font-family:var(--vscode-font-family); + font-weight:600; + letter-spacing:0.03em; + cursor:pointer; + border-radius:999px; + border:none; + background:transparent; + color:var(--vscode-editor-foreground); + box-shadow:none; + position:relative; + `; + + let aiPopover: HTMLElement | null = null; + const closeAiPopover = () => { + aiPopover?.remove(); + aiPopover = null; + }; + + aiBtn.onclick = (e) => { + e.stopPropagation(); + if (aiPopover) { + closeAiPopover(); + return; + } + aiPopover = document.createElement('div'); + aiPopover.style.cssText = ` + position:fixed; + visibility:hidden; + background:var(--vscode-menu-background); + border:1px solid var(--vscode-menu-border); + box-shadow:0 4px 12px rgba(0,0,0,0.2); + z-index:${EXPORT_MENU_Z_INDEX}; + min-width:200px; + border-radius:6px; + padding:4px 0; + `; + + const addItem = (label: string, glyph: ResultToolbarGlyph, onClick: () => void) => { + const item = document.createElement('div'); + item.style.cssText = + 'display:flex;align-items:center;gap:10px;padding:8px 14px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;'; + const ic = document.createElement('span'); + ic.className = RESULT_TOOLBAR_ICON_CLASS; + ic.style.flexShrink = '0'; + ic.style.opacity = '0.92'; + ic.innerHTML = resultToolbarSvg(glyph, 15); + const tx = document.createElement('span'); + tx.textContent = label; + tx.style.flex = '1'; + item.appendChild(ic); + item.appendChild(tx); + item.onmouseenter = () => { + item.style.background = 'var(--vscode-menu-selectionBackground)'; + item.style.color = 'var(--vscode-menu-selectionForeground)'; + }; + item.onmouseleave = () => { + item.style.background = 'transparent'; + item.style.color = 'var(--vscode-menu-foreground)'; + }; + item.onclick = (ev) => { + ev.stopPropagation(); + onClick(); + closeAiPopover(); + }; + aiPopover!.appendChild(item); + }; + + addItem('Send to Chat', 'menuChat', options.onSendToChat); + addItem('Analyze data', 'menuChart', options.onAnalyzeWithAI); + addItem('Optimize query', 'menuBolt', options.onOptimize); + + document.body.appendChild(aiPopover); + positionExportDropdown(aiPopover, aiBtn, 'below'); + aiPopover.style.visibility = 'visible'; + + setTimeout(() => { + const outsideClick = (ev: MouseEvent) => { + const t = ev.target as Node; + if (!aiBtn.contains(t) && !aiPopover?.contains(t)) { + closeAiPopover(); + document.removeEventListener('click', outsideClick); + } + }; + document.addEventListener('click', outsideClick); + }, 0); + }; + + wrap.appendChild(aiBtn); + return wrap; +} + /** - * Creates an action bar element with data actions (left) and AI actions (right). - * Layout: [ Select All | Copy | Import | Export ] | [ Send to Chat | Analyze with AI | Optimize ] + * Row tools + AI only (tests / legacy). Table secondary band uses createAiMenuButton + footer row tools. */ -export function createActionBar(options: ActionBarOptions): HTMLElement { +export function createActionBar(options: ActionBarOptions): ActionBarParts { const container = document.createElement('div'); container.style.cssText = ` display: flex; align-items: center; - justify-content: space-between; - padding: 4px 8px; - gap: 8px; + gap: 6px; + padding: 4px 10px; + border-bottom: 1px solid var(--vscode-widget-border); font-family: var(--vscode-font-family); + min-height: 32px; + flex-wrap: wrap; `; - // Left group: data actions - const leftGroup = document.createElement('div'); - leftGroup.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - `; - leftGroup.appendChild(createButton('☐ Select All', true, 'neutral')); - leftGroup.lastElementChild?.addEventListener('click', options.onSelectAll); - leftGroup.appendChild(createButton('⎘ Copy', true, 'neutral')); - leftGroup.lastElementChild?.addEventListener('click', options.onCopy); - leftGroup.appendChild(createButton('⬆ Import', true, 'neutral')); - leftGroup.lastElementChild?.addEventListener('click', options.onImport); - - // Export button β€” passed to onExport so the dropdown can anchor to it - const exportBtn = createButton('↓ Export', true, 'neutral'); - exportBtn.style.position = 'relative'; - exportBtn.onclick = () => options.onExport(exportBtn); - leftGroup.appendChild(exportBtn); - - // Vertical divider - const divider = document.createElement('div'); - divider.style.cssText = ` - border-left: 1px solid var(--vscode-panel-border); - align-self: stretch; - margin: 2px 4px; - `; + const primaryTools = createRowTools(options); + + const spacer = document.createElement('div'); + spacer.style.cssText = 'flex:1;min-width:12px;'; - // Right group: AI actions const rightGroup = document.createElement('div'); - rightGroup.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - `; - rightGroup.appendChild(createButton('✦ Send to Chat', true, 'ai')); - rightGroup.lastElementChild?.addEventListener('click', options.onSendToChat); - rightGroup.appendChild(createButton('β—Ž Analyze with AI', true, 'ai')); - rightGroup.lastElementChild?.addEventListener('click', options.onAnalyzeWithAI); - rightGroup.appendChild(createButton('⚑ Optimize', true, 'ai')); - rightGroup.lastElementChild?.addEventListener('click', options.onOptimize); - - container.appendChild(leftGroup); - container.appendChild(divider); + rightGroup.style.cssText = + 'display:flex;align-items:center;gap:6px;flex-shrink:0;flex-wrap:wrap;'; + + rightGroup.appendChild(createAiMenuButton(options)); + + container.appendChild(primaryTools); + container.appendChild(spacer); container.appendChild(rightGroup); - return container; + return { + container, + primaryTools, + rightGroup, + }; } diff --git a/src/renderer/components/CommitConfirmDialog.ts b/src/renderer/components/CommitConfirmDialog.ts new file mode 100644 index 0000000..c99e2df --- /dev/null +++ b/src/renderer/components/CommitConfirmDialog.ts @@ -0,0 +1,146 @@ +/** + * Modal confirming permanent grid edits before posting saveChanges to the extension host. + */ + +export interface CommitConfirmDialogOptions { + /** Primary label for confirm (includes count if desired). */ + confirmLabel?: string; + /** Called when user confirms; argument is whether "don't ask again" was checked. */ + onConfirm: (dontAskAgain: boolean) => void; + onCancel: () => void; +} + +const OVERLAY_Z_INDEX = 5000; + +/** Full-viewport overlay + card. Appended to document.body so it stacks above table chrome. */ +export function openCommitConfirmDialog(options: CommitConfirmDialogOptions): () => void { + const confirmLabel = options.confirmLabel ?? 'Commit'; + + const overlay = document.createElement('div'); + overlay.setAttribute('role', 'presentation'); + overlay.style.cssText = ` + position:fixed; + inset:0; + z-index:${OVERLAY_Z_INDEX}; + background:rgba(0,0,0,0.42); + display:flex; + align-items:center; + justify-content:center; + padding:16px; + box-sizing:border-box; + font-family:var(--vscode-font-family),system-ui,sans-serif; + `; + + const card = document.createElement('div'); + card.setAttribute('role', 'dialog'); + card.setAttribute('aria-modal', 'true'); + card.setAttribute('aria-labelledby', 'pg-commit-confirm-title'); + card.style.cssText = ` + width:min(380px, 100%); + border-radius:6px; + border:1px solid var(--vscode-widget-border); + background:var(--vscode-editor-background); + color:var(--vscode-editor-foreground); + box-shadow:0 12px 40px rgba(0,0,0,0.35); + padding:16px 18px; + display:flex; + flex-direction:column; + gap:12px; + `; + + const title = document.createElement('div'); + title.id = 'pg-commit-confirm-title'; + title.textContent = 'Commit changes to the database?'; + title.style.cssText = 'font-size:14px;font-weight:700;line-height:1.35;'; + + const body = document.createElement('div'); + body.style.cssText = + 'font-size:12px;line-height:1.5;color:var(--vscode-descriptionForeground);'; + body.textContent = + 'This will apply your edits and deletions directly to the table. This action is permanent and cannot be undone from this notebook.'; + + const cbRow = document.createElement('label'); + cbRow.style.cssText = + 'display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;user-select:none;margin-top:2px;'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.style.cursor = 'pointer'; + + const cbText = document.createElement('span'); + cbText.textContent = "Don't ask again"; + + cbRow.appendChild(checkbox); + cbRow.appendChild(cbText); + + const actions = document.createElement('div'); + actions.style.cssText = + 'display:flex;justify-content:flex-end;gap:8px;margin-top:6px;flex-wrap:wrap;'; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.style.cssText = ` + padding:6px 14px;font-size:12px;font-family:inherit;border-radius:4px; + cursor:pointer;background:var(--vscode-button-secondaryBackground); + color:var(--vscode-button-secondaryForeground); + border:1px solid var(--vscode-button-border,var(--vscode-widget-border)); + `; + + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.textContent = confirmLabel; + confirmBtn.style.cssText = ` + padding:6px 14px;font-size:12px;font-family:inherit;border-radius:4px;font-weight:600; + cursor:pointer; + background:color-mix(in srgb,var(--vscode-terminal-ansiYellow) 18%,var(--vscode-button-background)); + color:var(--vscode-button-foreground); + border:1px solid color-mix(in srgb,var(--vscode-terminal-ansiYellow) 42%,var(--vscode-panel-border)); + `; + + const tearDown = () => { + overlay.remove(); + document.removeEventListener('keydown', onKey); + }; + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + tearDown(); + options.onCancel(); + } + }; + + cancelBtn.onclick = () => { + tearDown(); + options.onCancel(); + }; + + confirmBtn.onclick = () => { + const dontAsk = checkbox.checked; + tearDown(); + options.onConfirm(dontAsk); + }; + + overlay.onclick = (e) => { + if (e.target === overlay) { + tearDown(); + options.onCancel(); + } + }; + + actions.appendChild(cancelBtn); + actions.appendChild(confirmBtn); + + card.appendChild(title); + card.appendChild(body); + card.appendChild(cbRow); + card.appendChild(actions); + overlay.appendChild(card); + document.body.appendChild(overlay); + + document.addEventListener('keydown', onKey); + setTimeout(() => confirmBtn.focus(), 0); + + return tearDown; +} diff --git a/src/renderer/components/InlineBanner.ts b/src/renderer/components/InlineBanner.ts new file mode 100644 index 0000000..cb6ab2d --- /dev/null +++ b/src/renderer/components/InlineBanner.ts @@ -0,0 +1,126 @@ +/** + * InlineBanner.ts + * Dismissible inline banner. Severity controls color. + */ + +export type BannerSeverity = 'warning' | 'info' | 'error'; + +export interface InlineBannerOptions { + severity: BannerSeverity; + message: string; + actionLabel?: string; + onAction?: () => void; + dismissible?: boolean; + /** Called when the user clicks the dismiss (Γ—) control. */ + onDismiss?: () => void; + /** Optional mute control (e.g. hide this hint forever). Rendered before dismiss. */ + onMuteForever?: () => void; +} + +const SEVERITY_STYLES: Record< + BannerSeverity, + { bg: string; border: string; icon: string; fg: string } +> = { + warning: { + bg: 'color-mix(in srgb, #f59e0b 12%, transparent)', + border: 'color-mix(in srgb, #f59e0b 35%, transparent)', + fg: 'var(--vscode-editorWarning-foreground)', + icon: '⚠', + }, + info: { + bg: 'color-mix(in srgb, #3b82f6 10%, transparent)', + border: 'color-mix(in srgb, #3b82f6 30%, transparent)', + fg: 'var(--vscode-textLink-foreground)', + icon: 'β“˜', + }, + error: { + bg: 'color-mix(in srgb, #ef4444 10%, transparent)', + border: 'color-mix(in srgb, #ef4444 30%, transparent)', + fg: 'var(--vscode-errorForeground)', + icon: 'βœ•', + }, +}; + +export function createInlineBanner(options: InlineBannerOptions): HTMLElement { + const { severity, message, actionLabel, onAction, dismissible = true, onDismiss, onMuteForever } = + options; + const s = SEVERITY_STYLES[severity]; + + const banner = document.createElement('div'); + banner.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + background: ${s.bg}; + border-bottom: 1px solid ${s.border}; + font-family: var(--vscode-font-family); + font-size: 11px; + color: var(--vscode-editor-foreground); + `; + + const icon = document.createElement('span'); + icon.textContent = s.icon; + icon.style.cssText = `color: ${s.fg}; font-size: 13px; flex-shrink: 0;`; + banner.appendChild(icon); + + const text = document.createElement('span'); + text.textContent = message; + text.style.flex = '1'; + banner.appendChild(text); + + if (actionLabel && onAction) { + const actionBtn = document.createElement('button'); + actionBtn.type = 'button'; + actionBtn.textContent = actionLabel; + actionBtn.style.cssText = ` + background: none; + border: 1px solid ${s.border}; + color: ${s.fg}; + border-radius: 3px; + padding: 1px 8px; + cursor: pointer; + font-size: 11px; + font-family: var(--vscode-font-family); + flex-shrink: 0; + `; + actionBtn.onclick = onAction; + banner.appendChild(actionBtn); + } + + const btnStyle = ` + background: none; border: none; cursor: pointer; + color: var(--vscode-descriptionForeground); + font-size: 15px; line-height: 1; + padding: 0 4px; + flex-shrink: 0; + `; + + if (onMuteForever) { + const muteBtn = document.createElement('button'); + muteBtn.type = 'button'; + muteBtn.textContent = 'πŸ”•'; + muteBtn.title = 'Mute forever β€” never show this hint again'; + muteBtn.style.cssText = btnStyle; + muteBtn.onclick = () => { + onMuteForever(); + banner.remove(); + }; + banner.appendChild(muteBtn); + } + + if (dismissible) { + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.textContent = 'Γ—'; + closeBtn.title = 'Dismiss for now (hint may return after a week or 100 streaming queries)'; + closeBtn.style.cssText = btnStyle; + closeBtn.onclick = () => { + onDismiss?.(); + banner.remove(); + }; + banner.appendChild(closeBtn); + } + + return banner; +} diff --git a/src/renderer/components/ResultFooter.ts b/src/renderer/components/ResultFooter.ts new file mode 100644 index 0000000..3e5a4d4 --- /dev/null +++ b/src/renderer/components/ResultFooter.ts @@ -0,0 +1,194 @@ +/** + * ResultFooter β€” optional row tools (far left); Add Row / Delete / Commit / Revert (right). + * Row count and execution time live in ResultIdentityBar. + */ + +import type { RowToolsOptions } from './ActionBar'; +import { createRowTools } from './ActionBar'; + +export interface ResultFooterOptions { + onAddRow?: () => void; + dirtyCount: number; + onCommit?: () => void; + /** Rows currently selected in the grid (table view); shows Delete Row (n) when > 0. */ + deleteSelectionCount?: number; + /** Mark selected rows for deletion (staged until Commit). */ + onDeleteSelected?: () => void; + /** Warning when PK is missing β€” applied to Delete button title + muted style. */ + deleteUnavailableReason?: string; + /** Discard pending edits / staged deletes (shown after Commit when dirtyCount > 0). */ + onRevert?: () => void; + /** All / Copy / Import / Export anchored in the footer (left, before stats). */ + rowTools?: RowToolsOptions; +} + +function formatExecutionTime(seconds: number): string { + const ms = Math.round(seconds * 1000); + return ms >= 1000 ? `${seconds.toFixed(2)}s` : `${ms}ms`; +} + +/** Stats line shown in the identity bar (footer no longer repeats row/time). */ +export function formatResultExecutionStats(totalRows: number, executionTimeSeconds?: number): string { + let statsText = `${totalRows.toLocaleString()} row${totalRows !== 1 ? 's' : ''}`; + if (executionTimeSeconds !== undefined) { + statsText += ` Β· ${formatExecutionTime(executionTimeSeconds)}`; + } + return statsText; +} + +export function createResultFooter(options: ResultFooterOptions): HTMLElement { + const { + onAddRow, + dirtyCount, + onCommit, + deleteSelectionCount = 0, + onDeleteSelected, + deleteUnavailableReason, + onRevert, + rowTools, + } = options; + + const footer = document.createElement('div'); + footer.dataset.resultFooter = 'true'; + footer.style.cssText = ` + display: flex; + align-items: center; + gap: 10px; + padding: 4px 12px; + border-top: 1px solid var(--vscode-widget-border); + background: var(--vscode-editor-background); + font-family: var(--vscode-font-family); + font-size: 11px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + `; + + if (rowTools) { + footer.appendChild(createRowTools(rowTools)); + } + + const spacer = document.createElement('div'); + spacer.style.flex = '1'; + footer.appendChild(spacer); + + if (onAddRow) { + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.textContent = '+ Add Row'; + addBtn.style.cssText = ` + padding: 2px 10px; + font-size: 11px; + font-family: var(--vscode-font-family); + background: none; + color: var(--vscode-button-secondaryForeground); + border: 1px solid var(--vscode-button-border, var(--vscode-widget-border)); + border-radius: 3px; + cursor: pointer; + white-space: nowrap; + transition: background 0.1s; + `; + addBtn.onmouseover = () => { + addBtn.style.background = 'var(--vscode-button-secondaryHoverBackground)'; + }; + addBtn.onmouseout = () => { + addBtn.style.background = 'none'; + }; + addBtn.onclick = onAddRow; + footer.appendChild(addBtn); + } + + /** Lighter tomato β€” stage rows for deletion */ + const TOMATO = '#ff8066'; + + if (deleteSelectionCount > 0 && onDeleteSelected) { + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.setAttribute('data-pg-result-delete', 'true'); + deleteBtn.textContent = `Delete Row (${deleteSelectionCount})`; + deleteBtn.style.cssText = ` + padding: 2px 10px; + font-size: 11px; + font-family: var(--vscode-font-family); + background: color-mix(in srgb, ${TOMATO} 11%, transparent); + color: ${TOMATO}; + border: 1px solid color-mix(in srgb, ${TOMATO} 32%, transparent); + border-radius: 3px; + cursor: pointer; + font-weight: 600; + white-space: nowrap; + transition: background 0.1s; + `; + if (deleteUnavailableReason) { + deleteBtn.style.opacity = '0.72'; + deleteBtn.title = deleteUnavailableReason; + } else { + deleteBtn.title = 'Stage selected rows for deletion (commit with Commit)'; + } + deleteBtn.onmouseover = () => { + deleteBtn.style.background = `color-mix(in srgb, ${TOMATO} 18%, transparent)`; + }; + deleteBtn.onmouseout = () => { + deleteBtn.style.background = `color-mix(in srgb, ${TOMATO} 11%, transparent)`; + }; + deleteBtn.onclick = onDeleteSelected; + footer.appendChild(deleteBtn); + } + + if (dirtyCount > 0 && onCommit) { + const commitBtn = document.createElement('button'); + commitBtn.type = 'button'; + commitBtn.setAttribute('data-pg-result-commit', 'true'); + commitBtn.textContent = `Commit (${dirtyCount})`; + commitBtn.style.cssText = ` + padding: 2px 10px; + font-size: 11px; + font-family: var(--vscode-font-family); + background: color-mix(in srgb, #f59e0b 15%, transparent); + color: #f59e0b; + border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent); + border-radius: 3px; + cursor: pointer; + font-weight: 600; + white-space: nowrap; + transition: background 0.1s; + `; + commitBtn.onmouseover = () => { + commitBtn.style.background = 'color-mix(in srgb, #f59e0b 25%, transparent)'; + }; + commitBtn.onmouseout = () => { + commitBtn.style.background = 'color-mix(in srgb, #f59e0b 15%, transparent)'; + }; + commitBtn.onclick = onCommit; + footer.appendChild(commitBtn); + } + + if (dirtyCount > 0 && onRevert) { + const revertBtn = document.createElement('button'); + revertBtn.type = 'button'; + revertBtn.textContent = 'Revert'; + revertBtn.title = 'Discard all unstaged cell edits and staged row deletions'; + revertBtn.style.cssText = ` + padding: 2px 10px; + font-size: 11px; + font-family: var(--vscode-font-family); + background: color-mix(in srgb, #22c55e 14%, transparent); + color: #22c55e; + border: 1px solid color-mix(in srgb, #22c55e 38%, transparent); + border-radius: 3px; + cursor: pointer; + font-weight: 600; + white-space: nowrap; + transition: background 0.1s; + `; + revertBtn.onmouseover = () => { + revertBtn.style.background = 'color-mix(in srgb, #22c55e 22%, transparent)'; + }; + revertBtn.onmouseout = () => { + revertBtn.style.background = 'color-mix(in srgb, #22c55e 14%, transparent)'; + }; + revertBtn.onclick = onRevert; + footer.appendChild(revertBtn); + } + + return footer; +} diff --git a/src/renderer/components/ResultIdentityBar.ts b/src/renderer/components/ResultIdentityBar.ts new file mode 100644 index 0000000..8e04d74 --- /dev/null +++ b/src/renderer/components/ResultIdentityBar.ts @@ -0,0 +1,407 @@ +/** + * ResultIdentityBar β€” statement badge + SQL preview (left), execution stats + actions (right). + */ + +export interface ResultIdentityBarOptions { + /** First line of SQL (truncated); shown after the command badge. */ + queryPreview: string; + /** Full SQL for tooltip (optional). */ + queryFull?: string; + /** Kernel command keyword (SELECT, INSERT, …) for badge tint. */ + command: string | undefined; + /** Bold stats at the right end (e.g. "50 rows Β· 30ms"). */ + statsLine?: string; + isCollapsed: boolean; + onToggleCollapse: () => void; + onOverflow: (anchorEl: HTMLElement) => void; + onExpand?: () => void; +} + +/** Pill tint per SQL leading keyword β€” semantic groups (read / write / DDL / session / admin). */ +const COMMAND_COLORS: Record = { + // Read & introspection + SELECT: { + bg: 'color-mix(in srgb, #3b82f6 14%, transparent)', + fg: '#3b82f6', + border: 'color-mix(in srgb, #3b82f6 35%, transparent)', + }, + WITH: { + bg: 'color-mix(in srgb, #2563eb 14%, transparent)', + fg: '#2563eb', + border: 'color-mix(in srgb, #2563eb 34%, transparent)', + }, + SHOW: { + bg: 'color-mix(in srgb, #64748b 14%, transparent)', + fg: '#64748b', + border: 'color-mix(in srgb, #64748b 34%, transparent)', + }, + DESCRIBE: { + bg: 'color-mix(in srgb, #475569 14%, transparent)', + fg: '#475569', + border: 'color-mix(in srgb, #475569 34%, transparent)', + }, + + // Data writes + INSERT: { + bg: 'color-mix(in srgb, #22c55e 14%, transparent)', + fg: '#22c55e', + border: 'color-mix(in srgb, #22c55e 35%, transparent)', + }, + UPDATE: { + bg: 'color-mix(in srgb, #f59e0b 14%, transparent)', + fg: '#f59e0b', + border: 'color-mix(in srgb, #f59e0b 35%, transparent)', + }, + DELETE: { + bg: 'color-mix(in srgb, #ef4444 14%, transparent)', + fg: '#ef4444', + border: 'color-mix(in srgb, #ef4444 35%, transparent)', + }, + MERGE: { + bg: 'color-mix(in srgb, #0891b2 14%, transparent)', + fg: '#0891b2', + border: 'color-mix(in srgb, #0891b2 34%, transparent)', + }, + COPY: { + bg: 'color-mix(in srgb, #047857 14%, transparent)', + fg: '#047857', + border: 'color-mix(in srgb, #047857 34%, transparent)', + }, + + // DDL β€” schema objects + CREATE: { + bg: 'color-mix(in srgb, #0d9488 14%, transparent)', + fg: '#0d9488', + border: 'color-mix(in srgb, #0d9488 34%, transparent)', + }, + ALTER: { + bg: 'color-mix(in srgb, #6366f1 14%, transparent)', + fg: '#6366f1', + border: 'color-mix(in srgb, #6366f1 34%, transparent)', + }, + DROP: { + bg: 'color-mix(in srgb, #be123c 14%, transparent)', + fg: '#be123c', + border: 'color-mix(in srgb, #be123c 34%, transparent)', + }, + TRUNCATE: { + bg: 'color-mix(in srgb, #ea580c 14%, transparent)', + fg: '#ea580c', + border: 'color-mix(in srgb, #ea580c 34%, transparent)', + }, + RENAME: { + bg: 'color-mix(in srgb, #7c3aed 14%, transparent)', + fg: '#7c3aed', + border: 'color-mix(in srgb, #7c3aed 34%, transparent)', + }, + COMMENT: { + bg: 'color-mix(in srgb, #52525b 14%, transparent)', + fg: '#52525b', + border: 'color-mix(in srgb, #52525b 32%, transparent)', + }, + CLUSTER: { + bg: 'color-mix(in srgb, #b45309 14%, transparent)', + fg: '#b45309', + border: 'color-mix(in srgb, #b45309 34%, transparent)', + }, + REFRESH: { + bg: 'color-mix(in srgb, #14b8a6 14%, transparent)', + fg: '#14b8a6', + border: 'color-mix(in srgb, #14b8a6 34%, transparent)', + }, + + // Plans & explain + EXPLAIN: { + bg: 'color-mix(in srgb, #8b5cf6 14%, transparent)', + fg: '#8b5cf6', + border: 'color-mix(in srgb, #8b5cf6 35%, transparent)', + }, + + // Transactions + BEGIN: { + bg: 'color-mix(in srgb, #78716c 14%, transparent)', + fg: '#78716c', + border: 'color-mix(in srgb, #78716c 34%, transparent)', + }, + START: { + bg: 'color-mix(in srgb, #78716c 14%, transparent)', + fg: '#78716c', + border: 'color-mix(in srgb, #78716c 34%, transparent)', + }, + COMMIT: { + bg: 'color-mix(in srgb, #15803d 14%, transparent)', + fg: '#15803d', + border: 'color-mix(in srgb, #15803d 34%, transparent)', + }, + ROLLBACK: { + bg: 'color-mix(in srgb, #991b1b 14%, transparent)', + fg: '#991b1b', + border: 'color-mix(in srgb, #991b1b 34%, transparent)', + }, + SAVEPOINT: { + bg: 'color-mix(in srgb, #ca8a04 14%, transparent)', + fg: '#ca8a04', + border: 'color-mix(in srgb, #ca8a04 34%, transparent)', + }, + RELEASE: { + bg: 'color-mix(in srgb, #a16207 14%, transparent)', + fg: '#a16207', + border: 'color-mix(in srgb, #a16207 34%, transparent)', + }, + + // Permissions + GRANT: { + bg: 'color-mix(in srgb, #059669 14%, transparent)', + fg: '#059669', + border: 'color-mix(in srgb, #059669 34%, transparent)', + }, + REVOKE: { + bg: 'color-mix(in srgb, #d97706 14%, transparent)', + fg: '#d97706', + border: 'color-mix(in srgb, #d97706 34%, transparent)', + }, + + // Session / parameters + SET: { + bg: 'color-mix(in srgb, #57534e 14%, transparent)', + fg: '#57534e', + border: 'color-mix(in srgb, #57534e 32%, transparent)', + }, + RESET: { + bg: 'color-mix(in srgb, #57534e 14%, transparent)', + fg: '#57534e', + border: 'color-mix(in srgb, #57534e 32%, transparent)', + }, + + // Prepared statements + PREPARE: { + bg: 'color-mix(in srgb, #9333ea 14%, transparent)', + fg: '#9333ea', + border: 'color-mix(in srgb, #9333ea 34%, transparent)', + }, + EXECUTE: { + bg: 'color-mix(in srgb, #7e22ce 14%, transparent)', + fg: '#7e22ce', + border: 'color-mix(in srgb, #7e22ce 34%, transparent)', + }, + DEALLOCATE: { + bg: 'color-mix(in srgb, #a855f7 14%, transparent)', + fg: '#a855f7', + border: 'color-mix(in srgb, #a855f7 34%, transparent)', + }, + + // Maintenance & stats + VACUUM: { + bg: 'color-mix(in srgb, #0369a1 14%, transparent)', + fg: '#0369a1', + border: 'color-mix(in srgb, #0369a1 34%, transparent)', + }, + ANALYZE: { + bg: 'color-mix(in srgb, #0284c7 14%, transparent)', + fg: '#0284c7', + border: 'color-mix(in srgb, #0284c7 34%, transparent)', + }, + REINDEX: { + bg: 'color-mix(in srgb, #0e7490 14%, transparent)', + fg: '#0e7490', + border: 'color-mix(in srgb, #0e7490 34%, transparent)', + }, + + // Async & scripting + LISTEN: { + bg: 'color-mix(in srgb, #db2777 14%, transparent)', + fg: '#db2777', + border: 'color-mix(in srgb, #db2777 34%, transparent)', + }, + NOTIFY: { + bg: 'color-mix(in srgb, #c026d3 14%, transparent)', + fg: '#c026d3', + border: 'color-mix(in srgb, #c026d3 34%, transparent)', + }, + UNLISTEN: { + bg: 'color-mix(in srgb, #be185d 14%, transparent)', + fg: '#be185d', + border: 'color-mix(in srgb, #be185d 34%, transparent)', + }, + DO: { + bg: 'color-mix(in srgb, #a855f7 14%, transparent)', + fg: '#a855f7', + border: 'color-mix(in srgb, #a855f7 34%, transparent)', + }, + + DEFAULT: { + bg: 'color-mix(in srgb, var(--vscode-descriptionForeground) 12%, transparent)', + fg: 'var(--vscode-descriptionForeground)', + border: 'var(--vscode-widget-border)', + }, +}; + +function commandColors(cmd: string | undefined) { + if (!cmd) return COMMAND_COLORS.DEFAULT; + const upper = cmd.toUpperCase(); + const key = upper.split(/\s+/)[0] ?? ''; + return COMMAND_COLORS[key] ?? COMMAND_COLORS.DEFAULT; +} + +export function commandKeywordLabel(command: string | undefined): string { + if (!command?.trim()) return 'QUERY'; + return command.trim().split(/\s+/)[0]!.toUpperCase(); +} + +/** Right-side control cluster β€” tight padding to the edge. */ +const ACTION_BTN_CSS = ` + background: none; border: none; cursor: pointer; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + border-radius: 3px; + transition: background 0.1s; + padding: 2px 3px; + line-height: 1; +`; + +export function createResultIdentityBar(options: ResultIdentityBarOptions): HTMLElement { + const { + queryPreview, + queryFull, + command, + statsLine, + onToggleCollapse, + onOverflow, + onExpand, + } = options; + const kw = commandKeywordLabel(command); + const colors = commandColors(kw); + + const bar = document.createElement('div'); + bar.style.cssText = ` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 6px 5px 10px; + border-bottom: 1px solid var(--vscode-widget-border); + user-select: none; + background: color-mix(in srgb, var(--vscode-editor-background) 82%, var(--vscode-sideBar-background)); + font-family: var(--vscode-font-family); + font-size: 12px; + `; + + const leftCluster = document.createElement('div'); + leftCluster.style.cssText = + 'display:flex;align-items:center;gap:7px;min-width:0;flex:1;cursor:pointer;'; + leftCluster.onclick = () => onToggleCollapse(); + + const badge = document.createElement('span'); + badge.textContent = `● ${kw}`; + badge.title = kw; + badge.style.cssText = ` + flex-shrink: 0; + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + font-family: var(--vscode-font-family), system-ui, sans-serif; + background: ${colors.bg}; + color: ${colors.fg}; + border: 1px solid ${colors.border}; + `; + + const sep = document.createElement('span'); + sep.textContent = 'Β·'; + sep.style.cssText = 'flex-shrink:0;opacity:0.45;font-weight:600;'; + + const preview = document.createElement('span'); + preview.textContent = queryPreview; + preview.title = queryFull?.trim() || queryPreview; + preview.style.cssText = ` + flex: 1; + min-width: 0; + font-family: var(--vscode-editor-font-family), monospace; + font-size: 11px; + color: var(--vscode-editor-foreground); + opacity: 0.92; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + + leftCluster.appendChild(badge); + leftCluster.appendChild(sep); + leftCluster.appendChild(preview); + + const rightCluster = document.createElement('div'); + rightCluster.style.cssText = + 'display:flex;align-items:center;flex-shrink:0;gap:3px;margin-left:auto;'; + + const statsEl = document.createElement('span'); + statsEl.dataset.resultStats = 'true'; + statsEl.textContent = statsLine ?? ''; + statsEl.style.cssText = ` + display: ${statsLine?.trim() ? 'inline-block' : 'none'}; + font-size: 12px; + font-weight: 650; + letter-spacing: 0.03em; + color: var(--vscode-editor-foreground); + opacity: 0.95; + padding: 0 6px 0 4px; + white-space: nowrap; + cursor: default; + `; + statsEl.onclick = (e) => e.stopPropagation(); + + const chevron = document.createElement('span'); + chevron.dataset.chevron = 'true'; + chevron.textContent = options.isCollapsed ? 'β–Ά' : 'β–Ό'; + chevron.title = options.isCollapsed ? 'Expand result' : 'Collapse result'; + chevron.style.cssText = + 'font-size:10px;opacity:0.72;flex-shrink:0;padding:2px 2px;cursor:pointer;'; + chevron.onclick = (e) => { + e.stopPropagation(); + onToggleCollapse(); + }; + + const overflowBtn = document.createElement('button'); + overflowBtn.type = 'button'; + overflowBtn.textContent = 'β‹―'; + overflowBtn.title = 'More: transpose, notices, explain, navigation'; + overflowBtn.style.cssText = `${ACTION_BTN_CSS} font-size: 15px;`; + overflowBtn.onmouseenter = () => { + overflowBtn.style.background = 'var(--vscode-list-hoverBackground)'; + }; + overflowBtn.onmouseleave = () => { + overflowBtn.style.background = 'none'; + }; + overflowBtn.onclick = (e) => { + e.stopPropagation(); + onOverflow(overflowBtn); + }; + + rightCluster.appendChild(statsEl); + rightCluster.appendChild(chevron); + rightCluster.appendChild(overflowBtn); + + if (onExpand) { + const expandBtn = document.createElement('button'); + expandBtn.type = 'button'; + expandBtn.textContent = 'β€’'; + expandBtn.title = 'Focus query cell'; + expandBtn.style.cssText = `${ACTION_BTN_CSS} font-size: 13px;`; + expandBtn.onmouseenter = () => { + expandBtn.style.background = 'var(--vscode-list-hoverBackground)'; + }; + expandBtn.onmouseleave = () => { + expandBtn.style.background = 'none'; + }; + expandBtn.onclick = (e) => { + e.stopPropagation(); + onExpand(); + }; + rightCluster.appendChild(expandBtn); + } + + bar.appendChild(leftCluster); + bar.appendChild(rightCluster); + + return bar; +} diff --git a/src/renderer/components/ResultToolbarUi.ts b/src/renderer/components/ResultToolbarUi.ts new file mode 100644 index 0000000..7bd935e --- /dev/null +++ b/src/renderer/components/ResultToolbarUi.ts @@ -0,0 +1,325 @@ +/** + * Shared SVG icons + ghost toolbar styles for notebook result chrome (tabs, row tools, filter). + * Icons: 24Γ—24 viewBox, stroke currentColor β€” scale via width/height on . + */ + +export const RESULT_TOOLBAR_ICON_PX = 14; +export const RESULT_TOOLBAR_SPARKLE_PX = 18; + +/** Inline SVG snippets (paths only inside root svg wrapper). */ +function wrapSvg(children: string, size = RESULT_TOOLBAR_ICON_PX): string { + return ``; +} + +export type ResultToolbarGlyph = + | 'table' + | 'chart' + | 'analyst' + | 'notices' + | 'transpose' + | 'review' + | 'explain' + | 'selectAll' + | 'checkboxEmpty' + | 'checkboxChecked' + | 'copy' + | 'copySuccess' + | 'import' + | 'export' + | 'menuChat' + | 'menuChart' + | 'menuBolt' + | 'plus' + | 'sparkles' + | 'chevronDown' + | 'close' + | 'previewEye' + | 'save' + | 'expandCell'; + +export function resultToolbarSvg( + glyph: ResultToolbarGlyph, + size: number = RESULT_TOOLBAR_ICON_PX, +): string { + const chev = Math.min(12, size); + switch (glyph) { + case 'table': + return wrapSvg( + '', + size, + ); + case 'chart': + return wrapSvg('', size); + case 'analyst': + return wrapSvg( + '', + size, + ); + case 'notices': + return wrapSvg('', size); + case 'transpose': + return wrapSvg( + '', + size, + ); + case 'review': + return wrapSvg( + '', + size, + ); + case 'explain': + return wrapSvg( + '', + size, + ); + case 'selectAll': + return wrapSvg('', size); + case 'checkboxEmpty': + return wrapSvg('', size); + case 'checkboxChecked': + return wrapSvg( + '', + size, + ); + case 'copy': + return wrapSvg( + '', + size, + ); + case 'copySuccess': + return wrapSvg( + '', + size, + ); + case 'import': + // Upload: arrow up into tray + return wrapSvg( + '', + size, + ); + case 'export': + // Download: arrow down to tray + return wrapSvg( + '', + size, + ); + case 'menuChat': + return wrapSvg( + '', + size, + ); + case 'menuChart': + return wrapSvg('', size); + case 'menuBolt': + return wrapSvg('', size); + case 'plus': + return wrapSvg('', size); + case 'sparkles': + return wrapSvg( + '', + size, + ); + case 'chevronDown': + return wrapSvg('', chev); + case 'close': + return wrapSvg('', Math.min(13, size)); + case 'previewEye': + return wrapSvg( + '', + Math.min(13, size), + ); + case 'save': + return wrapSvg( + '', + size, + ); + case 'expandCell': + return wrapSvg( + '', + size, + ); + default: + return wrapSvg('', size); + } +} + +export const RESULT_TOOLBAR_ICON_CLASS = 'pg-result-tb__ic'; +export const RESULT_TOOLBAR_LABEL_CLASS = 'pg-result-tb__tx'; + +export function fillToolbarButtonContent( + btn: HTMLButtonElement, + glyph: ResultToolbarGlyph, + label: string, +): void { + btn.innerHTML = ''; + const ic = document.createElement('span'); + ic.className = RESULT_TOOLBAR_ICON_CLASS; + ic.innerHTML = resultToolbarSvg(glyph); + const tx = document.createElement('span'); + tx.className = RESULT_TOOLBAR_LABEL_CLASS; + tx.textContent = label; + btn.appendChild(ic); + btn.appendChild(tx); +} + +/** Ghost tab / tool β€” no heavy grey fill when inactive. */ +export function applyResultViewTabStyle(btn: HTMLButtonElement, active: boolean): void { + btn.dataset.pgTabActive = active ? '1' : '0'; + const fg = active + ? 'var(--vscode-list-activeSelectionForeground)' + : 'var(--vscode-descriptionForeground)'; + const bg = active + ? 'color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 88%, transparent)' + : 'transparent'; + const border = active + ? 'color-mix(in srgb, var(--vscode-focusBorder) 45%, var(--vscode-widget-border))' + : 'color-mix(in srgb, var(--vscode-widget-border) 55%, transparent)'; + + btn.style.cssText = ` + display:inline-flex; + align-items:center; + gap:6px; + padding:5px 10px; + font-size:11px; + font-family:var(--vscode-font-family); + font-weight:500; + letter-spacing:0.02em; + line-height:1; + cursor:pointer; + border-radius:6px; + border:1px solid ${border}; + background:${bg}; + color:${fg}; + transition:background 0.14s ease,border-color 0.14s ease,color 0.14s ease,box-shadow 0.14s ease; + box-shadow:none; + `; + btn.style.setProperty('-webkit-font-smoothing', 'antialiased'); +} + +export function attachResultViewTabHover(btn: HTMLButtonElement): void { + const refresh = () => { + applyResultViewTabStyle(btn, btn.dataset.pgTabActive === '1'); + }; + btn.addEventListener('mouseenter', () => { + if (btn.dataset.pgTabActive !== '1') { + btn.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; + } + }); + btn.addEventListener('mouseleave', refresh); + btn.addEventListener('blur', refresh); +} + +/** Row tools + compact actions β€” same visual language (layout only; pair with `attachResultRowToolInteractions`). */ +export function applyResultRowToolStyle(btn: HTMLButtonElement): void { + btn.style.cssText = ` + display:inline-flex; + align-items:center; + gap:8px; + padding:7px 14px; + font-size:11px; + font-family:var(--vscode-font-family); + font-weight:500; + letter-spacing:0.02em; + cursor:pointer; + border-radius:6px; + border:1px solid color-mix(in srgb, var(--vscode-widget-border) 55%, transparent); + background:transparent; + color:var(--vscode-descriptionForeground); + transition:background 0.14s ease,border-color 0.14s ease,color 0.14s ease; + `; +} + +/** Style only (no innerHTML); use after `fillToolbarButtonContent`. */ +export function attachResultRowToolInteractions(btn: HTMLButtonElement): void { + btn.addEventListener('mouseenter', () => { + if (btn.dataset.pgRowToolFlash === 'copy') return; + btn.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 50%, transparent)'; + btn.style.borderColor = + 'color-mix(in srgb, var(--vscode-focusBorder) 28%, var(--vscode-widget-border))'; + btn.style.color = 'var(--vscode-editor-foreground)'; + }); + btn.addEventListener('mouseleave', () => { + if (btn.dataset.pgRowToolFlash === 'copy') return; + btn.style.background = 'transparent'; + btn.style.borderColor = 'color-mix(in srgb, var(--vscode-widget-border) 55%, transparent)'; + btn.style.color = 'var(--vscode-descriptionForeground)'; + }); +} + +export function fillRowToolButton(btn: HTMLButtonElement, glyph: ResultToolbarGlyph, label: string): void { + fillToolbarButtonContent(btn, glyph, label); + applyResultRowToolStyle(btn); + attachResultRowToolInteractions(btn); +} + +/** Compact chip for output hover toolbar (fades in on result card hover). */ +export function fillOutputHoverToolButton(btn: HTMLButtonElement, glyph: ResultToolbarGlyph, label: string): void { + fillToolbarButtonContent(btn, glyph, label); + btn.style.cssText = ` + display:inline-flex; + align-items:center; + gap:5px; + padding:4px 10px; + font-size:10px; + font-family:var(--vscode-font-family); + font-weight:500; + letter-spacing:0.02em; + cursor:pointer; + white-space:nowrap; + border-radius:6px; + border:1px solid color-mix(in srgb, var(--vscode-widget-border) 50%, transparent); + background:color-mix(in srgb, var(--vscode-editor-background) 88%, transparent); + color:var(--vscode-descriptionForeground); + transition:background 0.14s ease,border-color 0.14s ease,color 0.14s ease; + box-shadow:0 1px 2px rgba(0,0,0,0.06); + `; + btn.addEventListener('mouseenter', () => { + if ((btn as HTMLButtonElement).disabled) return; + btn.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 48%, transparent)'; + btn.style.borderColor = + 'color-mix(in srgb, var(--vscode-focusBorder) 26%, var(--vscode-widget-border))'; + btn.style.color = 'var(--vscode-editor-foreground)'; + }); + btn.addEventListener('mouseleave', () => { + if ((btn as HTMLButtonElement).disabled) return; + btn.style.background = 'color-mix(in srgb, var(--vscode-editor-background) 88%, transparent)'; + btn.style.borderColor = 'color-mix(in srgb, var(--vscode-widget-border) 50%, transparent)'; + btn.style.color = 'var(--vscode-descriptionForeground)'; + }); +} + +/** Filter bar β€œAdd filter” β€” matches toolbar ghost style. */ +export function applyAddFilterButtonStyle(btn: HTMLButtonElement): void { + fillToolbarButtonContent(btn, 'plus', 'Add filter'); + btn.style.cssText = ` + display:inline-flex; + align-items:center; + gap:6px; + padding:5px 12px; + font-size:12px; + font-family:var(--vscode-font-family); + font-weight:500; + cursor:pointer; + white-space:nowrap; + border-radius:6px; + border:1px solid color-mix(in srgb, var(--vscode-widget-border) 55%, transparent); + background:transparent; + color:var(--vscode-descriptionForeground); + transition:background 0.14s ease,border-color 0.14s ease,color 0.14s ease; + `; + btn.addEventListener('mouseenter', () => { + btn.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 50%, transparent)'; + btn.style.borderColor = + 'color-mix(in srgb, var(--vscode-focusBorder) 28%, var(--vscode-widget-border))'; + btn.style.color = 'var(--vscode-editor-foreground)'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.background = 'transparent'; + btn.style.borderColor = 'color-mix(in srgb, var(--vscode-widget-border) 55%, transparent)'; + btn.style.color = 'var(--vscode-descriptionForeground)'; + }); +} diff --git a/src/renderer/components/TransposeView.ts b/src/renderer/components/TransposeView.ts index 75a9920..d2bac8e 100644 --- a/src/renderer/components/TransposeView.ts +++ b/src/renderer/components/TransposeView.ts @@ -4,6 +4,10 @@ * Limited to MAX_ROWS_TO_TRANSPOSE rows to avoid UI overflow. */ +import type { ByteaDisplayFormat } from '../../common/types'; +import { BYTEA_DISPLAY_DEFAULT } from '../../common/types'; +import { formatValue } from '../utils/formatting'; + export const MAX_ROWS_TO_TRANSPOSE = 100; export interface TransposeResult { @@ -43,7 +47,8 @@ export function transposeResult( export function renderTransposeTable( columns: string[], rows: any[], - formatValue: (v: any) => string + columnTypes: Record | undefined, + byteaDisplayFormat: ByteaDisplayFormat = BYTEA_DISPLAY_DEFAULT, ): HTMLElement { const result = transposeResult(columns, rows); @@ -118,9 +123,15 @@ export function renderTransposeTable( overflow:hidden;text-overflow:ellipsis;white-space:nowrap; color:${v === null || v === undefined ? 'var(--vscode-descriptionForeground)' : 'var(--vscode-editor-foreground)'}; `; - td.textContent = v === null || v === undefined ? 'NULL' : formatValue(v); + const pgCol = row['Column'] as string; + const colType = columnTypes?.[pgCol]; + const display = + v === null || v === undefined + ? 'NULL' + : formatValue(v, colType, { byteaDisplayFormat }).text; + td.textContent = display; if (v !== null && v !== undefined) { - td.title = String(v); + td.title = display; } } tr.appendChild(td); diff --git a/src/renderer/components/ViewSelector.ts b/src/renderer/components/ViewSelector.ts new file mode 100644 index 0000000..3b496bb --- /dev/null +++ b/src/renderer/components/ViewSelector.ts @@ -0,0 +1,168 @@ +/** + * ViewSelector.ts + * Dropdown for Table / Chart / Analyst primary views. + */ + +export type PrimaryView = 'table' | 'chart' | 'analyst'; + +export interface ViewSelectorOption { + id: PrimaryView; + label: string; + icon: string; +} + +export interface ViewSelectorOptions { + current: PrimaryView; + available?: ViewSelectorOption[]; + onChange: (view: PrimaryView) => void; +} + +export interface ViewSelectorHandle { + element: HTMLElement; + setCurrentView: (view: PrimaryView) => void; +} + +const DEFAULT_VIEWS: ViewSelectorOption[] = [ + { id: 'table', label: 'Table', icon: '⊞' }, + { id: 'chart', label: 'Chart', icon: 'β—Ž' }, + { id: 'analyst', label: 'Analyst', icon: 'βŠ›' }, +]; + +export function createViewSelector(options: ViewSelectorOptions): ViewSelectorHandle { + const { current, available = DEFAULT_VIEWS, onChange } = options; + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position:relative;flex-shrink:0;'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.style.cssText = ` + display: flex; align-items: center; gap: 5px; + padding: 3px 8px; + font-size: 11px; + font-family: var(--vscode-font-family); + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 3px; + cursor: pointer; + white-space: nowrap; + transition: background 0.1s; + `; + + let currentId: PrimaryView = current; + + const renderBtnLabel = (id: PrimaryView) => { + currentId = id; + const opt = available.find((v) => v.id === id) ?? available[0]; + btn.innerHTML = ''; + const iconSpan = document.createElement('span'); + iconSpan.textContent = opt.icon; + const labelSpan = document.createElement('span'); + labelSpan.textContent = opt.label; + const chevronSpan = document.createElement('span'); + chevronSpan.textContent = 'β–Ύ'; + chevronSpan.style.cssText = 'font-size:9px;opacity:0.6;'; + btn.appendChild(iconSpan); + btn.appendChild(labelSpan); + btn.appendChild(chevronSpan); + }; + + renderBtnLabel(current); + + btn.onmouseover = () => { + btn.style.background = 'var(--vscode-button-secondaryHoverBackground)'; + }; + btn.onmouseout = () => { + btn.style.background = 'var(--vscode-button-secondaryBackground)'; + }; + + let popover: HTMLElement | null = null; + + const closePopover = () => { + popover?.remove(); + popover = null; + document.removeEventListener('click', outsideClick); + }; + + const outsideClick = (e: MouseEvent) => { + if (!wrapper.contains(e.target as Node)) closePopover(); + }; + + btn.onclick = (e) => { + e.stopPropagation(); + if (popover) { + closePopover(); + return; + } + + popover = document.createElement('div'); + popover.style.cssText = ` + position: absolute; + top: 100%; + left: 0; + margin-top: 2px; + min-width: 130px; + background: var(--vscode-menu-background); + border: 1px solid var(--vscode-menu-border); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + z-index: 500; + padding: 3px 0; + `; + + available.forEach((opt) => { + const item = document.createElement('div'); + item.style.cssText = ` + display: flex; align-items: center; gap: 8px; + padding: 5px 12px; + cursor: pointer; + font-size: 12px; + font-family: var(--vscode-font-family); + color: var(--vscode-menu-foreground); + ${opt.id === currentId ? 'font-weight:600;' : ''} + `; + const iconSpan = document.createElement('span'); + iconSpan.textContent = opt.icon; + const labelSpan = document.createElement('span'); + labelSpan.textContent = opt.label; + item.appendChild(iconSpan); + item.appendChild(labelSpan); + if (opt.id === currentId) { + const check = document.createElement('span'); + check.textContent = 'βœ“'; + check.style.cssText = 'margin-left:auto;font-size:11px;opacity:0.7;'; + item.appendChild(check); + } + item.onmouseenter = () => { + item.style.background = 'var(--vscode-menu-selectionBackground)'; + item.style.color = 'var(--vscode-menu-selectionForeground)'; + }; + item.onmouseleave = () => { + item.style.background = 'transparent'; + item.style.color = 'var(--vscode-menu-foreground)'; + }; + item.onclick = (ev) => { + ev.stopPropagation(); + renderBtnLabel(opt.id); + onChange(opt.id); + closePopover(); + }; + popover!.appendChild(item); + }); + + wrapper.appendChild(popover); + setTimeout(() => document.addEventListener('click', outsideClick), 0); + }; + + wrapper.appendChild(btn); + + return { + element: wrapper, + setCurrentView: (view: PrimaryView) => { + if (available.some((o) => o.id === view)) { + renderBtnLabel(view); + } + }, + }; +} diff --git a/src/renderer/components/analyst/AnalystPanel.ts b/src/renderer/components/analyst/AnalystPanel.ts index 22f8967..46dad2f 100644 --- a/src/renderer/components/analyst/AnalystPanel.ts +++ b/src/renderer/components/analyst/AnalystPanel.ts @@ -7,16 +7,33 @@ import { buildHistogram } from '../../../features/analyst/histogram'; import { computeColumnStats } from '../../../features/analyst/columnAggregates'; import { DISTINCT_COUNT_CAP } from '../../../features/analyst/constants'; import { computePivot, type PivotAgg } from '../../../features/analyst/pivot'; +import { MAX_PIVOT_DISTINCT } from '../../../features/analyst/constants'; import { ChartRenderer } from '../chart/ChartRenderer'; import { detectNumericColumns } from '../chart/ChartControls'; const HIST_COL_BUCKET = '__pg_hist_bucket'; const HIST_COL_COUNT = '__pg_hist_count'; +/** Context sent when asking AI to help reduce pivot cardinality (server-side pre-aggregation). */ +export interface PivotAiHelpContext { + rowDimension: string; + columnDimension: string; + valueColumn: string | null; + aggregation: PivotAgg; + errorMessage: string; + isStreamingWindow: boolean; + inMemoryRowCount: number; + maxDistinctPerAxis: number; +} + export interface AnalystPanelProps { columns: string[]; rows: Record[]; columnTypes?: Record; + isStreaming?: boolean; + onRunFullDataset?: () => void; + /** Opens SQL Assistant with query, sample results, and pivot fields prefilled. */ + onAskAiForPivotHelp?: (ctx: PivotAiHelpContext) => void; } function makeSectionTitle(text: string): HTMLElement { @@ -77,7 +94,14 @@ function formatStatCell(v: number | undefined): string { } export function renderAnalystPanel(props: AnalystPanelProps): HTMLElement { - const { columns, rows, columnTypes } = props; + const { + columns, + rows, + columnTypes, + isStreaming = false, + onRunFullDataset, + onAskAiForPivotHelp, + } = props; const wrapper = document.createElement('div'); wrapper.style.cssText = 'flex:1;overflow:auto;display:flex;flex-direction:column;padding:8px 12px;gap:4px;max-height:70vh;'; @@ -250,6 +274,67 @@ export function renderAnalystPanel(props: AnalystPanelProps): HTMLElement { err.style.cssText = 'font-size:12px;color:var(--vscode-errorForeground);'; err.textContent = result.error; pivotOut.appendChild(err); + + const isCardinalityLimitError = result.error.includes('too many distinct values'); + const showFullDatasetBtn = isStreaming && isCardinalityLimitError && !!onRunFullDataset; + const showAiBtn = isCardinalityLimitError && !!onAskAiForPivotHelp; + + if (showFullDatasetBtn || showAiBtn) { + const hint = document.createElement('div'); + hint.style.cssText = + 'display:flex;flex-direction:column;gap:8px;padding:8px 10px;border:1px solid color-mix(in srgb, #f59e0b 45%, transparent);background:color-mix(in srgb, #f59e0b 12%, transparent);border-radius:4px;margin-top:8px;'; + + const msg = document.createElement('div'); + msg.style.cssText = 'font-size:11px;color:var(--vscode-descriptionForeground);line-height:1.4;'; + if (showFullDatasetBtn && !showAiBtn) { + msg.textContent = + 'Pivot cardinality is capped on streamed rows. Run on full dataset for accurate pivot (may impact local machine performance).'; + } else if (showFullDatasetBtn && showAiBtn) { + msg.textContent = `The in-browser pivot allows at most ${MAX_PIVOT_DISTINCT} distinct values per row/column axis. For streamed results, load the full dataset and/or ask AI for a pre-aggregated query.`; + } else { + msg.textContent = `The in-browser pivot allows at most ${MAX_PIVOT_DISTINCT} distinct values per row/column axis. Ask AI to rewrite the SQL with GROUP BY, bucketing, or rollups so the pivot stays tractable.`; + } + hint.appendChild(msg); + + const btnRow = document.createElement('div'); + btnRow.style.cssText = + 'display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-start;'; + + if (showAiBtn) { + const aiBtn = document.createElement('button'); + aiBtn.type = 'button'; + aiBtn.textContent = 'Ask AI to optimize query'; + aiBtn.title = + 'Open SQL Assistant with this query, sample rows, and your pivot fields'; + aiBtn.style.cssText = + 'padding:3px 10px;font-size:11px;cursor:pointer;background:var(--vscode-button-background);color:var(--vscode-button-foreground);border:1px solid var(--vscode-contrastBorder, transparent);border-radius:3px;'; + aiBtn.onclick = () => + onAskAiForPivotHelp!({ + rowDimension: rowDim, + columnDimension: colDim, + valueColumn: valRaw || null, + aggregation: agg, + errorMessage: result.error, + isStreamingWindow: isStreaming, + inMemoryRowCount: rows.length, + maxDistinctPerAxis: MAX_PIVOT_DISTINCT, + }); + btnRow.appendChild(aiBtn); + } + + if (showFullDatasetBtn) { + const fullBtn = document.createElement('button'); + fullBtn.type = 'button'; + fullBtn.textContent = 'Run pivot on full dataset'; + fullBtn.style.cssText = + 'padding:3px 10px;font-size:11px;cursor:pointer;background:var(--vscode-button-secondaryBackground, var(--vscode-button-background));color:var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));border:1px solid var(--vscode-contrastBorder, transparent);border-radius:3px;'; + fullBtn.onclick = () => onRunFullDataset!(); + btnRow.appendChild(fullBtn); + } + + hint.appendChild(btnRow); + pivotOut.appendChild(hint); + } return; } diff --git a/src/renderer/components/table/CellEditors.ts b/src/renderer/components/table/CellEditors.ts index 57acdd6..9584378 100644 --- a/src/renderer/components/table/CellEditors.ts +++ b/src/renderer/components/table/CellEditors.ts @@ -17,74 +17,57 @@ export interface CellEditorOptions { modalMount?: HTMLElement; /** The table cell element β€” used to locate the output container for inline editor injection. */ anchorEl?: HTMLElement; + /** Mount bottom-docked panels after this node (e.g. table scroll area). */ + dockParent?: HTMLElement; + dockAfter?: HTMLElement; + /** Highlight this row while a bottom-docked editor is open. */ + editingRowEl?: HTMLTableRowElement | null; } -export type EditorType = 'text' | 'number' | 'boolean' | 'date' | 'time' | 'datetime' | 'json' | 'array' | 'fk' | 'longtext'; - -/** - * Types where a one-line input is a poor fit; use the same anchored modal as long-text / JSON (without JSON validation). - * Names are lowercase PostgreSQL typnames / common aliases (see pg_catalog / pg-types builtins). - */ -const PG_TYPES_WITH_MODAL_TEXT_EDITOR = new Set([ - 'text', - 'varchar', - 'character varying', - 'bpchar', - 'char', - 'character', - 'name', - 'xml', - 'interval', - 'point', - 'line', - 'lseg', - 'box', - 'path', - 'polygon', - 'circle', - 'bit', - 'varbit', - 'bit varying', - 'tsvector', - 'tsquery', - 'inet', - 'cidr', - 'macaddr', - 'macaddr8', - 'geometry', - 'geography', - 'bytea', - 'uuid', - 'money', - 'int4range', - 'int8range', - 'numrange', - 'daterange', - 'tsrange', - 'tstzrange', - 'int4multirange', - 'int8multirange', - 'nummultirange', - 'datemultirange', - 'tsmultirange', - 'tstzmultirange', -]); - -function pgTypeUsesModalTextEditor(columnType: string): boolean { +export type EditorType = + | 'number' + | 'boolean' + | 'date' + | 'time' + | 'datetime' + | 'json' + | 'array' + | 'fk' + | 'longtext'; + +/** First token of a pg typname, before `(…)`, for matching `numeric(12,4)` etc. */ +function pgTypeHead(columnType: string): string { const t = columnType.trim().toLowerCase(); - if (!t) { - return false; + if (t.startsWith('double precision')) { + return 'double precision'; } - if (t.startsWith('oid:')) { - return true; - } - if (PG_TYPES_WITH_MODAL_TEXT_EDITOR.has(t)) { - return true; - } - if (/^(varchar|bpchar|char|bit|varbit|numeric|decimal)\s*\(/.test(t)) { + const paren = t.indexOf('('); + const base = (paren >= 0 ? t.slice(0, paren) : t).trim(); + return base.split(/\s+/)[0] ?? base; +} + +function isPgNumericFamily(columnType: string): boolean { + const t = columnType.trim().toLowerCase(); + if (t.startsWith('double precision')) { return true; } - return false; + const head = pgTypeHead(columnType); + return [ + 'int2', + 'int4', + 'int8', + 'float4', + 'float8', + 'numeric', + 'decimal', + 'smallint', + 'integer', + 'bigint', + 'real', + 'serial', + 'bigserial', + 'smallserial', + ].includes(head); } /** String for inline inputs β€” never use String(object) (yields "[object Object]"). */ @@ -133,37 +116,44 @@ export function getEditorType(columnType: string, currentValue: any): EditorType if (type.startsWith('_') || type === 'array') { return 'array'; } // Boolean - if (type === 'bool' || type === 'boolean') { return 'boolean'; } + if (type === 'bool' || type === 'boolean') { + return 'boolean'; + } - // Numeric (money uses modal text β€” locale/formatting is easier in the expanded editor) - if (['int2', 'int4', 'int8', 'float4', 'float8', 'numeric', 'decimal', - 'smallint', 'integer', 'bigint', 'real', 'double precision'].includes(type)) { + // Numeric scalars β€” single-line editor, row height unchanged + if (isPgNumericFamily(columnType)) { return 'number'; } - // Date/time - if (type === 'date') { return 'date'; } - if (type === 'time' || type === 'timetz' || - type === 'time without time zone' || type === 'time with time zone') { + // Date/time β€” native inputs, compact row + if (type === 'date') { + return 'date'; + } + if ( + type === 'time' || + type === 'timetz' || + type === 'time without time zone' || + type === 'time with time zone' + ) { return 'time'; } - if (type === 'timestamp' || type === 'timestamptz' || - type === 'timestamp without time zone' || type === 'timestamp with time zone') { + if ( + type === 'timestamp' || + type === 'timestamptz' || + type === 'timestamp without time zone' || + type === 'timestamp with time zone' + ) { return 'datetime'; } // JSON - if (type === 'json' || type === 'jsonb') { return 'json'; } - - // XML, interval, geometry, full-width text types, etc. β€” anchored modal (same UX as long text) - if (pgTypeUsesModalTextEditor(type)) { - return 'longtext'; + if (type === 'json' || type === 'jsonb') { + return 'json'; } - // Long text detection (unknown OID label or legacy "string" + very long value) - if (typeof currentValue === 'string' && currentValue.length > 200) { return 'longtext'; } - - return 'text'; + // varchar, text, bytea, uuid, strings, enums, OID labels, ranges, geometry, … + // β†’ bottom-docked editor so notebook iframe rows are not stretched + return 'longtext'; } /** @@ -188,7 +178,7 @@ export function createCellEditor(options: CellEditorOptions): HTMLElement { case 'json': return createJsonEditor(options); case 'array': return createArrayEditor(options); case 'longtext': return createLongTextEditor(options); - default: return createTextEditor(options); + default: return createLongTextEditor(options); } } @@ -267,25 +257,7 @@ function createNumberEditor(opts: CellEditorOptions): HTMLElement { return input; } -// ─── Text ──────────────────────────────────────────────────────────── - -function createTextEditor(opts: CellEditorOptions): HTMLElement { - const input = document.createElement('input'); - input.type = 'text'; - input.value = - opts.currentValue !== null && opts.currentValue !== undefined - ? cellValueToEditString(opts.currentValue) - : ''; - applyEditorBaseStyle(input); - - const save = () => opts.onSave(input.value === '' && opts.isNullable ? null : input.value); - input.addEventListener('keydown', (e) => handleKeydown(e, save, opts.onCancel)); - input.addEventListener('blur', save); - setTimeout(() => { input.focus(); input.select(); }, 0); - return input; -} - -// ─── Long Text (modal overlay) ─────────────────────────────────────── +// ─── Long Text (bottom-docked panel) ───────────────────────────────── function modalPlainEditorTitle(opts: CellEditorOptions): string { const t = (opts.columnType || '').trim(); @@ -305,6 +277,10 @@ function createLongTextEditor(opts: CellEditorOptions): HTMLElement { onCancel: opts.onCancel, modalMount: opts.modalMount, anchorEl: opts.anchorEl, + dockParent: opts.dockParent, + dockAfter: opts.dockAfter, + editingRowEl: opts.editingRowEl, + columnName: opts.columnName, }); } @@ -426,6 +402,10 @@ function createJsonEditor(opts: CellEditorOptions): HTMLElement { onCancel: opts.onCancel, modalMount: opts.modalMount, anchorEl: opts.anchorEl, + dockParent: opts.dockParent, + dockAfter: opts.dockAfter, + editingRowEl: opts.editingRowEl, + columnName: opts.columnName, }); } @@ -461,13 +441,11 @@ function createArrayEditor(opts: CellEditorOptions): HTMLElement { const wrapper = document.createElement('div'); wrapper.style.cssText = ` background: var(--vscode-input-background); - border: 1px solid var(--vscode-focusBorder); + border: 1px solid var(--vscode-widget-border); border-radius: 3px; padding: 8px; - min-width: 200px; - max-width: 400px; - position: relative; - z-index: 1000; + width: 100%; + box-sizing: border-box; `; const items = parseArrayLiteral(opts.currentValue); @@ -477,7 +455,8 @@ function createArrayEditor(opts: CellEditorOptions): HTMLElement { header.innerHTML = `Array items (${opts.columnType})`; const itemsContainer = document.createElement('div'); - itemsContainer.style.cssText = 'display:flex;flex-direction:column;gap:4px;max-height:200px;overflow-y:auto;'; + itemsContainer.style.cssText = + 'display:flex;flex-direction:column;gap:4px;max-height:280px;overflow-y:auto;'; const renderItems = () => { itemsContainer.innerHTML = ''; @@ -497,7 +476,12 @@ function createArrayEditor(opts: CellEditorOptions): HTMLElement { input.addEventListener('input', () => { items[idx] = input.value; }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { items.splice(idx + 1, 0, ''); renderItems(); } - if (e.key === 'Escape') { opts.onCancel(); } + if (e.key === 'Escape') { + e.preventDefault(); + applyDockedAnchorCellStyle(opts.anchorEl, false); + applyEditingRowHighlight(opts.editingRowEl ?? null, false); + opts.onCancel(); + } }); const removeBtn = document.createElement('button'); @@ -538,7 +522,11 @@ function createArrayEditor(opts: CellEditorOptions): HTMLElement { background:var(--vscode-button-background);color:var(--vscode-button-foreground); border:none;border-radius:2px;padding:2px 10px;cursor:pointer;font-size:11px; `; - saveBtn.addEventListener('click', () => opts.onSave(toArrayLiteral(items))); + saveBtn.addEventListener('click', () => { + applyDockedAnchorCellStyle(opts.anchorEl, false); + applyEditingRowHighlight(opts.editingRowEl ?? null, false); + opts.onSave(toArrayLiteral(items)); + }); const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel'; @@ -547,7 +535,11 @@ function createArrayEditor(opts: CellEditorOptions): HTMLElement { color:var(--vscode-descriptionForeground);border-radius:2px; padding:2px 8px;cursor:pointer;font-size:11px; `; - cancelBtn.addEventListener('click', opts.onCancel); + cancelBtn.addEventListener('click', () => { + applyDockedAnchorCellStyle(opts.anchorEl, false); + applyEditingRowHighlight(opts.editingRowEl ?? null, false); + opts.onCancel(); + }); btnGroup.appendChild(saveBtn); btnGroup.appendChild(cancelBtn); @@ -557,7 +549,53 @@ function createArrayEditor(opts: CellEditorOptions): HTMLElement { wrapper.appendChild(header); wrapper.appendChild(itemsContainer); wrapper.appendChild(footer); - return wrapper; + + const outer = document.createElement('div'); + outer.setAttribute('data-inline-editor', 'true'); + outer.style.cssText = ` + position:relative; + width:100%; + flex-shrink:0; + box-sizing:border-box; + padding:12px; + margin:6px 0 0; + background:var(--vscode-editor-background); + border:2px solid var(--vscode-focusBorder); + border-radius:4px; + box-shadow:0 2px 12px rgba(0,0,0,0.28); + display:flex; + flex-direction:column; + gap:8px; + `; + + const titleBar = document.createElement('div'); + titleBar.style.cssText = + 'font-size:13px;font-weight:600;color:var(--vscode-editor-foreground);flex-shrink:0;'; + titleBar.textContent = `${opts.columnName} (${opts.columnType})`; + + outer.appendChild(titleBar); + outer.appendChild(wrapper); + + applyEditingRowHighlight(opts.editingRowEl ?? null, true); + applyDockedAnchorCellStyle(opts.anchorEl, true); + insertDockedPanel( + { + title: `${opts.columnName} array`, + initialContent: '', + isCode: false, + validate: () => null, + onSave: () => {}, + onCancel: () => {}, + anchorEl: opts.anchorEl, + dockParent: opts.dockParent, + dockAfter: opts.dockAfter, + editingRowEl: opts.editingRowEl ?? null, + }, + outer, + ); + outer.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + + return createDockedCellIndicator(opts.columnName, `${opts.columnName} (${opts.columnType})`); } // ─── FK Dropdown ────────────────────────────────────────────────────── @@ -654,12 +692,84 @@ function createFkEditor(opts: CellEditorOptions): HTMLElement { return wrapper; } -// ─── Inline Expanding Editor (replaces clipped fixed-position modal) ── +// ─── Bottom-docked multi-line editors (notebook output = sandboxed iframe) ── // -// Notebook output runs inside an iframe whose ancestors apply overflow:hidden. -// No fixed/absolute overlay can escape this clipping. Instead we inject an -// inline panel into the output's own DOM flow β€” it pushes the table down, -// stays fully visible, and scrolls into view automatically. +// `position:fixed` and high z-index cannot overlap adjacent notebook cells. +// Editors are injected in-flow below the table scroll area (sandboxed iframe). + +/** Matches footer Commit accent β€” marks the grid cell tied to the bottom docked editor */ +const PG_EDIT_AMBER = '#f59e0b'; + +function applyDockedAnchorCellStyle(anchorEl: HTMLElement | undefined, active: boolean) { + const td = anchorEl; + if (!td || td.tagName.toLowerCase() !== 'td') { + return; + } + if (active) { + td.style.boxSizing = 'border-box'; + td.style.border = `1.5px solid ${PG_EDIT_AMBER}`; + td.style.background = `color-mix(in srgb, ${PG_EDIT_AMBER} 14%, transparent)`; + td.style.color = PG_EDIT_AMBER; + td.style.verticalAlign = 'middle'; + } else { + td.style.border = ''; + td.style.background = ''; + td.style.color = ''; + td.style.verticalAlign = ''; + td.style.boxSizing = ''; + } +} + +function createDockedCellIndicator(columnLabel: string, title: string): HTMLElement { + const el = document.createElement('div'); + el.style.cssText = ` + padding:6px 8px; + font-size:11px; + font-weight:600; + color:${PG_EDIT_AMBER}; + border:1.5px solid ${PG_EDIT_AMBER}; + border-radius:4px; + background:color-mix(in srgb, ${PG_EDIT_AMBER} 14%, transparent); + white-space:nowrap; + overflow:hidden; + text-overflow:ellipsis; + max-width:100%; + box-sizing:border-box; + `; + el.textContent = `✎ ${columnLabel}`; + el.title = title; + el.setAttribute('aria-label', `Editing ${columnLabel}`); + return el; +} + +function applyEditingRowHighlight(row: HTMLElement | null | undefined, active: boolean) { + if (!row) { + return; + } + if (active) { + row.classList.add('pg-row-editing'); + row.style.background = + 'color-mix(in srgb, var(--vscode-list-inactiveSelectionBackground) 38%, transparent)'; + row.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); + } else { + row.classList.remove('pg-row-editing'); + row.style.background = ''; + } +} + +function insertDockedPanel(opts: ModalEditorOptions, panel: HTMLElement): HTMLElement { + if (opts.dockParent) { + if (opts.dockAfter?.parentNode === opts.dockParent) { + opts.dockParent.insertBefore(panel, opts.dockAfter.nextSibling); + } else { + opts.dockParent.appendChild(panel); + } + return opts.dockParent; + } + const container = opts.anchorEl ? findOutputContainer(opts.anchorEl) : document.body; + container.appendChild(panel); + return container; +} interface ModalEditorOptions { title: string; @@ -672,6 +782,11 @@ interface ModalEditorOptions { modalMount?: HTMLElement; /** The cell β€” used to find the output container and scroll into view. */ anchorEl?: HTMLElement; + dockParent?: HTMLElement; + dockAfter?: HTMLElement; + editingRowEl?: HTMLTableRowElement | null; + /** Shown in the table cell while the docked panel is open. */ + columnName?: string; } /** @@ -693,42 +808,30 @@ function findOutputContainer(start: HTMLElement): HTMLElement { } function createModalEditor(opts: ModalEditorOptions): HTMLElement { - // The inline placeholder returned to the caller (sits inside the ) - const placeholder = document.createElement('div'); - placeholder.style.cssText = ` - padding:2px 6px; - background:var(--vscode-input-background); - border:1px solid var(--vscode-focusBorder); - border-radius:2px; - font-size:11px; - color:var(--vscode-descriptionForeground); - cursor:pointer; - white-space:nowrap; - overflow:hidden; - text-overflow:ellipsis; - max-width:200px; - `; - const preview = opts.initialContent.slice(0, 60) + (opts.initialContent.length > 60 ? '...' : ''); - placeholder.textContent = preview || '(empty)'; - placeholder.title = 'Click to open editor'; + const columnLabel = + opts.columnName?.trim() || + opts.title.replace(/\s*\([^)]*\)\s*$/, '').trim() || + opts.title; + + const placeholder = createDockedCellIndicator(columnLabel, opts.title); const showEditor = () => { - const container = opts.anchorEl ? findOutputContainer(opts.anchorEl) : document.body; + applyEditingRowHighlight(opts.editingRowEl ?? null, true); + applyDockedAnchorCellStyle(opts.anchorEl, true); - // ── Wrapper: inline block in normal DOM flow ── const wrapper = document.createElement('div'); wrapper.setAttribute('data-inline-editor', 'true'); wrapper.style.cssText = ` position:relative; - z-index:100; width:100%; + flex-shrink:0; box-sizing:border-box; padding:12px; - margin:0; + margin:6px 0 0; background:var(--vscode-editor-background); border:2px solid var(--vscode-focusBorder); border-radius:4px; - box-shadow:0 4px 16px rgba(0,0,0,0.35); + box-shadow:0 2px 12px rgba(0,0,0,0.28); display:flex; flex-direction:column; gap:8px; @@ -818,6 +921,8 @@ function createModalEditor(opts: ModalEditorOptions): HTMLElement { const teardown = () => { keyboardTrapAbort.abort(); + applyDockedAnchorCellStyle(opts.anchorEl, false); + applyEditingRowHighlight(opts.editingRowEl ?? null, false); if (wrapper.parentNode) { wrapper.parentNode.removeChild(wrapper); } @@ -839,7 +944,7 @@ function createModalEditor(opts: ModalEditorOptions): HTMLElement { wrapper.addEventListener( 'keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' && e.ctrlKey) { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); e.stopPropagation(); doSave(); return; } if (e.key === 'Escape') { @@ -862,9 +967,7 @@ function createModalEditor(opts: ModalEditorOptions): HTMLElement { wrapper.appendChild(errorDiv); wrapper.appendChild(btnRow); - // Insert at the top of the output container (above the table) so the - // editor is always visible and never hidden beneath scrolled-away rows. - container.insertBefore(wrapper, container.firstChild); + insertDockedPanel(opts, wrapper); wrapper.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); setTimeout(() => { textarea.focus(); textarea.setSelectionRange(0, 0); }, 0); }; diff --git a/src/renderer/components/table/FilterBar.ts b/src/renderer/components/table/FilterBar.ts index 7cde356..774c45c 100644 --- a/src/renderer/components/table/FilterBar.ts +++ b/src/renderer/components/table/FilterBar.ts @@ -5,6 +5,11 @@ */ import { FilterClause, FilterOperator, FilterState } from '../../../common/types'; +import { + RESULT_TOOLBAR_ICON_CLASS, + applyAddFilterButtonStyle, + resultToolbarSvg, +} from '../ResultToolbarUi'; export interface FilterBarOptions { columns: string[]; @@ -333,7 +338,7 @@ export class FilterBar { globalInput.style.cssText = ` min-width:220px; flex:1; - max-width:320px; + max-width:100%; background:var(--vscode-input-background); color:var(--vscode-input-foreground); border:1px solid var(--vscode-widget-border); @@ -350,33 +355,37 @@ export class FilterBar { const addFilterBtn = document.createElement('button'); addFilterBtn.type = 'button'; - addFilterBtn.textContent = '+ Add filter'; addFilterBtn.title = 'Add a column filter'; - addFilterBtn.style.cssText = ` - background:var(--vscode-button-secondaryBackground); - color:var(--vscode-button-secondaryForeground); - border:1px solid var(--vscode-widget-border); - border-radius:16px; - padding:4px 12px; - cursor:pointer; - font-size:12px; - white-space:nowrap; - `; + applyAddFilterButtonStyle(addFilterBtn); addFilterBtn.addEventListener('click', () => this.openAddFilterPanel(columns, addFilterBtn)); const clearBtn = document.createElement('button'); clearBtn.type = 'button'; - clearBtn.textContent = 'βœ•'; clearBtn.title = 'Clear all filters'; + clearBtn.setAttribute('aria-label', 'Clear all filters'); clearBtn.style.cssText = ` + display:inline-flex; + align-items:center; + justify-content:center; background:none; border:none; color:var(--vscode-descriptionForeground); cursor:pointer; - font-size:13px; - padding:0 6px; + padding:4px 6px; line-height:1; + border-radius:4px; `; + const clearIc = document.createElement('span'); + clearIc.className = RESULT_TOOLBAR_ICON_CLASS; + clearIc.innerHTML = resultToolbarSvg('close'); + clearBtn.appendChild(clearIc); + clearBtn.addEventListener('mouseenter', () => { + clearBtn.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; + }); + clearBtn.addEventListener('mouseleave', () => { + clearBtn.style.background = 'none'; + }); clearBtn.addEventListener('click', () => { this.filterState.globalQuery = ''; this.filterState.clauses = []; @@ -387,30 +396,6 @@ export class FilterBar { const badge = document.createElement('span'); this.badge = badge; - const rightGroup = document.createElement('div'); - rightGroup.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;flex-shrink:0;'; - - const addRowBtn = document.createElement('button'); - addRowBtn.type = 'button'; - addRowBtn.textContent = '+ Add Row'; - addRowBtn.title = 'Insert a new row'; - addRowBtn.disabled = !this.onAddRow; - addRowBtn.style.cssText = ` - background:var(--vscode-button-background); - color:var(--vscode-button-foreground); - border:1px solid transparent; - border-radius:4px; - padding:4px 12px; - cursor:pointer; - font-size:12px; - white-space:nowrap; - box-shadow:0 0 0 1px color-mix(in srgb, var(--vscode-button-background) 35%, transparent); - opacity:${this.onAddRow ? '1' : '0.5'}; - `; - addRowBtn.addEventListener('click', () => { - this.onAddRow?.(); - }); - const chipRow = document.createElement('div'); chipRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;flex:1 1 100%;min-width:0;margin-top:4px;'; @@ -462,10 +447,7 @@ export class FilterBar { leftGroup.appendChild(clearBtn); leftGroup.appendChild(badge); - rightGroup.appendChild(addRowBtn); - this.container.appendChild(leftGroup); - this.container.appendChild(rightGroup); this.container.appendChild(chipRow); if (this.addFilterPanel) { diff --git a/src/renderer/components/table/TableRenderer.ts b/src/renderer/components/table/TableRenderer.ts index ae825a3..527477d 100644 --- a/src/renderer/components/table/TableRenderer.ts +++ b/src/renderer/components/table/TableRenderer.ts @@ -1,6 +1,14 @@ import { createButton } from '../ui'; -import { formatValue } from '../../utils/formatting'; -import { TableInfo, TableRenderOptions, FkColumnInfo, FilterState } from '../../../common/types'; +import { columnTypeSupportsPrettyPreview, formatValue } from '../../utils/formatting'; +import { RESULT_TOOLBAR_ICON_CLASS, resultToolbarSvg } from '../ResultToolbarUi'; +import { + TableInfo, + TableRenderOptions, + FkColumnInfo, + FilterState, + BYTEA_DISPLAY_DEFAULT, + ByteaDisplayFormat, +} from '../../../common/types'; import { createCellEditor } from './CellEditors'; import { FilterBar } from './FilterBar'; import { ColumnStatsTooltip, attachColumnStatsTooltip } from '../ColumnStats'; @@ -54,7 +62,6 @@ export class TableRenderer { private selectedIndices: Set = new Set(); private modifiedCells: Map = new Map(); private rowsMarkedForDeletion: Set = new Set(); - private dateTimeDisplayMode: Map = new Map(); private pendingInserts: PendingInsertRow[] = []; // Sort & filter state @@ -66,6 +73,17 @@ export class TableRenderer { private filterBar: FilterBar | null = null; private statsTooltip: ColumnStatsTooltip | null = null; + private byteaDisplayFormat: ByteaDisplayFormat = BYTEA_DISPLAY_DEFAULT; + + /** First visible row label (1-based); used with sliding-window results for absolute # column. */ + private rowNumberBaseline = 1; + + /** Columns using locale / indented β€œpretty” cell display (header eye toggle). */ + private columnPrettyPreview = new Set(); + + /** Set `true` to show the header eye toggle again (logic below stays wired). */ + private static readonly SHOW_COLUMN_PREVIEW_HEADER_TOGGLE = false; + private renderedCount = 0; private readonly CHUNK_SIZE = 50; private currentlyEditingCell: HTMLElement | null = null; @@ -116,6 +134,11 @@ export class TableRenderer { this.columnTypes = options.columnTypes || {}; this.tableInfo = options.tableInfo; this.foreignKeys = options.foreignKeys || []; + this.byteaDisplayFormat = options.byteaDisplayFormat ?? BYTEA_DISPLAY_DEFAULT; + this.rowNumberBaseline = + typeof options.rowNumberBaseline === 'number' && Number.isFinite(options.rowNumberBaseline) + ? Math.max(1, Math.floor(options.rowNumberBaseline)) + : 1; this.selectedIndices = options.initialSelectedIndices ? new Set(options.initialSelectedIndices) : new Set(); @@ -169,7 +192,6 @@ export class TableRenderer { columns: this.columns, rows: this.rows, filterState: this.filterState, - onAddRow: () => this.addPendingRow(), onFilterChange: (state) => { this.filterState = this.cloneFilterState(state); this.events.onFilterChange?.(state); @@ -195,6 +217,31 @@ export class TableRenderer { this.maybeAppendFilterEmptyHint(); } + /** Used by the result footer and other host UI to start a new row. */ + public triggerAddRow() { + this.addPendingRow(); + } + + /** Discard unstaged edits and staged deletions; restore displayed values from originalRows. */ + public revertAllPendingChanges(): void { + this.cleanupInlineEditors(); + this.modifiedCells.clear(); + this.rowsMarkedForDeletion.clear(); + this.currentlyEditingCell = null; + const n = Math.min(this.rows.length, this.originalRows.length); + for (let i = 0; i < n; i++) { + const row = this.rows[i]; + const orig = this.originalRows[i]; + if (!row || !orig) { + continue; + } + for (const col of this.columns) { + row[col] = orig[col]; + } + } + this.rerenderTable(); + } + private addPendingRow() { const tempId = `insert-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const emptyValues: Record = {}; @@ -407,52 +454,54 @@ export class TableRenderer { const th = document.createElement('th'); th.setAttribute('data-sortable', 'true'); th.setAttribute('scope', 'col'); + + const colType = this.columnTypes[col] ?? ''; + const isNumeric = this.isNumericColumn(col); + th.style.cssText = ` - text-align:left;padding:8px 12px; + text-align:left;padding:0; border-bottom:1px solid var(--vscode-widget-border); border-right:1px solid var(--vscode-widget-border); - font-weight:600;color:var(--vscode-editor-foreground); position:sticky;top:0;background:var(--vscode-editor-background); z-index:10;user-select:none;max-width:400px;cursor:pointer; + ${isNumeric ? 'text-align:right;' : ''} `; - const container = document.createElement('div'); - container.style.cssText = - 'display:flex;justify-content:space-between;align-items:center;gap:4px;'; - - const leftSide = document.createElement('div'); - leftSide.style.cssText = 'display:flex;align-items:center;gap:4px;overflow:hidden;'; + // Name row + const nameRow = document.createElement('div'); + nameRow.style.cssText = ` + display:flex;align-items:center;gap:4px;overflow:hidden; + padding:5px 10px 2px; + ${isNumeric ? 'flex-direction:row-reverse;' : ''} + `; if (this.tableInfo?.primaryKeys?.includes(col)) { const pkIcon = document.createElement('span'); pkIcon.textContent = '⚿'; pkIcon.title = 'Primary Key'; - pkIcon.style.cssText = - 'color:var(--vscode-textLink-foreground);font-size:12px;flex-shrink:0;'; - leftSide.appendChild(pkIcon); + pkIcon.setAttribute('aria-label', 'Primary Key'); + pkIcon.style.cssText = 'color:var(--vscode-textLink-foreground);font-size:11px;flex-shrink:0;'; + nameRow.appendChild(pkIcon); } - // FK icon if (this.foreignKeys.some((fk) => fk.column === col)) { const fkIcon = document.createElement('span'); - fkIcon.textContent = 'πŸ”—'; + fkIcon.textContent = 'β†—'; fkIcon.title = 'Foreign Key'; - fkIcon.style.cssText = 'font-size:10px;flex-shrink:0;'; - leftSide.appendChild(fkIcon); + fkIcon.setAttribute('aria-label', 'Foreign Key'); + fkIcon.style.cssText = 'font-size:10px;opacity:0.6;flex-shrink:0;'; + nameRow.appendChild(fkIcon); } const colName = document.createElement('span'); colName.textContent = col; - colName.style.cssText = 'overflow:hidden;text-overflow:ellipsis;'; - leftSide.appendChild(colName); - container.appendChild(leftSide); - - const rightSide = document.createElement('div'); - rightSide.style.cssText = 'display:flex;align-items:center;gap:4px;flex-shrink:0;'; + colName.style.cssText = 'overflow:hidden;text-overflow:ellipsis;font-weight:600;font-size:12px;color:var(--vscode-editor-foreground);'; + nameRow.appendChild(colName); - // Sort indicator + // Sort indicator (pushed to end) const sortIcon = document.createElement('span'); - sortIcon.style.cssText = 'font-size:10px;opacity:0.5;'; + sortIcon.setAttribute('aria-hidden', 'true'); + sortIcon.style.cssText = 'font-size:9px;opacity:0.5;margin-left:auto;flex-shrink:0;'; if (this.sortColumn === col) { sortIcon.textContent = this.sortDirection === 'asc' ? 'β–²' : 'β–Ό'; sortIcon.style.opacity = '1'; @@ -460,21 +509,110 @@ export class TableRenderer { } else { sortIcon.textContent = 'β‡…'; } - rightSide.appendChild(sortIcon); - - if (this.columnTypes[col]) { - const typeBadge = document.createElement('span'); - typeBadge.textContent = this.columnTypes[col]; - typeBadge.style.cssText = ` - font-size:10px;font-family:var(--vscode-editor-font-family),monospace; - color:var(--vscode-descriptionForeground);margin-left:4px; - `; - rightSide.appendChild(typeBadge); + nameRow.appendChild(sortIcon); + + // Type row: type label + optional preview toggle (eye), aligned with sort edge + const typeRow = document.createElement('div'); + typeRow.style.cssText = ` + display:flex; + align-items:center; + gap:4px; + padding:0 10px 4px; + font-size:10px; + font-family:var(--vscode-editor-font-family),monospace; + color:var(--vscode-descriptionForeground); + opacity:0.75; + min-height:20px; + `; + + const typeLabel = document.createElement('span'); + typeLabel.style.cssText = ` + flex:1; + min-width:0; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + ${isNumeric ? 'text-align:right;' : ''} + `; + typeLabel.textContent = colType; + + const showPreviewToggle = + TableRenderer.SHOW_COLUMN_PREVIEW_HEADER_TOGGLE && + Boolean(colType) && + columnTypeSupportsPrettyPreview(colType); + + let previewBtn: HTMLButtonElement | undefined; + if (TableRenderer.SHOW_COLUMN_PREVIEW_HEADER_TOGGLE) { + previewBtn = document.createElement('button'); + previewBtn.type = 'button'; + previewBtn.title = 'Toggle prettified preview for this column'; + previewBtn.setAttribute('aria-label', 'Toggle prettified preview'); + previewBtn.style.cssText = ` + flex-shrink:0; + display:inline-flex; + align-items:center; + justify-content:center; + width:22px; + height:22px; + padding:0; + border:none; + border-radius:4px; + background:transparent; + color:var(--vscode-descriptionForeground); + cursor:pointer; + opacity:0.55; + `; + const previewIc = document.createElement('span'); + previewIc.className = RESULT_TOOLBAR_ICON_CLASS; + previewIc.innerHTML = resultToolbarSvg('previewEye'); + previewBtn.appendChild(previewIc); + + const syncPreviewBtn = () => { + const on = this.columnPrettyPreview.has(col); + previewBtn!.setAttribute('aria-pressed', on ? 'true' : 'false'); + previewBtn!.style.opacity = on ? '1' : '0.55'; + previewBtn!.style.color = on ? 'var(--vscode-textLink-foreground)' : 'var(--vscode-descriptionForeground)'; + }; + syncPreviewBtn(); + + previewBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.columnPrettyPreview.has(col)) { + this.columnPrettyPreview.delete(col); + } else { + this.columnPrettyPreview.add(col); + } + syncPreviewBtn(); + this.rerenderTable(); + }); + + previewBtn.addEventListener('mouseenter', () => { + previewBtn!.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; + }); + previewBtn.addEventListener('mouseleave', () => { + previewBtn!.style.background = 'transparent'; + }); + } + + if (!colType) { + typeRow.style.display = 'none'; + } else if (showPreviewToggle && previewBtn) { + if (isNumeric) { + typeRow.appendChild(previewBtn); + typeRow.appendChild(typeLabel); + } else { + typeRow.appendChild(typeLabel); + typeRow.appendChild(previewBtn); + } + } else { + typeRow.appendChild(typeLabel); } - container.appendChild(rightSide); - th.appendChild(container); - // Sort click handler β€” cycles none β†’ asc β†’ desc β†’ none + th.appendChild(nameRow); + th.appendChild(typeRow); + + // Sort click handler th.addEventListener('click', () => { if (this.sortColumn === col) { if (this.sortDirection === 'none') { @@ -494,41 +632,6 @@ export class TableRenderer { this.rerenderTable(); }); - // DateTime toggle row - if (this.columnTypes[col]) { - const lowerType = this.columnTypes[col].toLowerCase(); - const isDateTime = - lowerType.includes('timestamp') || - lowerType === 'timestamptz' || - lowerType === 'date' || - lowerType === 'time' || - lowerType === 'timetz'; - if (isDateTime) { - if (!this.dateTimeDisplayMode.has(col)) { - this.dateTimeDisplayMode.set(col, false); - } - const toggleRow = document.createElement('div'); - toggleRow.style.cssText = 'display:flex;align-items:center;gap:4px;margin-top:2px;'; - const toggle = document.createElement('button'); - const isFormatted = this.dateTimeDisplayMode.get(col); - toggle.textContent = isFormatted ? 'πŸ“†' : '#'; - toggle.title = isFormatted - ? 'Showing formatted β€” click for raw' - : 'Showing raw β€” click for formatted'; - toggle.style.cssText = ` - background:var(--vscode-button-secondaryBackground);color:var(--vscode-button-secondaryForeground); - border:none;border-radius:3px;padding:1px 4px;cursor:pointer;font-size:10px;line-height:1; - `; - toggle.onclick = (e) => { - e.stopPropagation(); - this.dateTimeDisplayMode.set(col, !isFormatted); - this.rerenderTable(); - }; - toggleRow.appendChild(toggle); - th.appendChild(toggleRow); - } - } - // Attach column stats tooltip (hover with delay) if (this.statsTooltip) { attachColumnStatsTooltip(th, col, () => this.displayRows, this.statsTooltip, 600); @@ -544,6 +647,13 @@ export class TableRenderer { return th; } + /** Returns true if the column's pg type is numeric. */ + private isNumericColumn(col: string): boolean { + // Strip leading '_' β€” PG array types are prefixed (e.g. _int4 for integer[]) + const t = (this.columnTypes[col] ?? '').toLowerCase().replace(/^_/, ''); + return /^(int|int2|int4|int8|float|float4|float8|numeric|decimal|double|real|bigint|smallint|serial|bigserial|money)/.test(t); + } + private addResizeHandle(th: HTMLElement) { const handle = document.createElement('div'); handle.style.cssText = ` @@ -670,7 +780,7 @@ export class TableRenderer { // Row number cell const selectTd = document.createElement('td'); - selectTd.textContent = String(displayIndex + 1); + selectTd.textContent = String(this.rowNumberBaseline + displayIndex); selectTd.style.cssText = ` border-bottom:1px solid var(--vscode-widget-border); border-right:1px solid var(--vscode-widget-border); @@ -796,15 +906,6 @@ export class TableRenderer { const td = document.createElement('td'); const val = row[col]; const colType = this.columnTypes[col]; - let { text, type } = formatValue(val, colType); - - const isDateTime = type === 'date' || type === 'timestamp' || type === 'time'; - if (isDateTime) { - const isFormatted = this.dateTimeDisplayMode.get(col) ?? false; - if (!isFormatted) { - text = val !== null && val !== undefined ? String(val) : 'NULL'; - } - } td.style.cssText = ` padding:6px 12px; @@ -839,14 +940,81 @@ export class TableRenderer { this.applyModifiedCellDecoration(td, sourceIndex, col); + // Right-align numeric columns + if (this.isNumericColumn(col)) { + td.style.textAlign = 'right'; + } + if (val === null || val === undefined) { - const nullSpan = document.createElement('span'); - nullSpan.textContent = 'NULL'; - nullSpan.style.cssText = - 'color:var(--vscode-descriptionForeground);font-style:italic;opacity:0.6;'; - td.appendChild(nullSpan); + const pill = document.createElement('span'); + pill.textContent = '[null]'; + pill.setAttribute('aria-label', 'NULL'); + pill.style.cssText = ` + display:inline-block; + font-family:var(--vscode-editor-font-family),monospace; + font-size:11px; + font-weight:500; + color:var(--vscode-descriptionForeground); + opacity:0.45; + vertical-align:middle; + `; + td.appendChild(pill); + td.style.verticalAlign = 'middle'; } else { - td.textContent = text; + const prettyPreview = this.columnPrettyPreview.has(col); + const { kind, text: formattedText } = formatValue(val, colType, { + byteaDisplayFormat: this.byteaDisplayFormat, + prettyPreview, + }); + + const multilinePretty = + prettyPreview && + Boolean(colType) && + columnTypeSupportsPrettyPreview(colType) && + (kind === 'json' || + kind === 'object' || + (colType!.toLowerCase() === 'xml' && typeof val === 'string')); + + if (kind === 'boolean-true' || kind === 'boolean-false') { + td.textContent = formattedText; + } else if (kind === 'json' || kind === 'object') { + const mono = document.createElement('span'); + mono.textContent = formattedText; + mono.style.cssText = ` + font-family:var(--vscode-editor-font-family),monospace; + font-size:11px; + display:block; + max-width:100%; + ${ + multilinePretty + ? 'white-space:pre-wrap;word-break:break-word;' + : 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;' + } + `; + mono.title = formattedText; + if (multilinePretty) { + td.style.whiteSpace = 'normal'; + td.style.verticalAlign = 'top'; + } + td.appendChild(mono); + } else if (kind === 'bytea') { + const mono = document.createElement('span'); + mono.textContent = formattedText; + mono.style.cssText = + 'font-family:var(--vscode-editor-font-family),monospace;font-size:11px;'; + td.appendChild(mono); + } else if (multilinePretty) { + const wrap = document.createElement('span'); + wrap.textContent = formattedText; + wrap.style.cssText = + 'font-family:var(--vscode-editor-font-family),monospace;font-size:11px;white-space:pre-wrap;word-break:break-word;display:block;'; + wrap.title = formattedText; + td.appendChild(wrap); + td.style.whiteSpace = 'normal'; + td.style.verticalAlign = 'top'; + } else { + td.textContent = formattedText; + } } return td; @@ -908,11 +1076,12 @@ export class TableRenderer { // Blur any existing editor if (this.currentlyEditingCell) { - const existing = this.currentlyEditingCell.querySelector('input, textarea'); + const existing = this.currentlyEditingCell.querySelector('input, textarea, select'); if (existing) (existing as HTMLElement).blur(); } this.currentlyEditingCell = td; + const editingRow = td.closest('tr'); td.scrollIntoView({ block: 'nearest', inline: 'nearest' }); const currentValue = this.rows[sourceIndex]?.[col]; const originalValue = this.originalRows[sourceIndex]?.[col] ?? currentValue; @@ -968,6 +1137,9 @@ export class TableRenderer { onSave, onCancel, anchorEl: td, + dockParent: this.mainContainer, + dockAfter: this.tableContainer, + editingRowEl: editingRow instanceof HTMLTableRowElement ? editingRow : null, }); td.appendChild(editorEl); @@ -1226,6 +1398,11 @@ export class TableRenderer { this.rerenderTable(); } + /** Scrollable region for attaching listeners (streaming result pager). */ + public getScrollContainer(): HTMLElement { + return this.tableContainer; + } + public dispose() { this.teardownVirtualScroll(); if (this.loadMoreObserver) { diff --git a/src/renderer/features/export.ts b/src/renderer/features/export.ts index 5b8688b..c5a1f35 100644 --- a/src/renderer/features/export.ts +++ b/src/renderer/features/export.ts @@ -1,4 +1,72 @@ -import { createButton } from '../components/ui'; +import { + RESULT_TOOLBAR_ICON_CLASS, + RESULT_TOOLBAR_LABEL_CLASS, + attachResultRowToolInteractions, + applyResultRowToolStyle, + fillToolbarButtonContent, + resultToolbarSvg, +} from '../components/ResultToolbarUi'; + +/** Update label span on toolbar-style buttons (Export footer, etc.). */ +export function setExportToolbarButtonLabel(btn: HTMLButtonElement, label: string): void { + const tx = btn.querySelector(`.${RESULT_TOOLBAR_LABEL_CLASS}`); + if (tx) tx.textContent = label; +} + +/** Prefer above anchor β€” notebook/output below often stacks over `position:fixed` menus (export footer). */ +export const EXPORT_MENU_Z_INDEX = '2147483646'; + +export type DropdownPlacementPreference = 'above' | 'below'; + +/** + * Place a fixed menu relative to its anchor. + * `above`: default for footer Export β€” avoids overlapping the next notebook cell below. + * `below`: use for toolbar controls (e.g. Ask AI) so the menu isn’t clipped by cells above. + */ +export function positionExportDropdown( + menu: HTMLElement, + anchor: HTMLElement, + preference: DropdownPlacementPreference = 'above', +): void { + menu.style.position = 'fixed'; + menu.style.zIndex = EXPORT_MENU_Z_INDEX; + + const rect = anchor.getBoundingClientRect(); + const mw = menu.offsetWidth || menu.getBoundingClientRect().width; + const mh = menu.offsetHeight || menu.getBoundingClientRect().height; + const vp = 8; + + let top: number; + if (preference === 'below') { + top = rect.bottom + 4; + if (top + mh > window.innerHeight - vp) { + const aboveTop = rect.top - mh - 4; + if (aboveTop >= vp) { + top = aboveTop; + } else { + top = Math.max(vp, Math.min(rect.bottom + 4, window.innerHeight - mh - vp)); + } + } + } else { + top = rect.top - mh - 4; + if (top < vp) { + top = rect.bottom + 4; + } + const maxTop = window.innerHeight - mh - vp; + if (top > maxTop) { + top = Math.max(vp, maxTop); + } + } + + let left = rect.left; + if (left + mw > window.innerWidth - vp) { + left = window.innerWidth - mw - vp; + } + left = Math.max(vp, left); + + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; +} export const createExportButton = ( columns: string[], @@ -7,7 +75,16 @@ export const createExportButton = ( context?: { postMessage?: (msg: any) => void }, originalQuery?: string ) => { - const exportBtn = createButton('Export β–Ό', true); + const exportBtn = document.createElement('button'); + exportBtn.type = 'button'; + fillToolbarButtonContent(exportBtn, 'export', 'Export'); + const chev = document.createElement('span'); + chev.className = RESULT_TOOLBAR_ICON_CLASS; + chev.style.opacity = '0.72'; + chev.innerHTML = resultToolbarSvg('chevronDown'); + exportBtn.appendChild(chev); + applyResultRowToolStyle(exportBtn); + attachResultRowToolInteractions(exportBtn); exportBtn.style.position = 'relative'; exportBtn.addEventListener('click', (e: MouseEvent) => { @@ -26,7 +103,7 @@ export const createExportButton = ( menu.style.background = 'var(--vscode-menu-background)'; menu.style.border = '1px solid var(--vscode-menu-border)'; menu.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)'; - menu.style.zIndex = '1000'; + menu.style.zIndex = EXPORT_MENU_Z_INDEX; menu.style.minWidth = '150px'; menu.style.borderRadius = '3px'; menu.style.padding = '4px 0'; @@ -180,16 +257,16 @@ export const createExportButton = ( if (tableInfo) { menu.appendChild(createMenuItem('Copy SQL INSERT', () => { navigator.clipboard.writeText(getSQLInsert()).then(() => { - exportBtn.textContent = 'Copied!'; - setTimeout(() => exportBtn.textContent = 'Export β–Ό', 2000); + setExportToolbarButtonLabel(exportBtn, 'Copied!'); + setTimeout(() => setExportToolbarButtonLabel(exportBtn, 'Export'), 2000); }); })); } menu.appendChild(createMenuItem('Copy to Clipboard', () => { navigator.clipboard.writeText(getCSV()).then(() => { - exportBtn.textContent = 'Copied!'; - setTimeout(() => exportBtn.textContent = 'Export β–Ό', 2000); + setExportToolbarButtonLabel(exportBtn, 'Copied!'); + setTimeout(() => setExportToolbarButtonLabel(exportBtn, 'Export'), 2000); }); })); @@ -201,7 +278,7 @@ export const createExportButton = ( divider.style.margin = '4px 8px'; menu.appendChild(divider); - menu.appendChild(createMenuItem('πŸ“₯ Export All Data (via kernel)', () => { + menu.appendChild(createMenuItem('Export all data (via kernel)', () => { context.postMessage!({ type: 'export_request', rows: rows, @@ -213,26 +290,7 @@ export const createExportButton = ( document.body.appendChild(menu); - const buttonRect = exportBtn.getBoundingClientRect(); - const menuWidth = Math.max(180, menu.getBoundingClientRect().width || 180); - const menuHeight = menu.getBoundingClientRect().height || 0; - const viewportPadding = 8; - const spaceBelow = window.innerHeight - buttonRect.bottom - viewportPadding; - const spaceAbove = buttonRect.top - viewportPadding; - - let top = buttonRect.bottom + 4; - if (menuHeight > 0 && spaceBelow < menuHeight && spaceAbove > spaceBelow) { - top = Math.max(viewportPadding, buttonRect.top - menuHeight - 4); - } - - let left = buttonRect.left; - if (left + menuWidth > window.innerWidth - viewportPadding) { - left = window.innerWidth - menuWidth - viewportPadding; - } - left = Math.max(viewportPadding, left); - - menu.style.left = `${left}px`; - menu.style.top = `${top}px`; + positionExportDropdown(menu, exportBtn); menu.style.visibility = 'visible'; // Close menu when clicking outside diff --git a/src/renderer/utils/formatting.ts b/src/renderer/utils/formatting.ts index b53127a..dd4e6b8 100644 --- a/src/renderer/utils/formatting.ts +++ b/src/renderer/utils/formatting.ts @@ -1,3 +1,5 @@ +import type { ByteaDisplayFormat } from '../../common/types'; +import { BYTEA_DISPLAY_DEFAULT } from '../../common/types'; /** * Detect numeric columns in the dataset @@ -106,53 +108,389 @@ export function formatValueForSQL(val: any, colType?: string): string { return `'${String(val).replace(/'/g, "''")}'`; } +export type ValueKind = + | 'null' + | 'boolean-true' + | 'boolean-false' + | 'timestamp' + | 'date' + | 'time' + | 'number' + | 'text' + | 'json' + | 'object' + | 'bytea' + | 'interval'; + +/** Compact single-line JSON for grid cells (json/jsonb, composites, structured UDTs). */ +function compactJsonForDisplay(val: unknown): string { + if (val === null || val === undefined) return String(val); + if (typeof val === 'string') { + const t = val.trim(); + if ( + (t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']')) + ) { + try { + return JSON.stringify(JSON.parse(t)); + } catch { + return val; + } + } + return val; + } + try { + return JSON.stringify(val); + } catch { + return String(val); + } +} + +export interface FormatValueOptions { + byteaDisplayFormat?: ByteaDisplayFormat; + /** Locale-friendly / indented display for bool, temporal, xml, json, interval (grid preview toggle). */ + prettyPreview?: boolean; +} + +/** Types where β€œpretty preview” improves on raw/pg-style cell text. */ +export function columnTypeSupportsPrettyPreview(colType: string | undefined): boolean { + if (!colType?.trim()) return false; + let t = colType.toLowerCase().trim().replace(/^_/, ''); + const bracket = t.indexOf('['); + if (bracket !== -1) t = t.slice(0, bracket); + + if (t === 'bool' || t === 'boolean') return true; + if (t.includes('timestamp') || t === 'date') return true; + if (t.includes('interval')) return true; + if (t.includes('timetz') || (/\btime\b/.test(t) && !t.includes('timestamp'))) return true; + if (t === 'xml') return true; + if (t.includes('json')) return true; + return false; +} + +function formatTemporalPrettyLocale(val: unknown, lowerType: string): string { + try { + let d: Date | null = null; + if (val instanceof Date) { + d = val; + } else if (typeof val === 'string') { + const parsed = new Date(val); + if (!Number.isNaN(parsed.getTime())) d = parsed; + } + if (!d || Number.isNaN(d.getTime())) { + return formatTemporalRaw(val, lowerType); + } + + if (lowerType.includes('timestamp')) { + return d.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + timeZoneName: 'short', + }); + } + if (lowerType === 'date') { + return d.toLocaleDateString(undefined, { dateStyle: 'full', timeZone: 'UTC' }); + } + if (/\btime\b/.test(lowerType)) { + return d.toLocaleTimeString(undefined, { timeStyle: 'medium' }); + } + return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + } catch { + return typeof val === 'string' ? val : String(val); + } +} + +function prettyPrintXmlString(xml: string): string { + const trimmed = xml.trim(); + try { + const doc = new DOMParser().parseFromString(trimmed, 'text/xml'); + if (doc.querySelector('parsererror')) { + return trimmed.replace(/>\s*\n<'); + } + const ser = new XMLSerializer(); + const out = ser.serializeToString(doc.documentElement); + return out.replace(/>\n<'); + } catch { + return trimmed.replace(/>\s*\n<'); + } +} + +function prettifyCellDisplay( + val: any, + colType: string, + base: { text: string; isNull: boolean; type: string; kind: ValueKind }, +): string { + const lt = colType.toLowerCase(); + if (base.isNull) return base.text; + + if (base.kind === 'boolean-true' || base.kind === 'boolean-false') { + const v = + typeof val === 'boolean' + ? val + : String(val).toLowerCase() === 't' || String(val).toLowerCase() === 'true'; + return v ? 'Yes' : 'No'; + } + + if (lt.includes('interval')) { + return base.text; + } + + if (isPgTemporalType(lt)) { + return formatTemporalPrettyLocale(val, lt); + } + + if (lt === 'xml' && typeof val === 'string') { + return prettyPrintXmlString(val); + } + + if (lt.includes('json')) { + try { + const parsed = typeof val === 'string' ? JSON.parse(val) : val; + return JSON.stringify(parsed, null, 2); + } catch { + return base.text; + } + } + + return base.text; +} + +/** Node/pg round-trip + JSON.parse yields `{ type: "Buffer", data: number[] }`. */ +export function isSerializedBuffer(val: unknown): val is { type: 'Buffer'; data: number[] } { + if (typeof val !== 'object' || val === null) return false; + const o = val as { type?: unknown; data?: unknown }; + return ( + o.type === 'Buffer' && + Array.isArray(o.data) && + o.data.every((x) => typeof x === 'number' && x >= 0 && x <= 255) + ); +} + +function uint8FromBufferLike(val: unknown): Uint8Array | null { + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(val)) { + return new Uint8Array(val as Buffer); + } + if (isSerializedBuffer(val)) { + return new Uint8Array(val.data); + } + return null; +} + +export function formatByteaForDisplay(bytes: Uint8Array, mode: ByteaDisplayFormat): string { + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + switch (mode) { + case 'postgresql': + return '\\x' + hex; + case 'json': + return JSON.stringify({ type: 'Buffer', data: Array.from(bytes) }); + case 'hex0x': + default: + return '0x' + hex; + } +} + +function shouldFormatAsBytea(val: unknown, colType?: string): boolean { + const t = colType?.toLowerCase(); + if (t === 'bytea') return true; + return isSerializedBuffer(val); +} + +/** date / time / timestamp / timestamptz / interval β€” show driver text, not locale formatting. */ +function isPgTemporalType(lowerType: string): boolean { + if (lowerType.includes('json')) return false; + if (lowerType.includes('interval')) return true; + if (lowerType.includes('timestamp')) return true; + if (lowerType === 'date') return true; + if (lowerType.includes('timetz')) return true; + return /\btime\b/.test(lowerType); +} + +/** + * Plain object shape after JSON round-trip (postgres-interval instance loses prototype methods). + * Matches `postgres-interval` / pg driver field names. + */ +export function formatIntervalPlainObject(val: unknown): string | null { + if (!val || typeof val !== 'object' || Array.isArray(val)) return null; + + const keys = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'milliseconds']; + const value = val as Record; + if (!keys.some((k) => k in value)) return null; + + const toNum = (v: unknown): number => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; + + const parts: string[] = []; + const units: Array<{ key: string; label: string }> = [ + { key: 'years', label: 'year' }, + { key: 'months', label: 'month' }, + { key: 'days', label: 'day' }, + { key: 'hours', label: 'hour' }, + { key: 'minutes', label: 'minute' }, + { key: 'seconds', label: 'second' }, + { key: 'milliseconds', label: 'millisecond' }, + ]; + + for (const unit of units) { + const n = toNum(value[unit.key]); + if (n === 0) continue; + const abs = Math.abs(n); + parts.push(`${n} ${unit.label}${abs === 1 ? '' : 's'}`); + } + + return parts.length > 0 ? parts.join(' ') : '0 seconds'; +} + +/** Raw display similar to pgAdmin data grid (verbatim strings from PG; ISO for JS Date). */ +function formatTemporalRaw(val: unknown, lowerType: string): string { + if (val === null || val === undefined) { + return '[null]'; + } + + if (lowerType.includes('interval')) { + if (typeof val === 'string') { + return val; + } + if (typeof val === 'object' && val !== null) { + const v = val as { toPostgres?: () => string; toISOString?: () => string }; + if (typeof v.toPostgres === 'function') { + try { + return v.toPostgres(); + } catch { + /* fall through */ + } + } + if (typeof v.toISOString === 'function') { + try { + return v.toISOString(); + } catch { + /* fall through */ + } + } + const plain = formatIntervalPlainObject(val); + if (plain !== null) return plain; + } + return String(val); + } + + if (typeof val === 'string') { + return val; + } + + if (val instanceof Date) { + const iso = val.toISOString(); + if (lowerType.includes('timestamp')) { + return iso.replace('T', ' ').replace(/\.\d{3}Z$/, '') + '+00'; + } + if (/\btime\b/.test(lowerType)) { + return iso.slice(11, 19); + } + if (lowerType === 'date') { + return iso.slice(0, 10); + } + return iso.replace('T', ' ').replace(/\.\d{3}Z$/, '') + '+00'; + } + + return String(val); +} + /** * Format value with detailed type info (for Table Renderer) */ -export function formatValue(val: any, colType?: string): { text: string, isNull: boolean, type: string } { - if (val === null) return { text: 'NULL', isNull: true, type: 'null' }; - if (typeof val === 'boolean') return { text: val ? 'TRUE' : 'FALSE', isNull: false, type: 'boolean' }; - if (typeof val === 'number') return { text: String(val), isNull: false, type: 'number' }; +export function formatValue( + val: any, + colType?: string, + options?: FormatValueOptions, +): { text: string; isNull: boolean; type: string; kind: ValueKind; datePart?: string; timePart?: string } { + const base = computeFormatValue(val, colType, options); + if (options?.prettyPreview && colType && columnTypeSupportsPrettyPreview(colType)) { + return { ...base, text: prettifyCellDisplay(val, colType, base) }; + } + return base; +} + +function computeFormatValue( + val: any, + colType?: string, + options?: FormatValueOptions, +): { text: string; isNull: boolean; type: string; kind: ValueKind; datePart?: string; timePart?: string } { + if (val === null || val === undefined) { + return { text: '[null]', isNull: true, type: 'null', kind: 'null' }; + } + + const byteaMode = options?.byteaDisplayFormat ?? BYTEA_DISPLAY_DEFAULT; + const bytesEarly = uint8FromBufferLike(val); + if (bytesEarly !== null && shouldFormatAsBytea(val, colType)) { + return { + text: formatByteaForDisplay(bytesEarly, byteaMode), + isNull: false, + type: 'bytea', + kind: 'bytea', + }; + } + + if (typeof val === 'boolean') { + return { text: val ? 'TRUE' : 'FALSE', isNull: false, type: 'boolean', kind: val ? 'boolean-true' : 'boolean-false' }; + } + + if (typeof val === 'number') { + return { text: String(val), isNull: false, type: 'number', kind: 'number' }; + } + + if (colType && isPgTemporalType(colType.toLowerCase())) { + const lt = colType.toLowerCase(); + const text = formatTemporalRaw(val, lt); + return { text, isNull: false, type: lt, kind: 'text' }; + } + if (val instanceof Date) { - const tz = getTimezoneAbbr(val); - return { text: `${val.toLocaleString()} ${tz}`, isNull: false, type: 'date' }; + const iso = val.toISOString(); + return { + text: iso.replace('T', ' ').replace(/\.\d{3}Z$/, '') + '+00', + isNull: false, + type: 'timestamp', + kind: 'text', + }; } - // Handle date/timestamp strings based on column type or string pattern if (typeof val === 'string' && colType) { const lowerType = colType.toLowerCase(); - // Check if it's a timestamp or date type - if (lowerType.includes('timestamp') || lowerType === 'timestamptz') { - const date = new Date(val); - if (!isNaN(date.getTime())) { - const tz = getTimezoneAbbr(date); - return { text: `${date.toLocaleString()} ${tz}`, isNull: false, type: 'timestamp' }; - } - } else if (lowerType === 'date') { - const date = new Date(val); - if (!isNaN(date.getTime())) { - const tz = getTimezoneAbbr(date); - return { text: `${date.toLocaleDateString()} ${tz}`, isNull: false, type: 'date' }; - } - } else if (lowerType === 'time' || lowerType === 'timetz') { - const today = new Date(); - const timeDate = new Date(`${today.toDateString()} ${val}`); - if (!isNaN(timeDate.getTime())) { - const tz = getTimezoneAbbr(timeDate); - return { text: `${timeDate.toLocaleTimeString()} ${tz}`, isNull: false, type: 'time' }; - } + + if (lowerType === 'json' || lowerType === 'jsonb') { + return { text: compactJsonForDisplay(val), isNull: false, type: 'json', kind: 'json' }; } } - // Handle JSON/JSONB types - if (colType && (colType.toLowerCase() === 'json' || colType.toLowerCase() === 'jsonb')) { - return { text: JSON.stringify(val), isNull: false, type: 'json' }; + if (colType) { + const lowerType = colType.toLowerCase(); + if (lowerType === 'json' || lowerType === 'jsonb') { + return { text: compactJsonForDisplay(val), isNull: false, type: 'json', kind: 'json' }; + } } - if (typeof val === 'object') return { text: JSON.stringify(val), isNull: false, type: 'object' }; - return { text: String(val), isNull: false, type: 'string' }; + if (typeof val === 'object') { + const intervalPlain = formatIntervalPlainObject(val); + if (intervalPlain !== null) { + return { + text: intervalPlain, + isNull: false, + type: 'interval', + kind: 'interval', + }; + } + return { + text: compactJsonForDisplay(val), + isNull: false, + type: 'object', + kind: 'object', + }; + } + + return { text: String(val), isNull: false, type: 'string', kind: 'text' }; } + /** * Helper helpers for color conversion */ diff --git a/src/renderer/utils/queryPreview.ts b/src/renderer/utils/queryPreview.ts new file mode 100644 index 0000000..8274807 --- /dev/null +++ b/src/renderer/utils/queryPreview.ts @@ -0,0 +1,14 @@ +/** Single-line SQL preview for the result identity bar (multiple cells / history). */ + +const QUERY_PREVIEW_MAX_CHARS = 52; + +export function buildQueryPreview(sql: string | undefined, fallbackLabel: string): string { + if (!sql?.trim()) { + return fallbackLabel; + } + const oneLine = sql.replace(/\s+/g, ' ').trim(); + if (oneLine.length <= QUERY_PREVIEW_MAX_CHARS) { + return oneLine; + } + return `${oneLine.slice(0, QUERY_PREVIEW_MAX_CHARS - 1)}…`; +} diff --git a/src/services/CursorStreamBannerPolicy.ts b/src/services/CursorStreamBannerPolicy.ts new file mode 100644 index 0000000..a4f4941 --- /dev/null +++ b/src/services/CursorStreamBannerPolicy.ts @@ -0,0 +1,60 @@ +import type * as vscode from 'vscode'; + +const MUTED_FOREVER_KEY = 'pgstudio.cursorStream.bannerMutedForever.v1'; +const DISMISSED_AT_KEY = 'pgstudio.cursorStream.bannerDismissedAt.v1'; +const COUNT_AT_DISMISS_KEY = 'pgstudio.cursorStream.bannerCountAtDismiss.v1'; +const SLIDING_EXEC_COUNT_KEY = 'pgstudio.cursorStream.slidingExecCount.v1'; + +/** Snooze banner for 7 days after dismiss. */ +const SNOOZE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; +/** Or after this many sliding-window query results since dismiss. */ +const SNOOZE_EXEC_THRESHOLD = 100; + +export class CursorStreamBannerPolicy { + /** + * Increment global sliding-result counter for this workspace (call once per sliding response). + * Returns the new count after increment. + */ + public static incrementSlidingExecCount(workspaceState: vscode.Memento): number { + const next = (workspaceState.get(SLIDING_EXEC_COUNT_KEY) ?? 0) + 1; + void workspaceState.update(SLIDING_EXEC_COUNT_KEY, next); + return next; + } + + /** + * Whether to show the streaming cursor hint banner for this result (after increment). + */ + public static shouldShowBanner( + globalState: vscode.Memento, + workspaceState: vscode.Memento, + currentSlidingExecCount: number, + ): boolean { + if (globalState.get(MUTED_FOREVER_KEY, false)) { + return false; + } + const dismissedAt = workspaceState.get(DISMISSED_AT_KEY); + if (!dismissedAt) { + return true; + } + const countAtDismiss = workspaceState.get(COUNT_AT_DISMISS_KEY) ?? 0; + if (Date.now() - dismissedAt >= SNOOZE_WEEK_MS) { + return true; + } + if (currentSlidingExecCount - countAtDismiss >= SNOOZE_EXEC_THRESHOLD) { + return true; + } + return false; + } + + /** User closed the banner with X β€” snooze until week or 100 sliding queries. */ + public static async recordDismiss(workspaceState: vscode.Memento): Promise { + const count = workspaceState.get(SLIDING_EXEC_COUNT_KEY) ?? 0; + await workspaceState.update(DISMISSED_AT_KEY, Date.now()); + await workspaceState.update(COUNT_AT_DISMISS_KEY, count); + } + + /** User chose mute forever. */ + public static async recordMuteForever(globalState: vscode.Memento): Promise { + await globalState.update(MUTED_FOREVER_KEY, true); + } +} diff --git a/src/services/ResultCursorService.ts b/src/services/ResultCursorService.ts new file mode 100644 index 0000000..d4f9c8a --- /dev/null +++ b/src/services/ResultCursorService.ts @@ -0,0 +1,287 @@ +import * as vscode from 'vscode'; +import type { Client } from 'pg'; +import { randomUUID } from 'crypto'; +import { getPgDataTypeName } from '../common/pgDataTypeNames'; + +/** Idle cursor sessions are closed to free server resources */ +const SESSION_IDLE_CLOSE_MS = 30 * 60 * 1000; + +export interface SlidingWindowPayload { + sessionId: string; + windowStartRow: number; + windowSize: number; + hasMoreBefore: boolean; + hasMoreAfter: boolean; +} + +interface SessionRecord { + cursorQuoted: string; + client: Client; + windowSize: number; + notebookUri: string; + cellUri: string; + idleTimer?: NodeJS.Timeout; +} + +function stripSqlComments(sql: string): string { + return sql.replace(/--[^\n]*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim(); +} + +function stripTrailingSemicolon(sql: string): string { + const t = sql.trimEnd(); + return t.endsWith(';') ? t.slice(0, -1).trimEnd() : t; +} + +function quoteIdent(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +export class ResultCursorService { + private static sessions = new Map(); + + public static isGloballyEnabled(): boolean { + return vscode.workspace + .getConfiguration('postgresExplorer') + .get('performance.slidingWindowSelects', true); + } + + public static getWindowSizeCap(): number { + const raw = vscode.workspace + .getConfiguration('postgresExplorer') + .get('performance.slidingWindowRowCap', 100); + return Math.min(Math.max(Number(raw) || 100, 10), 500); + } + + /** SELECT / WITH … SELECT suitable for cursor (no bound params in v1). */ + public static isEligibleQuery(sql: string): boolean { + const clean = stripSqlComments(sql).trim(); + if (/^\s*EXPLAIN\b/i.test(clean)) { + return false; + } + if (/^\s*SELECT\b/i.test(clean)) { + return true; + } + if (/^\s*WITH\b/i.test(clean)) { + const upper = clean.toUpperCase(); + const lastInsert = upper.lastIndexOf(' INSERT '); + const lastSelect = upper.lastIndexOf(' SELECT '); + if (lastInsert !== -1 && (lastSelect === -1 || lastInsert > lastSelect)) { + return false; + } + return lastSelect !== -1; + } + return false; + } + + /** + * Closes sliding sessions previously opened for the same cell output (before re-run). + */ + public static closeSessionsForCellUri(cellUri: string): void { + const toClose: string[] = []; + for (const [id, s] of ResultCursorService.sessions) { + if (s.cellUri === cellUri) { + toClose.push(id); + } + } + for (const id of toClose) { + void ResultCursorService.closeSession(id); + } + } + + public static async closeSession(sessionId: string): Promise { + const s = ResultCursorService.sessions.get(sessionId); + if (!s) { + return; + } + if (s.idleTimer) { + clearTimeout(s.idleTimer); + } + ResultCursorService.sessions.delete(sessionId); + try { + await s.client.query(`CLOSE ${s.cursorQuoted}`); + } catch (e) { + console.warn('[ResultCursorService] CLOSE cursor failed:', e); + } + } + + private static refreshIdleTimer(sessionId: string): void { + const s = ResultCursorService.sessions.get(sessionId); + if (!s) { + return; + } + if (s.idleTimer) { + clearTimeout(s.idleTimer); + } + s.idleTimer = setTimeout(() => { + void ResultCursorService.closeSession(sessionId); + }, SESSION_IDLE_CLOSE_MS); + s.idleTimer.unref?.(); + } + + /** + * Opens a SCROLL cursor and reads the first window. Returns null if cursor cannot be used (caller falls back to normal query). + */ + public static async tryOpenSession(options: { + client: Client; + notebookUri: string; + cellUri: string; + sql: string; + inTransaction: boolean; + windowSize: number; + }): Promise<{ + sessionId: string; + payload: SlidingWindowPayload; + rows: any[]; + fields: Array<{ name: string; dataTypeID: number }>; + } | null> { + const innerSql = stripTrailingSemicolon(options.sql.trim()); + const cursorName = `pgstudio_sw_${randomUUID().replace(/-/g, '')}`; + const cursorQuoted = quoteIdent(cursorName); + const { client, inTransaction, windowSize } = options; + const sessionId = randomUUID(); + let beganOwnReadOnlyTx = false; + + try { + if (inTransaction) { + await client.query( + `DECLARE ${cursorQuoted} SCROLL CURSOR WITHOUT HOLD FOR ${innerSql}` + ); + } else { + await client.query('BEGIN READ ONLY ISOLATION LEVEL READ COMMITTED'); + beganOwnReadOnlyTx = true; + await client.query( + `DECLARE ${cursorQuoted} SCROLL CURSOR WITH HOLD FOR ${innerSql}` + ); + await client.query('COMMIT'); + beganOwnReadOnlyTx = false; + } + + ResultCursorService.sessions.set(sessionId, { + cursorQuoted, + client, + windowSize, + notebookUri: options.notebookUri, + cellUri: options.cellUri, + }); + ResultCursorService.refreshIdleTimer(sessionId); + + let page: { rows: any[]; fields: Array<{ name: string; dataTypeID: number }> } | null; + try { + page = await ResultCursorService.fetchWindowInternal(sessionId, 1); + } catch (e) { + console.warn('[ResultCursorService] first FETCH failed:', e); + await ResultCursorService.closeSession(sessionId); + return null; + } + if (!page) { + await ResultCursorService.closeSession(sessionId); + return null; + } + + const hasMoreBefore = false; + const hasMoreAfter = page.rows.length === windowSize; + + return { + sessionId, + rows: page.rows, + fields: page.fields, + payload: { + sessionId, + windowStartRow: 1, + windowSize, + hasMoreBefore, + hasMoreAfter, + }, + }; + } catch (e) { + console.warn('[ResultCursorService] tryOpenSession failed, falling back to buffered query:', e); + if (beganOwnReadOnlyTx) { + await client.query('ROLLBACK').catch(() => {}); + } + await ResultCursorService.closeSession(sessionId).catch(() => {}); + return null; + } + } + + private static async fetchWindowInternal( + sessionId: string, + pageStartRow: number + ): Promise<{ rows: any[]; fields: Array<{ name: string; dataTypeID: number }> } | null> { + const s = ResultCursorService.sessions.get(sessionId); + if (!s) { + return null; + } + + // MOVE ABSOLUTE uses cursor row positioning. To fetch a page beginning at 1-based row N with + // FETCH FORWARD k, move to N-1 first (or 0 for the first page). + const moveTarget = Math.max(0, pageStartRow - 1); + await s.client.query(`MOVE ABSOLUTE ${moveTarget} FROM ${s.cursorQuoted}`); + const result = await s.client.query(`FETCH FORWARD ${s.windowSize} FROM ${s.cursorQuoted}`); + + const rows = result.rows || []; + const fields = (result.fields || []).map((f: { name: string; dataTypeID: number }) => ({ + name: f.name, + dataTypeID: f.dataTypeID, + })); + + ResultCursorService.refreshIdleTimer(sessionId); + + return { rows, fields }; + } + + public static async fetchPage( + sessionId: string, + pageStartRow: number + ): Promise<{ + rows: any[]; + fields: Array<{ name: string; dataTypeID: number }>; + windowStartRow: number; + windowSize: number; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + } | null> { + const s = ResultCursorService.sessions.get(sessionId); + if (!s) { + return null; + } + + try { + const internal = await ResultCursorService.fetchWindowInternal(sessionId, pageStartRow); + if (!internal) { + return null; + } + const hasMoreBefore = pageStartRow > 1; + const hasMoreAfter = internal.rows.length === s.windowSize; + + return { + ...internal, + windowStartRow: pageStartRow, + windowSize: s.windowSize, + hasMoreBefore, + hasMoreAfter, + }; + } catch (e) { + console.error('[ResultCursorService] fetchPage failed:', e); + await ResultCursorService.closeSession(sessionId); + throw e; + } + } + + public static columnTypesFromFields( + fields: Array<{ name: string; dataTypeID: number }>, + columns: string[] + ): Record { + const columnTypes: Record = { + ...(fields.reduce((acc: Record, f) => { + acc[f.name] = getPgDataTypeName(f.dataTypeID); + return acc; + }, {}) || {}), + }; + for (const c of columns) { + if (columnTypes[c] === undefined) { + columnTypes[c] = 'text'; + } + } + return columnTypes; + } +} diff --git a/src/services/handlers/CoreHandlers.ts b/src/services/handlers/CoreHandlers.ts index 6b7ab6c..4c6b8a0 100644 --- a/src/services/handlers/CoreHandlers.ts +++ b/src/services/handlers/CoreHandlers.ts @@ -4,6 +4,7 @@ import { ConnectionUtils } from '../../utils/connectionUtils'; import { WorkspaceStateService } from '../WorkspaceStateService'; import { PostgresMetadata } from '../../common/types'; import { DatabaseTreeItem } from '../../providers/DatabaseTreeProvider'; +import { ConnectionManager } from '../ConnectionManager'; export class ShowConnectionSwitcherHandler implements IMessageHandler { constructor(private statusBar: any) { } @@ -300,28 +301,171 @@ export class ImportRequestHandler implements IMessageHandler { } export class ExportRequestHandler implements IMessageHandler { - async handle(message: any) { - // This logic was in NotebookKernel previously - // It requires UI interaction so it fits here. - const { rows: displayRows, columns } = message; + private sanitizeFilenamePart(value: string): string { + return value + .trim() + .replace(/[\\/:*?"<>|]+/g, '_') + .replace(/\s+/g, '_') + .slice(0, 80); + } + + private inferDefaultBasename(tableInfo?: any): string { + const schema = typeof tableInfo?.schema === 'string' ? this.sanitizeFilenamePart(tableInfo.schema) : ''; + const table = typeof tableInfo?.table === 'string' ? this.sanitizeFilenamePart(tableInfo.table) : ''; + if (schema && table) { + return `${schema}_${table}_export`; + } + if (table) { + return `${table}_export`; + } + return 'query_export'; + } + + private getDefaultExportUri(ext: 'csv' | 'json' | 'md', tableInfo?: any): vscode.Uri { + const filename = `${this.inferDefaultBasename(tableInfo)}.${ext}`; + const wsFolder = vscode.workspace.workspaceFolders?.[0]?.uri; + if (wsFolder) { + return vscode.Uri.joinPath(wsFolder, filename); + } + return vscode.Uri.file(filename); + } + + private async openSavedFile(uri: vscode.Uri): Promise { + try { + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: false, preserveFocus: false }); + } catch (err) { + console.warn('Export file open failed:', err); + } + } - const selection = await vscode.window.showQuickPick(['Save as CSV', 'Save as JSON', 'Copy to Clipboard']); - if (!selection) return; + private isReadOnlyExportQuery(query: string): boolean { + const clean = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim(); + return /^\s*(SELECT|WITH)\b/i.test(clean); + } - const rowsToExport = displayRows; + async handle(message: any, context: { editor?: vscode.NotebookEditor }) { + const { + rows: displayRows = [], + columns = [], + query, + format, + tableInfo, + } = message ?? {}; + + const effectiveFormat: 'csv' | 'json' | 'markdown' | 'clipboard' | 'sqlinsert' = + format === 'json' || + format === 'markdown' || + format === 'clipboard' || + format === 'sqlinsert' + ? format + : 'csv'; + + let rowsToExport: any[] = Array.isArray(displayRows) ? displayRows : []; + let columnsToExport: string[] = Array.isArray(columns) ? columns : []; + + // Re-run query for full export when we have enough context. + if (typeof query === 'string' && query.trim() && context.editor) { + if (!this.isReadOnlyExportQuery(query)) { + vscode.window.showWarningMessage( + 'Full export rerun is only enabled for SELECT queries; exporting visible rows only.', + ); + } else { + const metadata = context.editor.notebook.metadata as PostgresMetadata | undefined; + const connectionId = metadata?.connectionId; + if (!connectionId) { + vscode.window.showWarningMessage('No active connection found; exporting visible rows only.'); + } else { + const connection = ConnectionUtils.findConnection(connectionId); + if (!connection) { + vscode.window.showWarningMessage('Connection configuration missing; exporting visible rows only.'); + } else { + try { + const client = await ConnectionManager.getInstance().getSessionClient( + { + id: connection.id, + host: connection.host, + port: connection.port, + username: connection.username, + database: metadata?.databaseName || connection.database, + name: connection.name, + } as any, + context.editor.notebook.uri.toString(), + ); + const result = await client.query(query); + rowsToExport = result.rows || []; + const queriedColumns = result.fields?.map((f: any) => f.name) || []; + columnsToExport = + queriedColumns.length > 0 + ? queriedColumns + : rowsToExport.length > 0 + ? Object.keys(rowsToExport[0]) + : columnsToExport; + } catch (err: any) { + vscode.window.showWarningMessage( + `Full export query failed (${err?.message ?? String(err)}); exporting visible rows only.`, + ); + } + } + } + } + } - if (selection === 'Copy to Clipboard') { - const csv = this.rowsToCsv(rowsToExport, columns); + if (effectiveFormat === 'clipboard') { + const csv = this.rowsToCsv(rowsToExport, columnsToExport); await vscode.env.clipboard.writeText(csv); - vscode.window.showInformationMessage('Copied to clipboard'); - } else if (selection === 'Save as CSV') { - const csv = this.rowsToCsv(rowsToExport, columns); - const uri = await vscode.window.showSaveDialog({ filters: { 'CSV': ['csv'] } }); - if (uri) await vscode.workspace.fs.writeFile(uri, Buffer.from(csv)); - } else if (selection === 'Save as JSON') { + vscode.window.showInformationMessage( + `Copied ${rowsToExport.length.toLocaleString()} rows to clipboard`, + ); + return; + } + + if (effectiveFormat === 'sqlinsert') { + const sql = this.rowsToSqlInsert(rowsToExport, columnsToExport, tableInfo); + await vscode.env.clipboard.writeText(sql); + vscode.window.showInformationMessage( + `Copied SQL INSERT script (${rowsToExport.length.toLocaleString()} rows)`, + ); + return; + } + + if (effectiveFormat === 'csv') { + const csv = this.rowsToCsv(rowsToExport, columnsToExport); + const uri = await vscode.window.showSaveDialog({ + filters: { CSV: ['csv'] }, + defaultUri: this.getDefaultExportUri('csv', tableInfo), + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(csv)); + await this.openSavedFile(uri); + vscode.window.showInformationMessage(`Exported ${rowsToExport.length.toLocaleString()} rows (CSV)`); + } + return; + } + + if (effectiveFormat === 'json') { const json = JSON.stringify(rowsToExport, null, 2); - const uri = await vscode.window.showSaveDialog({ filters: { 'JSON': ['json'] } }); - if (uri) await vscode.workspace.fs.writeFile(uri, Buffer.from(json)); + const uri = await vscode.window.showSaveDialog({ + filters: { JSON: ['json'] }, + defaultUri: this.getDefaultExportUri('json', tableInfo), + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(json)); + await this.openSavedFile(uri); + vscode.window.showInformationMessage(`Exported ${rowsToExport.length.toLocaleString()} rows (JSON)`); + } + return; + } + + const markdown = this.rowsToMarkdown(rowsToExport, columnsToExport); + const uri = await vscode.window.showSaveDialog({ + filters: { Markdown: ['md'] }, + defaultUri: this.getDefaultExportUri('md', tableInfo), + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(markdown)); + await this.openSavedFile(uri); + vscode.window.showInformationMessage(`Exported ${rowsToExport.length.toLocaleString()} rows (Markdown)`); } } @@ -334,6 +478,50 @@ export class ExportRequestHandler implements IMessageHandler { }).join(',')).join('\n'); return `${header}\n${body}`; } + + private rowsToMarkdown(rows: any[], columns: string[]): string { + const header = `| ${columns.join(' | ')} |`; + const separator = `| ${columns.map(() => '---').join(' | ')} |`; + const body = rows + .map((row) => { + return `| ${columns + .map((col) => { + const val = row[col]; + if (val === null || val === undefined) return 'NULL'; + const str = typeof val === 'object' ? JSON.stringify(val) : String(val); + return str.replace(/\|/g, '\\|').replace(/\n/g, ' '); + }) + .join(' | ')} |`; + }) + .join('\n'); + return `${header}\n${separator}\n${body}`; + } + + private rowsToSqlInsert(rows: any[], columns: string[], tableInfo?: any): string { + if (!tableInfo?.schema || !tableInfo?.table) { + return '-- Table information not available for INSERT script'; + } + const tableName = `"${String(tableInfo.schema).replace(/"/g, '""')}"."${String( + tableInfo.table, + ).replace(/"/g, '""')}"`; + const cols = columns.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(', '); + + return rows + .map((row) => { + const values = columns + .map((col) => { + const val = row[col]; + if (val === null || val === undefined) return 'NULL'; + if (typeof val === 'number') return String(val); + if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE'; + const str = typeof val === 'object' ? JSON.stringify(val) : String(val); + return `'${str.replace(/'/g, "''")}'`; + }) + .join(', '); + return `INSERT INTO ${tableName} (${cols}) VALUES (${values});`; + }) + .join('\n'); + } } export class ShowErrorMessageHandler implements IMessageHandler { @@ -342,6 +530,85 @@ export class ShowErrorMessageHandler implements IMessageHandler { } } +export class RunDerivedQueryHandler implements IMessageHandler { + private isReadOnlyQuery(query: string): boolean { + const clean = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim(); + return /^\s*(SELECT|WITH)\b/i.test(clean); + } + + async handle(message: any, context: { editor?: vscode.NotebookEditor }) { + if (!context.editor) { + return; + } + + const sql = typeof message?.query === 'string' ? message.query.trim() : ''; + if (!sql) { + vscode.window.showErrorMessage('No derived query provided.'); + return; + } + if (!this.isReadOnlyQuery(sql)) { + vscode.window.showErrorMessage('Only SELECT/WITH derived queries are allowed.'); + return; + } + + const notebook = context.editor.notebook; + const insertionIndex = Math.min( + notebook.cellCount, + Math.max(0, (context.editor.selection?.end ?? notebook.cellCount)), + ); + + const fullDatasetRequested = + message?.fullDataset === true || + (typeof message?.source === 'string' && message.source.startsWith('streaming-')); + const cellSql = fullDatasetRequested ? `-- pgstudio:full-dataset\n${sql}` : sql; + const cell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, cellSql, 'sql'); + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(insertionIndex, [cell])]); + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + vscode.window.showErrorMessage('Failed to insert derived query cell.'); + return; + } + + const editor = await vscode.window.showNotebookDocument(notebook, { preserveFocus: false }); + const range = new vscode.NotebookRange(insertionIndex, insertionIndex + 1); + editor.revealRange(range, vscode.NotebookEditorRevealType.InCenterIfOutsideViewport); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [range], + document: notebook.uri, + }); + } +} + +const SKIP_GRID_COMMIT_CONFIRM_KEY = 'postgresExplorer.skipGridCommitConfirm'; + +/** Persist "don't ask again" for notebook grid Commit β†’ saveChanges flow. */ +export class GridCommitPreferenceHandler implements IMessageHandler { + constructor(private readonly extensionContext: vscode.ExtensionContext) {} + + async handle( + message: any, + context: { + postMessage?: (msg: any) => Thenable; + editor?: vscode.NotebookEditor; + }, + ): Promise { + if (message.action === 'get' && message.requestId && context.postMessage) { + const skipConfirm = this.extensionContext.globalState.get(SKIP_GRID_COMMIT_CONFIRM_KEY) === true; + await context.postMessage({ + type: 'gridCommitPreferenceResponse', + requestId: message.requestId, + skipConfirm, + }); + return; + } + + if (message.action === 'set' && typeof message.skipConfirm === 'boolean') { + await this.extensionContext.globalState.update(SKIP_GRID_COMMIT_CONFIRM_KEY, message.skipConfirm); + } + } +} + export class RetryCellHandler implements IMessageHandler { async handle(message: any, context: { editor?: vscode.NotebookEditor }) { if (!context.editor) return; @@ -353,6 +620,48 @@ export class RetryCellHandler implements IMessageHandler { } } +/** Result hover toolbar: focus source cell then optional command (Expand / Ask AI / Save). */ +export class NotebookOutputToolbarHandler implements IMessageHandler { + async handle(message: any, context: { editor?: vscode.NotebookEditor }): Promise { + const idx = typeof message?.cellIndex === 'number' ? message.cellIndex : -1; + const action = message?.action as string | undefined; + const editor = context.editor; + + if (!editor || idx < 0 || idx >= editor.notebook.cellCount) { + vscode.window.showWarningMessage( + 'Could not find the SQL cell for this result. Re-run the query to refresh the output.', + ); + return; + } + + const notebook = editor.notebook; + const cell = notebook.cellAt(idx); + const range = new vscode.NotebookRange(idx, idx + 1); + + await vscode.window.showNotebookDocument(notebook, { preserveFocus: false }); + const active = vscode.window.activeNotebookEditor; + if (!active || active.notebook.uri.toString() !== notebook.uri.toString()) { + return; + } + + active.selection = range; + active.revealRange(range, vscode.NotebookEditorRevealType.InCenterIfOutsideViewport); + + if (action === 'expand') { + return; + } + + if (action === 'aiAssist') { + await vscode.commands.executeCommand('postgres-explorer.aiAssist', cell); + return; + } + + if (action === 'saveQuery') { + await vscode.commands.executeCommand('postgres-explorer.saveQueryToLibraryUI'); + } + } +} + export class ShowConnectionInfoHandler implements IMessageHandler { async handle(message: any, context: { editor?: vscode.NotebookEditor }) { if (!context.editor) return; diff --git a/src/services/handlers/CursorStreamBannerHandler.ts b/src/services/handlers/CursorStreamBannerHandler.ts new file mode 100644 index 0000000..64124bc --- /dev/null +++ b/src/services/handlers/CursorStreamBannerHandler.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; +import { IMessageHandler } from '../MessageHandler'; +import { CursorStreamBannerPolicy } from '../CursorStreamBannerPolicy'; + +/** Notebook renderer: user dismissed streaming cursor banner (snooze). */ +export class CursorStreamBannerDismissHandler implements IMessageHandler { + constructor(private readonly context: vscode.ExtensionContext) {} + + async handle(_message: unknown): Promise { + await CursorStreamBannerPolicy.recordDismiss(this.context.workspaceState); + } +} + +/** Notebook renderer: user muted the banner permanently. */ +export class CursorStreamBannerMuteHandler implements IMessageHandler { + constructor(private readonly context: vscode.ExtensionContext) {} + + async handle(_message: unknown): Promise { + await CursorStreamBannerPolicy.recordMuteForever(this.context.globalState); + } +} diff --git a/src/services/handlers/CursorWindowHandler.ts b/src/services/handlers/CursorWindowHandler.ts new file mode 100644 index 0000000..76b3882 --- /dev/null +++ b/src/services/handlers/CursorWindowHandler.ts @@ -0,0 +1,93 @@ +/** + * Handles paginated FETCH requests for sliding-window SELECT results (SCROLL cursor). + */ + +import * as vscode from 'vscode'; +import { IMessageHandler } from '../MessageHandler'; +import { ResultCursorService } from '../ResultCursorService'; +import { safelyPostMessage } from './messaging'; + +export class CursorWindowHandler implements IMessageHandler { + async handle( + message: { + type: string; + requestId: string; + sessionId: string; + pageStartRow: number; + }, + context: { + postMessage?: (msg: any) => Thenable; + } + ): Promise { + const postMessage = context.postMessage; + if (!postMessage) { + return; + } + + const { requestId, sessionId, pageStartRow } = message; + const start = + typeof pageStartRow === 'number' && Number.isFinite(pageStartRow) + ? Math.max(1, Math.floor(pageStartRow)) + : 1; + + try { + const page = await ResultCursorService.fetchPage(sessionId, start); + if (!page) { + await safelyPostMessage( + postMessage, + { + type: 'resultCursorResponse', + requestId, + sessionId, + error: 'Cursor session expired or closed. Re-run the query to refresh results.', + rows: [], + windowStartRow: start, + hasMoreBefore: false, + hasMoreAfter: false, + slidingWindow: undefined, + }, + { contextLabel: 'Cursor window', notifyOnFailure: false }, + ); + return; + } + + await safelyPostMessage( + postMessage, + { + type: 'resultCursorResponse', + requestId, + sessionId, + rows: page.rows, + windowStartRow: page.windowStartRow, + hasMoreBefore: page.hasMoreBefore, + hasMoreAfter: page.hasMoreAfter, + slidingWindow: { + sessionId, + windowStartRow: page.windowStartRow, + windowSize: page.windowSize, + hasMoreBefore: page.hasMoreBefore, + hasMoreAfter: page.hasMoreAfter, + }, + }, + { contextLabel: 'Cursor window', notifyOnFailure: false }, + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + await vscode.window.showWarningMessage(`Could not load result page: ${msg}`); + await safelyPostMessage( + postMessage, + { + type: 'resultCursorResponse', + requestId, + sessionId, + error: msg, + rows: [], + windowStartRow: start, + hasMoreBefore: false, + hasMoreAfter: false, + }, + { contextLabel: 'Cursor window', notifyOnFailure: false }, + ); + } + } +} diff --git a/src/test/unit/CellEditors.test.ts b/src/test/unit/CellEditors.test.ts index 8a8b4cb..ab31747 100644 --- a/src/test/unit/CellEditors.test.ts +++ b/src/test/unit/CellEditors.test.ts @@ -5,6 +5,7 @@ describe('getEditorType', () => { const cases: { type: string; value: unknown; expected: ReturnType }[] = [ { type: 'jsonb', value: {}, expected: 'json' }, { type: 'varchar', value: 'hi', expected: 'longtext' }, + { type: 'varchar', value: 'x'.repeat(501), expected: 'longtext' }, { type: 'text', value: '', expected: 'longtext' }, { type: 'xml', value: '', expected: 'longtext' }, { type: 'interval', value: '1 day', expected: 'longtext' }, @@ -13,7 +14,7 @@ describe('getEditorType', () => { { type: 'box', value: '(1,1),(0,0)', expected: 'longtext' }, { type: 'line', value: '{1,2,3}', expected: 'longtext' }, { type: 'varchar(64)', value: 'x', expected: 'longtext' }, - { type: 'numeric(10,2)', value: '1.00', expected: 'longtext' }, + { type: 'numeric(10,2)', value: '1.00', expected: 'number' }, { type: 'int4range', value: '[1,10)', expected: 'longtext' }, { type: 'bytea', value: '\\xdead', expected: 'longtext' }, { type: 'uuid', value: '00000000-0000-0000-0000-000000000000', expected: 'longtext' }, @@ -21,7 +22,10 @@ describe('getEditorType', () => { { type: 'oid:12345', value: 'enum_val', expected: 'longtext' }, { type: 'int4', value: 1, expected: 'number' }, { type: 'unknown', value: 'x'.repeat(201), expected: 'longtext' }, - { type: '', value: 'short', expected: 'text' }, + { type: '', value: 'short', expected: 'longtext' }, + { type: 'bool', value: true, expected: 'boolean' }, + { type: 'date', value: '2024-01-01', expected: 'date' }, + { type: 'timestamp', value: '2024-01-01T00:00:00Z', expected: 'datetime' }, ]; cases.forEach(({ type, value, expected }) => { diff --git a/src/test/unit/CommonHelpers.test.ts b/src/test/unit/CommonHelpers.test.ts index 4833054..9a6c92b 100644 --- a/src/test/unit/CommonHelpers.test.ts +++ b/src/test/unit/CommonHelpers.test.ts @@ -102,14 +102,30 @@ describe('shared helpers', () => { expect(formatValueForSQL(true, 'boolean')).to.equal('true'); expect(formatValueForSQL("O'Reilly")).to.equal("'O''Reilly'"); - expect(formatValue(null)).to.deep.equal({ text: 'NULL', isNull: true, type: 'null' }); - expect(formatValue(false)).to.deep.equal({ text: 'FALSE', isNull: false, type: 'boolean' }); - expect(formatValue(99)).to.deep.equal({ text: '99', isNull: false, type: 'number' }); - expect(formatValue(new Date('2024-06-15T12:00:00.000Z')).type).to.equal('date'); + expect(formatValue(null)).to.deep.equal({ text: '[null]', isNull: true, type: 'null', kind: 'null' }); + expect(formatValue(false)).to.deep.equal({ + text: 'FALSE', + isNull: false, + type: 'boolean', + kind: 'boolean-false', + }); + expect(formatValue(99)).to.deep.equal({ text: '99', isNull: false, type: 'number', kind: 'number' }); + expect(formatValue(new Date('2024-06-15T12:00:00.000Z')).type).to.equal('timestamp'); expect(formatValue({ key: 'value' }).text).to.equal('{"key":"value"}'); - expect(formatValue('2024-06-15T12:00:00.000Z', 'timestamp').type).to.equal('timestamp'); - expect(formatValue('2024-06-15', 'date').type).to.equal('date'); - expect(formatValue('12:30:00', 'timetz').type).to.equal('time'); + expect( + formatValue({ type: 'Buffer', data: [0xde, 0xad] }, 'bytea', { byteaDisplayFormat: 'hex0x' }).text, + ).to.equal('0xdead'); + expect( + formatValue({ type: 'Buffer', data: [0xde, 0xad] }, 'bytea', { byteaDisplayFormat: 'postgresql' }).text, + ).to.equal('\\xdead'); + expect(formatValue({ type: 'Buffer', data: [1, 2] }, 'bytea', { byteaDisplayFormat: 'json' }).kind).to.equal( + 'bytea', + ); + expect(formatValue('2024-06-15T12:00:00.000Z', 'timestamp').text).to.equal( + '2024-06-15T12:00:00.000Z', + ); + expect(formatValue('2024-06-15', 'date').text).to.equal('2024-06-15'); + expect(formatValue('12:30:00', 'timetz').text).to.equal('12:30:00'); expect(rgbaToHex('rgba(255, 0, 17, 0.6)')).to.equal('#ff0011'); expect(rgbaToHex('not rgba')).to.equal('not rgba'); diff --git a/src/test/unit/RendererSurface.test.ts b/src/test/unit/RendererSurface.test.ts index 5b1e0b5..f05a071 100644 --- a/src/test/unit/RendererSurface.test.ts +++ b/src/test/unit/RendererSurface.test.ts @@ -116,7 +116,7 @@ describe('renderer surface components', () => { const analyze = sandbox.spy(); const optimize = sandbox.spy(); - const actionBar = createActionBar({ + const actionBarParts = createActionBar({ onSelectAll: selectAll, onCopy: copy, onImport: importData, @@ -126,28 +126,27 @@ describe('renderer surface components', () => { onOptimize: optimize, }); + const actionBar = actionBarParts.container; const buttons = Array.from(actionBar.querySelectorAll('button')) as HTMLButtonElement[]; - expect(buttons.map(btn => btn.textContent)).to.deep.equal([ - '☐ Select All', - '⎘ Copy', - '⬆ Import', - '↓ Export', - '✦ Send to Chat', - 'β—Ž Analyze with AI', - '⚑ Optimize', - ]); + expect(buttons.length).to.equal(5); + expect(buttons[0].textContent).to.contain('All'); buttons[0].click(); buttons[1].click(); buttons[2].click(); buttons[3].click(); + expect(exportData.firstCall.args[0]).to.equal(buttons[3]); buttons[4].click(); - buttons[5].click(); - buttons[6].click(); + for (const label of ['Send to Chat', 'Analyze data', 'Optimize query']) { + const item = Array.from(document.querySelectorAll('body div')).find( + (d) => d.textContent === label, + ); + expect(item, label).to.exist; + (item as HTMLElement).click(); + } expect(selectAll.calledOnce).to.be.true; expect(copy.calledOnce).to.be.true; expect(importData.calledOnce).to.be.true; expect(exportData.calledOnce).to.be.true; - expect(exportData.firstCall.args[0]).to.equal(buttons[3]); expect(sendToChat.calledOnce).to.be.true; expect(analyze.calledOnce).to.be.true; expect(optimize.calledOnce).to.be.true; diff --git a/src/test/unit/chat/ChatScripts.test.ts b/src/test/unit/chat/ChatScripts.test.ts new file mode 100644 index 0000000..4f675de --- /dev/null +++ b/src/test/unit/chat/ChatScripts.test.ts @@ -0,0 +1,120 @@ +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { JSDOM } from 'jsdom'; + +describe('chat template scroll behavior', () => { + const scriptPath = path.join(process.cwd(), 'templates/chat/scripts.js'); + + let sandbox: sinon.SinonSandbox; + let dom: JSDOM; + let windowRef: Window & typeof globalThis; + let messagesContainer: HTMLElement; + let scrollIntoViewSpy: sinon.SinonSpy; + let scrollToSpy: sinon.SinonSpy; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + dom = new JSDOM( + ` + + +
+
+
+ + + + + + +
+
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+
+ + `, + { runScripts: 'outside-only', url: 'http://localhost/' } + ); + + windowRef = dom.window as Window & typeof globalThis; + (windowRef as any).acquireVsCodeApi = () => ({ postMessage: sandbox.stub() }); + + scrollIntoViewSpy = sandbox.spy(); + Object.defineProperty(windowRef.HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoViewSpy + }); + + messagesContainer = windowRef.document.getElementById('messagesContainer') as HTMLElement; + scrollToSpy = sandbox.spy(); + (messagesContainer as any).scrollTo = scrollToSpy; + Object.defineProperty(messagesContainer, 'scrollHeight', { + configurable: true, + get: () => 4800, + }); + + windowRef.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as typeof window.requestAnimationFrame; + + windowRef.eval(fs.readFileSync(scriptPath, 'utf8')); + }); + + afterEach(() => { + sandbox.restore(); + dom.window.close(); + }); + + it('anchors the assistant message to the start when the last turn is the model', () => { + windowRef.renderMessages( + [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Here is the answer.' } + ], + false + ); + + const assistantMessage = windowRef.document.querySelector('.message.assistant') as HTMLElement; + expect(assistantMessage).to.exist; + expect(scrollIntoViewSpy.callCount).to.be.greaterThan(0); + const lastCall = scrollIntoViewSpy.getCall(scrollIntoViewSpy.callCount - 1); + expect((lastCall.thisValue as HTMLElement).classList.contains('assistant')).to.equal( + true, + 'last scrollIntoView should be on the assistant turn' + ); + expect(lastCall.args[0]).to.deep.include({ block: 'start' }); + expect(scrollToSpy.called).to.be.false; + }); + + it('scrolls to composer when the last turn is the user (after send)', () => { + windowRef.renderMessages([{ role: 'user', content: 'Next question' }], false); + + const userMessage = windowRef.document.querySelector('.message.user') as HTMLElement; + expect(userMessage).to.exist; + expect(scrollToSpy.called).to.be.true; + const inputWrap = windowRef.document.getElementById('inputWrapper') as HTMLElement; + expect(scrollIntoViewSpy.called).to.be.true; + const lastIv = scrollIntoViewSpy.getCall(scrollIntoViewSpy.callCount - 1); + expect(lastIv.thisValue).to.equal(inputWrap); + expect(lastIv.args[0]).to.deep.include({ block: 'end' }); + }); +}); diff --git a/src/ui/renderer/renderer_v2.ts b/src/ui/renderer/renderer_v2.ts index 14caa97..f0b7957 100644 --- a/src/ui/renderer/renderer_v2.ts +++ b/src/ui/renderer/renderer_v2.ts @@ -1,29 +1,62 @@ import type { ActivationFunction } from 'vscode-notebook-renderer'; import { Chart, registerables } from 'chart.js'; import { - createButton, - createTab, - createBreadcrumb, - BreadcrumbSegment, -} from '../../renderer/components/ui'; -import { createExportButton } from '../../renderer/features/export'; + createExportButton, + positionExportDropdown, + setExportToolbarButtonLabel, + EXPORT_MENU_Z_INDEX, +} from '../../renderer/features/export'; import { TableRenderer, TableEvents } from '../../renderer/components/table/TableRenderer'; import { ChartRenderer } from '../../renderer/components/chart/ChartRenderer'; import { ChartControls } from '../../renderer/components/chart/ChartControls'; import { ExplainVisualizer } from '../../renderer/components/ExplainVisualizer'; import { createErrorPanel } from '../../renderer/components/ErrorPanel'; -import { createActionBar } from '../../renderer/components/ActionBar'; +import { + createAiMenuButton, + type AiMenuOptions, + type RowToolsOptions, +} from '../../renderer/components/ActionBar'; +import { + RESULT_TOOLBAR_ICON_CLASS, + RESULT_TOOLBAR_LABEL_CLASS, + applyResultRowToolStyle, + applyResultViewTabStyle, + attachResultRowToolInteractions, + attachResultViewTabHover, + fillToolbarButtonContent, + fillOutputHoverToolButton, + resultToolbarSvg, + type ResultToolbarGlyph, +} from '../../renderer/components/ResultToolbarUi'; +import { createResultIdentityBar } from '../../renderer/components/ResultIdentityBar'; +import { createInlineBanner } from '../../renderer/components/InlineBanner'; +import { openCommitConfirmDialog } from '../../renderer/components/CommitConfirmDialog'; +import { + createResultFooter, + formatResultExecutionStats, +} from '../../renderer/components/ResultFooter'; import { showImportModal } from '../../renderer/features/import'; import { createTransactionBanner } from '../../renderer/components/TransactionBanner'; -import { parseBreadcrumbFromSql } from '../../renderer/utils/sqlParsing'; +import { buildQueryPreview } from '../../renderer/utils/queryPreview'; import { addResultToHistory, getResultHistory, renderTabStrip, } from '../../renderer/components/ResultTabStrip'; import { renderTransposeTable } from '../../renderer/components/TransposeView'; -import { renderAnalystPanel } from '../../renderer/components/analyst/AnalystPanel'; -import type { NoticeLogEntry } from '../../common/types'; +import { + renderAnalystPanel, + type PivotAiHelpContext, +} from '../../renderer/components/analyst/AnalystPanel'; +import { + BYTEA_DISPLAY_DEFAULT, + type ByteaDisplayFormat, + type NoticeLogEntry, + type QueryResults, + type FilterState, + type SortState, + type TableRenderOptions, +} from '../../common/types'; import { normalizeNoticesPayload, renderNoticesLiveStream, @@ -96,6 +129,69 @@ function clearTransactionUI(): void { document.querySelectorAll('.amber-gutter').forEach((el) => el.classList.remove('amber-gutter')); } +const PIVOT_HELP_SQL_INLINE_MAX_CHARS = 12000; + +/** Rows attached as CSV when using Send to Chat from results (full grids are rarely useful). */ +const CHAT_SEND_SAMPLE_ROW_CAP = 10; + +function buildChatResultsSampleJson( + columns: string[], + rows: unknown[], + maxRows: number, +): string | undefined { + if (maxRows <= 0 || rows.length === 0) { + return undefined; + } + return JSON.stringify({ + columns, + rows: rows.slice(0, maxRows), + }); +} + +/** User message for SQL Assistant when pivot cardinality exceeds the client cap. */ +function buildPivotOptimizeUserMessage(ctx: PivotAiHelpContext, sourceSql: string): string { + const trimmed = sourceSql.trim(); + let sqlInline = trimmed; + let truncationNote = ''; + if (trimmed.length > PIVOT_HELP_SQL_INLINE_MAX_CHARS) { + sqlInline = trimmed.slice(0, PIVOT_HELP_SQL_INLINE_MAX_CHARS); + truncationNote = `\n-- … truncated for chat prompt (${trimmed.length.toLocaleString()} chars total); full SQL is attached as a file.`; + } + + const valueLine = + ctx.aggregation === 'count' && !ctx.valueColumn + ? 'Count rows (no separate value column)' + : ctx.valueColumn ?? 'β€”'; + + return [ + 'PgStudio Analyst tab: the in-browser pivot failed because there are too many distinct row or column labels.', + '', + 'Help me rewrite my PostgreSQL query using server-side pre-aggregation (GROUP BY, rollups, bucketing, date_trunc, FILTER, CASE expressions, etc.) so pivot dimensions stay within a manageable cardinality.', + '', + `Pivot error: ${ctx.errorMessage}`, + '', + 'Pivot configuration:', + `- Row dimension: ${ctx.rowDimension}`, + `- Column dimension: ${ctx.columnDimension}`, + `- Value column / measure: ${valueLine}`, + `- Aggregation: ${ctx.aggregation}`, + '', + 'Context:', + `- UI cap (distinct values per axis): ${ctx.maxDistinctPerAxis}`, + `- Rows currently in this result grid: ${ctx.inMemoryRowCount.toLocaleString()}`, + `- Streaming sliding window: ${ctx.isStreamingWindow ? 'yes (only a subset of server rows may be loaded)' : 'no'}`, + '', + 'No result grid CSV is attached (usually redundant here; use the attached SQL file and pivot fields above).', + '', + 'Source SQL (also attached as a .sql file):', + '```sql', + sqlInline + truncationNote, + '```', + '', + 'Please propose efficient PostgreSQL that returns an aggregation-friendly result set I can pivot in the notebook, plus any index notes if relevant.', + ].join('\n'); +} + export const activate: ActivationFunction = (context) => { return { renderOutputItem(data, element) { @@ -128,24 +224,46 @@ export const activate: ActivationFunction = (context) => { notices, executionTime, tableInfo, - success, columnTypes, backendPid, breadcrumb, autoLimitApplied, autoLimitValue, } = json; + const exportQuery: string | undefined = + typeof json.exportQuery === 'string' && json.exportQuery.trim().length > 0 + ? json.exportQuery + : query; + + let slideMeta: QueryResults['slidingWindow'] = json.slidingWindow; + + const byteaDisplayFormat: ByteaDisplayFormat = + json.byteaDisplayFormat === 'postgresql' || + json.byteaDisplayFormat === 'json' || + json.byteaDisplayFormat === 'hex0x' + ? json.byteaDisplayFormat + : BYTEA_DISPLAY_DEFAULT; const noticeItems = normalizeNoticesPayload(notices); + const sourceCellIndex = + typeof json.sourceCellIndex === 'number' && json.sourceCellIndex >= 0 + ? json.sourceCellIndex + : -1; + // Transaction state from payload const transactionState: { isActive: boolean; statementCount: number } | undefined = json.transactionState; const pendingCommit: boolean = !!json.pendingCommit; // Data Management - const originalRows: any[] = rows ? JSON.parse(JSON.stringify(rows)) : []; + let originalRows: any[] = rows ? JSON.parse(JSON.stringify(rows)) : []; let currentRows: any[] = rows ? JSON.parse(JSON.stringify(rows)) : []; + let slideBufferedStartRow = slideMeta?.windowStartRow ?? 1; + let slideHasMoreBefore = slideMeta?.hasMoreBefore ?? false; + let slideHasMoreAfter = slideMeta?.hasMoreAfter ?? false; + let localFilterState: FilterState = { globalQuery: '', clauses: [] }; + let localSortState: SortState = { column: null, direction: 'none' }; const selectedIndices = new Set(); const modifiedCells = new Map(); const rowsMarkedForDeletion = new Set(); @@ -153,6 +271,168 @@ export const activate: ActivationFunction = (context) => { // FK lookup pending callbacks β€” keyed by requestId const fkCallbacks = new Map void>(); + const buildTableRenderOptions = (): TableRenderOptions => ({ + columns, + rows: currentRows, + originalRows, + columnTypes, + tableInfo, + foreignKeys: tableInfo?.foreignKeys, + initialSelectedIndices: selectedIndices, + modifiedCells, + rowsMarkedForDeletion, + byteaDisplayFormat, + ...(slideMeta?.sessionId ? { rowNumberBaseline: slideBufferedStartRow } : {}), + }); + + const quoteIdentifier = (value: string): string => `"${value.replace(/"/g, '""')}"`; + const escapeSqlLiteral = (value: string): string => value.replace(/'/g, "''"); + const hasActiveLocalFilter = (): boolean => + localFilterState.globalQuery.trim().length > 0 || localFilterState.clauses.length > 0; + const hasActiveLocalSort = (): boolean => + !!localSortState.column && localSortState.direction !== 'none'; + const buildDerivedQueryFromLocalScope = (): string | undefined => { + const base = (exportQuery || query || '').trim(); + if (!base) return undefined; + const baseNoSemicolon = base.replace(/;\s*$/, ''); + const alias = 'pgstudio_src'; + const globalParts: string[] = []; + const whereParts: string[] = []; + + const appendLikeCondition = (column: string, mode: 'contains' | 'startsWith' | 'endsWith', raw: string) => { + const v = escapeSqlLiteral(raw); + const pattern = mode === 'contains' ? `%${v}%` : mode === 'startsWith' ? `${v}%` : `%${v}`; + whereParts.push(`CAST(${alias}.${quoteIdentifier(column)} AS text) ILIKE '${pattern}'`); + }; + + const globalQuery = localFilterState.globalQuery.trim(); + if (globalQuery) { + const pat = `%${escapeSqlLiteral(globalQuery)}%`; + for (const c of columns) { + globalParts.push(`CAST(${alias}.${quoteIdentifier(c)} AS text) ILIKE '${pat}'`); + } + if (globalParts.length > 0) { + whereParts.push(`(${globalParts.join(' OR ')})`); + } + } + + for (const clause of localFilterState.clauses) { + if (!columns.includes(clause.column)) continue; + const value = clause.value ?? ''; + if (clause.operator === 'equals') { + whereParts.push( + `CAST(${alias}.${quoteIdentifier(clause.column)} AS text) = '${escapeSqlLiteral(value)}'`, + ); + } else if (clause.operator === 'contains') { + appendLikeCondition(clause.column, 'contains', value); + } else if (clause.operator === 'startsWith') { + appendLikeCondition(clause.column, 'startsWith', value); + } else if (clause.operator === 'endsWith') { + appendLikeCondition(clause.column, 'endsWith', value); + } + } + + const hasWhere = whereParts.length > 0; + const sortColumn = + localSortState.column && columns.includes(localSortState.column) + ? localSortState.column + : null; + const hasSort = !!sortColumn && localSortState.direction !== 'none'; + + if (!hasWhere && !hasSort) { + return undefined; + } + + const whereSql = hasWhere ? `\nWHERE ${whereParts.join('\n AND ')}` : ''; + const orderSql = hasSort + ? `\nORDER BY ${alias}.${quoteIdentifier(sortColumn!)} ${localSortState.direction.toUpperCase()}` + : ''; + + return `SELECT *\nFROM (\n${baseNoSemicolon}\n) AS ${alias}${whereSql}${orderSql};`; + }; + + const buildFullDatasetRerunQuery = (): string | undefined => { + const scoped = buildDerivedQueryFromLocalScope(); + if (scoped) { + return scoped; + } + const base = (exportQuery || query || '').trim(); + if (!base) { + return undefined; + } + return base.endsWith(';') ? base : `${base};`; + }; + + const createAnalyticsStreamingWarning = ( + modeLabel: 'Chart' | 'Analyst', + ): HTMLElement | null => { + if (!slideMeta?.sessionId) { + return null; + } + const banner = createInlineBanner({ + severity: 'warning', + message: `${modeLabel} in streaming mode uses loaded rows only. Run on full dataset for accurate results; this may have performance impact depending on local machine capacity.`, + actionLabel: 'Run on full dataset', + onAction: () => { + const rerunQuery = buildFullDatasetRerunQuery(); + if (!rerunQuery) { + context.postMessage?.({ + type: 'showErrorMessage', + message: 'No query available to rerun for full dataset.', + }); + return; + } + context.postMessage?.({ + type: 'runDerivedQuery', + query: rerunQuery, + source: `streaming-${modeLabel.toLowerCase()}-full-dataset`, + fullDataset: true, + }); + }, + dismissible: false, + }); + banner.setAttribute('data-streaming-analytics-hint', modeLabel.toLowerCase()); + return banner; + }; + + const refreshStreamingScopeNotice = (): void => { + mainContainer.querySelector('[data-streaming-scope-hint="true"]')?.remove(); + if (!slideMeta?.sessionId) return; + const activeFilter = hasActiveLocalFilter(); + const activeSort = hasActiveLocalSort(); + if (!activeFilter && !activeSort) return; + + const scopeBits: string[] = []; + if (activeFilter) scopeBits.push('filter'); + if (activeSort) scopeBits.push('sort'); + const msg = `Streaming mode: ${scopeBits.join(' + ')} is applied to loaded rows only.`; + + const hint = createInlineBanner({ + severity: 'warning', + message: msg, + actionLabel: 'Apply to full dataset', + onAction: () => { + const derived = buildDerivedQueryFromLocalScope(); + if (!derived) { + context.postMessage?.({ + type: 'showErrorMessage', + message: 'No active local filter/sort to apply.', + }); + return; + } + context.postMessage?.({ + type: 'runDerivedQuery', + query: derived, + source: 'streaming-local-scope', + fullDataset: true, + }); + }, + dismissible: false, + }); + hint.setAttribute('data-streaming-scope-hint', 'true'); + mainContainer.appendChild(hint); + }; + // Result history for tab strip β€” persists across re-renders in same output element const historyEntry = { columns, @@ -165,6 +445,7 @@ export const activate: ActivationFunction = (context) => { query, notices: noticeItems.length ? [...noticeItems] : undefined, timestamp: Date.now(), + byteaDisplayFormat, }; const resultHistory = addResultToHistory(element, historyEntry); @@ -182,205 +463,122 @@ export const activate: ActivationFunction = (context) => { box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08); `; - // Header - const header = document.createElement('div'); - header.style.cssText = ` - padding: 6px 12px; - border-bottom: 1px solid var(--vscode-widget-border); - cursor: pointer; display: flex; align-items: center; gap: 8px; user-select: none; - background: ${success ? 'color-mix(in srgb, var(--vscode-testing-iconPassed) 18%, var(--vscode-editor-background))' : 'color-mix(in srgb, var(--vscode-editor-background) 85%, var(--vscode-sideBar-background))'}; - `; - if (success) { - header.style.borderLeft = '4px solid var(--vscode-testing-iconPassed)'; - } + const contentContainer = document.createElement('div'); + contentContainer.style.cssText = 'display: flex; flex-direction: column; height: 100%;'; + + let switchTab: (mode: string) => void = () => {}; + let showOverflowMenu: (anchorEl: HTMLElement) => void = () => {}; + + let isExpanded = true; + + const updateIdentityStats = (): void => { + const el = mainContainer.querySelector('[data-result-stats]') as HTMLElement | null; + if (!el) return; + let text: string; + if (slideMeta) { + const lastRow = slideMeta.windowStartRow + Math.max(currentRows.length, 1) - 1; + text = `${slideMeta.windowStartRow.toLocaleString()}–${lastRow.toLocaleString()} Β· window ${slideMeta.windowSize.toLocaleString()} Β· streaming`; + if (executionTime !== undefined) { + const ms = Math.round(executionTime * 1000); + text += ms >= 1000 ? ` Β· ${executionTime.toFixed(2)}s` : ` Β· ${ms}ms`; + } + } else { + text = formatResultExecutionStats(currentRows.length, executionTime); + } + el.textContent = text; + el.style.display = text.trim() ? 'inline-block' : 'none'; + }; + + const identityBar = createResultIdentityBar({ + queryPreview: buildQueryPreview(query, (command || 'QUERY').toUpperCase()), + queryFull: query, + command, + statsLine: json.error + ? undefined + : slideMeta + ? (() => { + const lastRow = slideMeta.windowStartRow + Math.max(rows?.length ?? 0, 1) - 1; + let t = `${slideMeta.windowStartRow.toLocaleString()}–${lastRow.toLocaleString()} Β· window ${slideMeta.windowSize.toLocaleString()} Β· streaming`; + if (executionTime !== undefined) { + const ms = Math.round(executionTime * 1000); + t += ms >= 1000 ? ` Β· ${executionTime.toFixed(2)}s` : ` Β· ${ms}ms`; + } + return t; + })() + : formatResultExecutionStats(currentRows.length, executionTime), + isCollapsed: false, + onToggleCollapse: () => { + isExpanded = !isExpanded; + contentContainer.style.display = isExpanded ? 'flex' : 'none'; + const ch = identityBar.querySelector('[data-chevron]'); + if (ch) { + ch.textContent = isExpanded ? 'β–Ό' : 'β–Ά'; + } + }, + onOverflow: (anchorEl) => showOverflowMenu(anchorEl), + onExpand: () => + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'expand', + cellIndex: sourceCellIndex, + }), + }); + mainContainer.appendChild(identityBar); - const chevron = document.createElement('span'); - chevron.textContent = 'β–Ό'; - const chevronBase = 'font-size: 10px; display: inline-block;'; - chevron.style.cssText = prefersReducedMotion() - ? chevronBase - : `${chevronBase} transition: transform 0.2s;`; - - const title = document.createElement('span'); - title.textContent = command || 'QUERY'; - title.style.cssText = 'font-weight: 600; text-transform: uppercase;'; - - const summary = document.createElement('span'); - summary.style.marginLeft = 'auto'; - summary.style.opacity = '0.7'; - summary.style.fontSize = '0.9em'; - - let summaryText = ''; - if (rowCount !== undefined && rowCount !== null) summaryText += `${rowCount} rows`; - if (noticeItems.length) - summaryText += summaryText - ? `, ${noticeItems.length} notices` - : `${noticeItems.length} notices`; - if (executionTime !== undefined) - summaryText += summaryText - ? `, ${executionTime.toFixed(3)}s` - : `${executionTime.toFixed(3)}s`; - if (!summaryText) summaryText = 'No results'; - summary.textContent = summaryText; - - header.appendChild(chevron); - header.appendChild(title); - header.appendChild(summary); - - // Pending commit badge β€” shown when result was produced inside an open transaction if (autoLimitApplied) { - const limitBadge = document.createElement('span'); - limitBadge.textContent = - autoLimitValue !== undefined ? `LIMIT ${autoLimitValue} applied` : 'LIMIT applied'; - limitBadge.title = 'A row limit was appended to this SELECT by settings (auto-limit).'; - limitBadge.style.cssText = ` - font-size: 10px; - font-weight: 600; - padding: 2px 6px; - border-radius: 10px; - background: color-mix(in srgb, var(--vscode-textLink-foreground) 15%, transparent); - color: var(--vscode-textLink-foreground); - border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 40%, transparent); - margin-left: 8px; - text-transform: uppercase; - letter-spacing: 0.04em; - `; - header.appendChild(limitBadge); + const limitMsg = + autoLimitValue !== undefined + ? `Auto-LIMIT applied: showing ${rowCount?.toLocaleString() ?? '?'} rows (limit ${autoLimitValue})` + : 'A row limit was appended to this SELECT.'; + mainContainer.appendChild(createInlineBanner({ severity: 'info', message: limitMsg })); } - if (pendingCommit) { - const pendingBadge = document.createElement('span'); - pendingBadge.textContent = 'pending commit'; - pendingBadge.style.cssText = ` - font-size: 10px; - font-weight: 600; - padding: 2px 6px; - border-radius: 10px; - background: rgba(255, 176, 0, 0.25); - color: #ffb000; - border: 1px solid rgba(255, 176, 0, 0.5); - margin-left: 8px; - text-transform: uppercase; - letter-spacing: 0.04em; - `; - header.appendChild(pendingBadge); + if (slideMeta && json.showSlidingCursorBanner === true && !json.error) { + mainContainer.appendChild( + createInlineBanner({ + severity: 'info', + message: + 'Server-side cursor: only one window of rows is loaded at a time. Scroll the grid near the top or bottom edge to fetch the previous or next page.', + onDismiss: () => context.postMessage?.({ type: 'cursorStreamBannerDismiss' }), + onMuteForever: () => context.postMessage?.({ type: 'cursorStreamBannerMute' }), + }), + ); } - mainContainer.appendChild(header); - - // Performance Warning if (json.performanceAnalysis?.isDegraded || json.slowQuery) { - const perfBanner = document.createElement('div'); const degraded = Boolean(json.performanceAnalysis?.isDegraded); - const warningText = degraded - ? json.performanceAnalysis.analysis - : 'This query crossed the slow-query threshold. Consider reviewing indexes and filters.'; - perfBanner.style.cssText = ` - padding: 6px 12px; - background: ${degraded ? 'rgba(255, 165, 0, 0.15)' : BRAND_ACCENT_MUTED}; - border-bottom: 1px solid ${degraded ? 'rgba(255, 165, 0, 0.3)' : BRAND_ACCENT}; - color: var(--vscode-editorWarning-foreground); - font-size: 11px; - display: flex; align-items: center; gap: 6px; - `; - perfBanner.innerHTML = `${degraded ? '⚠️' : '🐘'} ${warningText}`; - mainContainer.appendChild(perfBanner); + const perfMsg = degraded + ? json.performanceAnalysis!.analysis + : 'Slow query detected. Consider reviewing indexes and filters.'; + mainContainer.appendChild( + createInlineBanner({ severity: degraded ? 'warning' : 'info', message: perfMsg }), + ); } - // Breadcrumb Navigation - if (breadcrumb) { - // Auto-populate schema/table from SQL when not provided in the payload (12.1) - let resolvedSchema = breadcrumb.schema; - let resolvedTable = breadcrumb.object?.name; - if ((!resolvedSchema || !resolvedTable) && query) { - const parsed = parseBreadcrumbFromSql(query); - if (!resolvedSchema && parsed.schema) { - resolvedSchema = parsed.schema; - } - if (!resolvedTable && parsed.table) { - resolvedTable = parsed.table; - } - } - - const segments: BreadcrumbSegment[] = []; - - if (breadcrumb.connectionName) { - segments.push({ label: breadcrumb.connectionName, id: 'connection', type: 'connection' }); - } - if (breadcrumb.database) { - segments.push({ label: breadcrumb.database, id: 'database', type: 'database' }); - } - if (resolvedSchema) { - segments.push({ - label: resolvedSchema, - id: 'schema', - type: 'schema', - onClick: () => { - context.postMessage?.({ - type: 'breadcrumbNavigate', - segment: resolvedSchema, - segmentType: 'schema', - }); - }, - }); - } - if (resolvedTable) { - segments.push({ - label: resolvedTable, - id: 'object', - type: 'object', - isLast: true, - }); - } - - // Mark last segment - if (segments.length > 0) { - segments[segments.length - 1].isLast = true; - } + if (noticeItems.length > 0) { + mainContainer.appendChild( + createInlineBanner({ + severity: 'warning', + message: `${noticeItems.length} notice${noticeItems.length !== 1 ? 's' : ''} from PostgreSQL`, + actionLabel: 'View', + onAction: () => switchTab('notices'), + }), + ); + } - const breadcrumbEl = createBreadcrumb(segments, { - onConnectionDropdown: (anchorEl: HTMLElement) => { - // Also emit breadcrumbNavigate for connection (12.2) - context.postMessage?.({ - type: 'breadcrumbNavigate', - segment: breadcrumb.connectionName, - segmentType: 'connection', - }); - context.postMessage?.({ - type: 'showConnectionSwitcher', - connectionId: breadcrumb.connectionId, - }); - }, - onDatabaseDropdown: (anchorEl: HTMLElement) => { - // Also emit breadcrumbNavigate for database (12.2) - context.postMessage?.({ - type: 'breadcrumbNavigate', - segment: breadcrumb.database, - segmentType: 'database', - }); - context.postMessage?.({ - type: 'showDatabaseSwitcher', - connectionId: breadcrumb.connectionId, - currentDatabase: breadcrumb.database, - }); - }, - }); - mainContainer.appendChild(breadcrumbEl); + if (pendingCommit) { + mainContainer.appendChild( + createInlineBanner({ + severity: 'info', + message: + 'This result was produced inside an open transaction β€” changes are not durable until COMMIT.', + dismissible: false, + }), + ); } - // Content Container - const contentContainer = document.createElement('div'); - contentContainer.style.cssText = 'display: flex; flex-direction: column; height: 100%;'; mainContainer.appendChild(contentContainer); - let isExpanded = true; - header.onclick = () => { - isExpanded = !isExpanded; - contentContainer.style.display = isExpanded ? 'flex' : 'none'; - chevron.style.transform = isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)'; - header.style.borderBottom = isExpanded ? '1px solid var(--vscode-widget-border)' : 'none'; - }; - // Error Section if (json.error) { const errorPanel = createErrorPanel({ @@ -405,207 +603,112 @@ export const activate: ActivationFunction = (context) => { const exportBtn = createExportButton(columns, currentRows, tableInfo, context, query); exportBtn.style.display = 'none'; - // Actions Bar β€” built with ActionBar component - const actionsBar = createActionBar({ - onSelectAll: () => { - // Toggle: if all rows are selected, deselect all; otherwise select all - if (selectedIndices.size === currentRows.length && currentRows.length > 0) { - selectedIndices.clear(); - } else { - currentRows.forEach((_: any, i: number) => selectedIndices.add(i)); - } - tableRenderer.updateSelection(selectedIndices); - updateActionsVisibility(); - }, - onCopy: () => { - // Copy selected rows (or all rows if none selected) to clipboard as CSV - const rowsToCopy = - selectedIndices.size > 0 - ? Array.from(selectedIndices).map((i) => currentRows[i]) - : currentRows; - const escapeCSV = (val: any): string => { - if (val === null || val === undefined) return ''; - const str = typeof val === 'object' ? JSON.stringify(val) : String(val); - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; - }; - const csv = [ - columns.map((c: string) => `"${c.replace(/"/g, '""')}"`).join(','), - ...rowsToCopy.map((r: any) => columns.map((c: string) => escapeCSV(r[c])).join(',')), - ].join('\n'); - navigator.clipboard?.writeText(csv); - }, - onImport: () => { - showImportModal(columns, tableInfo, context); - }, - onExport: (anchorBtn: HTMLElement) => { - // Remove existing dropdown if open (toggle) - const existing = document.querySelector('.export-dropdown'); - if (existing) { - existing.remove(); - return; - } + const gridPrefRequestId = `gcp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + let skipGridCommitConfirm = false; + if (!json.error) { + context.postMessage?.({ + type: 'gridCommitPreference', + action: 'get', + requestId: gridPrefRequestId, + }); + } - const stringifyValue = (val: any): string => { - if (val === null || val === undefined) return ''; - if (typeof val === 'object') return JSON.stringify(val); - return String(val); - }; - const getCSV = () => { - const header = columns.map((c: string) => `"${c.replace(/"/g, '""')}"`).join(','); - const body = currentRows - .map((row: any) => - columns - .map((col: string) => { - const str = stringifyValue(row[col]); - return str.includes(',') || str.includes('"') || str.includes('\n') - ? `"${str.replace(/"/g, '""')}"` - : str; - }) - .join(','), - ) - .join('\n'); - return `${header}\n${body}`; + /** Export dropdown for footer row tools + kernel export flows */ + const openResultExportMenu = (anchorBtn: HTMLElement): void => { + const existing = document.querySelector('.export-dropdown'); + if (existing) { + existing.remove(); + return; + } + + const menu = document.createElement('div'); + menu.className = 'export-dropdown'; + menu.style.cssText = + `position:fixed;background:var(--vscode-menu-background);border:1px solid var(--vscode-menu-border);box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:${EXPORT_MENU_Z_INDEX};min-width:160px;border-radius:3px;padding:4px 0;visibility:hidden;`; + + const addItem = (label: string, onClick: () => void) => { + const item = document.createElement('div'); + item.textContent = label; + item.style.cssText = + 'padding:6px 12px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;'; + item.onmouseenter = () => { + item.style.background = 'var(--vscode-menu-selectionBackground)'; + item.style.color = 'var(--vscode-menu-selectionForeground)'; }; - const downloadFile = (content: string, filename: string, type: string) => { - const blob = new Blob([content], { type }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + item.onmouseleave = () => { + item.style.background = 'transparent'; + item.style.color = 'var(--vscode-menu-foreground)'; }; - - const menu = document.createElement('div'); - menu.className = 'export-dropdown'; - menu.style.cssText = - 'position:fixed;background:var(--vscode-menu-background);border:1px solid var(--vscode-menu-border);box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:1000;min-width:160px;border-radius:3px;padding:4px 0;visibility:hidden;'; - - const addItem = (label: string, onClick: () => void) => { - const item = document.createElement('div'); - item.textContent = label; - item.style.cssText = - 'padding:6px 12px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;'; - item.onmouseenter = () => { - item.style.background = 'var(--vscode-menu-selectionBackground)'; - item.style.color = 'var(--vscode-menu-selectionForeground)'; - }; - item.onmouseleave = () => { - item.style.background = 'transparent'; - item.style.color = 'var(--vscode-menu-foreground)'; - }; - item.onclick = (e) => { - e.stopPropagation(); - onClick(); - menu.remove(); - }; - menu.appendChild(item); + item.onclick = (e) => { + e.stopPropagation(); + onClick(); + menu.remove(); }; + menu.appendChild(item); + }; - addItem('Save as CSV', () => - downloadFile(getCSV(), `export_${Date.now()}.csv`, 'text/csv'), - ); - addItem('Save as JSON', () => - downloadFile( - JSON.stringify(currentRows, null, 2), - `export_${Date.now()}.json`, - 'application/json', - ), - ); - addItem('Save as Markdown', () => { - const header = `| ${columns.join(' | ')} |`; - const sep = `| ${columns.map(() => '---').join(' | ')} |`; - const body = currentRows - .map( - (row: any) => - `| ${columns - .map((col: string) => { - const v = row[col]; - if (v === null || v === undefined) return 'NULL'; - return (typeof v === 'object' ? JSON.stringify(v) : String(v)) - .replace(/\|/g, '\\|') - .replace(/\n/g, ' '); - }) - .join(' | ')} |`, - ) - .join('\n'); - downloadFile(`${header}\n${sep}\n${body}`, `export_${Date.now()}.md`, 'text/markdown'); - }); - addItem('Copy to Clipboard', () => { - navigator.clipboard?.writeText(getCSV()).then(() => { - anchorBtn.textContent = 'Copied!'; - setTimeout(() => { - anchorBtn.textContent = '↓ Export'; - }, 2000); - }); + const postExport = ( + format: 'csv' | 'json' | 'markdown' | 'clipboard' | 'sqlinsert', + ): void => { + context.postMessage?.({ + type: 'export_request', + format, + query: exportQuery, + columns, + rows: currentRows, // fallback only if full query export fails + tableInfo, }); - if (tableInfo) { - addItem('Copy SQL INSERT', () => { - const tableName = `"${tableInfo.schema}"."${tableInfo.table}"`; - const cols = columns.map((c: string) => `"${c}"`).join(', '); - const inserts = currentRows - .map((row: any) => { - const vals = columns - .map((col: string) => { - const v = row[col]; - if (v === null || v === undefined) return 'NULL'; - if (typeof v === 'number') return v; - if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE'; - const s = typeof v === 'object' ? JSON.stringify(v) : String(v); - return `'${s.replace(/'/g, "''")}'`; - }) - .join(', '); - return `INSERT INTO ${tableName} (${cols}) VALUES (${vals});`; - }) - .join('\n'); - navigator.clipboard?.writeText(inserts); - }); - } - - document.body.appendChild(menu); + }; - const buttonRect = anchorBtn.getBoundingClientRect(); - const menuWidth = Math.max(180, menu.getBoundingClientRect().width || 180); - const menuHeight = menu.getBoundingClientRect().height || 0; - const viewportPadding = 8; - const spaceBelow = window.innerHeight - buttonRect.bottom - viewportPadding; - const spaceAbove = buttonRect.top - viewportPadding; + addItem('Save as CSV', () => postExport('csv')); + addItem('Save as JSON', () => postExport('json')); + addItem('Save as Markdown', () => postExport('markdown')); + addItem('Copy to Clipboard', () => { + postExport('clipboard'); + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Working...'); + setTimeout(() => { + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Export'); + }, 2000); + }); + if (tableInfo) { + addItem('Copy SQL INSERT', () => { + postExport('sqlinsert'); + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Working...'); + setTimeout(() => { + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Export'); + }, 2000); + }); + } - let top = buttonRect.bottom + 4; - if (menuHeight > 0 && spaceBelow < menuHeight && spaceAbove > spaceBelow) { - top = Math.max(viewportPadding, buttonRect.top - menuHeight - 4); - } + document.body.appendChild(menu); - let left = buttonRect.left; - if (left + menuWidth > window.innerWidth - viewportPadding) { - left = window.innerWidth - menuWidth - viewportPadding; - } - left = Math.max(viewportPadding, left); + positionExportDropdown(menu, anchorBtn); + menu.style.visibility = 'visible'; + setTimeout(() => { + const close = () => { + menu.remove(); + document.removeEventListener('click', close); + }; + document.addEventListener('click', close); + }, 0); + }; - menu.style.left = `${left}px`; - menu.style.top = `${top}px`; - menu.style.visibility = 'visible'; - setTimeout(() => { - const close = () => { - menu.remove(); - document.removeEventListener('click', close); - }; - document.addEventListener('click', close); - }, 0); - }, + const aiMenuCallbacks: AiMenuOptions = { onSendToChat: () => { - const resultsJson = JSON.stringify({ columns, rows: currentRows }); + const resultsJson = buildChatResultsSampleJson( + columns, + currentRows, + CHAT_SEND_SAMPLE_ROW_CAP, + ); context.postMessage?.({ type: 'sendToChat', data: { query: json.query || '', - results: resultsJson, - message: 'I ran this query and got these results. Please help me understand them.', + ...(resultsJson ? { results: resultsJson } : {}), + message: + currentRows.length === 0 + ? 'I ran this query. There were no rows; please help me interpret or fix it.' + : `I ran this query. The attachment includes at most ${CHAT_SEND_SAMPLE_ROW_CAP} sample rows from the result (not the full grid). Please help me understand the results.`, }, }); }, @@ -637,60 +740,7 @@ export const activate: ActivationFunction = (context) => { executionTime: json.executionTime, }); }, - }); - - // Capture left/right groups before appending extra elements - const leftActions = actionsBar.firstElementChild as HTMLElement; - const rightActions = actionsBar.children[2] as HTMLElement; // index 2: right group (0=left, 1=divider, 2=right) - - // Delete button β€” appended to leftActions, shown when rows are selected - const deleteBtn = createButton('πŸ—‘οΈ Delete Selected', true, 'destructive'); - deleteBtn.style.display = 'none'; - deleteBtn.style.marginLeft = '8px'; - leftActions.appendChild(deleteBtn); - - // Detect if this is an EXPLAIN query (either JSON or text format) - const isExplainQuery = - json.explainPlan || - (query && /^\s*EXPLAIN/i.test(query)) || - command === 'EXPLAIN' || - (columns.length === 1 && columns[0] === 'QUERY PLAN'); - - if (isExplainQuery) { - const explainPlanBtn = createButton('🧭 View Plan', true, 'ai'); - explainPlanBtn.title = json.explainPlan - ? 'Open EXPLAIN ANALYZE plan view' - : 'Convert to JSON format and open visual plan view'; - - explainPlanBtn.onclick = () => { - if (json.explainPlan) { - if (explainTab) { - switchTab('explain'); - } else { - context.postMessage?.({ - type: 'showExplainPlan', - plan: json.explainPlan, - query: query || '', - }); - } - } else { - console.log('Converting EXPLAIN to JSON, query:', query); - if (!query) { - alert('Cannot convert EXPLAIN plan: query not available'); - return; - } - context.postMessage?.({ - type: 'convertExplainToJson', - query: query, - }); - } - }; - rightActions.appendChild(explainPlanBtn); - } - - if (!json.error) { - contentContainer.appendChild(actionsBar); - } + }; // Save Changes Logic const parseCellKey = (key: string): { rowIndex: number; colName: string } | null => { @@ -755,128 +805,293 @@ export const activate: ActivationFunction = (context) => { return rowsForDiff; }; + const buildDeletionReviewRows = (): Array<{ + rowIndex: number; + rowLabel: string; + }> => { + const sorted = Array.from(rowsMarkedForDeletion).sort((a, b) => a - b); + return sorted.map((rowIndex) => { + const pkLabel = tableInfo?.primaryKeys?.length + ? tableInfo.primaryKeys + .map((pk: string) => `${pk}=${formatDiffValue(originalRows[rowIndex]?.[pk])}`) + .join(', ') + : `row #${rowIndex + 1}`; + return { + rowIndex, + rowLabel: pkLabel, + }; + }); + }; + const renderReviewChangesView = (): HTMLElement => { const diffRows = buildEditDiffRows(); + const deletionRows = buildDeletionReviewRows(); + const pendingCount = modifiedCells.size + rowsMarkedForDeletion.size; + const wrap = document.createElement('div'); - wrap.style.cssText = 'height:100%;overflow:auto;'; + wrap.style.cssText = 'height:100%;overflow:auto;display:flex;flex-direction:column;'; const header = document.createElement('div'); header.style.cssText = - 'padding:10px 12px;border-bottom:1px solid var(--vscode-widget-border);display:flex;flex-direction:column;gap:2px;'; + 'padding:10px 12px;border-bottom:1px solid var(--vscode-widget-border);display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:10px;'; + + const headerText = document.createElement('div'); + headerText.style.cssText = 'display:flex;flex-direction:column;gap:2px;min-width:0;flex:1;'; + const titleEl = document.createElement('div'); titleEl.textContent = 'Review Changes'; titleEl.style.cssText = 'font-size:13px;font-weight:700;'; + const subtitleEl = document.createElement('div'); const editedRowCount = new Set(diffRows.map((r) => r.rowIndex)).size; - subtitleEl.textContent = `${editedRowCount} row${editedRowCount !== 1 ? 's' : ''}, ${diffRows.length} edited cell${diffRows.length !== 1 ? 's' : ''}`; + const subParts: string[] = []; + if (diffRows.length > 0) { + subParts.push( + `${editedRowCount} row${editedRowCount !== 1 ? 's' : ''}, ${diffRows.length} edited cell${diffRows.length !== 1 ? 's' : ''}`, + ); + } + if (deletionRows.length > 0) { + subParts.push( + `${deletionRows.length} row${deletionRows.length !== 1 ? 's' : ''} marked for deletion`, + ); + } + subtitleEl.textContent = subParts.length > 0 ? subParts.join(' Β· ') : 'No pending changes'; subtitleEl.style.cssText = 'font-size:11px;color:var(--vscode-descriptionForeground);'; - header.appendChild(titleEl); - header.appendChild(subtitleEl); - wrap.appendChild(header); - if (diffRows.length === 0) { - const empty = document.createElement('div'); - empty.style.cssText = - 'padding:20px 16px;color:var(--vscode-descriptionForeground);font-size:12px;'; - empty.textContent = 'No edited cells to review yet.'; + headerText.appendChild(titleEl); + headerText.appendChild(subtitleEl); + header.appendChild(headerText); + + if (pendingCount > 0) { + const revertReviewBtn = document.createElement('button'); + revertReviewBtn.type = 'button'; + revertReviewBtn.textContent = 'Revert all'; + revertReviewBtn.title = 'Discard all unstaged edits and staged deletions'; + revertReviewBtn.style.cssText = ` + flex-shrink:0;padding:4px 12px;font-size:11px;font-family:var(--vscode-font-family); + cursor:pointer;border-radius:3px;font-weight:600; + background:color-mix(in srgb,#22c55e 14%,transparent); + color:#22c55e; + border:1px solid color-mix(in srgb,#22c55e 38%,transparent); + `; + revertReviewBtn.onmouseover = () => { + revertReviewBtn.style.background = 'color-mix(in srgb,#22c55e 22%,transparent)'; + }; + revertReviewBtn.onmouseout = () => { + revertReviewBtn.style.background = 'color-mix(in srgb,#22c55e 14%,transparent)'; + }; + revertReviewBtn.onclick = () => { + tableRenderer.revertAllPendingChanges(); + syncPendingChangesUi(); + switchTab('table'); + }; + header.appendChild(revertReviewBtn); + } + + wrap.appendChild(header); + + if (diffRows.length === 0 && deletionRows.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = + 'padding:20px 16px;color:var(--vscode-descriptionForeground);font-size:12px;'; + empty.textContent = 'No pending edits or deletions to review.'; wrap.appendChild(empty); return wrap; } - const table = document.createElement('table'); - table.style.cssText = - 'width:100%;border-collapse:separate;border-spacing:0;font-size:12px;line-height:1.45;'; - - const thead = document.createElement('thead'); - const htr = document.createElement('tr'); - ['Row', 'Column', 'Old Value', 'New Value'].forEach((label) => { - const th = document.createElement('th'); - th.textContent = label; - th.style.cssText = - 'position:sticky;top:0;z-index:1;text-align:left;padding:8px 10px;background:var(--vscode-editor-background);border-bottom:1px solid var(--vscode-widget-border);font-weight:600;'; - htr.appendChild(th); - }); - thead.appendChild(htr); - table.appendChild(thead); - - const tbody = document.createElement('tbody'); - diffRows.forEach((row, idx) => { - const tr = document.createElement('tr'); - const stripe = idx % 2 === 0 ? 'transparent' : 'var(--vscode-keybindingTable-rowsBackground)'; - tr.style.background = stripe; - - const rowTd = document.createElement('td'); - rowTd.textContent = row.rowLabel; - rowTd.style.cssText = - 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;white-space:nowrap;'; - - const colTd = document.createElement('td'); - colTd.textContent = row.colName; - colTd.style.cssText = - 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;'; - - const oldTd = document.createElement('td'); - oldTd.textContent = row.oldValue; - oldTd.style.cssText = - 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; - oldTd.title = row.oldValue; - - const newTd = document.createElement('td'); - newTd.textContent = row.newValue; - newTd.style.cssText = - 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:color-mix(in srgb, #f59e0b 12%, transparent);'; - newTd.title = row.newValue; - - tr.appendChild(rowTd); - tr.appendChild(colTd); - tr.appendChild(oldTd); - tr.appendChild(newTd); - tbody.appendChild(tr); - }); + const appendEditTable = () => { + if (diffRows.length === 0) return; + + const sectionLabel = document.createElement('div'); + sectionLabel.textContent = 'Cell edits'; + sectionLabel.style.cssText = + 'padding:8px 12px 4px;font-size:11px;font-weight:600;color:var(--vscode-descriptionForeground);text-transform:uppercase;letter-spacing:0.04em;'; + wrap.appendChild(sectionLabel); + + const table = document.createElement('table'); + table.style.cssText = + 'width:100%;border-collapse:separate;border-spacing:0;font-size:12px;line-height:1.45;'; + + const thead = document.createElement('thead'); + const htr = document.createElement('tr'); + ['Row', 'Column', 'Old Value', 'New Value'].forEach((label) => { + const th = document.createElement('th'); + th.textContent = label; + th.style.cssText = + 'position:sticky;top:0;z-index:1;text-align:left;padding:8px 10px;background:var(--vscode-editor-background);border-bottom:1px solid var(--vscode-widget-border);font-weight:600;'; + htr.appendChild(th); + }); + thead.appendChild(htr); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + diffRows.forEach((row, idx) => { + const tr = document.createElement('tr'); + const stripe = idx % 2 === 0 ? 'transparent' : 'var(--vscode-keybindingTable-rowsBackground)'; + tr.style.background = stripe; + + const rowTd = document.createElement('td'); + rowTd.textContent = row.rowLabel; + rowTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;white-space:nowrap;'; + + const colTd = document.createElement('td'); + colTd.textContent = row.colName; + colTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;'; + + const oldTd = document.createElement('td'); + oldTd.textContent = row.oldValue; + oldTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + oldTd.title = row.oldValue; + + const newTd = document.createElement('td'); + newTd.textContent = row.newValue; + newTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:color-mix(in srgb, #f59e0b 12%, transparent);'; + newTd.title = row.newValue; + + tr.appendChild(rowTd); + tr.appendChild(colTd); + tr.appendChild(oldTd); + tr.appendChild(newTd); + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + wrap.appendChild(table); + }; + + const appendDeletionCards = () => { + if (deletionRows.length === 0) return; + + const sectionLabel = document.createElement('div'); + sectionLabel.textContent = 'Rows to delete'; + sectionLabel.style.cssText = + 'padding:12px 12px 4px;font-size:11px;font-weight:600;color:var(--vscode-descriptionForeground);text-transform:uppercase;letter-spacing:0.04em;'; + wrap.appendChild(sectionLabel); + + const divider = document.createElement('div'); + divider.style.cssText = + 'height:1px;margin:2px 12px 12px;background:color-mix(in srgb,var(--vscode-widget-border) 85%,transparent);'; + wrap.appendChild(divider); + + const cardsWrap = document.createElement('div'); + cardsWrap.style.cssText = + 'display:flex;flex-direction:column;gap:12px;padding:0 12px 16px;'; + + deletionRows.forEach(({ rowIndex, rowLabel }) => { + const rowData = originalRows[rowIndex] as Record | undefined; + + const card = document.createElement('article'); + card.style.cssText = ` + border:1px solid color-mix(in srgb, var(--vscode-widget-border) 70%, transparent); + border-radius:8px; + overflow:hidden; + background:color-mix(in srgb, #dc2626 7%, var(--vscode-editor-background)); + box-shadow:0 1px 2px rgba(0,0,0,0.06); + `; + + const head = document.createElement('header'); + head.style.cssText = ` + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:8px 12px; + border-bottom:1px solid color-mix(in srgb, var(--vscode-widget-border) 55%, transparent); + background:color-mix(in srgb, #dc2626 11%, transparent); + `; + + const title = document.createElement('div'); + title.style.cssText = + 'font-size:12px;font-weight:700;font-family:var(--vscode-editor-font-family),monospace;color:var(--vscode-editor-foreground);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + title.textContent = `Row ${rowLabel}`; + + const undoBtn = document.createElement('button'); + undoBtn.type = 'button'; + undoBtn.textContent = 'Undo'; + undoBtn.title = 'Remove this row from the deletion queue'; + undoBtn.style.cssText = ` + flex-shrink:0;padding:3px 10px;font-size:11px;font-family:var(--vscode-font-family); + cursor:pointer;border-radius:4px;font-weight:600; + background:transparent;color:var(--vscode-textLink-foreground); + border:1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 38%, transparent); + `; + undoBtn.onmouseover = () => { + undoBtn.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; + }; + undoBtn.onmouseout = () => { + undoBtn.style.background = 'transparent'; + }; + undoBtn.onclick = () => { + rowsMarkedForDeletion.delete(rowIndex); + syncPendingChangesUi(); + tableRenderer.render(buildTableRenderOptions()); + switchTab('review'); + }; - table.appendChild(tbody); - wrap.appendChild(table); + head.appendChild(title); + head.appendChild(undoBtn); + + const body = document.createElement('div'); + body.style.cssText = + 'padding:10px 12px;display:flex;flex-wrap:wrap;gap:10px 16px;align-items:flex-start;'; + + columns.forEach((colName: string) => { + const chip = document.createElement('span'); + chip.style.cssText = + 'display:inline-flex;align-items:baseline;gap:4px;font-size:11px;font-family:var(--vscode-editor-font-family),monospace;line-height:1.4;max-width:100%;word-break:break-word;'; + const k = document.createElement('span'); + k.style.cssText = 'color:var(--vscode-descriptionForeground);font-weight:600;flex-shrink:0;'; + k.textContent = `${colName}=`; + const v = document.createElement('span'); + v.style.color = 'var(--vscode-editor-foreground)'; + v.textContent = formatDiffValue(rowData?.[colName]); + chip.appendChild(k); + chip.appendChild(v); + body.appendChild(chip); + }); + + const foot = document.createElement('footer'); + foot.style.cssText = + 'padding:7px 12px 10px;font-size:10px;color:var(--vscode-descriptionForeground);font-style:italic;border-top:1px dashed color-mix(in srgb, var(--vscode-widget-border) 55%, transparent);'; + foot.textContent = 'β†’ Will be removed when you commit.'; + + card.appendChild(head); + card.appendChild(body); + card.appendChild(foot); + cardsWrap.appendChild(card); + }); + + wrap.appendChild(cardsWrap); + }; + + appendEditTable(); + appendDeletionCards(); return wrap; }; - const saveBtn = createButton('Save Changes', true, 'success'); - saveBtn.style.marginRight = '8px'; - - let reviewTab: HTMLElement | null = null; - const updateReviewTabLabel = () => { - if (!reviewTab) return; - const editedRows = new Set( - Array.from(modifiedCells.keys()) - .map((key) => parseCellKey(key)?.rowIndex) - .filter((idx): idx is number => typeof idx === 'number'), - ).size; - reviewTab.textContent = editedRows > 0 ? `Review Changes (${editedRows})` : 'Review Changes'; - }; + let reviewTabBtn: HTMLButtonElement | null = null; - const updateSaveButtonVisibility = () => { - // Show save button if there are edits OR deletions - const hasChanges = modifiedCells.size > 0 || rowsMarkedForDeletion.size > 0; - updateReviewTabLabel(); - - if (hasChanges) { - if (!rightActions.contains(saveBtn)) rightActions.prepend(saveBtn); - - // Build button text with counts - const parts = []; - if (modifiedCells.size > 0) - parts.push(`${modifiedCells.size} edit${modifiedCells.size !== 1 ? 's' : ''}`); - if (rowsMarkedForDeletion.size > 0) - parts.push( - `${rowsMarkedForDeletion.size} deletion${rowsMarkedForDeletion.size !== 1 ? 's' : ''}`, - ); - saveBtn.innerText = `πŸ’Ύ Save Changes (${parts.join(', ')})`; - } else { - if (rightActions.contains(saveBtn)) rightActions.removeChild(saveBtn); - } - }; + /** Active view β€” used so the footer hides Delete when not on the table tab */ + let currentMode: string = 'table'; + + let syncReviewTabButton: () => void = () => {}; + + let stopPendingSaveLoading: (() => void) | undefined; + + let refreshResultFooter: () => void = () => {}; - saveBtn.onclick = () => { - console.log('Renderer: Save button clicked'); + function syncPendingChangesUi(): void { + syncReviewTabButton(); + refreshResultFooter(); + } + + function runPerformSaveCommit(): void { + console.log('Renderer: Commit / save invoked'); console.log('Renderer: Modified cells size:', modifiedCells.size); console.log('Renderer: Rows marked for deletion:', rowsMarkedForDeletion.size); @@ -904,7 +1119,6 @@ export const activate: ActivationFunction = (context) => { } }); - // Build deletions array const deletions: any[] = []; rowsMarkedForDeletion.forEach((rowIndex) => { if (tableInfo?.primaryKeys) { @@ -914,7 +1128,7 @@ export const activate: ActivationFunction = (context) => { }); deletions.push({ keys: pkValues, - row: originalRows[rowIndex], // Include full row for reference + row: originalRows[rowIndex], }); } }); @@ -924,9 +1138,14 @@ export const activate: ActivationFunction = (context) => { if (updates.length > 0 || deletions.length > 0) { console.log('Renderer: Posting saveChanges message'); - const stopLoading = startButtonLoading(saveBtn, 'Saving...'); - // stopLoading is called when saveSuccess or saveFailed arrives - (saveBtn as any)._stopLoading = stopLoading; + stopPendingSaveLoading?.(); + stopPendingSaveLoading = undefined; + const commitBtn = contentContainer.querySelector( + '[data-pg-result-commit]', + ) as HTMLButtonElement | null; + stopPendingSaveLoading = commitBtn + ? startButtonLoading(commitBtn, 'Saving...') + : undefined; context.postMessage?.({ type: 'saveChanges', updates, @@ -938,17 +1157,63 @@ export const activate: ActivationFunction = (context) => { ? 'No primary keys found for this table.' : 'Unknown error preparing updates.'; console.warn(`Renderer: Save failed. ${reason}`); - - // Inform user nicely context.postMessage?.({ type: 'showErrorMessage', message: `Cannot save changes: ${reason} (Primary keys are required to identify rows)`, }); } - }; + } + + function performSave(): void { + const dirty = modifiedCells.size + rowsMarkedForDeletion.size; + if (dirty <= 0) { + return; + } + if (skipGridCommitConfirm) { + runPerformSaveCommit(); + return; + } + openCommitConfirmDialog({ + confirmLabel: `Commit (${dirty})`, + onConfirm: (dontAskAgain) => { + if (dontAskAgain) { + skipGridCommitConfirm = true; + context.postMessage?.({ + type: 'gridCommitPreference', + action: 'set', + skipConfirm: true, + }); + } + runPerformSaveCommit(); + }, + onCancel: () => {}, + }); + } + + let applyCursorResponse: ((message: any) => void) | undefined; + + function markSelectedRowsForDeletion(): void { + if (selectedIndices.size === 0) return; + selectedIndices.forEach((index) => { + rowsMarkedForDeletion.add(index); + }); + selectedIndices.clear(); + syncPendingChangesUi(); + tableRenderer.render(buildTableRenderOptions()); + updateActionsVisibility(); + } // Listen for messages from extension host context.onDidReceiveMessage?.((message: any) => { + if ( + message.type === 'gridCommitPreferenceResponse' && + message.requestId === gridPrefRequestId && + message.skipConfirm === true + ) { + skipGridCommitConfirm = true; + return; + } + // FK lookup response β€” resolve the waiting dropdown callback if (message.type === 'fkLookupResponse') { const cb = fkCallbacks.get(message.requestId); @@ -959,6 +1224,11 @@ export const activate: ActivationFunction = (context) => { return; } + if (message.type === 'resultCursorResponse') { + applyCursorResponse?.(message); + return; + } + // In-grid insert row result if (message.type === 'insertSuccess') { tableRenderer.replaceInsertRow(message.tempId, message.actualRow); @@ -974,9 +1244,8 @@ export const activate: ActivationFunction = (context) => { 'Renderer: Received saveSuccess, clearing modified cells and removing deleted rows', ); - // Stop the loading spinner on the save button - (saveBtn as any)._stopLoading?.(); - (saveBtn as any)._stopLoading = undefined; + stopPendingSaveLoading?.(); + stopPendingSaveLoading = undefined; // Update originalRows with edited values before removing any rows. // The renderer now tracks edits by stable source index, so applying @@ -1001,65 +1270,201 @@ export const activate: ActivationFunction = (context) => { modifiedCells.clear(); rowsMarkedForDeletion.clear(); - // Update save button visibility - updateSaveButtonVisibility(); + syncPendingChangesUi(); // Re-render table to remove highlights and deleted rows if (tableRenderer) { - tableRenderer.render({ - columns, - rows: currentRows, - originalRows, - columnTypes, - tableInfo, - initialSelectedIndices: selectedIndices, - modifiedCells, - }); + tableRenderer.render(buildTableRenderOptions()); } } if (message.type === 'saveFailed') { - // Restore save button on error - (saveBtn as any)._stopLoading?.(); - (saveBtn as any)._stopLoading = undefined; + stopPendingSaveLoading?.(); + stopPendingSaveLoading = undefined; } }); - // Tabs - const tabs = document.createElement('div'); - tabs.style.cssText = - 'display: flex; padding: 0 12px; margin-top: 8px; border-bottom: 1px solid var(--vscode-panel-border);'; + /** Last Table / Chart / Analyst view when browsing notices etc. */ + let lastPrimaryMode: 'table' | 'chart' | 'analyst' = 'table'; - const tableTab = createTab('Table', 'table', true, () => switchTab('table')); - const chartTab = createTab('Chart', 'chart', false, () => switchTab('chart')); - const analystTab = createTab('Analyst', 'analyst', false, () => switchTab('analyst')); + // Secondary band: left = Table / Chart / … + optional View Plan; right = Export chart + AI (after chart init) + const secondaryTabsOuter = document.createElement('div'); + secondaryTabsOuter.style.cssText = + 'display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;padding:6px 12px;border-bottom:1px solid var(--vscode-panel-border);background:var(--vscode-editor-background);'; - const noticesTabLabel = - noticeItems.length > 0 ? `Notices (${noticeItems.length})` : 'Notices'; - const noticesTab = createTab(noticesTabLabel, 'notices', false, () => switchTab('notices')); - reviewTab = createTab('Review Changes', 'review', false, () => switchTab('review')); - updateReviewTabLabel(); + const secondaryTabsLeft = document.createElement('div'); + secondaryTabsLeft.style.cssText = + 'display:flex;flex-wrap:wrap;align-items:center;gap:6px;flex:1;min-width:0;'; - let explainTab: HTMLElement | null = null; + const secondaryTabsRight = document.createElement('div'); + secondaryTabsRight.style.cssText = + 'display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto;'; + + const isExplainQuery = + json.explainPlan || + (query && /^\s*EXPLAIN/i.test(query)) || + command === 'EXPLAIN' || + (columns.length === 1 && columns[0] === 'QUERY PLAN'); + + if (isExplainQuery) { + const explainPlanBtn = document.createElement('button'); + explainPlanBtn.type = 'button'; + fillToolbarButtonContent(explainPlanBtn, 'explain', 'View Plan'); + applyResultRowToolStyle(explainPlanBtn); + attachResultRowToolInteractions(explainPlanBtn); + explainPlanBtn.title = json.explainPlan + ? 'Open EXPLAIN ANALYZE plan view' + : 'Convert to JSON format and open visual plan view'; + + explainPlanBtn.onclick = () => { + if (json.explainPlan) { + switchTab('explain'); + } else { + console.log('Converting EXPLAIN to JSON, query:', query); + if (!query) { + alert('Cannot convert EXPLAIN plan: query not available'); + return; + } + context.postMessage?.({ + type: 'convertExplainToJson', + query: query, + }); + } + }; + secondaryTabsLeft.appendChild(explainPlanBtn); + } + + const tableViewBtn = document.createElement('button'); + tableViewBtn.type = 'button'; + fillToolbarButtonContent(tableViewBtn, 'table', 'Table'); + tableViewBtn.onclick = () => switchTab('table'); + attachResultViewTabHover(tableViewBtn); + + const chartViewBtn = document.createElement('button'); + chartViewBtn.type = 'button'; + fillToolbarButtonContent(chartViewBtn, 'chart', 'Chart'); + chartViewBtn.onclick = () => switchTab('chart'); + attachResultViewTabHover(chartViewBtn); + + const analystViewBtn = document.createElement('button'); + analystViewBtn.type = 'button'; + fillToolbarButtonContent(analystViewBtn, 'analyst', 'Analyst'); + analystViewBtn.onclick = () => switchTab('analyst'); + attachResultViewTabHover(analystViewBtn); + + const syncPrimaryButtons = () => { + applyResultViewTabStyle(tableViewBtn, lastPrimaryMode === 'table'); + applyResultViewTabStyle(chartViewBtn, lastPrimaryMode === 'chart'); + applyResultViewTabStyle(analystViewBtn, lastPrimaryMode === 'analyst'); + }; + syncPrimaryButtons(); + + const noticesBtn = document.createElement('button'); + noticesBtn.type = 'button'; + const noticesLabel = + noticeItems.length > 0 ? `Notices (${noticeItems.length})` : 'Notices'; + fillToolbarButtonContent(noticesBtn, 'notices', noticesLabel); + noticesBtn.onclick = () => switchTab('notices'); + applyResultViewTabStyle(noticesBtn, false); + attachResultViewTabHover(noticesBtn); + + const transposeBtn = document.createElement('button'); + transposeBtn.type = 'button'; + fillToolbarButtonContent(transposeBtn, 'transpose', 'Transpose'); + transposeBtn.onclick = () => switchTab('transpose'); + applyResultViewTabStyle(transposeBtn, false); + attachResultViewTabHover(transposeBtn); + + reviewTabBtn = document.createElement('button'); + reviewTabBtn.type = 'button'; + reviewTabBtn.onclick = () => switchTab('review'); + + let explainTabBtn: HTMLButtonElement | null = null; if (json.explainPlan) { - explainTab = createTab('Explain Plan', 'explain', false, () => switchTab('explain')); + explainTabBtn = document.createElement('button'); + explainTabBtn.type = 'button'; + fillToolbarButtonContent(explainTabBtn, 'explain', 'Explain Plan'); + explainTabBtn.onclick = () => switchTab('explain'); + applyResultViewTabStyle(explainTabBtn, false); + attachResultViewTabHover(explainTabBtn); } - const transposeTab = createTab('⇄ Transpose', 'transpose', false, () => - switchTab('transpose'), - ); + const REVIEW_AMBER = '#f59e0b'; + syncReviewTabButton = () => { + if (!reviewTabBtn) return; + const pending = modifiedCells.size + rowsMarkedForDeletion.size; + const isActive = currentMode === 'review'; + + reviewTabBtn.replaceChildren(); + const ic = document.createElement('span'); + ic.className = RESULT_TOOLBAR_ICON_CLASS; + ic.innerHTML = resultToolbarSvg('review'); + const title = document.createElement('span'); + title.className = RESULT_TOOLBAR_LABEL_CLASS; + title.textContent = 'Review Changes'; + reviewTabBtn.appendChild(ic); + reviewTabBtn.appendChild(title); + + if (pending > 0) { + const badge = document.createElement('span'); + badge.textContent = String(pending); + badge.title = `${pending} pending change(s)`; + badge.style.cssText = ` + display:inline-block; + margin-left:6px; + min-width:18px; + text-align:center; + padding:0 6px; + border-radius:999px; + font-size:10px; + font-weight:700; + line-height:16px; + vertical-align:middle; + background:color-mix(in srgb, ${REVIEW_AMBER} 26%, transparent); + color:${REVIEW_AMBER}; + border:1px solid color-mix(in srgb, ${REVIEW_AMBER} 48%, transparent); + `; + reviewTabBtn.appendChild(badge); + } + + applyResultViewTabStyle(reviewTabBtn, isActive); + if (pending > 0) { + reviewTabBtn.style.background = isActive + ? `color-mix(in srgb, ${REVIEW_AMBER} 18%, color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 88%, transparent))` + : `color-mix(in srgb, ${REVIEW_AMBER} 14%, transparent)`; + reviewTabBtn.style.borderColor = `color-mix(in srgb, ${REVIEW_AMBER} 42%, var(--vscode-widget-border))`; + } + if (!isActive) { + reviewTabBtn.style.color = 'var(--vscode-editor-foreground)'; + } + }; + + reviewTabBtn.addEventListener('mouseenter', () => { + if (!reviewTabBtn || currentMode === 'review') return; + const pending = modifiedCells.size + rowsMarkedForDeletion.size; + reviewTabBtn.style.background = pending > 0 + ? `color-mix(in srgb, ${REVIEW_AMBER} 16%, color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 48%, transparent))` + : 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; + }); + reviewTabBtn.addEventListener('mouseleave', () => syncReviewTabButton()); + + secondaryTabsLeft.appendChild(tableViewBtn); + secondaryTabsLeft.appendChild(chartViewBtn); + secondaryTabsLeft.appendChild(analystViewBtn); + secondaryTabsLeft.appendChild(noticesBtn); + secondaryTabsLeft.appendChild(transposeBtn); + secondaryTabsLeft.appendChild(reviewTabBtn); + if (explainTabBtn) secondaryTabsLeft.appendChild(explainTabBtn); + + secondaryTabsOuter.appendChild(secondaryTabsLeft); + secondaryTabsOuter.appendChild(secondaryTabsRight); - tabs.appendChild(tableTab); - tabs.appendChild(chartTab); - tabs.appendChild(analystTab); - tabs.appendChild(noticesTab); - tabs.appendChild(reviewTab); - if (explainTab) tabs.appendChild(explainTab); - tabs.appendChild(transposeTab); if (!json.error) { - contentContainer.appendChild(tabs); + contentContainer.appendChild(secondaryTabsOuter); } + syncReviewTabButton(); + // Views Containers const viewContainer = document.createElement('div'); viewContainer.style.cssText = @@ -1076,7 +1481,7 @@ export const activate: ActivationFunction = (context) => { updateActionsVisibility(); }, onDataChange: (_rowIndex, _col, _newVal, _originalVal) => { - updateSaveButtonVisibility(); + syncPendingChangesUi(); updateActionsVisibility(); if (currentMode === 'review') { switchTab('review'); @@ -1097,11 +1502,282 @@ export const activate: ActivationFunction = (context) => { limit: 50, }); }, + onSortChange: (column, direction) => { + localSortState = { column, direction }; + refreshStreamingScopeNotice(); + }, + onFilterChange: (state) => { + localFilterState = { + globalQuery: state.globalQuery || '', + clauses: state.clauses.map((c) => ({ ...c })), + }; + refreshStreamingScopeNotice(); + }, }); // Store for cleanup on disposal tableInstances.set(element, tableRenderer); + let slideFetchBusy = false; + let pendingSlideRequestId = ''; + let pendingSlideTargetStart: number | undefined; + let suppressSlideScrollUntil = 0; + let slideScrollCleanup: (() => void) | undefined; + const DEFAULT_ROW_HEIGHT_PX = 30; + const getSlideWindowSize = (): number => + Math.max(10, slideMeta?.windowSize ?? 100); + const getMaxBufferedRows = (): number => getSlideWindowSize() * 3; + const estimateDataRowHeight = (): number => { + const row = tableRenderer + .getScrollContainer() + .querySelector('tr[data-source-index]') as HTMLElement | null; + if (!row) { + return DEFAULT_ROW_HEIGHT_PX; + } + return Math.max(16, row.offsetHeight || DEFAULT_ROW_HEIGHT_PX); + }; + const syncSlideMetaFromBuffer = (): void => { + if (!slideMeta?.sessionId) { + return; + } + slideMeta = { + sessionId: slideMeta.sessionId, + windowStartRow: slideBufferedStartRow, + windowSize: getSlideWindowSize(), + hasMoreBefore: slideHasMoreBefore, + hasMoreAfter: slideHasMoreAfter, + }; + }; + + const attachSlideScroll = (): void => { + slideScrollCleanup?.(); + slideScrollCleanup = undefined; + if (!slideMeta?.sessionId) { + return; + } + const root = tableRenderer.getScrollContainer(); + let ticking = false; + const EDGE_PX = 72; + const onScroll = (): void => { + if (!slideMeta?.sessionId || slideFetchBusy) { + return; + } + if (Date.now() < suppressSlideScrollUntil) { + return; + } + if (ticking) { + return; + } + ticking = true; + requestAnimationFrame(() => { + ticking = false; + if (!slideMeta?.sessionId || slideFetchBusy) { + return; + } + const distBottom = root.scrollHeight - root.scrollTop - root.clientHeight; + const distTop = root.scrollTop; + let nextStart: number | undefined; + if (slideHasMoreAfter && distBottom < EDGE_PX) { + nextStart = slideBufferedStartRow + currentRows.length; + } else if (slideHasMoreBefore && distTop < EDGE_PX) { + nextStart = Math.max(1, slideBufferedStartRow - getSlideWindowSize()); + } + if (nextStart === undefined || nextStart === slideBufferedStartRow) { + return; + } + slideFetchBusy = true; + pendingSlideTargetStart = nextStart; + pendingSlideRequestId = `slide-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + context.postMessage?.({ + type: 'resultCursorFetch', + sessionId: slideMeta.sessionId, + pageStartRow: nextStart, + requestId: pendingSlideRequestId, + }); + }); + }; + root.addEventListener('scroll', onScroll, { passive: true }); + slideScrollCleanup = (): void => { + root.removeEventListener('scroll', onScroll); + }; + }; + + applyCursorResponse = (message: any): void => { + if (pendingSlideRequestId && message.requestId !== pendingSlideRequestId) { + return; + } + const previousWindowStart = slideBufferedStartRow; + const requestedStart = pendingSlideTargetStart; + const rootBefore = tableRenderer.getScrollContainer(); + const prevScrollTop = rootBefore.scrollTop; + const rowHeight = estimateDataRowHeight(); + let scrollAdjustPx = 0; + slideFetchBusy = false; + pendingSlideRequestId = ''; + pendingSlideTargetStart = undefined; + if (message.error) { + slideScrollCleanup?.(); + slideScrollCleanup = undefined; + slideMeta = undefined; + mainContainer.insertBefore( + createInlineBanner({ severity: 'warning', message: String(message.error) }), + contentContainer, + ); + refreshStreamingScopeNotice(); + return; + } + const incomingRows = message.rows ? JSON.parse(JSON.stringify(message.rows)) : []; + const incomingOriginalRows = JSON.parse(JSON.stringify(incomingRows)); + const movedForward = + typeof requestedStart === 'number' && requestedStart > previousWindowStart; + const movedBackward = + typeof requestedStart === 'number' && requestedStart < previousWindowStart; + + if (!slideMeta?.sessionId || !requestedStart) { + currentRows = incomingRows; + originalRows = incomingOriginalRows; + slideBufferedStartRow = message.slidingWindow?.windowStartRow ?? slideBufferedStartRow; + } else if (movedForward) { + currentRows = [...currentRows, ...incomingRows]; + originalRows = [...originalRows, ...incomingOriginalRows]; + } else if (movedBackward) { + currentRows = [...incomingRows, ...currentRows]; + originalRows = [...incomingOriginalRows, ...originalRows]; + slideBufferedStartRow = requestedStart; + scrollAdjustPx += incomingRows.length * rowHeight; + } else { + currentRows = incomingRows; + originalRows = incomingOriginalRows; + slideBufferedStartRow = requestedStart; + } + + const maxBufferedRows = getMaxBufferedRows(); + if (currentRows.length > maxBufferedRows) { + const overflow = currentRows.length - maxBufferedRows; + if (movedForward) { + currentRows = currentRows.slice(overflow); + originalRows = originalRows.slice(overflow); + slideBufferedStartRow += overflow; + scrollAdjustPx -= overflow * rowHeight; + } else if (movedBackward) { + currentRows = currentRows.slice(0, currentRows.length - overflow); + originalRows = originalRows.slice(0, originalRows.length - overflow); + } else { + currentRows = currentRows.slice(0, maxBufferedRows); + originalRows = originalRows.slice(0, maxBufferedRows); + } + } + + if (message.slidingWindow) { + if (movedForward) { + slideHasMoreAfter = message.slidingWindow.hasMoreAfter; + } else if (movedBackward) { + slideHasMoreBefore = message.slidingWindow.hasMoreBefore; + } else { + slideHasMoreBefore = message.slidingWindow.hasMoreBefore; + slideHasMoreAfter = message.slidingWindow.hasMoreAfter; + } + if (slideBufferedStartRow > 1) { + slideHasMoreBefore = true; + } + syncSlideMetaFromBuffer(); + } + refreshStreamingScopeNotice(); + selectedIndices.clear(); + modifiedCells.clear(); + rowsMarkedForDeletion.clear(); + if (currentMode === 'table') { + tableRenderer.render(buildTableRenderOptions()); + } + updateIdentityStats(); + refreshResultFooter(); + suppressSlideScrollUntil = Date.now() + 120; + requestAnimationFrame(() => { + const root = tableRenderer.getScrollContainer(); + const nextScrollTop = Math.max(0, prevScrollTop + scrollAdjustPx); + root.scrollTop = nextScrollTop; + }); + }; + + const rowToolHandlers: RowToolsOptions = { + onSelectAll: () => { + if (selectedIndices.size === currentRows.length && currentRows.length > 0) { + selectedIndices.clear(); + } else { + currentRows.forEach((_: any, i: number) => selectedIndices.add(i)); + } + tableRenderer.updateSelection(selectedIndices); + refreshResultFooter(); + }, + onCopy: () => { + const rowsToCopy = + selectedIndices.size > 0 + ? Array.from(selectedIndices).map((i) => currentRows[i]) + : currentRows; + const escapeCSV = (val: any): string => { + if (val === null || val === undefined) return ''; + const str = typeof val === 'object' ? JSON.stringify(val) : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + const csv = [ + columns.map((c: string) => `"${c.replace(/"/g, '""')}"`).join(','), + ...rowsToCopy.map((r: any) => columns.map((c: string) => escapeCSV(r[c])).join(',')), + ].join('\n'); + navigator.clipboard?.writeText(csv); + }, + onImport: () => { + showImportModal(columns, tableInfo, context); + }, + onExport: openResultExportMenu, + }; + + refreshResultFooter = () => { + if (json.error) return; + contentContainer.querySelector('[data-result-footer="true"]')?.remove(); + const dirty = modifiedCells.size + rowsMarkedForDeletion.size; + const tableView = currentMode === 'table'; + const sel = tableView ? selectedIndices.size : 0; + contentContainer.appendChild( + createResultFooter({ + rowTools: + columns.length > 0 + ? { + ...rowToolHandlers, + allRowsSelected: + tableView && + currentRows.length > 0 && + selectedIndices.size === currentRows.length, + } + : undefined, + onAddRow: tableInfo + ? () => { + switchTab('table'); + requestAnimationFrame(() => tableRenderer.triggerAddRow()); + } + : undefined, + dirtyCount: dirty, + onCommit: dirty > 0 ? performSave : undefined, + deleteSelectionCount: sel, + onDeleteSelected: sel > 0 && tableView ? markSelectedRowsForDeletion : undefined, + deleteUnavailableReason: + sel > 0 && !tableInfo?.primaryKeys + ? 'Warning: No primary keys detected. Deletion may fail.' + : undefined, + onRevert: + dirty > 0 + ? () => { + tableRenderer.revertAllPendingChanges(); + syncPendingChangesUi(); + } + : undefined, + }), + ); + updateIdentityStats(); + }; + // CHART RENDERER const chartCanvas = document.createElement('canvas'); const chartRenderer = new ChartRenderer(chartCanvas); @@ -1109,7 +1785,11 @@ export const activate: ActivationFunction = (context) => { // Store for cleanup on disposal chartInstances.set(element, chartRenderer); - const exportChartBtn = createButton('πŸ“· Export Chart', true); + const exportChartBtn = document.createElement('button'); + exportChartBtn.type = 'button'; + fillToolbarButtonContent(exportChartBtn, 'chart', 'Export Chart'); + applyResultRowToolStyle(exportChartBtn); + attachResultRowToolInteractions(exportChartBtn); exportChartBtn.style.display = 'none'; // Hidden by default exportChartBtn.onclick = () => { const dataUrl = chartRenderer.exportImage('png'); @@ -1120,7 +1800,8 @@ export const activate: ActivationFunction = (context) => { a.click(); } }; - leftActions.appendChild(exportChartBtn); + secondaryTabsRight.appendChild(exportChartBtn); + secondaryTabsRight.appendChild(createAiMenuButton(aiMenuCallbacks)); const updateActionsVisibility = () => { if (currentMode === 'chart') { @@ -1128,98 +1809,36 @@ export const activate: ActivationFunction = (context) => { } else { exportChartBtn.style.display = 'none'; } - - // Update Select All Button Text and delete button - if (currentMode === 'table') { - if (selectedIndices.size > 0) { - deleteBtn.style.display = 'inline-block'; - deleteBtn.innerText = `πŸ—‘οΈ Delete (${selectedIndices.size})`; - if (!tableInfo?.primaryKeys) { - deleteBtn.title = 'Warning: No Primary Keys detected. Deletion may fail.'; - deleteBtn.style.opacity = '0.7'; - } else { - deleteBtn.title = 'Delete selected rows'; - deleteBtn.style.opacity = '1'; - } - } else { - deleteBtn.style.display = 'none'; - } - } else { - deleteBtn.style.display = 'none'; - } - }; - - deleteBtn.onclick = () => { - console.log('[renderer_v2] Delete button clicked!'); - const selectedCount = selectedIndices.size; - console.log('[renderer_v2] selectedCount:', selectedCount); - if (selectedCount === 0) return; - - // Mark selected rows for deletion - selectedIndices.forEach((index) => { - rowsMarkedForDeletion.add(index); - }); - - console.log('[renderer_v2] Rows marked for deletion:', Array.from(rowsMarkedForDeletion)); - - // Clear selection - selectedIndices.clear(); - - // Update save button visibility - updateSaveButtonVisibility(); - - // Re-render table to show strikethrough on marked rows - if (tableRenderer) { - tableRenderer.render({ - columns, - rows: currentRows, - originalRows, - columnTypes, - tableInfo, - initialSelectedIndices: selectedIndices, - modifiedCells, - rowsMarkedForDeletion, // Pass to renderer for styling - }); - } - - // Update actions visibility - updateActionsVisibility(); + refreshResultFooter(); }; // Switch Tab Logic - let currentMode = 'table'; - const allTabs = () => - explainTab - ? [tableTab, chartTab, analystTab, noticesTab, reviewTab!, explainTab, transposeTab] - : [tableTab, chartTab, analystTab, noticesTab, reviewTab!, transposeTab]; - const setActiveTab = (activeTab: HTMLElement) => { - allTabs().forEach((t) => { - t.style.borderBottom = '2px solid transparent'; - t.style.opacity = '0.6'; - }); - activeTab.style.borderBottom = '2px solid var(--vscode-focusBorder)'; - activeTab.style.opacity = '1'; + + const setSecondaryActive = (mode: string | null) => { + applyResultViewTabStyle(noticesBtn, mode === 'notices'); + applyResultViewTabStyle(transposeBtn, mode === 'transpose'); + if (explainTabBtn) applyResultViewTabStyle(explainTabBtn, mode === 'explain'); + syncReviewTabButton(); }; - const switchTab = (mode: string) => { + switchTab = (mode: string) => { currentMode = mode; viewContainer.innerHTML = ''; + if (mode === 'table' || mode === 'chart' || mode === 'analyst') { + lastPrimaryMode = mode; + syncPrimaryButtons(); + setSecondaryActive(null); + } else { + syncPrimaryButtons(); + setSecondaryActive(mode); + } + if (mode === 'table') { - setActiveTab(tableTab); updateActionsVisibility(); - tableRenderer.render({ - columns, - rows: currentRows, - originalRows, - columnTypes, - tableInfo, - foreignKeys: tableInfo?.foreignKeys, - initialSelectedIndices: selectedIndices, - modifiedCells, - }); + tableRenderer.render(buildTableRenderOptions()); + attachSlideScroll(); } else if (mode === 'notices') { - setActiveTab(noticesTab); updateActionsVisibility(); viewContainer.appendChild( renderNoticesPanel(noticeItems, { @@ -1237,22 +1856,18 @@ export const activate: ActivationFunction = (context) => { }), ); } else if (mode === 'transpose') { - setActiveTab(transposeTab); updateActionsVisibility(); - const transposeEl = renderTransposeTable(columns, currentRows, (v: any) => { - if (v === null || v === undefined) return 'NULL'; - if (typeof v === 'object') return JSON.stringify(v); - return String(v); - }); + const transposeEl = renderTransposeTable( + columns, + currentRows, + columnTypes, + byteaDisplayFormat, + ); viewContainer.appendChild(transposeEl); } else if (mode === 'review') { - setActiveTab(reviewTab || tableTab); updateActionsVisibility(); viewContainer.appendChild(renderReviewChangesView()); } else if (mode === 'explain') { - // Explain Mode - setActiveTab(explainTab || tableTab); - updateActionsVisibility(); const explainWrapper = document.createElement('div'); @@ -1271,18 +1886,52 @@ export const activate: ActivationFunction = (context) => { 'No explain plan data available. Run EXPLAIN (ANALYZE, FORMAT JSON) to get a visual plan.'; } } else if (mode === 'analyst') { - setActiveTab(analystTab); updateActionsVisibility(); + const streamingHint = createAnalyticsStreamingWarning('Analyst'); + if (streamingHint) { + viewContainer.appendChild(streamingHint); + } viewContainer.appendChild( renderAnalystPanel({ columns, rows: currentRows, columnTypes, + isStreaming: !!slideMeta?.sessionId, + onAskAiForPivotHelp: (pivotCtx) => { + const sqlText = (buildFullDatasetRerunQuery() || exportQuery || query || '').trim(); + context.postMessage?.({ + type: 'sendToChat', + data: { + query: sqlText || query || '', + message: buildPivotOptimizeUserMessage(pivotCtx, sqlText || query || ''), + }, + }); + }, + onRunFullDataset: () => { + const rerunQuery = buildFullDatasetRerunQuery(); + if (!rerunQuery) { + context.postMessage?.({ + type: 'showErrorMessage', + message: 'No query available to rerun for full dataset.', + }); + return; + } + context.postMessage?.({ + type: 'runDerivedQuery', + query: rerunQuery, + source: 'streaming-analyst-pivot-full-dataset', + fullDataset: true, + }); + }, }), ); } else { - setActiveTab(chartTab); + // chart updateActionsVisibility(); + const streamingHint = createAnalyticsStreamingWarning('Chart'); + if (streamingHint) { + viewContainer.appendChild(streamingHint); + } const chartWrapper = document.createElement('div'); chartWrapper.style.cssText = @@ -1290,7 +1939,7 @@ export const activate: ActivationFunction = (context) => { const controlsContainer = document.createElement('div'); controlsContainer.style.cssText = - 'width: 140px; min-width: 140px; max-width: 140px; display: flex; flex-direction: column;'; + 'width: 20%; min-width: 160px; max-width: 240px; display: flex; flex-direction: column; border-right: 1px solid var(--vscode-widget-border);'; const canvasContainer = document.createElement('div'); canvasContainer.style.cssText = @@ -1299,8 +1948,8 @@ export const activate: ActivationFunction = (context) => { const innerContainer = document.createElement('div'); innerContainer.style.cssText = 'display: flex; flex: 1; overflow: hidden; height: 100%;'; - innerContainer.appendChild(canvasContainer); innerContainer.appendChild(controlsContainer); + innerContainer.appendChild(canvasContainer); chartWrapper.appendChild(innerContainer); viewContainer.appendChild(chartWrapper); @@ -1313,6 +1962,80 @@ export const activate: ActivationFunction = (context) => { }, }); } + refreshResultFooter(); + }; + + showOverflowMenu = (anchorEl: HTMLElement) => { + const existing = document.querySelector('.result-overflow-menu'); + if (existing) { + existing.remove(); + return; + } + + const menu = document.createElement('div'); + menu.className = 'result-overflow-menu'; + menu.style.cssText = + 'position:fixed;background:var(--vscode-menu-background);border:1px solid var(--vscode-menu-border);box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:1000;min-width:170px;border-radius:4px;padding:3px 0;'; + + const addItem = (label: string, onClick: () => void) => { + const item = document.createElement('div'); + item.textContent = label; + item.style.cssText = + 'padding:6px 14px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;font-family:var(--vscode-font-family);'; + item.onmouseenter = () => { + item.style.background = 'var(--vscode-menu-selectionBackground)'; + item.style.color = 'var(--vscode-menu-selectionForeground)'; + }; + item.onmouseleave = () => { + item.style.background = 'transparent'; + item.style.color = 'var(--vscode-menu-foreground)'; + }; + item.onclick = (e) => { + e.stopPropagation(); + onClick(); + menu.remove(); + }; + menu.appendChild(item); + }; + + addItem('⇄ Transpose', () => switchTab('transpose')); + if (noticeItems.length > 0) { + addItem(`Notices (${noticeItems.length})`, () => switchTab('notices')); + } + if (json.explainPlan) { + addItem('Explain Plan', () => switchTab('explain')); + } + if (breadcrumb?.connectionName) { + addItem('Switch connection…', () => + context.postMessage?.({ + type: 'showConnectionSwitcher', + connectionId: breadcrumb.connectionId, + }), + ); + } + if (breadcrumb?.database) { + addItem('Switch database…', () => + context.postMessage?.({ + type: 'showDatabaseSwitcher', + connectionId: breadcrumb.connectionId, + currentDatabase: breadcrumb.database, + }), + ); + } + + document.body.appendChild(menu); + const rect = anchorEl.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 4}px`; + const mw = 200; + menu.style.left = `${Math.max(8, Math.min(rect.right - mw, window.innerWidth - mw - 8))}px`; + + setTimeout(() => { + const close = () => { + menu.remove(); + document.removeEventListener('click', close); + }; + document.addEventListener('click', close); + }, 0); }; // Initial Render @@ -1354,12 +2077,151 @@ export const activate: ActivationFunction = (context) => { originalRows: entry.rows || [], columnTypes: entry.columnTypes, tableInfo: entry.tableInfo, + byteaDisplayFormat: entry.byteaDisplayFormat ?? BYTEA_DISPLAY_DEFAULT, }); element.appendChild(histTableContainer); }); if (tabStripEl) element.appendChild(tabStripEl); - element.appendChild(mainContainer); + const outputRoot = document.createElement('div'); + outputRoot.setAttribute('data-pg-output-hover-root', 'true'); + outputRoot.style.cssText = 'position:relative;'; + + const hoverToolbar = document.createElement('div'); + hoverToolbar.setAttribute('role', 'toolbar'); + hoverToolbar.setAttribute('aria-label', 'Result quick actions'); + hoverToolbar.style.cssText = ` + position:absolute; + top:10px; + right:12px; + z-index:35; + display:flex; + flex-wrap:wrap; + justify-content:flex-end; + align-items:center; + gap:6px; + max-width:min(420px, calc(100% - 20px)); + opacity:0; + pointer-events:none; + transition:opacity 0.18s ease; + padding:5px 8px; + border-radius:10px; + background:color-mix(in srgb, var(--vscode-editor-background) 86%, transparent); + border:1px solid color-mix(in srgb, var(--vscode-widget-border) 42%, transparent); + box-shadow:0 4px 18px rgba(0,0,0,0.1); + backdrop-filter:blur(10px); + `; + if (prefersReducedMotion()) { + hoverToolbar.style.transition = 'none'; + } + + const setToolbarVisible = (v: boolean): void => { + hoverToolbar.style.opacity = v ? '1' : '0'; + hoverToolbar.style.pointerEvents = v ? 'auto' : 'none'; + }; + outputRoot.addEventListener('mouseenter', () => setToolbarVisible(true)); + outputRoot.addEventListener('mouseleave', () => setToolbarVisible(false)); + outputRoot.addEventListener('focusin', () => setToolbarVisible(true)); + outputRoot.addEventListener('focusout', (e) => { + const next = e.relatedTarget as Node | null; + if (next && outputRoot.contains(next)) { + return; + } + setToolbarVisible(false); + }); + + const addHoverTool = ( + glyph: ResultToolbarGlyph, + label: string, + onClick: () => void, + opts?: { disabled?: boolean; title?: string }, + ): void => { + const btn = document.createElement('button'); + btn.type = 'button'; + fillOutputHoverToolButton(btn, glyph, label); + const title = opts?.title ?? label; + btn.title = title; + btn.setAttribute('aria-label', title); + if (opts?.disabled) { + btn.disabled = true; + btn.style.opacity = '0.42'; + btn.style.cursor = 'not-allowed'; + } + btn.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (btn.disabled) { + return; + } + onClick(); + }); + hoverToolbar.appendChild(btn); + }; + + const queryTrimmed = (query || '').trim(); + const cellLinked = sourceCellIndex >= 0; + + addHoverTool( + 'menuBolt', + 'Optimize', + () => { + aiMenuCallbacks.onOptimize(); + }, + { + disabled: !queryTrimmed, + title: queryTrimmed ? 'Suggest optimizations for this query' : 'No query text', + }, + ); + addHoverTool( + 'sparkles', + 'Ask AI', + () => { + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'aiAssist', + cellIndex: sourceCellIndex, + }); + }, + { + disabled: !cellLinked, + title: cellLinked + ? 'Ask AI to modify this query' + : 'Re-run the cell to link actions to the source cell', + }, + ); + addHoverTool( + 'save', + 'Save', + () => { + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'saveQuery', + cellIndex: sourceCellIndex, + }); + }, + { + disabled: !cellLinked, + title: cellLinked ? 'Save query to library' : 'Re-run the cell to link actions to the source cell', + }, + ); + addHoverTool( + 'expandCell', + 'Expand', + () => { + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'expand', + cellIndex: sourceCellIndex, + }); + }, + { + disabled: !cellLinked, + title: cellLinked ? 'Focus the SQL cell in the editor' : 'Re-run the cell to link actions to the source cell', + }, + ); + + outputRoot.appendChild(mainContainer); + outputRoot.appendChild(hoverToolbar); + element.appendChild(outputRoot); // Transaction state: show banner and amber gutter ensureAmberGutterStyle(); diff --git a/templates/chat/scripts.js b/templates/chat/scripts.js index b9a306b..f5cbcdc 100644 --- a/templates/chat/scripts.js +++ b/templates/chat/scripts.js @@ -51,7 +51,6 @@ let currentContext = { connectionName: null, database: null }; -let lastUserMessage = null; let historySearchDebounceTimer = null; // Phase B: Quick actions and snippets configuration @@ -906,6 +905,53 @@ function highlightMentionsInText(text) { return html; } +/** + * Wrap @mentions in markdown-rendered HTML (plain text nodes only; skips pre/code so SQL stays literal). + */ +function highlightMentionsInMarkdownHtml(htmlString) { + const div = document.createElement('div'); + div.innerHTML = htmlString || ''; + const textNodes = []; + const walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT); + let n; + while ((n = walker.nextNode())) { + textNodes.push(n); + } + for (const textNode of textNodes) { + const parent = textNode.parentElement; + if (parent && parent.closest('pre, code')) { + continue; + } + const text = textNode.nodeValue || ''; + if (!/@([\w]+(?:\.[\w]+)?)/.test(text)) { + continue; + } + const frag = document.createDocumentFragment(); + let lastIdx = 0; + text.replace(/@([\w]+(?:\.[\w]+)?)/g, (full, ident, offset) => { + if (offset > lastIdx) { + frag.appendChild(document.createTextNode(text.slice(lastIdx, offset))); + } + const span = document.createElement('span'); + span.className = 'mention-inline'; + span.textContent = '@' + ident; + frag.appendChild(span); + lastIdx = offset + full.length; + return ''; + }); + if (lastIdx < text.length) { + frag.appendChild(document.createTextNode(text.slice(lastIdx))); + } + textNode.parentNode.replaceChild(frag, textNode); + } + return div.innerHTML; +} + +/** User bubble body: same markdown pipeline as assistant, plus @mention styling outside code blocks. */ +function renderUserMessageMarkdownBody(text) { + return highlightMentionsInMarkdownHtml(parseMarkdown(text)); +} + // Quirky loading messages const quirkyMessages = [ "🧠 Negotiating with the AI overlords…", @@ -1101,9 +1147,6 @@ function sendMessage() { const message = resolvedFollowUp || rawMessage; if (!message && attachedFiles.length === 0 && selectedMentions.length === 0) return; - // Phase B: Track last message for retry functionality - lastUserMessage = message; - // Dismiss error card when sending new message dismissError(); @@ -1134,6 +1177,7 @@ function sendMessage() { function sendSuggestion(text) { chatInput.value = text; resizeChatInput(); + scrollToInputArea('smooth'); chatInput.focus(); chatInput.selectionStart = chatInput.selectionEnd = chatInput.value.length; } @@ -1637,7 +1681,6 @@ function typeText(element, text, callback) { if (charIndex < plainText.length) { cursor.before(plainText[charIndex]); charIndex++; - messagesContainer.scrollTop = messagesContainer.scrollHeight; } else { clearInterval(typingAnimation); typingAnimation = null; @@ -1668,7 +1711,7 @@ window.addEventListener('message', event => { if (message.isTyping) { typingIndicator.classList.add('visible'); startLoadingMessages(); - messagesContainer.scrollTop = messagesContainer.scrollHeight; + scrollMessagesToEnd('auto'); // Swap send button with stop button sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; @@ -1795,7 +1838,169 @@ window.addEventListener('message', event => { } }); -// Toast notification function +/** Scroll transcript so newest content sits above the composer (ChatGPT-style when sending). */ +function scrollMessagesToEnd(behavior = 'smooth') { + if (!messagesContainer) return; + requestAnimationFrame(() => { + messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior }); + }); +} + +/** Focus composer and ensure it stays in view after send / suggestion chip. */ +function scrollToInputArea(behavior = 'smooth') { + scrollMessagesToEnd(behavior); + requestAnimationFrame(() => { + if (typeof inputWrapper !== 'undefined' && inputWrapper?.scrollIntoView) { + try { + inputWrapper.scrollIntoView({ block: 'end', behavior }); + } catch (_) {} + } + if (chatInput && !chatInput.disabled) { + chatInput.focus({ preventScroll: true }); + } + }); +} + +/** Anchor the top of the latest assistant reply under the viewport top so readers start at the beginning. */ +function scrollLastAssistantMessageIntoViewStart() { + const nodes = messagesContainer.querySelectorAll('.message.assistant'); + const last = nodes[nodes.length - 1]; + if (last && last.scrollIntoView) { + last.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } +} + +/** After rendering, scroll based on whose turn ended: assistant β†’ show reply from top; user β†’ composer. */ +function applyChatScrollStrategy(messages, options) { + const opts = options || {}; + if (!messages.length || opts.skip) return; + requestAnimationFrame(() => { + const last = messages[messages.length - 1]; + if (!last) return; + if (last.role === 'assistant') { + scrollLastAssistantMessageIntoViewStart(); + } else if (last.role === 'user') { + scrollToInputArea('smooth'); + } + }); +} + +/** Plain copy text for clipboard (markdown source for assistant when available). */ +function getPlainCopyTextForMessage(msg, cleanedAssistantContent) { + if (!msg || !msg.role) return ''; + if (msg.role === 'user') { + const c = msg.content || ''; + return c.split('\n\nπŸ“Ž')[0].split('\n\nπŸ–ΌοΈ')[0].trim() || c; + } + return cleanedAssistantContent != null ? cleanedAssistantContent : msg.content || ''; +} + +const MSG_ICON_SVG_COPY = + ''; + +const MSG_ICON_SVG_RETRY = + ''; + +function mkMsgIconBtn(title, ariaLabel, svgInner, onClick) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'msg-action-btn msg-action-btn--icon'; + b.title = title; + b.setAttribute('aria-label', ariaLabel); + b.innerHTML = svgInner; + b.addEventListener('click', onClick); + return b; +} + +/** Icon-only Copy + Retry for assistant footer (same row as usage). */ +function buildAssistantIconActions(plainTextForClipboard) { + const row = document.createElement('div'); + row.className = 'msg-actions msg-actions--inline'; + + row.appendChild( + mkMsgIconBtn('Copy message text', 'Copy message', MSG_ICON_SVG_COPY, async ev => { + ev.stopPropagation(); + try { + await navigator.clipboard.writeText(plainTextForClipboard || ''); + showToast('Copied', 'info'); + } catch (e) { + console.warn('[PgStudio] Copy failed', e); + } + }), + ); + row.appendChild( + mkMsgIconBtn('Replace the assistant reply without duplicating your message', 'Retry response', MSG_ICON_SVG_RETRY, ev => { + ev.stopPropagation(); + vscode.postMessage({ type: 'regenerateAssistant' }); + }), + ); + + return row; +} + +/** Same icon styling as assistant; resend truncates later turns in-place (extension). */ +function buildUserIconActions(plainTextForClipboard, userMessageIndex) { + const row = document.createElement('div'); + row.className = 'msg-actions msg-actions--inline'; + + row.appendChild( + mkMsgIconBtn('Copy message text', 'Copy message', MSG_ICON_SVG_COPY, async ev => { + ev.stopPropagation(); + try { + await navigator.clipboard.writeText(plainTextForClipboard || ''); + showToast('Copied', 'info'); + } catch (e) { + console.warn('[PgStudio] Copy failed', e); + } + }), + ); + row.appendChild( + mkMsgIconBtn( + 'Resend this message and replace replies after it', + 'Resend message', + MSG_ICON_SVG_RETRY, + ev => { + ev.stopPropagation(); + vscode.postMessage({ type: 'resendUserMessage', userIndex: userMessageIndex }); + }, + ), + ); + + return row; +} + +/** Token/time line + icon actions on one row (assistant only). */ +function buildAssistantFooterRow(usageText, plainTextForClipboard) { + const wrap = document.createElement('div'); + wrap.className = 'message-usage-row'; + + const usageEl = document.createElement('div'); + usageEl.className = 'message-usage'; + usageEl.setAttribute('role', 'status'); + usageEl.setAttribute('aria-live', 'polite'); + usageEl.textContent = usageText || ''; + + wrap.appendChild(usageEl); + wrap.appendChild(buildAssistantIconActions(plainTextForClipboard)); + + return wrap; +} + +/** Foot row under user bubbles: icons aligned with assistant (right). */ +function buildUserFooterRow(plainTextForClipboard, userMessageIndex) { + const wrap = document.createElement('div'); + wrap.className = 'message-usage-row message-usage-row--user'; + + const spacer = document.createElement('div'); + spacer.className = 'message-usage message-usage--user-spacer'; + spacer.setAttribute('aria-hidden', 'true'); + + wrap.appendChild(spacer); + wrap.appendChild(buildUserIconActions(plainTextForClipboard, userMessageIndex)); + + return wrap; +} + function showToast(text, type = 'info') { const toast = document.createElement('div'); toast.className = 'toast toast-' + type; @@ -1838,6 +2043,7 @@ function renderMessages(messages, animate = false) { lastMessageCount = messages.length; let activeSuggestionBubbles = []; + let skipDefaultEndScroll = false; // Clear existing messages (but keep typing indicator) const messageElements = messagesContainer.querySelectorAll('.message'); @@ -1896,15 +2102,16 @@ function renderMessages(messages, animate = false) { // Add the text message after attachments if exists const textWithoutAttachments = msg.content.split('\n\nπŸ“Ž')[0].split('\n\nπŸ–ΌοΈ')[0].trim(); if (textWithoutAttachments && textWithoutAttachments !== 'Please analyze the attached file(s)') { - const textP = document.createElement('p'); - textP.innerHTML = highlightMentionsInText(textWithoutAttachments); - contentDiv.appendChild(textP); + const textWrap = document.createElement('div'); + textWrap.className = 'message-user-text'; + textWrap.innerHTML = renderUserMessageMarkdownBody(textWithoutAttachments); + contentDiv.appendChild(textWrap); } } else if (msg.role === 'user') { - // User message without attachments - highlight any @mentions + // User message without attachments β€” markdown + @mentions (same typography as assistant) const text = msg.content.split('\n\nπŸ“Ž')[0].trim(); if (text && text !== 'Please analyze the referenced database objects' && text !== 'Please analyze the attached file(s)') { - contentDiv.innerHTML = highlightMentionsInText(text); + contentDiv.innerHTML = renderUserMessageMarkdownBody(text); } else { contentDiv.textContent = msg.content; } @@ -1916,22 +2123,19 @@ function renderMessages(messages, animate = false) { const bubbles = extracted.bubbles; if (isNewAssistantMessage && isLastMessage) { - // Will be typed out + // Will be typed out β€” anchor assistant turn at top so the reply is read from the start bubbleDiv.appendChild(contentDiv); messageDiv.appendChild(roleDiv); messageDiv.appendChild(bubbleDiv); + messageDiv.appendChild(buildAssistantFooterRow(msg.usage || '', cleanContent)); messagesContainer.insertBefore(messageDiv, typingIndicator); + messageDiv.scrollIntoView({ block: 'start', behavior: 'smooth' }); + skipDefaultEndScroll = true; typeText(contentDiv, cleanContent, () => { - if (msg.usage) { - const usageDiv = document.createElement('div'); - usageDiv.className = 'message-usage'; - usageDiv.setAttribute('role', 'status'); - usageDiv.setAttribute('aria-live', 'polite'); - usageDiv.textContent = msg.usage; - messageDiv.appendChild(usageDiv); - messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' }); + const usageEl = messageDiv.querySelector('.message-usage-row .message-usage'); + if (usageEl) { + usageEl.textContent = msg.usage || ''; } - // Show bubbles after typing finishes if (bubbles.length > 0) { showSuggestionBubbles(bubbles); } else { @@ -1953,23 +2157,27 @@ function renderMessages(messages, animate = false) { messageDiv.appendChild(roleDiv); messageDiv.appendChild(bubbleDiv); - // Append usage info if available - if (msg.usage) { - const usageDiv = document.createElement('div'); - usageDiv.className = 'message-usage'; - usageDiv.setAttribute('role', 'status'); - usageDiv.setAttribute('aria-live', 'polite'); - usageDiv.textContent = msg.usage; - messageDiv.appendChild(usageDiv); + let copyPlain = ''; + if (msg.role === 'user') { + copyPlain = getPlainCopyTextForMessage(msg); + } else if (msg.role === 'assistant') { + copyPlain = safeJsonTailExtract(msg.content).content; + } else { + copyPlain = msg.content || ''; + } + if (msg.role === 'user') { + messageDiv.appendChild(buildUserFooterRow(copyPlain, idx)); + } + if (msg.role === 'assistant') { + messageDiv.appendChild(buildAssistantFooterRow(msg.usage || '', copyPlain)); } messagesContainer.insertBefore(messageDiv, typingIndicator); }); - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: 'smooth' - }); + if (!skipDefaultEndScroll) { + applyChatScrollStrategy(messages); + } if (activeSuggestionBubbles.length > 0) { showSuggestionBubbles(activeSuggestionBubbles); @@ -2053,14 +2261,14 @@ function showSuggestionBubbles(bubbles) { pill.title = text; pill.onclick = () => { chatInput.value = text; - chatInput.focus(); dismissBubbleStrip(); + scrollToInputArea('smooth'); + chatInput.focus(); }; pillRow.appendChild(pill); }); lastAssistant.appendChild(pillRow); - messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' }); } /** @@ -2166,19 +2374,9 @@ function dismissError() { } } -/** - * Retry the last user message - */ function retryLastMessage() { - if (!lastUserMessage) { - console.warn('[PgStudio] No previous message to retry'); - return; - } - dismissError(); - chatInput.value = lastUserMessage; - chatInput.focus(); - sendMessage(); + vscode.postMessage({ type: 'regenerateAssistant' }); } /** diff --git a/templates/chat/styles.css b/templates/chat/styles.css index af87c46..044e747 100644 --- a/templates/chat/styles.css +++ b/templates/chat/styles.css @@ -444,6 +444,36 @@ letter-spacing: 0.01em; } + .message-usage-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 6px; + padding: 2px 4px 0; + min-height: 22px; + } + + .message-usage-row .message-usage { + flex: 1; + min-width: 0; + margin-top: 0; + text-align: right; + } + + .message-usage-row--user { + justify-content: flex-end; + margin-top: 6px; + } + + .message-usage--user-spacer { + flex: 1; + min-width: 0; + margin-top: 0; + padding: 0; + opacity: 0; + } + .message-content pre { background-color: var(--vscode-textCodeBlock-background); padding: 12px; @@ -1960,6 +1990,23 @@ opacity: 1; } + .message:focus-within .msg-actions { + opacity: 1; + } + + .message.user .msg-actions { + justify-content: flex-end; + width: 100%; + max-width: 92%; + align-self: flex-end; + } + + .message.user .message-usage-row--user { + align-self: flex-end; + width: 100%; + max-width: 92%; + } + .msg-action-btn { display: flex; align-items: center; @@ -1980,6 +2027,43 @@ border-color: var(--vscode-button-secondaryBackground); } + .msg-actions--inline { + margin-top: 0; + flex-shrink: 0; + gap: 2px; + } + + .msg-action-btn--icon { + padding: 4px; + border: none; + border-radius: 4px; + color: var(--vscode-descriptionForeground); + line-height: 0; + min-width: 26px; + min-height: 26px; + justify-content: center; + } + + .msg-action-btn--icon:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-icon-foreground); + border-color: transparent; + } + + .msg-action-btn--icon:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + } + + .msg-action-btn--icon svg { + display: block; + opacity: 0.85; + } + + .msg-action-btn--icon:hover svg { + opacity: 1; + } + /* History date group headers (Phase F prep) */ .history-date-group-header { padding: 6px 12px 4px;