1import ViewerToolbarController from "controllers/viewer_toolbar_controller" 2 3// Toolbar for the shared SQLite viewer. Drives the renderer through the outlet and 4// renders everything the viewer reports that isn't pixels: the table list, the 5// database facts, the legends, the comparison summary and the status line. 6// 7// The viewer is the source of truth. Every control here reflects a protocol event 8// rather than what the user just clicked, which is why the handlers write to the 9// viewer and the on* callbacks write to the DOM. 10export default class extends ViewerToolbarController { 11 static outlets = ["contents--file-content-component--sqlite-renderer"] 12 13 static targets = [ 14 "tableSearch", "tableList", "rowFilter", "structure", "zoom", "zoomReadout", 15 "valueLegend", "databaseFacts", "changesGroup", "changesOnly", "diffSummary", 16 "statusLegend", "status" 17 ] 18 19 // A filter is re-run over the whole table, so a keystroke per character would 20 // queue a full pass each time. 21 static FILTER_DEBOUNCE = 200 22 // How long a copy confirmation stays before the status returns to the view summary. 23 static STATUS_HOLD = 4000 24 25 get renderers() { 26 return this.contentsFileContentComponentSqliteRendererOutlets 27 } 28 29 // Stimulus names this from the identifier. Registering both ways round: the 30 // renderer may connect before or after the toolbar. 31 contentsFileContentComponentSqliteRendererOutletConnected(renderer) { 32 renderer.registerToolbar(this) 33 } 34 35 contentsFileContentComponentSqliteRendererOutletDisconnected(renderer) { 36 renderer.unregisterToolbar(this) 37 } 38 39 disconnect() { 40 clearTimeout(this.filterTimer) 41 clearTimeout(this.statusTimer) 42 } 43 44 // --- controls --------------------------------------------------------------- 45 46 // "" opens the overview (every table sized by its bytes); a name opens that table. 47 setLevel(event) { 48 const overview = event.target.value === "overview" 49 if (overview) this.lastTable = this.currentTable 50 this.forEachRenderer((r) => r.selectTable(overview ? "" : (this.lastTable || this.firstTable || ""))) 51 } 52 53 selectTable(event) { 54 const table = event.currentTarget.dataset.table 55 this.forEachRenderer((r) => r.selectTable(table)) 56 } 57 58 filterTables() { 59 const needle = this.tableSearchTarget.value.trim().toLowerCase() 60 for (const item of this.tableListTarget.children) { 61 item.hidden = needle !== "" && !item.dataset.table.toLowerCase().includes(needle) 62 } 63 } 64 65 setFilter() { 66 clearTimeout(this.filterTimer) 67 const filter = this.rowFilterTarget.value 68 this.filterTimer = setTimeout(() => { 69 this.forEachRenderer((r) => r.setView({ filter })) 70 }, this.constructor.FILTER_DEBOUNCE) 71 } 72 73 // Escape in the filter box clears the filter rather than the grid's selection, 74 // which is what Escape on the canvas means. 75 filterKeydown(event) { 76 if (event.key !== "Escape") return 77 event.stopPropagation() 78 if (!this.rowFilterTarget.value) return 79 this.rowFilterTarget.value = "" 80 this.setFilter() 81 } 82 83 setShowValues(event) { 84 this.forEachRenderer((r) => r.setRenderOption("showValues", event.target.checked)) 85 } 86 87 setColorTiles(event) { 88 this.forEachRenderer((r) => r.setRenderOption("colorTiles", event.target.checked)) 89 } 90 91 setZoom(event) { 92 const zoom = parseFloat(event.target.value) 93 this.zoomReadoutTarget.value = `${zoom.toFixed(1)}x` 94 this.forEachRenderer((r) => r.setView({ zoom })) 95 } 96 97 toggleStructure() { 98 this.forEachRenderer((r) => r.toggleStructure()) 99 } 100 101 toggleChangesOnly() { 102 const next = this.changesOnlyTarget.getAttribute("aria-pressed") !== "true" 103 this.forEachRenderer((r) => r.setView({ changesOnly: next })) 104 } 105 106 nextChange() { 107 this.forEachRenderer((r) => r.nextChange(false)) 108 } 109 110 previousChange() { 111 this.forEachRenderer((r) => r.nextChange(true)) 112 } 113 114 // --- viewer state ----------------------------------------------------------- 115 116 onDatabase(database) { 117 this.firstTable = database.tables?.[0]?.name ?? "" 118 this.tables = database.tables ?? []
119 this.#renderTableList() 120 this.#renderDatabaseFacts(database) 121 } 122 123 onView(view) { 124 this.currentTable = view.table 125 this.selectSegment(view.level === "VIEW_LEVEL_OVERVIEW" ? "overview" : "grid") 126 127 // Don't write a control the user is currently holding: the viewer echoes each 128 // change back, and writing mid-drag or mid-word fights the input. 129 if (document.activeElement !== this.zoomTarget) { 130 this.#applyZoomRange(view) 131 this.zoomTarget.value = view.zoom ?? 1 132 this.zoomReadoutTarget.value = `${(view.zoom ?? 1).toFixed(1)}x` 133 } 134 if (document.activeElement !== this.rowFilterTarget) { 135 this.rowFilterTarget.value = view.filter ?? "" 136 } 137 138 // Past the core's cap, sorting and filtering are refused. The protocol says to 139 // disable the control rather than offer one that fails. 140 const sortable = this.#openTable()?.sortable !== false 141 this.rowFilterTarget.disabled = !sortable 142 this.rowFilterTarget.title = sortable ? "" : "This table is too large to sort or filter" 143 144 // The comparison controls exist only in the diff variant. 145 if (this.hasChangesOnlyTarget) this.#setPressed(this.changesOnlyTarget, view.changesOnly === true) 146 this.#renderChangesGroup(view) 147 this.#renderStatus(view) 148 } 149 150 onRender(render) { 151 if (this.hasValueLegendTarget) this.#renderLegend(this.valueLegendTarget, render.legend?.valueClasses, "Values") 152 // Diff-only, like the comparison summary it sits under. 153 if (this.hasStatusLegendTarget) this.#renderLegend(this.statusLegendTarget, render.legend?.diffStatuses, "Changes") 154 } 155 156 onDiff(diff) { 157 this.diff = diff 158 this.#renderDiffSummary(diff) 159 if (!this.lastView) return 160 this.#renderChangesGroup(this.lastView) 161 // The status carries "comparingâ¦", which only this event can retract. 162 this.#renderStatus(this.lastView) 163 } 164 165 onStructure(open) { 166 if (this.hasStructureTarget) this.#setPressed(this.structureTarget, open) 167 } 168 169 // Called by the renderer when it gives up on a file: there is nothing left for these 170 // controls to act on. 171 hide() { 172 this.element.hidden = true 173 } 174 175 setStatus(message) { 176 if (!this.hasStatusTarget) return 177 clearTimeout(this.statusTimer) 178 this.statusTarget.value = message 179 this.statusTimer = setTimeout(() => { 180 if (this.lastView) this.#renderStatus(this.lastView) 181 }, this.constructor.STATUS_HOLD) 182 } 183 184 // --- rendering -------------------------------------------------------------- 185 186 #renderTableList() { 187 this.tableListTarget.replaceChildren(...this.tables.map((table) => { 188 const item = document.createElement("li") 189 item.dataset.table = table.name 190 191 const button = document.createElement("button") 192 button.type = "button" 193 button.className = "w-full flex items-center justify-between gap-2 px-2 py-1 rounded text-left text-sm text-color-700 hover:bg-color-100 cursor-pointer" 194 button.dataset.table = table.name 195 button.dataset.action = `click->${this.identifier}#selectTable` 196 197 const name = document.createElement("span") 198 name.className = "truncate" 199 name.textContent = table.shadowOf ? `${table.name} (shadow of ${table.shadowOf})` : table.name 200 201 const count = document.createElement("span") 202 count.className = "shrink-0 tabular-nums text-xs text-color-400" 203 // The trailing ellipsis is the lower-bound marker: until `counted`, the number 204 // is what the core has seen so far, not the table's size. 205 count.textContent = `${Number(table.rows ?? 0).toLocaleString()}${table.counted ? "" : "â¦"}` 206 207 button.append(name, count) 208 item.append(button) 209 return item 210 })) 211 this.filterTables() 212 } 213 214 #renderDatabaseFacts(database) { 215 if (!this.hasDatabaseFactsTarget) return 216 217 const facts = [ 218 ["Page size", database.pageSize], 219 ["Pages", database.pageCountValid ? database.pageCount : null], 220 ["Encoding", (database.encoding ?? "").replace("ENCODING_", "")], 221 ["Schema format", database.schemaFormat], 222 ["Tables", database.tables?.length], 223 ["Journal", database.wal ? "WAL" : "rollback"] 224 ] 225 this.databaseFactsTarget.replaceChildren(...facts 226 .filter(([, value]) => value !== null && value !== undefined && value !== "") 227 .flatMap(([label, value]) => { 228 const dt = document.createElement("dt") 229 dt.className = "text-color-400" 230 dt.textContent = label 231 const dd = document.createElement("dd") 232 dd.className = "mb-1 tabular-nums" 233 dd.textContent = String(value) 234 return [dt, dd] 235 })) 236 } 237 238 // Hidden until the comparison has something to step through: a disabled step 239 // button says less than an absent one. 240 #renderChangesGroup(view) { 241 if (!this.hasChangesGroupTarget) return
242 this.changesGroupTarget.hidden = !((view.changedRows ?? 0) > 0 || view.changesOnly === true) 243 } 244 245 #renderDiffSummary(diff) { 246 if (!this.hasDiffSummaryTarget) return 247 248 const tables = diff?.tables ?? [] 249 const totals = tables.reduce((acc, t) => ({ 250 added: acc.added + (t.added ?? 0), 251 removed: acc.removed + (t.removed ?? 0), 252 changed: acc.changed + (t.changed ?? 0), 253 unkeyed: acc.unkeyed + (t.comparable === false ? 1 : 0) 254 }), { added: 0, removed: 0, changed: 0, unkeyed: 0 }) 255 256 const header = document.createElement("p") 257 header.className = "mb-2 pb-2 border-b border-color-200 text-xs text-color-500 tabular-nums" 258 const unkeyed = totals.unkeyed ? ` · ${totals.unkeyed} without a key` : "" 259 header.textContent = `${tables.length} tables · +${totals.added} â${totals.removed} ~${totals.changed}${unkeyed}` 260 261 const rows = tables.map((table) => { 262 const button = document.createElement("button") 263 button.type = "button" 264 button.className = "w-full flex items-center justify-between gap-2 px-1 py-1 rounded text-left text-xs text-color-700 hover:bg-color-100 cursor-pointer" 265 button.dataset.table = table.name 266 button.dataset.action = `click->${this.identifier}#selectTable` 267 268 const name = document.createElement("span") 269 name.className = "truncate" 270 name.textContent = table.name 271 272 const counts = document.createElement("span") 273 counts.className = "shrink-0 tabular-nums text-color-500" 274 // Without a key the rows aren't paired, so the three counts mean nothing â 275 // the row totals are the only claim the comparison can make. 276 counts.textContent = table.comparable === false 277 ? `${table.beforeRows ?? 0} â ${table.afterRows ?? 0} rows, no key` 278 : `+${table.added ?? 0} â${table.removed ?? 0} ~${table.changed ?? 0}${table.capped ? " (capped)" : ""}` 279 280 button.append(name, counts) 281 return button 282 }) 283 284 this.diffSummaryTarget.replaceChildren(header, ...rows) 285 } 286 287 // The legend is the only place colour crosses the wire. A host deriving its own 288 // swatches is silently wrong the first time a hue moves. 289 #renderLegend(target, swatches, label) { 290 if (!target) return 291 if (!swatches?.length) { target.classList.add("hidden"); return } 292 293 const heading = document.createElement("p") 294 heading.className = "mb-1 text-xs text-color-400" 295 heading.textContent = label 296 297 const items = swatches.map((swatch) => { 298 const row = document.createElement("span") 299 row.className = "inline-flex items-center gap-1.5 mr-3 text-xs text-color-600" 300 const chip = document.createElement("i") 301 chip.className = "inline-block size-3 rounded-xs" 302 chip.style.backgroundColor = swatchColour(swatch.rgb) 303 const name = document.createElement("span") 304 name.textContent = swatch.label 305 row.append(chip, name) 306 return row 307 }) 308 309 target.replaceChildren(heading, ...items) 310 target.classList.remove("hidden") 311 } 312 313 #renderStatus(view) { 314 this.lastView = view 315 const parts = [] 316 317 if (view.level === "VIEW_LEVEL_OVERVIEW") { 318 parts.push(`${this.tables?.length ?? 0} tables`) 319 } else { 320 const total = Number(view.totalRows ?? 0).toLocaleString() 321 parts.push(`${total}${view.rowsCounted ? "" : "+"} rows`) 322 } 323 // Zero matches is an answer, not an empty grid. 324 if (view.filter) parts.push(`${Number(view.matchedRows ?? 0).toLocaleString()} matching`) 325 if (view.selection?.length) parts.push(`${this.#selectedRows(view)} selected`) 326 if (view.changesOnly) parts.push("changes only") 327 if (view.sorting) parts.push("listingâ¦") 328 if (this.diff?.comparing) parts.push("comparingâ¦") 329 330 if (this.hasStatusTarget) this.statusTarget.value = parts.join(" · ") 331 } 332 333 // The wrapper publishes each run as a [first, last] pair, inclusive at both ends. 334 // Reading them as {first, last} produced "NaN selected". 335 #selectedRows(view) { 336 return view.selection.reduce((n, [first, last]) => n + (last - first + 1), 0) 337 } 338 339 // The zoom range is per-table (row height against a fixed font), so the slider's 340 // bounds come from the viewer rather than being fixed in the markup. 341 #applyZoomRange(view) { 342 if (view.minZoom) this.zoomTarget.min = view.minZoom 343 if (view.maxZoom) this.zoomTarget.max = view.maxZoom 344 } 345 346 #openTable() { 347 return this.tables?.find((t) => t.name === this.currentTable) ?? null 348 } 349 350 // Important, and not decoration: this toolbar renders in the strip variant, whose icon 351 // buttons already carry bg-color-white. A plain bg-color-200 is a second background utility 352 // of equal specificity, so the winner is left to stylesheet order and the pressed state 353 // showed nothing at all. 354 #setPressed(target, pressed) { 355 target?.setAttribute("aria-pressed", pressed ? "true" : "false") 356 target?.classList.toggle("bg-color-200!", pressed) 357 } 358} 359 360// The wrapper hands the legend's colour over as a CSS string ("#007f35"); re-encoding one 361// as a number produced "##007f35", which every browser drops, leaving the swatch blank. 362function swatchColour(rgb) { 363 if (typeof rgb === "number") return `#${rgb.toString(16).padStart(6, "0")}` 364 return String(rgb ?? "") 365}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.