From ad3b09602cc5b294e159a2d9a7df92035e10629a Mon Sep 17 00:00:00 2001 From: tmphost-dev Date: Fri, 23 Oct 2026 06:10:34 +0000 Subject: [PATCH] Highlight the SQL in the query details sidebar Prism (core + SQL grammar) colours the query, tuned for Postgres: #, @ and ? are JSON operators rather than comments, double-quoted names are identifiers, dollar-quoted strings and $1 parameters are recognised. It ships only with the lazy database page. Co-Authored-By: Claude Opus 5.5 --- frontend/package-lock.json | 10 +++++++ frontend/package.json | 1 + frontend/src/components/QueryDetail.vue | 30 ++++++++++++++++++++- frontend/src/composables/useSqlHighlight.js | 27 +++++++++++++++++++ frontend/test/ActivityPanel.test.js | 2 ++ frontend/test/composables.test.js | 22 +++++++++++++++ 6 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 frontend/src/composables/useSqlHighlight.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 36d758e..d6ecd50 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "pinia": "^4.1.0", + "prismjs": "^1.30.0", "sql-formatter": "^15.9.0", "vue": "^3.5.44", "vue-router": "^5.4.0" @@ -3322,6 +3323,15 @@ "node": ">= 0.8.0" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2c14972..f5c25a2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "pinia": "^4.1.0", + "prismjs": "^1.30.0", "sql-formatter": "^15.9.0", "vue": "^3.5.44", "vue-router": "^5.4.0" diff --git a/frontend/src/components/QueryDetail.vue b/frontend/src/components/QueryDetail.vue index d79d51b..96baf53 100644 --- a/frontend/src/components/QueryDetail.vue +++ b/frontend/src/components/QueryDetail.vue @@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { copyToClipboard } from '../composables/useClipboard' import { formatDuration, formatFullDateTime } from '../composables/useFormat' import { formatSql } from '../composables/useSqlFormat' +import { highlightSql } from '../composables/useSqlHighlight' import { BTN_GHOST, SECTION_LABEL } from '../ui' import { STATUS_STYLES } from './queryStatus' @@ -13,6 +14,7 @@ const emit = defineEmits(['close']) /** Off shows the SQL exactly as it was logged. */ const formatted = ref(true) const shownSql = computed(() => (formatted.value ? formatSql(props.query.sql) : props.query.sql)) +const highlighted = computed(() => highlightSql(shownSql.value)) const copy = () => copyToClipboard(shownSql.value, 'Query copied') @@ -76,8 +78,34 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) -
{{ shownSql }}
+ +
       
     
   
 
+
+
diff --git a/frontend/src/composables/useSqlHighlight.js b/frontend/src/composables/useSqlHighlight.js
new file mode 100644
index 0000000..5e23b63
--- /dev/null
+++ b/frontend/src/composables/useSqlHighlight.js
@@ -0,0 +1,27 @@
+// Kept apart from useFormat for the same reason as useSqlFormat: Prism only
+// ships with the lazy-loaded database page that shows query details.
+import Prism from 'prismjs'
+import 'prismjs/components/prism-sql'
+
+// Prism's SQL grammar is generic. Postgres differs where it would miscolour:
+// `#` and `//` are operators (`data #> '{a}'`), not comments; a double-quoted
+// name is an identifier, not a string; strings can be dollar-quoted;
+// parameters are `$1`, not `@name`; and `::` casts and the `#`, `@` and `?`
+// JSON operators are operators.
+const grammar = Prism.languages.extend('sql', {
+  comment: { pattern: /(^|[^\\])(?:\/\*[\s\S]*?\*\/|--.*)/, lookbehind: true },
+  variable: /\$\d+\b/,
+  string: { pattern: /\$([A-Za-z_]\w*)?\$[\s\S]*?\$\1\$|'(?:[^']|'')*'/, greedy: true },
+  identifier: { pattern: /"(?:[^"]|"")*"/, greedy: true },
+  operator: /::|[-+*/=%^~#@?]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,
+  // Coloured as keywords: grey like `=`, `and` and `in` fade into the punctuation.
+  'word-operator': { pattern: /\b(?:AND|BETWEEN|ILIKE|IN|IS|LIKE|NOT|OR|SIMILAR TO)\b/i, alias: 'keyword' },
+})
+
+/**
+ * The SQL as HTML, each token wrapped in a ``. Prism
+ * escapes the text, so the result is safe for v-html.
+ */
+export function highlightSql(sql) {
+  return Prism.highlight(sql ? String(sql) : '', grammar, 'pgsql')
+}
diff --git a/frontend/test/ActivityPanel.test.js b/frontend/test/ActivityPanel.test.js
index e955a47..bd996ed 100644
--- a/frontend/test/ActivityPanel.test.js
+++ b/frontend/test/ActivityPanel.test.js
@@ -79,6 +79,8 @@ describe('ActivityPanel', () => {
 
     await w.findAll('[data-test="query-row"]')[0].trigger('click')
     expect(w.find('[data-test="detail-sql"]').text()).toBe(`select\n${'  col,\n'.repeat(200)}  id\nfrom\n  orders\nwhere\n  note = 'x'`)
+    expect(w.find('[data-test="detail-sql"] .token.keyword').text()).toBe('select')
+    expect(w.find('[data-test="detail-sql"] .token.string').text()).toBe("'x'")
     expect(w.find('[data-test="detail-rows"]').text()).toBe('3')
     expect(w.find('[data-test="detail-result"]').attributes('href')).toBe('https://tmphost.io/r/1')
     expect(w.findAll('[data-test="query-row"]')[0].attributes('aria-selected')).toBe('true')
diff --git a/frontend/test/composables.test.js b/frontend/test/composables.test.js
index 2af07c7..9661c6f 100644
--- a/frontend/test/composables.test.js
+++ b/frontend/test/composables.test.js
@@ -3,6 +3,7 @@ import { useToasts } from '../src/composables/useToasts'
 import { copyToClipboard } from '../src/composables/useClipboard'
 import { formatCell, formatDateTime, formatDuration, formatFullDateTime, toKebabCase } from '../src/composables/useFormat'
 import { formatSql } from '../src/composables/useSqlFormat'
+import { highlightSql } from '../src/composables/useSqlHighlight'
 import { useQueryConsole } from '../src/composables/useQueryConsole'
 import { ApiError } from '../src/api'
 
@@ -79,6 +80,27 @@ describe('useFormat', () => {
     expect(formatSql('')).toBe('')
     expect(formatSql(null)).toBe('')
   })
+  it('highlights SQL as escaped HTML, reading it the way Postgres does', () => {
+    const tokens = (sql) => {
+      const el = document.createElement('div')
+      el.innerHTML = highlightSql(sql)
+      return [...el.querySelectorAll('.token')].map((t) => [t.classList[1], t.textContent])
+    }
+    expect(tokens(`select count(*), "Name" from users where id = $1 and note = 'it''s' -- hi`)).toEqual([
+      ['keyword', 'select'], ['function', 'count'], ['punctuation', '('], ['operator', '*'], ['punctuation', ')'],
+      ['punctuation', ','], ['identifier', '"Name"'], ['keyword', 'from'], ['keyword', 'where'], ['operator', '='],
+      ['variable', '$1'], ['word-operator', 'and'], ['operator', '='], ['string', "'it''s'"], ['comment', '-- hi'],
+    ])
+    // #> is a JSON operator, not a comment; a dollar-quoted body is one string.
+    expect(tokens("select doc #> '{a}', id::text, $fn$ select 1 $fn$")).toEqual([
+      ['keyword', 'select'], ['operator', '#'], ['operator', '>'], ['string', "'{a}'"], ['punctuation', ','],
+      ['operator', '::'], ['keyword', 'text'], ['punctuation', ','],
+      ['string', '$fn$ select 1 $fn$'],
+    ])
+    expect(highlightSql('select \'\'')).toBe('select \'<b>\'')
+    expect(highlightSql('')).toBe('')
+    expect(highlightSql(null)).toBe('')
+  })
 })
 
 describe('useQueryConsole', () => {