Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Altinity SQL Browser

An OAuth-gated **SQL browser for any ClickHouse cluster** — schema explorer,
tabbed SQL editor with syntax highlighting, streaming results with table / JSON
/ chart views, saved queries, history, and shareable links. It ships as a
tabbed SQL editor with syntax highlighting, find/replace, bracket matching, and
schema-aware autocomplete, streaming results with table / JSON / chart views,
saved queries, history, and shareable links. It ships as a
**single self-contained HTML file served from ClickHouse itself** (no Node
server, no CDN, no external fonts) — the page makes **zero third-party
requests** and renders in the OS's native UI font. Its only bundled runtime
Expand All @@ -27,6 +28,41 @@ The browser never holds a static credential — each user authenticates with you
IdP and ClickHouse sees their JWT. There is **no app-specific backend**: the
only moving parts are ClickHouse's HTTP handlers and your OAuth provider.

## SQL editor

The editor is a hand-rolled `<textarea>` over a syntax-highlighted `<pre>` (no
editor library — it adds nothing to the single served file). On top of that:

- **Find / replace** — `Cmd/Ctrl+F` opens a panel with a live match count,
prev/next (Enter / Shift+Enter), case / whole-word / regex toggles, and a
replace row. Matches highlight via a transparent overlay layered below the
syntax tokens, so highlighting and search never interfere.
- **Bracket matching + auto-close** — typing `(` `[` or a quote inserts the
pair (or wraps the selection); typing a closer or quote steps over it;
Backspace inside an empty pair deletes both. The pair adjacent to the caret
is highlighted. (`{`/`}` auto-close is intentionally omitted.)
- **Autocomplete** — typing a word (or after `table.`) opens a ranked dropdown
of keywords, functions, databases, tables, and already-loaded columns;
↑/↓/Enter/Tab/Esc and click to accept; functions insert `name(`.
- **Signature help + hover docs** — inside a function call, a popover shows the
signature with the active argument bolded; hovering a function or a
ClickHouse keyword shows its signature/description. Both read the same cached
reference data — `system.functions.{syntax,description}` (loaded with #25) and
a small built-in keyword-doc set — so they never query on the keystroke path.

**The keystroke rule:** none of this runs SQL while you type. Reference data —
the server's keyword and function lists — is fetched **once per connection**
from `system.keywords` and `system.functions` (best-effort; it falls back to a
built-in set on older ClickHouse), cached in memory, and merged with the
in-memory schema. Highlighting then tracks the connected server's actual
keyword/function set, so it's version-correct. Folding and multi-cursor are out
of scope for a textarea and tracked separately (CodeMirror, issue #21).

> Design source of truth: the handoff bundle under `design/` (imported from the
> "Altinity Play" Claude Design project) — read `design/README.md` before UI
> work. The `.jsx` files there are React prototypes; production is the vanilla
> ES-module code under `src/`.

## Quick start (development)

```bash
Expand Down Expand Up @@ -197,16 +233,20 @@ Preview the rendered artifacts without touching ClickHouse:
```
src/
core/ pure logic — format, jwt, pkce, sql-highlight, share, sort,
stream, storage, chart-data (roles/autoChart/pivot + Chart.js
config builder; no DOM, no globals)
stream, storage, chart-data, and the editor logic: completions
(reference data + ranking), editor-search (find), editor-brackets
(match/auto-close), editor-marks (overlay), editor-geometry
(caret) — no DOM, no globals
net/ oauth-config, oauth, ch-client (injected fetch seam)
ui/ dom (hyperscript), icons, + render modules (login, editor, tabs,
schema, results, saved-history, shortcuts, splitters, toast, app)
ui/ dom (hyperscript), icons, + render modules (login, editor +
editor-search/editor-complete, tabs, schema, results,
saved-history, shortcuts, splitters, toast, app)
state.js state model + pure operations
main.js bootstrap (OAuth callback, share-links, initial render)
styles.css
build/ esbuild → single-file dist/sql.html
deploy/ install.sh, uninstall.sh, http_handlers.xml, config.json.example
design/ imported design handoff bundle (UI spec; reference only, not built)
tests/ vitest + happy-dom, one spec per module
docs/ ARCHITECTURE.md, DEPLOYMENT.md, ASSET-DISTRIBUTION.md,
CLICKHOUSE-OAUTH.md, CLICKHOUSE-OSS-OAUTH.md
Expand Down
212 changes: 212 additions & 0 deletions design/Altinity Play.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Altinity Play — Redesigned</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root { width: 100%; height: 100vh; }

:root {
--ui: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
}

/* DARK theme (default) */
[data-theme='dark'] {
--bg: #0E0E10;
--bg-header: #131316;
--bg-side: #131316;
--bg-tabs: #131316;
--bg-toolbar: #15151A;
--bg-editor: #0E0E10;
--bg-gutter: #131316;
--bg-table: #0E0E10;
--bg-th: #15151A;
--bg-input: #1A1A20;
--bg-chip: #1F1F26;
--bg-hover: rgba(255, 255, 255, .04);
--bg-highlight: rgba(255, 107, 53, .08);
--bg-modal: #1A1A20;
--fg: #E6E6E8;
--fg-mute: #A0A0A8;
--fg-faint: #6B6B74;
--num: #92E1D8;
--border: #1F1F26;
--border-faint: #1A1A20;
}

/* LIGHT theme */
[data-theme='light'] {
--bg: #FAFAFA;
--bg-header: #FFFFFF;
--bg-side: #F5F5F4;
--bg-tabs: #F5F5F4;
--bg-toolbar: #FAFAF9;
--bg-editor: #FFFFFF;
--bg-gutter: #FAFAF9;
--bg-table: #FFFFFF;
--bg-th: #F5F5F4;
--bg-input: #FFFFFF;
--bg-chip: #EEECE8;
--bg-hover: rgba(0, 0, 0, .04);
--bg-highlight: rgba(255, 107, 53, .12);
--bg-modal: #FFFFFF;
--fg: #1A1A1F;
--fg-mute: #57575E;
--fg-faint: #94949C;
--num: #0F766E;
--border: #E5E3DE;
--border-faint: #EEECE7;
}

/* SQL syntax */
.sql-keyword { color: #C586C0; font-weight: 500; }
[data-theme='light'] .sql-keyword { color: #AF00DB; }
.sql-func { color: #DCDCAA; }
[data-theme='light'] .sql-func { color: #795E26; }
.sql-string { color: #CE9178; }
[data-theme='light'] .sql-string { color: #A31515; }
.sql-number { color: #B5CEA8; }
[data-theme='light'] .sql-number { color: #098658; }
.sql-comment { color: #6A9955; font-style: italic; }
[data-theme='light'] .sql-comment { color: #008000; font-style: italic; }
.sql-ident { color: var(--fg); }
.sql-op { color: var(--fg-mute); }

/* Toolbar buttons */
.tb-btn {
height: 26px;
padding: 0 9px;
background: transparent;
color: var(--fg-mute);
border: 1px solid transparent;
border-radius: 5px;
font-size: 11.5px;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
flex-shrink: 0;
transition: background .12s, color .12s;
}
.tb-btn:hover {
background: var(--bg-hover);
color: var(--fg);
}
.tb-select {
height: 26px;
padding: 0 22px 0 9px;
background-color: transparent;
background-repeat: no-repeat;
background-position: right 8px center;
color: var(--fg-mute);
border: 1px solid var(--border);
border-radius: 5px;
font-size: 11.5px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
}
[data-theme='dark'] .tb-select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'><path fill='%23A0A0A8' d='M0 0h8L4 5z'/></svg>");
}
[data-theme='light'] .tb-select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'><path fill='%2357575E' d='M0 0h8L4 5z'/></svg>");
}
.tb-select:hover { color: var(--fg); }

/* Header buttons */
.hd-btn {
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--fg-mute);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
}
.hd-btn:hover { background: var(--bg-hover); color: var(--fg); }

/* Row hover */
table.results tbody tr:hover td { background: var(--bg-hover); }
tr.row:hover td { background: var(--bg-hover); }

/* Scrollbars */
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border); border-radius: 5px;
border: 2px solid transparent; background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--fg-faint); background-clip: content-box; border: 2px solid transparent; }

/* Compact density tweaks */
[data-density='compact'] .qtabs { height: 28px; }

/* Query-running loader */
@keyframes twk-spin { to { transform: rotate(360deg); } }
.spin { animation: twk-spin .8s linear infinite; }
@keyframes twk-indet {
0% { left: -40%; }
100% { left: 100%; }
}
.indet { animation: twk-indet 1.1s ease-in-out infinite; }
@keyframes twk-runsweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
.runsweep { animation: twk-runsweep 1.1s ease-in-out infinite; }
</style>
</head>
<body>
<div id="root"></div>

<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>

<script type="text/babel">
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "dark",
"accent": "#3B82F6",
"density": "comfortable",
"sidebar": true,
"slowQuery": true
}/*EDITMODE-END*/;
window.TWEAK_DEFAULTS = TWEAK_DEFAULTS;
</script>

<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx?v=6"></script>
<script type="text/babel" src="editor-data.jsx?v=6"></script>
<script type="text/babel" src="editor-search.jsx?v=6"></script>
<script type="text/babel" src="editor-complete.jsx?v=7"></script>
<script type="text/babel" src="sql-editor.jsx?v=7"></script>
<script type="text/babel" src="components.jsx?v=6"></script>
<script type="text/babel" src="app.jsx?v=6"></script>

<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
14 changes: 14 additions & 0 deletions design/IMPORTED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Design reference bundle (imported)

This directory is a verbatim snapshot of the **"sql browser"** Claude Design
project's `design_handoff_altinity_play/` handoff bundle — the UI source-of-truth
for the editor-enhancement work (issues #23–#27).

**Reference only — not shipped.** These are React/Babel prototypes. The production
app is the zero-dependency vanilla-ES-module SPA under `src/`. esbuild bundles only
`src/main.js` → `dist/sql.html`, so nothing here is built into the served artifact,
and `tests/` coverage (`include: ['src/**/*.js']`) never sees it.

Start with `README.md` (the full handoff: design tokens, region-by-region spec, and
the per-issue editor-enhancement reference). The `.jsx` files are the reference
implementations to port.
87 changes: 87 additions & 0 deletions design/ISSUE-publish-as-markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Feature: "Publish" — export all saved queries as a Markdown cookbook

## Summary

Add a one-way **Markdown export** ("Publish" / "Copy as Markdown") that turns the
user's saved queries into a single human-readable Markdown document they can paste
into other tools (GitHub, GitLab, Notion, Obsidian, wikis, PRs, Slack) or download
as a `.md` file.

This complements — does **not** replace — the existing **JSON export/import**:

| | JSON export | Markdown publish |
|---|---|---|
| Purpose | Backup / transfer / re-import | Share / document / paste elsewhere |
| Round-trips back in? | ✅ lossless | ❌ one-way (metadata not recoverable) |
| Human-readable | meh | ✅ great |

**Markdown is strictly export-only.** Do not attempt to re-import it — `starred`,
timestamps, and ids do not survive. JSON remains the canonical round-trip format.

## Output format

Each saved query becomes a heading + a fenced `sql` block (the fence gives free
syntax highlighting wherever it's pasted). Group **starred first**, then the rest,
and include a linked table of contents once there are more than ~10 queries
(headings auto-anchor on GitHub).

```markdown
# Saved queries
_42 queries · exported from Altinity SQL Browser · 2026-06-21_

## ⭐ Starred

### Worst-delay carriers (2023)
​```sql
SELECT Reporting_Airline, avg(DepDelayMinutes) AS avg_delay
FROM airline.ontime
WHERE Year = 2023 AND Cancelled = 0
GROUP BY Reporting_Airline
ORDER BY avg_delay DESC
LIMIT 15
​```

## All queries

### Busiest origin airports
​```sql
SELECT Origin, count() AS flights FROM airline.ontime ...
​```
```

## UX

- Primary action: **Copy to clipboard** (the stated use case is "cut it and use
elsewhere").
- Secondary: **Download `.md`**.
- Show a **preview modal** with the generated Markdown in a scrollable `<pre>` so
the user can eyeball it before copying — Markdown is reviewed-before-paste,
unlike the fire-and-forget JSON backup.
- Sits alongside the existing Export / Import controls at the bottom of the Saved
panel.

## Open decisions

1. **Scope** — publish *all* saved queries, or let the user pick (starred-only, or
multi-select)?
2. **Naming** — "Copy as Markdown" (honest about what it does) vs keep "Publish"
and eventually make it *actually* publish (create a GitHub Gist or a shareable
read-only URL, with copy/download as offline fallbacks).
3. **`description` field (recommended)** — saved queries are currently `name` +
`sql` only. A published cookbook is far more useful if each query carries a
one-line description, rendered as prose under its heading. Consider adding an
optional `description` field to the saved-query schema as part of this work.

## Implementation notes

- **Fence safety**: SQL almost never contains a literal triple-backtick, but scan
each query and bump to a 4-backtick fence if one is present.
- Clipboard via `navigator.clipboard.writeText`; download via
`Blob` + `URL.createObjectURL` (same pattern as the JSON export).
- Suggested filename: `sql-browser-queries-YYYY-MM-DD.md`.

## Context

Discussed during the design handoff. Deferred from the current design round for
more thought before committing. See the handoff README's "Export / Import saved
queries" section for the JSON counterpart this builds on.
Loading
Loading