diff --git a/.github/workflows/jetbrains-ci.yml b/.github/workflows/jetbrains-ci.yml index 3302d3e..4bd1a98 100644 --- a/.github/workflows/jetbrains-ci.yml +++ b/.github/workflows/jetbrains-ci.yml @@ -89,6 +89,9 @@ jobs: distribution: temurin java-version: 21 - uses: gradle/actions/setup-gradle@v6 + with: + # One cache writer: two filled the repo quota and evicted the pnpm caches. + cache-read-only: true - run: ./gradlew test -PintegrationTests=true --stacktrace env: # Rootful Docker on the runner; skip the rootless strategy, whose JNA collides with the IntelliJ Platform's bundled jnidispatch. diff --git a/packages/jetbrains/CHANGELOG.md b/packages/jetbrains/CHANGELOG.md index b4f96cb..8359a0c 100644 --- a/packages/jetbrains/CHANGELOG.md +++ b/packages/jetbrains/CHANGELOG.md @@ -5,6 +5,43 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.5.0] - 2026-08-09 + +### Fixed +- A two-line answer is no longer clipped to one, with the note below it painting over the rest. The + text pane reported its height measured at its unwrapped width rather than the width it was given. +- The **Explain** button comes back after a failure. It disabled itself on click and, if the model + call failed, stayed disabled for the rest of that turn. +- A MongoDB turn no longer says "Writing SQL" or "Finding relevant tables" while it builds a pipeline. +- Content appended to a turn already on screen is scrolled into view, so an approval bar below the + fold is reachable. Scrolling only follows when you are already at the bottom, so it will not pull + you away from an earlier turn you are reading. +- A backticked placeholder such as `?`, a date, or `:param` is no longer reported as a name missing + from your schema. +- A proposed write showed three separate lines saying nothing had run. It shows one. +- The copy control reports failure instead of showing a tick when the clipboard write is refused. + +### Added +- **A copy control on every answer**, not only on result grids: explanations, schema answers, and + errors each copy the model's own text rather than the rendered markup. +- **A query inside a prose answer renders as a real code block** with syntax highlighting and its own + Copy, instead of running together with the sentences around it. +- **The transcript reads as a conversation.** Your question sits on the right in a tinted bubble, + AskSQL answers on the left, and a rule separates one turn from the next. +- Progress is shown for the states that used to sit blank: preparing a turn, answering from the + schema, and correcting a query the database rejected. +- A MongoDB pipeline's collection name is selectable and travels with Copy, as a + `db.getCollection(...).aggregate(...)` call that pastes straight into mongosh. +- Progress is announced to screen readers where the IDE runtime supports it. + +### Changed +- Connections are released when the project's services are disposed rather than through an + experimental platform listener, so cleanup also covers disabling or unloading the plugin. The + listener also woke both connection registries on every project close, including in projects where + AskSQL was never opened. +- The guard detects `LIMIT ALL` through JSqlParser's supported API. The two methods it used before + are deprecated, and the guard failing to compile on a future parser release is not a small problem. + ## [0.4.2] - 2026-08-09 ### Fixed diff --git a/packages/jetbrains/README.md b/packages/jetbrains/README.md index 3da9fd7..469a66b 100644 --- a/packages/jetbrains/README.md +++ b/packages/jetbrains/README.md @@ -16,9 +16,13 @@ plain language, review the generated SQL, approve it, and get results. ![Schema tree browsing a connected MySQL database above the chat panel, with sample questions to get started](https://github.com/rahulmahadik/AskSQL/raw/HEAD/packages/jetbrains/images/schema-and-chat.png) -![AskSQL settings configured against a local Ollama model, no API key needed](https://github.com/rahulmahadik/AskSQL/raw/HEAD/packages/jetbrains/images/settings-ollama.png) +![AskSQL settings configured against a local Ollama model, no API key needed](https://github.com/rahulmahadik/AskSQL/raw/HEAD/packages/jetbrains/images/settings.png) -![The AI provider dropdown in settings, listing OpenAI, Anthropic, Gemini, Groq, Ollama and the rest](https://github.com/rahulmahadik/AskSQL/raw/HEAD/packages/jetbrains/images/settings-providers.png) +![The AI provider dropdown in settings, listing OpenAI, Anthropic, Google, Groq, Ollama, an OpenAI-compatible endpoint, LM Studio and NVIDIA](https://github.com/rahulmahadik/AskSQL/raw/HEAD/packages/jetbrains/images/settings-ai-providers.png) + +![A query result drawn as a bar chart, with a Table toggle beside Export CSV, Copy, Open in Editor and Explain](https://github.com/rahulmahadik/AskSQL/raw/HEAD/packages/jetbrains/images/show-chart.png) + +![Adding a database connection from the AskSQL settings page](https://github.com/rahulmahadik/AskSQL/raw/HEAD/packages/jetbrains/images/settings-add-db-connection.png) ## Getting started (using the plugin) @@ -28,7 +32,7 @@ plain language, review the generated SQL, approve it, and get results. `build/distributions/asksql-jetbrains-.zip`, which installs via **Plugins → ⚙ → Install Plugin from Disk...**.) 2. Restart the IDE when prompted. -3. Open the **AskSQL** tool window (usually a tab on the right/bottom edge). +3. Open the **AskSQL** tool window (a tab on the right edge). 4. **Add a connection**: on the empty-state screen, click **Add Connection** (Postgres, MySQL, SQLite, DuckDB, Oracle, or MongoDB), or click **Try sample data** for a ready-made SQLite database with no setup, good for a first look. @@ -131,8 +135,11 @@ These hold on every engine (the plan doc carries the full list): unless **Send sample column values to the model** is turned on in Settings. It defaults to off, so out of the box the model sees field names, types and presence percentages only. Full row data (arbitrary query results) is never sent on any engine. -- Chat history and query results are **in-memory only**; nothing is written to disk - except settings, and secrets live only in the OS keychain via PasswordSafe. +- Chat history and query results are **in-memory only**, and secrets live only in the OS + keychain via PasswordSafe. Apart from settings, the only things the plugin writes to disk + are under the IDE's system directory and are ones you asked for: the JDBC driver jars it + downloads on demand (`asksql/drivers`), the DuckDB database built when you load data files + (`asksql/uploads`), the sample SQLite database (`asksql/sample`), and a CSV you export. - Zero telemetry. ## Requirements @@ -142,7 +149,8 @@ These hold on every engine (the plan doc carries the full list): - Docker, only if you want to run the Testcontainers-backed integration tests (`./gradlew test -PintegrationTests=true`). Everything else (build, unit tests, `runIde`) needs no Docker. -- Node.js 18+, only for `./gradlew parityVectors` (see below). Never required to build +- Node.js 20+ (the monorepo's `engines` floor; CI uses 22), only for + `./gradlew parityVectors` (see below), which builds `@asksql/core` from this repo. Never required to build or run the plugin itself. ## Compatibility @@ -157,10 +165,17 @@ Every release is run through the JetBrains **Plugin Verifier**: 15 verifications | IDE | Versions verified | | --- | --- | -| IntelliJ IDEA Community | 2024.2 (floor), 2024.3, 2025.1, 2025.2, 2025.3 | -| IntelliJ IDEA Ultimate, PyCharm Professional, WebStorm, PhpStorm, GoLand, Rider, CLion, RubyMine, RustRover | 2026.1 (latest stable) | +| IntelliJ IDEA Community | 2024.2 (floor), 2024.3, 2025.1 (251.29188.72), 2025.2.6.2 (252.28539.54), 2025.3 (253.28294.334) | +| IntelliJ IDEA Ultimate, PyCharm Professional, WebStorm, PhpStorm, GoLand, Rider, CLion, RubyMine, RustRover | 2026.1.4 (latest stable) | | Android Studio | whichever build is installed locally | +The IC entries from 2025.1 on are pinned by build number because ideaIC publishes both build-number +and marketing-version artifacts; the other IDEs publish marketing versions only. + +`untilBuild` is deliberately open-ended (`null`), so the plugin stays installable on IntelliJ +majors that did not exist when it was published. The Plugin Verifier is the safety net: a +verification failure blocks the release. + 2024.1 is out of reach: `com.intellij.util.net.JdkProxyProvider`, which routes model calls through the IDE's proxy settings, arrives in 242. diff --git a/packages/jetbrains/build.gradle.kts b/packages/jetbrains/build.gradle.kts index 4ff4977..a8d4ca0 100644 --- a/packages/jetbrains/build.gradle.kts +++ b/packages/jetbrains/build.gradle.kts @@ -223,6 +223,11 @@ tasks { } } systemProperty("idea.force.use.core.classloader", "true") + // Painting Swing to a PNG needs real font metrics and a window peer: opt in with -PrenderUi=true. + if (providers.gradleProperty("renderUi").orNull == "true") { + systemProperty("java.awt.headless", "false") + systemProperty("renderUi", "true") + } maxHeapSize = "2g" } diff --git a/packages/jetbrains/gradle.properties b/packages/jetbrains/gradle.properties index 8f85a9a..9f6a9de 100644 --- a/packages/jetbrains/gradle.properties +++ b/packages/jetbrains/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.rahulmahadik.asksql pluginName = AskSQL -pluginVersion = 0.4.2 +pluginVersion = 0.5.0 # IntelliJ Platform target used to COMPILE and RUN the sandbox. Broad # compatibility is governed by pluginSinceBuild/pluginUntilBuild in diff --git a/packages/jetbrains/images/onboarding.png b/packages/jetbrains/images/onboarding.png index 9b64f01..c9d6191 100644 Binary files a/packages/jetbrains/images/onboarding.png and b/packages/jetbrains/images/onboarding.png differ diff --git a/packages/jetbrains/images/open-in-editor.png b/packages/jetbrains/images/open-in-editor.png new file mode 100644 index 0000000..ae69b64 Binary files /dev/null and b/packages/jetbrains/images/open-in-editor.png differ diff --git a/packages/jetbrains/images/schema-and-chat.png b/packages/jetbrains/images/schema-and-chat.png index f689901..46b6deb 100644 Binary files a/packages/jetbrains/images/schema-and-chat.png and b/packages/jetbrains/images/schema-and-chat.png differ diff --git a/packages/jetbrains/images/settings-add-db-connection-success.png b/packages/jetbrains/images/settings-add-db-connection-success.png new file mode 100644 index 0000000..8158914 Binary files /dev/null and b/packages/jetbrains/images/settings-add-db-connection-success.png differ diff --git a/packages/jetbrains/images/settings-add-db-connection.png b/packages/jetbrains/images/settings-add-db-connection.png new file mode 100644 index 0000000..3213800 Binary files /dev/null and b/packages/jetbrains/images/settings-add-db-connection.png differ diff --git a/packages/jetbrains/images/settings-ai-providers.png b/packages/jetbrains/images/settings-ai-providers.png new file mode 100644 index 0000000..ad49395 Binary files /dev/null and b/packages/jetbrains/images/settings-ai-providers.png differ diff --git a/packages/jetbrains/images/settings-more.png b/packages/jetbrains/images/settings-more.png new file mode 100644 index 0000000..79cfc1c Binary files /dev/null and b/packages/jetbrains/images/settings-more.png differ diff --git a/packages/jetbrains/images/settings-ollama.png b/packages/jetbrains/images/settings-ollama.png deleted file mode 100644 index 6e7222c..0000000 Binary files a/packages/jetbrains/images/settings-ollama.png and /dev/null differ diff --git a/packages/jetbrains/images/settings.png b/packages/jetbrains/images/settings.png new file mode 100644 index 0000000..0b74777 Binary files /dev/null and b/packages/jetbrains/images/settings.png differ diff --git a/packages/jetbrains/images/show-chart.png b/packages/jetbrains/images/show-chart.png new file mode 100644 index 0000000..5a8ecbe Binary files /dev/null and b/packages/jetbrains/images/show-chart.png differ diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/AskSqlProjectCloseListener.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/AskSqlProjectCloseListener.kt deleted file mode 100644 index bde33b6..0000000 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/AskSqlProjectCloseListener.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.rahulmahadik.asksql.ide - -import com.intellij.openapi.project.Project -import com.intellij.openapi.project.ProjectCloseListener -import com.rahulmahadik.asksql.ide.db.ConnectionRegistry -import com.rahulmahadik.asksql.ide.db.MongoClientRegistry - -/** Registered in `plugin.xml` under `applicationListeners`; `projectClosing` fires before the project's services are torn down. */ -class AskSqlProjectCloseListener : ProjectCloseListener { - override fun projectClosing(project: Project) { - project.getService(ConnectionRegistry::class.java).closeAll() - project.getService(MongoClientRegistry::class.java).closeAll() - } -} diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/ConnectionRegistry.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/ConnectionRegistry.kt index ffb8a9e..c50afaa 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/ConnectionRegistry.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/ConnectionRegistry.kt @@ -1,5 +1,6 @@ package com.rahulmahadik.asksql.ide.db +import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project @@ -17,7 +18,7 @@ import java.util.concurrent.atomic.AtomicInteger * in-flight [withConnection] still uses: each [Slot] tracks a lease count and closes only once the last lease ends. */ @Service(Service.Level.PROJECT) -class ConnectionRegistry(private val project: Project, private val scope: CoroutineScope) { +class ConnectionRegistry(private val project: Project, private val scope: CoroutineScope) : Disposable { private val log = logger() @@ -140,7 +141,11 @@ class ConnectionRegistry(private val project: Project, private val scope: Corout } } - /** Called by [com.rahulmahadik.asksql.ide.AskSqlProjectCloseListener] to release every connection deterministically before the scope is torn down. */ + /** [closeNowOrCancel] is synchronous, so it is safe at dispose time. */ + override fun dispose() { + closeAll() + } + fun closeAll() { slots.keys.toList().forEach { id -> slots.remove(id)?.let { diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/MongoClientRegistry.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/MongoClientRegistry.kt index 518ce8f..efe5d24 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/MongoClientRegistry.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/db/MongoClientRegistry.kt @@ -1,5 +1,6 @@ package com.rahulmahadik.asksql.ide.db +import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project @@ -13,7 +14,7 @@ import java.util.concurrent.atomic.AtomicInteger /** Owns the lifecycle of every configured [MongoClient] for a project. */ @Service(Service.Level.PROJECT) -class MongoClientRegistry(private val project: Project, private val scope: CoroutineScope) { +class MongoClientRegistry(private val project: Project, private val scope: CoroutineScope) : Disposable { private val log = logger() @@ -101,7 +102,11 @@ class MongoClientRegistry(private val project: Project, private val scope: Corou } } - /** Called by [com.rahulmahadik.asksql.ide.AskSqlProjectCloseListener] to release every client deterministically before the scope is torn down. */ + /** [closeNowOrCancel] is synchronous, so it is safe at dispose time. */ + override fun dispose() { + closeAll() + } + fun closeAll() { slots.keys.toList().forEach { id -> slots.remove(id)?.let { diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Grounding.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Grounding.kt index b435d14..a13c99c 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Grounding.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/engine/Grounding.kt @@ -90,7 +90,15 @@ object Grounding { return (aliases + ctes).toList() } - private val PROSE_IDENTIFIER_RE = Regex("""`([^`\s]+)`|"([\w.]+)"|\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b""", RegexOption.IGNORE_CASE) + // Java's \s is ASCII-only where JavaScript's is not; these are the extras JS matches. + // UNICODE_CHARACTER_CLASS would widen \w below and diverge the other way. + private val PROSE_IDENTIFIER_RE = Regex( + """`([^`\s\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+)`|"([\w.]+)"|\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b""", + RegexOption.IGNORE_CASE, + ) + + /** An identifier, optionally schema-qualified. Placeholders, literals and operators do not match. */ + private val IDENTIFIER_SHAPE = Regex("""[a-z_][a-z0-9_$-]*(?:\.[a-z_][a-z0-9_$-]*)*""", RegexOption.IGNORE_CASE) /** * Identifier-shaped names in a prose answer absent from the catalog - the grounding floor for @@ -141,6 +149,8 @@ object Grounding { val scanned = if (documentStyle) answer.replace(Regex("```[\\s\\S]*?```"), " ") else answer for (m in PROSE_IDENTIFIER_RE.findAll(scanned)) { if (documentStyle && m.groupValues[2].isNotEmpty()) continue // "shipped" is a value + // Backticks wrap anything, so a placeholder or a literal can arrive here. + if (m.groupValues[1].isNotEmpty() && !IDENTIFIER_SHAPE.matches(m.groupValues[1])) continue val raw = (m.groupValues[1].ifEmpty { m.groupValues[2] }.ifEmpty { m.groupValues[3] }).lowercase() if (raw.startsWith("$")) continue // $lookup / $group are operators // Backticked SQL vocabulary is not a name claim; a call with parentheses is a function. diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/guard/SqlGuard.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/guard/SqlGuard.kt index 574f47a..184b029 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/guard/SqlGuard.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/guard/SqlGuard.kt @@ -6,6 +6,7 @@ import com.rahulmahadik.asksql.ide.model.GuardPolicy import com.rahulmahadik.asksql.ide.model.GuardVerdict import com.rahulmahadik.asksql.ide.model.LimitStyle import net.sf.jsqlparser.JSQLParserException +import net.sf.jsqlparser.expression.AllValue import net.sf.jsqlparser.expression.Expression import net.sf.jsqlparser.expression.ExpressionVisitorAdapter import net.sf.jsqlparser.expression.Function @@ -535,13 +536,12 @@ object SqlGuard { return LimitStatus.Ok } val limit = target?.limit ?: return LimitStatus.None - // `LIMIT ALL` parses to a Limit with no row count. Reading that as "no limit present" left - // the statement uncapped, since the clause is already there for an append to bind to. - if (limit.isLimitAll) { - return LimitStatus.Unbounded { - limit.isLimitAll = false - limit.rowCount = LongValue(maxRows.toLong()) - } + // `LIMIT ALL` parses to a Limit whose row count is an AllValue, which bounds nothing. Reading + // that as "no limit present" left the statement uncapped, since the clause is already there + // for an append to bind to. Overwriting the row count is what clears it (the deprecated + // setLimitAll(false) was a no-op). + if (limit.rowCount is AllValue) { + return LimitStatus.Unbounded { limit.rowCount = LongValue(maxRows.toLong()) } } val rowCount: Expression = limit.rowCount ?: return LimitStatus.None val value = (rowCount as? LongValue)?.value ?: return LimitStatus.NonLiteral diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/ChatPanel.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/ChatPanel.kt index f1f15ac..103d8c0 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/ChatPanel.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/ChatPanel.kt @@ -50,6 +50,18 @@ internal fun selectionAfterRefresh( previouslySelectedId: String?, ): ConnectionDescriptor? = descriptors.firstOrNull { it.id == previouslySelectedId } ?: descriptors.firstOrNull() +/** Matches the VS Code extension's `STAGE_LABEL` wording, except where MongoDB has no SQL to name. */ +internal fun stageLabel(stage: Stage, isSql: Boolean): String = when (stage) { + Stage.CATALOG -> "Reading schema…" + Stage.PRUNE -> if (isSql) "Finding relevant tables…" else "Finding relevant collections…" + Stage.LLM -> if (isSql) "Writing SQL…" else "Writing the pipeline…" + Stage.REPAIR -> if (isSql) "Correcting the SQL…" else "Correcting the pipeline…" + Stage.EXTRACT -> "Reading the reply…" + Stage.GUARD -> "Checking safety…" + Stage.EXECUTE -> "Running the query…" + Stage.DONE -> "" +} + /** * The Chat tab: connection/model pickers, transcript, and question input. Owns a UI-lifetime * [CoroutineScope] (a plain Swing component has no platform-injected scope), cancelled in [dispose]. @@ -300,6 +312,8 @@ class ChatPanel(private val project: Project) : Disposable { val turn = TurnPanel(project, question) transcript.addTurn(turn) + // Reading the keychain and building the LLM client both precede the first StageEvent. + turn.updateStatus("Getting ready…") val sqlContext = contextTurns.toList() val mongoContext = mongoContextTurns.toList() @@ -320,7 +334,7 @@ class ChatPanel(private val project: Project) : Disposable { password = password, llmClient = llmClient, context = sqlContext, - onEvent = { event -> onEngineEvent(turn, event) }, + onEvent = { event -> onEngineEvent(turn, event, descriptor.engine.isSql) }, customInstructions = AskSqlAppSettings.getInstance().customInstructions, glossaryText = AskSqlAppSettings.getInstance().glossary, ) @@ -331,6 +345,7 @@ class ChatPanel(private val project: Project) : Disposable { AskSqlAppSettings.getInstance().answerSchemaQuestions && (code == AskSqlErrorCode.LLM_CANNOT_ANSWER || code == AskSqlErrorCode.LLM_REFUSAL) ) { + onEdt { turn.updateStatus("Answering from your schema…") } val sa = engineService.pipeline.explainSchema(question, descriptor, password, llmClient, sqlContext) onEdt { turn.showSchemaAnswer(sa.answer, sa.unknownReferences, sa.isSchemaChange, sa.proposedSql) } // A prose turn is still a turn: without it, "run that query" has nothing to refer to. @@ -368,7 +383,7 @@ class ChatPanel(private val project: Project) : Disposable { password = password, llmClient = llmClient, context = mongoContext, - onEvent = { event -> onEngineEvent(turn, event) }, + onEvent = { event -> onEngineEvent(turn, event, descriptor.engine.isSql) }, customInstructions = AskSqlAppSettings.getInstance().customInstructions, ) } catch (e: Exception) { @@ -378,6 +393,7 @@ class ChatPanel(private val project: Project) : Disposable { AskSqlAppSettings.getInstance().answerSchemaQuestions && (code == AskSqlErrorCode.LLM_CANNOT_ANSWER || code == AskSqlErrorCode.LLM_REFUSAL) ) { + onEdt { turn.updateStatus("Answering from your schema…") } val sa = engineService.mongoPipeline.explainSchema(question, descriptor, password, llmClient, mongoContext) onEdt { turn.showSchemaAnswer(sa.answer, sa.unknownReferences, sa.isSchemaChange, sa.proposedSql, proposedIsPipeline = true) } return@launch @@ -451,12 +467,13 @@ class ChatPanel(private val project: Project) : Disposable { onEdt { turn.updateStatus(""); turn.showCannotAnswer("Cancelled.", leadIn = null) } } catch (e: Exception) { val presented = ErrorPresenter.present(e) - onEdt { turn.updateStatus("") } // Only a query the DATABASE itself rejected is worth asking the model to repair. if (presented.code == AskSqlErrorCode.DB_QUERY_ERROR) { + // Another model round-trip; showError and its suggested-fix twin clear this. + onEdt { turn.updateStatus("Correcting the SQL…") } trySuggestSqlFix(turn, descriptor, password, sql, question, presented) } else { - onEdt { turn.showError(presented.userMessage) } + onEdt { turn.updateStatus(""); turn.showError(presented.userMessage) } } } finally { endBusy() @@ -530,11 +547,11 @@ class ChatPanel(private val project: Project) : Disposable { onEdt { turn.updateStatus(""); turn.showCannotAnswer("Cancelled.", leadIn = null) } } catch (e: Exception) { val presented = ErrorPresenter.present(e) - onEdt { turn.updateStatus("") } if (presented.code == AskSqlErrorCode.DB_QUERY_ERROR) { + onEdt { turn.updateStatus("Correcting the pipeline…") } trySuggestMongoFix(turn, descriptor, password, pipelineJson, question, presented) } else { - onEdt { turn.showError(presented.userMessage) } + onEdt { turn.updateStatus(""); turn.showError(presented.userMessage) } } } finally { endBusy() @@ -568,14 +585,14 @@ class ChatPanel(private val project: Project) : Disposable { } } - private fun onEngineEvent(turn: TurnPanel, event: EngineEvent) { + private fun onEngineEvent(turn: TurnPanel, event: EngineEvent, isSql: Boolean) { // Raw tokens are the model's unparsed reply; the stage label and spinner already show // progress. Dropped BEFORE the EDT hop: one token arrives per SSE frame, and scheduling a // no-op invokeLater for each would queue thousands of EDT dispatches per answer. if (event is EngineEvent.Token) return onEdt { when (event) { - is EngineEvent.StageEvent -> turn.updateStatus(stageLabel(event.stage)) + is EngineEvent.StageEvent -> turn.updateStatus(stageLabel(event.stage, isSql)) is EngineEvent.Token -> Unit is EngineEvent.Warning -> turn.updateStatus(event.message) EngineEvent.Done -> turn.updateStatus("") @@ -583,18 +600,6 @@ class ChatPanel(private val project: Project) : Disposable { } } - /** Matches the VS Code extension's `STAGE_LABEL` wording. */ - private fun stageLabel(stage: Stage): String = when (stage) { - Stage.CATALOG -> "Reading schema…" - Stage.PRUNE -> "Finding relevant tables…" - Stage.LLM -> "Writing SQL…" - Stage.REPAIR -> "Correcting the SQL…" - Stage.EXTRACT -> "Reading the reply…" - Stage.GUARD -> "Checking safety…" - Stage.EXECUTE -> "Running the query…" - Stage.DONE -> "" - } - private inline fun onEdt(crossinline block: () -> Unit) { if (SwingUtilities.isEventDispatchThread()) block() else ApplicationManager.getApplication().invokeLater { block() } } @@ -602,7 +607,7 @@ class ChatPanel(private val project: Project) : Disposable { /** Shows the turn's spinner for the whole model round-trip. */ private fun explainSql(turn: TurnPanel, descriptor: ConnectionDescriptor, password: String?, sql: String) { scope.launch { - onEdt { turn.updateStatus("Describing the query…") } + onEdt { turn.explainStarted(); turn.updateStatus("Describing the query…") } try { val engineService = AskSqlEngineService.getInstance(project) val llmClient = engineService.currentLlmClient() @@ -617,7 +622,7 @@ class ChatPanel(private val project: Project) : Disposable { private fun explainMongoPipeline(turn: TurnPanel, descriptor: ConnectionDescriptor, password: String?, pipelineJson: String) { scope.launch { - onEdt { turn.updateStatus("Describing the pipeline…") } + onEdt { turn.explainStarted(); turn.updateStatus("Describing the pipeline…") } try { val engineService = AskSqlEngineService.getInstance(project) val llmClient = engineService.currentLlmClient() diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/SqlBlockPanel.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/SqlBlockPanel.kt index 681939d..0cbc32e 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/SqlBlockPanel.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/SqlBlockPanel.kt @@ -11,8 +11,18 @@ import java.awt.datatransfer.StringSelection import javax.swing.JButton import javax.swing.JPanel -/** Read-only query display: an [EditorTextField] over a platform file type ("sql" or "json"), highlighted only when the host IDE bundles that language. */ -class SqlBlockPanel(private val project: Project, sql: String, fileExtension: String = "sql", languageId: String = "SQL") { +/** + * Read-only query display: an [EditorTextField] over a platform file type ("sql" or "json"), highlighted + * only when the host IDE bundles that language. [clipboardText] defaults to the shown text; a MongoDB + * pipeline overrides it so the clipboard also carries the collection the JSON does not name. + */ +class SqlBlockPanel( + private val project: Project, + sql: String, + fileExtension: String = "sql", + languageId: String = "SQL", + private val clipboardText: String = sql, +) { val component: JPanel = JPanel(BorderLayout()) val sqlText: String = sql @@ -28,7 +38,7 @@ class SqlBlockPanel(private val project: Project, sql: String, fileExtension: St component.add(field, BorderLayout.CENTER) val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)) - toolbar.add(JButton("Copy").apply { addActionListener { copyToClipboard() } }) + toolbar.add(copyButton("Copy") { clipboardText }) toolbar.add( JButton("Open in Scratch").apply { addActionListener { OpenSqlInScratchAction.open(project, sqlText, "asksql-query.$fileExtension", languageId) } @@ -38,6 +48,6 @@ class SqlBlockPanel(private val project: Project, sql: String, fileExtension: St } fun copyToClipboard() { - CopyPasteManager.getInstance().setContents(StringSelection(sqlText)) + CopyPasteManager.getInstance().setContents(StringSelection(clipboardText)) } } diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TranscriptView.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TranscriptView.kt index 41d197b..4aa288e 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TranscriptView.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TranscriptView.kt @@ -94,10 +94,17 @@ class TranscriptView(project: Project, private val onSamplePick: (String) -> Uni fun addTurn(turn: TurnPanel) { if (turns.isEmpty()) showTranscript() turns.addLast(turn) + // Follow the answer only while already at the bottom. + turn.onContentAppended = { if (isNearBottom()) scrollToBottom() } val wrapper = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) + // The rule belongs to the turn above it, so it sits inside the strut. + border = JBUI.Borders.compound( + JBUI.Borders.emptyBottom(8), + JBUI.Borders.customLine(com.intellij.ui.JBColor.border(), 0, 0, 1, 0), + ) add(turn.component) - add(javax.swing.Box.createVerticalStrut(4)) + add(javax.swing.Box.createVerticalStrut(JBUI.scale(8))) } wrappers.addLast(wrapper) turnsContainer.add(wrapper) @@ -119,6 +126,12 @@ class TranscriptView(project: Project, private val onSamplePick: (String) -> Uni showEmptyState() } + /** Within one line of the end counts as "following along"; an exact test never matches mid-layout. */ + private fun isNearBottom(): Boolean { + val bar = scrollPane.verticalScrollBar + return bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(48) + } + private fun scrollToBottom() { SwingUtilities.invokeLater { val bar = scrollPane.verticalScrollBar diff --git a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TurnPanel.kt b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TurnPanel.kt index 1af0240..7a8b61a 100644 --- a/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TurnPanel.kt +++ b/packages/jetbrains/src/main/kotlin/com/rahulmahadik/asksql/ide/ui/TurnPanel.kt @@ -1,27 +1,30 @@ package com.rahulmahadik.asksql.ide.ui +import com.intellij.icons.AllIcons +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.project.Project import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBPanel import com.intellij.util.ui.JBUI +import com.intellij.util.ui.accessibility.AccessibleAnnouncerUtil +import com.intellij.util.ui.accessibility.ScreenReader import com.rahulmahadik.asksql.ide.model.AskSqlResultSet import java.awt.BorderLayout +import java.awt.Dimension import java.awt.FlowLayout +import java.awt.datatransfer.StringSelection import javax.swing.BoxLayout import javax.swing.JButton import javax.swing.JEditorPane import javax.swing.JPanel +import javax.swing.JTextField +import javax.swing.Timer +import javax.swing.text.View internal fun escapeHtml(text: String): String = text.replace("&", "&").replace("<", "<").replace(">", ">") -/** - * Models answer in Markdown, so raw `**bold**` and backticks would render as literal asterisks. - * Escape first, then translate the few marks that actually show up in an explanation. - */ -private val FENCED_BLOCK_RE = Regex("""```[A-Za-z0-9]*\n?[\s\S]*?```""") - -// Hoisted like FENCED_BLOCK_RE: markdownToHtml runs on the EDT as each answer renders, and an +// Hoisted: markdownToHtml runs on the EDT as each answer renders, and an // inline Regex(...) recompiles its Pattern on every call. private val EXPLANATION_HEADING_RE = Regex("""^\s*(\*\*|__)?\s*Explanation\s*(\*\*|__)?\s*:\s*""", RegexOption.IGNORE_CASE) private val FENCED_CAPTURE_RE = Regex("""```[A-Za-z0-9]*\n?([\s\S]*?)```""") @@ -30,6 +33,7 @@ private val BOLD_UNDERSCORES_RE = Regex("""(?") +/** + * Bubble tint for the question: an explicit light/dark pair, so it stays distinct from the + * transcript background under any theme, including custom ones. + */ +internal val QUESTION_BUBBLE_BACKGROUND = com.intellij.ui.JBColor(java.awt.Color(0xE8F0FE), java.awt.Color(0x2E3641)) +internal val QUESTION_BUBBLE_BORDER = com.intellij.ui.JBColor(java.awt.Color(0xCFDFF8), java.awt.Color(0x3C4657)) + +/** The question sits on the right of the turn, opposite the assistant. */ +internal fun questionHtml(question: String): String = "
${escapeHtml(question)}
" + +/** One run of a model answer: prose to render as HTML, or the body of a fenced block to render as code. */ +internal sealed interface AnswerSegment { + data class Prose(val text: String) : AnswerSegment + data class Code(val code: String, val tag: String) : AnswerSegment +} + +// Three or more: a model quoting fenced content emits a four-backtick fence. +private val FENCED_SEGMENT_RE = Regex("""`{3,}([A-Za-z0-9+#_-]*)[ \t]*\r?\n?([\s\S]*?)`{3,}""") + +/** An unterminated fence stays prose, so a half-streamed answer never loses its text. */ +internal fun splitFencedSegments(answer: String): List { + val segments = mutableListOf() + var cursor = 0 + for (m in FENCED_SEGMENT_RE.findAll(answer)) { + answer.substring(cursor, m.range.first).takeIf { it.isNotBlank() }?.let { segments += AnswerSegment.Prose(it) } + m.groupValues[2].trim('\n', '\r').takeIf { it.isNotBlank() }?.let { segments += AnswerSegment.Code(it, m.groupValues[1].lowercase()) } + cursor = m.range.last + 1 + } + answer.substring(cursor).takeIf { it.isNotBlank() }?.let { segments += AnswerSegment.Prose(it) } + return segments +} + +private val SQL_FENCE_TAGS = setOf("sql", "postgres", "postgresql", "psql", "mysql", "mariadb", "sqlite", "tsql", "plsql", "oracle") + +/** Fence tag to the file type [SqlBlockPanel] highlights with; an unknown tag stays plain text. */ +internal fun fenceLanguage(tag: String, defaultIsJson: Boolean): Pair = when { + tag == "json" || (tag.isEmpty() && defaultIsJson) -> "json" to "JSON" + tag in SQL_FENCE_TAGS || tag.isEmpty() -> "sql" to "SQL" + else -> "txt" to "TEXT" +} + +/** Each block is a real editor on the EDT, so a runaway reply renders the rest as plain text instead. */ +internal const val MAX_INLINE_CODE_BLOCKS = 5 + +/** The `mongosh` call MongoExtract accepts; the collection is outside the JSON. */ +internal fun mongoShellSnippet(collection: String, pipelineJson: String): String { + val quoted = collection.replace("\\", "\\\\").replace("\"", "\\\"") + return "db.getCollection(\"$quoted\").aggregate($pipelineJson)" +} + +/** Selectable one-line text that reads as a label; a [JBLabel]'s content cannot be copied out. */ +internal fun selectableText(text: String): JTextField = + object : JTextField(text) { + // Otherwise the column hands this field any leftover vertical space. + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + }.apply { + isEditable = false + isOpaque = false + border = JBUI.Borders.empty(0, 2, 4, 2) + alignmentX = 0f + font = com.intellij.util.ui.UIUtil.getLabelFont() + foreground = com.intellij.util.ui.UIUtil.getLabelForeground() + } + +private const val EXPLAIN_BUSY_TEXT = "Describing…" + +/** The sentence the engine appends to a proposed write. */ +internal const val READ_ONLY_LINE_MARKER = "AskSQL is read-only" + +/** How long a copy button shows its outcome icon before it reverts to the copy icon. */ +private const val COPY_FEEDBACK_MS = 1200 + +internal const val COPY_TOOLTIP = "Copy to clipboard" +internal const val COPY_FAILED_TOOLTIP = "Copy failed" + +/** + * Copy control for one answer: [source] is read on click, so the clipboard gets the model's raw + * text, not the rendered HTML. A null [label] gives the borderless icon-only form. + */ +internal fun copyButton(label: String?, source: () -> String): JButton = + JButton(label, AllIcons.Actions.Copy).apply { + toolTipText = COPY_TOOLTIP + isFocusPainted = false + // The icon-only form has no text, so a screen reader has nothing else to read. + accessibleContext.accessibleName = label ?: COPY_TOOLTIP + if (label == null) { + isContentAreaFilled = false + isBorderPainted = false + isOpaque = false + border = JBUI.Borders.empty(2) + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + } + addActionListener { + val copied = runCatching { + CopyPasteManager.getInstance().setContents(StringSelection(source())) + }.isSuccess + icon = if (copied) AllIcons.Actions.Checked else AllIcons.General.Error + toolTipText = if (copied) COPY_TOOLTIP else COPY_FAILED_TOOLTIP + val revert = Timer(COPY_FEEDBACK_MS) { + icon = AllIcons.Actions.Copy + toolTipText = COPY_TOOLTIP + } + revert.isRepeats = false + revert.start() + } + } + +/** + * Copy affordance under one answer, hugging the right edge through its own FlowLayout.RIGHT: in a + * BoxLayout Y_AXIS column a child with a different alignmentX is offset, which indents the column. + */ +internal fun copyRow(source: () -> String): JPanel = + object : JPanel(FlowLayout(FlowLayout.RIGHT, 0, 0)) { + // Otherwise the column hands this row any leftover vertical space. + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + }.apply { + isOpaque = false + border = JBUI.Borders.empty(0, 8, 2, 8) + alignmentX = 0.5f + add(copyButton(null, source)) + } + /** * One question-answer turn in the transcript. All mutation methods must run on the EDT; * [ChatPanel]'s coroutine callbacks hop back via `invokeLater` before touching this class. @@ -54,7 +180,24 @@ class TurnPanel(private val project: Project, question: String) { border = JBUI.Borders.empty(6, 8) } - private val questionLabel = wrappingHtml("${escapeHtml(question)}") + private val questionLabel = wrappingHtml(questionHtml(question)) + + /** + * The question reads as a bubble. Only the question is tinted: [SqlBlockPanel]'s editor, + * [ResultTablePanel]'s table and the progress icon all paint their own backgrounds. + */ + private val questionBubble = object : JPanel(BorderLayout()) { + // Otherwise the column hands the bubble any leftover vertical space. + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + }.apply { + isOpaque = true + background = QUESTION_BUBBLE_BACKGROUND + border = JBUI.Borders.compound( + JBUI.Borders.customLine(QUESTION_BUBBLE_BORDER, 1), + JBUI.Borders.empty(6, 8), + ) + add(questionLabel, BorderLayout.CENTER) + } private val statusLabel = JBLabel(" ").apply { foreground = com.intellij.ui.JBColor.GRAY } /** Animated "working" indicator, visible only while [statusLabel] is non-blank. */ private val statusIcon = com.intellij.util.ui.AsyncProcessIcon("askSqlTurnProgress").apply { isVisible = false } @@ -73,34 +216,71 @@ class TurnPanel(private val project: Project, question: String) { var resultTablePanel: ResultTablePanel? = null private set + /** Set by [TranscriptView]: a turn keeps growing after it is added. */ + internal var onContentAppended: (() -> Unit)? = null + + private fun refresh() { + component.revalidate() + component.repaint() + onContentAppended?.invoke() + } + init { // BoxLayout Y_AXIS aligns children by alignmentX; keep every row left-aligned so nothing indents. questionLabel.alignmentX = 0f + questionBubble.alignmentX = 0f statusRow.alignmentX = 0f bodyPanel.alignmentX = 0f - component.add(roleHeader("You", com.intellij.icons.AllIcons.General.User)) - component.add(questionLabel) + component.add(roleHeader("You", com.intellij.icons.AllIcons.General.User, rightAligned = true)) + component.add(questionBubble) component.add(javax.swing.Box.createVerticalStrut(8)) component.add(roleHeader("AskSQL", AskSqlIcons.ASSISTANT)) component.add(statusRow) component.add(bodyPanel) } - /** A small "You"/"AskSQL" row above each side of the turn, so who said what is obvious at a glance. */ - private fun roleHeader(name: String, icon: javax.swing.Icon): JPanel = - JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)).apply { + /** + * A small "You"/"AskSQL" row above each side of the turn. The user's row hugs the right edge + * through its own FlowLayout.RIGHT while keeping alignmentX 0f: mixing alignmentX values in a + * BoxLayout column indents it. + */ + private fun roleHeader(name: String, icon: javax.swing.Icon, rightAligned: Boolean = false): JPanel = + JPanel(FlowLayout(if (rightAligned) FlowLayout.RIGHT else FlowLayout.LEFT, 4, 0)).apply { isOpaque = false border = JBUI.Borders.emptyBottom(2) alignmentX = 0f - add(JBLabel(icon)) - add(JBLabel(name).apply { font = font.deriveFont(java.awt.Font.BOLD); foreground = com.intellij.ui.JBColor.GRAY }) + val nameLabel = JBLabel(name).apply { font = font.deriveFont(java.awt.Font.BOLD); foreground = com.intellij.ui.JBColor.GRAY } + // Mirrored so the icon stays on the outer edge of each side. + if (rightAligned) { + add(nameLabel) + add(JBLabel(icon)) + } else { + add(JBLabel(icon)) + add(nameLabel) + } } fun updateStatus(text: String) { statusLabel.text = text val busy = text.isNotBlank() statusIcon.isVisible = busy - if (busy) statusIcon.resume() else statusIcon.suspend() + if (busy) { + announce(text) + statusIcon.resume() + } else { + statusIcon.suspend() + } + } + + /** + * Setting the label's text fires only ACCESSIBLE_VISIBLE_DATA_PROPERTY, which no reader speaks + * for an unfocused, non-focusable label; the platform announcer speaks it, queued so a run of + * stage labels is not clipped. Off the JBR there is no announcer and this is a no-op. + */ + private fun announce(message: String) { + if (ScreenReader.isActive() && AccessibleAnnouncerUtil.isAnnouncingAvailable()) { + AccessibleAnnouncerUtil.announce(statusLabel, message, false) + } } fun showSqlPendingApproval(sql: String, explanation: String? = null, onRun: () -> Unit, onCancel: () -> Unit) { @@ -110,12 +290,12 @@ class TurnPanel(private val project: Project, question: String) { stack.add(SqlBlockPanel(project, sql).component) explanation?.takeIf { it.isNotBlank() }?.let { stack.add(wrappingHtml("${markdownToHtml(it)}").apply { border = JBUI.Borders.empty(4, 2) }) + stack.add(copyRow { it }) explanationShown = true } stack.add(ApprovalBar(onRun, onCancel).component) bodyPanel.add(stack) - component.revalidate() - component.repaint() + refresh() } fun showSqlOnly(sql: String, explanation: String?) { @@ -123,25 +303,34 @@ class TurnPanel(private val project: Project, question: String) { bodyPanel.add(SqlBlockPanel(project, sql).component) // Stashed, not shown yet: showResult appends it AFTER the result table, so a turn reads question, query, result, explanation. pendingAskExplanation = explanation - component.revalidate() - component.repaint() + refresh() } - /** MongoDB counterpart to [showSqlPendingApproval]; the pipeline's target collection lives outside the JSON text, so it is shown as its own label above the block. */ + /** The block shows the pipeline JSON but copies the full shell call, so a paste into mongosh runs. */ + private fun mongoBlock(collection: String, pipelineJson: String) = + SqlBlockPanel( + project, + pipelineJson, + fileExtension = "json", + languageId = "JSON", + clipboardText = mongoShellSnippet(collection, pipelineJson), + ) + + /** MongoDB counterpart to [showSqlPendingApproval]; the collection sits above the block. */ fun showMongoPipelinePendingApproval(collection: String, pipelineJson: String, explanation: String? = null, onRun: () -> Unit, onCancel: () -> Unit) { bodyPanel.removeAll() val stack = JPanel() stack.layout = BoxLayout(stack, BoxLayout.Y_AXIS) - stack.add(JBLabel("Collection: $collection").apply { border = JBUI.Borders.empty(0, 2, 4, 2) }) - stack.add(SqlBlockPanel(project, pipelineJson, fileExtension = "json", languageId = "JSON").component) + stack.add(selectableText("Collection: $collection")) + stack.add(mongoBlock(collection, pipelineJson).component) explanation?.takeIf { it.isNotBlank() }?.let { stack.add(wrappingHtml("${markdownToHtml(it)}").apply { border = JBUI.Borders.empty(4, 2) }) + stack.add(copyRow { it }) explanationShown = true } stack.add(ApprovalBar(onRun, onCancel).component) bodyPanel.add(stack) - component.revalidate() - component.repaint() + refresh() } /** MongoDB counterpart to [showSqlOnly]. */ @@ -149,17 +338,32 @@ class TurnPanel(private val project: Project, question: String) { bodyPanel.removeAll() val stack = JPanel() stack.layout = BoxLayout(stack, BoxLayout.Y_AXIS) - stack.add(JBLabel("Collection: $collection").apply { border = JBUI.Borders.empty(0, 2, 4, 2) }) - stack.add(SqlBlockPanel(project, pipelineJson, fileExtension = "json", languageId = "JSON").component) + stack.add(selectableText("Collection: $collection")) + stack.add(mongoBlock(collection, pipelineJson).component) bodyPanel.add(stack) pendingAskExplanation = explanation - component.revalidate() - component.repaint() + refresh() } private var explanationShown = false - /** The single failure label currently shown for this turn, if any; see [showFailure]. */ + /** Toggles the Explain button between idle and in-flight; null until [showResult] builds one. */ + private var setExplainBusy: ((Boolean) -> Unit)? = null + /** The button is shared by the click path and the automatic one, so it only idles when both are done. */ + private var explainsInFlight = 0 + + /** Called by every explain request for this turn, automatic or clicked. */ + fun explainStarted() { + explainsInFlight++ + setExplainBusy?.invoke(true) + } + + private fun explainSettled() { + if (explainsInFlight > 0) explainsInFlight-- + if (explainsInFlight == 0) setExplainBusy?.invoke(false) + } + /** The failure currently shown for this turn: its label and copy row. */ private var failureLabel: JEditorPane? = null + private var failureCopyRow: JPanel? = null fun showResult( resultSet: AskSqlResultSet, @@ -176,7 +380,7 @@ class TurnPanel(private val project: Project, question: String) { val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)) toolbar.add(JButton("Export CSV").apply { addActionListener { onExportCsv(panel) } }) - toolbar.add(JButton("Copy").apply { addActionListener { onCopyResult(panel) } }) + toolbar.add(JButton("Copy", AllIcons.Actions.Copy).apply { addActionListener { onCopyResult(panel) } }) toolbar.add(JButton("Open in Editor").apply { addActionListener { onOpenInEditor(panel) } }) // Table stays the default; the button only appears when [Charts.infer] finds something to draw. Charts.infer(resultSet)?.let { spec -> @@ -196,8 +400,20 @@ class TurnPanel(private val project: Project, question: String) { ) } if (onExplain != null) { + // The turn's spinner sits above a tall result, so the button itself carries the in-flight state. val explainButton = JButton("Explain") - explainButton.addActionListener { explainButton.isEnabled = false; onExplain() } + explainButton.preferredSize = Dimension( + explainButton.getFontMetrics(explainButton.font).stringWidth(EXPLAIN_BUSY_TEXT) + JBUI.scale(28), + explainButton.preferredSize.height, + ) + // Restored on both outcomes: a failed explain must not remove the feature for this turn. + setExplainBusy = { busy -> + explainButton.isEnabled = !busy + explainButton.text = if (busy) EXPLAIN_BUSY_TEXT else "Explain" + } + // An automatic explain may already be running by the time this button exists. + if (explainsInFlight > 0) setExplainBusy?.invoke(true) + explainButton.addActionListener { onExplain() } toolbar.add(explainButton) } if (resultSet.warnings.isNotEmpty()) { @@ -210,24 +426,51 @@ class TurnPanel(private val project: Project, question: String) { pendingAskExplanation?.let { explanation -> if (explanation.isNotBlank()) { bodyPanel.add(wrappingHtml("${markdownToHtml(explanation)}").apply { border = JBUI.Borders.empty(4, 2) }) + bodyPanel.add(copyRow { explanation }) explanationShown = true } pendingAskExplanation = null } - component.revalidate() - component.repaint() + refresh() } /** True once any description has been shown for this turn (inline prose or a dedicated Explain call); lets the caller skip a redundant auto-explain. */ fun hasExplanation(): Boolean = explanationShown + /** Renders an answer with each fenced block as a real code block with its own Copy, bounded in height. */ + private fun addProseWithCodeBlocks(text: String, defaultIsJson: Boolean, extracted: String? = null): Int { + var blocks = 0 + for (segment in splitFencedSegments(text)) { + when (segment) { + is AnswerSegment.Prose -> + bodyPanel.add(wrappingHtml(markdownToHtml(segment.text)).apply { border = JBUI.Borders.empty(6, 8) }) + is AnswerSegment.Code -> if (blocks < MAX_INLINE_CODE_BLOCKS) { + blocks++ + val (extension, language) = fenceLanguage(segment.tag, defaultIsJson) + // The extracted statement has had trailing prose stripped. + val code = extracted?.takeIf { segment.code.trim().startsWith(it.trim()) }?.trim() ?: segment.code + bodyPanel.add(boundedBlock(SqlBlockPanel(project, code, fileExtension = extension, languageId = language).component)) + } else { + bodyPanel.add(wrappingHtml(markdownToHtml("```\n${segment.code}\n```")).apply { border = JBUI.Borders.empty(6, 8) }) + } + } + } + return blocks + } + + /** Bounded height: [SqlBlockPanel]'s panel has an unbounded maximum. */ + private fun boundedBlock(component: JPanel): JPanel = + object : JPanel(BorderLayout()) { + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + }.apply { isOpaque = false; add(component, BorderLayout.CENTER) } + /** Appends the model's plain-language explanation below the result; called by the "Explain" button or the auto-explain path. */ fun appendExplanation(text: String) { - val label = wrappingHtml(markdownToHtml(text)).apply { border = JBUI.Borders.empty(6, 8) } explanationShown = true - bodyPanel.add(label) - component.revalidate() - component.repaint() + addProseWithCodeBlocks(text, defaultIsJson = false) + bodyPanel.add(copyRow { text }) + explainSettled() + refresh() } fun showExplanationError(userMessage: String) { @@ -246,60 +489,53 @@ class TurnPanel(private val project: Project, question: String) { updateStatus("") // A query in a prose answer is the same artifact as a generated one, so it gets the same // block and the same Copy button rather than being flattened into the text. - val fence = if (proposedSql != null) FENCED_BLOCK_RE.find(answer) else null - if (fence != null) { - val before = answer.substring(0, fence.range.first).trimEnd() - val after = answer.substring(fence.range.last + 1).trimStart() - if (before.isNotBlank()) { - bodyPanel.add(wrappingHtml(markdownToHtml(before)).apply { border = JBUI.Borders.empty(6, 8) }) - } - bodyPanel.add( - if (proposedIsPipeline) { - SqlBlockPanel(project, proposedSql!!, fileExtension = "json", languageId = "JSON").component - } else { - SqlBlockPanel(project, proposedSql!!).component - }, - ) - if (after.isNotBlank()) { - bodyPanel.add(wrappingHtml(markdownToHtml(after)).apply { border = JBUI.Borders.empty(6, 8) }) - } - } else { - bodyPanel.add(wrappingHtml(markdownToHtml(answer)).apply { border = JBUI.Borders.empty(6, 8) }) + val blocks = addProseWithCodeBlocks(answer, defaultIsJson = proposedIsPipeline, extracted = proposedSql) + // The model can propose a statement without fencing it. + if (blocks == 0 && proposedSql != null) { + val (extension, language) = fenceLanguage(if (proposedIsPipeline) "json" else "sql", proposedIsPipeline) + bodyPanel.add(boundedBlock(SqlBlockPanel(project, proposedSql, fileExtension = extension, languageId = language).component)) } if (unknownReferences.isNotEmpty()) { val names = escapeHtml(unknownReferences.joinToString(", ")) val note = if (isSchemaChange) { - "Proposed names not in your current schema: $names. AskSQL is read-only and ran nothing." + "Proposed names not in your current schema: $names." } else { "Heads up: this mentioned names not in your schema ($names), so treat those with caution." } bodyPanel.add(wrappingHtml(note).apply { border = JBUI.Borders.empty(2, 8) }) } - bodyPanel.add( - wrappingHtml("Generated from your schema by the model - no query was run, so treat it as guidance.") - .apply { border = JBUI.Borders.empty(2, 8) }, - ) - component.revalidate() - component.repaint() + if (!answer.contains(READ_ONLY_LINE_MARKER)) { + bodyPanel.add( + wrappingHtml("Generated from your schema by the model - no query was run, so treat it as guidance.") + .apply { border = JBUI.Borders.empty(2, 8) }, + ) + } + // One row for the whole answer: the clipboard gets the model's text, not the notes around it. + bodyPanel.add(copyRow { answer }) + refresh() } fun showError(userMessage: String) { updateStatus("") - showFailure(wrappingHtml(errorHtml(userMessage))) + showFailure(wrappingHtml(errorHtml(userMessage)), userMessage) } /** A turn has at most one outcome: a new failure replaces the previous one, and a real result clears it. */ - private fun showFailure(label: JEditorPane) { - failureLabel?.let { bodyPanel.remove(it) } + private fun showFailure(label: JEditorPane, message: String) { + clearFailure() failureLabel = label + val copy = copyRow { message } + failureCopyRow = copy bodyPanel.add(label) - component.revalidate() - component.repaint() + bodyPanel.add(copy) + refresh() } private fun clearFailure() { failureLabel?.let { bodyPanel.remove(it) } + failureCopyRow?.let { bodyPanel.remove(it) } failureLabel = null + failureCopyRow = null } /** @@ -318,9 +554,8 @@ class TurnPanel(private val project: Project, question: String) { if (e.eventType == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) onOpenSettings() } } - showFailure(label) - component.revalidate() - component.repaint() + showFailure(label, userMessage) + refresh() } /** Replaces the rejected query with the corrected one; a turn shows a single statement. Approved like any other SQL. */ @@ -341,7 +576,7 @@ class TurnPanel(private val project: Project, question: String) { replaceBodyWithSuggestion( errorMessage = errorMessage, heading = "Corrected to match your schema - collection: $collection", - block = SqlBlockPanel(project, pipelineJson, fileExtension = "json", languageId = "JSON").component, + block = mongoBlock(collection, pipelineJson).component, onRunFix = onRunFix, onDismiss = onDismiss, ) @@ -356,30 +591,89 @@ class TurnPanel(private val project: Project, question: String) { ) { bodyPanel.removeAll() failureLabel = null + failureCopyRow = null // Clears the rejected query's explanation; the caller auto-explains the corrected one. pendingAskExplanation = null explanationShown = false val stack = JPanel() stack.layout = BoxLayout(stack, BoxLayout.Y_AXIS) stack.add(wrappingHtml(errorHtml(errorMessage)).apply { border = JBUI.Borders.empty(2) }) - stack.add(JBLabel(heading).apply { border = JBUI.Borders.empty(6, 2, 2, 2) }) + stack.add(selectableText(heading).apply { border = JBUI.Borders.empty(6, 2, 2, 2) }) stack.add(block) // Dismiss drops the stack; the caller re-shows the error. stack.add(ApprovalBar(onRunFix, { bodyPanel.remove(stack); onDismiss() }).component) bodyPanel.add(stack) - component.revalidate() - component.repaint() + refresh() } - /** A rich-text label that actually word-wraps, unlike [JBLabel] with HTML content. */ - private fun wrappingHtml(innerHtml: String): JEditorPane = JEditorPane("text/html", "$innerHtml").apply { - isEditable = false - isOpaque = false - border = null - putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) - font = com.intellij.util.ui.UIUtil.getLabelFont() - } + /** + * A rich-text label that word-wraps, unlike [JBLabel] with HTML content. Its height is measured + * against the width the column gives it, not its natural unwrapped width. + */ + private fun wrappingHtml(innerHtml: String): JEditorPane = + object : JEditorPane("text/html", "$innerHtml") { + private var wrappedWidth = -1 + + /** + * On a resize this pane is measured before its own width has caught up, so the height + * comes from the width it wrapped at last time; a second measure after that pass settles it. + */ + override fun setBounds(x: Int, y: Int, w: Int, h: Int) { + val rewrapped = w != wrappedWidth + super.setBounds(x, y, w, h) + if (!rewrapped) return + wrappedWidth = w + javax.swing.SwingUtilities.invokeLater { + invalidate() + revalidate() + repaint() + } + } + + /** + * The width this pane will be given. A turn is measured before anything in it has bounds, + * so the column is the first ancestor with a width; the pane is stretched to that, less + * the borders in between. + */ + private fun wrapWidth(): Int { + var borders = insets.left + insets.right + if (width > 0) return width - borders + var ancestor: java.awt.Container? = parent + while (ancestor != null) { + val ancestorInsets = ancestor.insets + if (ancestor.width > 0) { + return ancestor.width - ancestorInsets.left - ancestorInsets.right - borders + } + borders += ancestorInsets.left + ancestorInsets.right + ancestor = ancestor.parent + } + return 0 + } + + override fun getPreferredSize(): Dimension { + val insets = insets + val inner = wrapWidth() + // Nothing is sized yet: the natural, unwrapped size is the only answer available. + if (inner <= 0) return super.getPreferredSize() + // getUI(), not the inherited `ui` field: that one is typed ComponentUI and has no root view. + val root = getUI().getRootView(this) + root.setSize(inner.toFloat(), 0f) + return Dimension( + inner + insets.left + insets.right, + root.getPreferredSpan(View.Y_AXIS).toInt() + insets.top + insets.bottom, + ) + } + + // Otherwise the column hands the pane its leftover vertical space. + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + }.apply { + isEditable = false + isOpaque = false + border = null + putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) + font = com.intellij.util.ui.UIUtil.getLabelFont() + } /** A theme-aware error color. */ private fun errorHtml(message: String): String { diff --git a/packages/jetbrains/src/main/resources/META-INF/plugin.xml b/packages/jetbrains/src/main/resources/META-INF/plugin.xml index 25b9692..a261f4b 100644 --- a/packages/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/packages/jetbrains/src/main/resources/META-INF/plugin.xml @@ -36,8 +36,8 @@ the rows in your tables, unless you switch on sample column values yourself. Secrets live in your OS keychain.
  • Costs nothing until you ask. Installed and idle, it loads no classes, adds - nothing to IDE startup, and sits at 0.2% CPU. Connecting to a wide schema takes three - catalog queries rather than one per table.
  • + nothing to IDE startup, and sits at 0.2% CPU. Connecting to a wide Postgres schema + reads its keys and indexes in three catalog queries rather than three per table.
  • Every JetBrains IDE. IntelliJ IDEA, DataGrip, PyCharm, WebStorm, GoLand, PhpStorm, Rider, CLion, RubyMine, RustRover and Android Studio (2024.2 and newer).
  • @@ -94,10 +94,8 @@ - - - +