PageSourceSearch

https://filament.com/assets/contents/sqlite_toolbar_component/sqlite_toolbar_component_controller-100d2a07.js

js filament.com collected 2026-09-25 14:01:45 UTC 14,097 bytes, 365 lines download raw bytes

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.