1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2026-03-24 15:06:17 +08:00

change repo structure

This commit is contained in:
lana-k
2026-01-15 21:53:12 +01:00
parent 85b5a200e2
commit 7edc196a02
64 changed files with 74 additions and 74 deletions

View File

@@ -0,0 +1,70 @@
<template>
<div class="record-navigator">
<icon-button
:disabled="modelValue === 0"
tooltip="First row"
tooltipPosition="top-left"
class="first"
@click="$emit('update:modelValue', 0)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="modelValue === 0"
tooltip="Previous row"
tooltipPosition="top-left"
class="prev"
@click="$emit('update:modelValue', modelValue - 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="modelValue === total - 1"
tooltip="Next row"
tooltipPosition="top-left"
class="next"
@click="$emit('update:modelValue', modelValue + 1)"
>
<arrow-icon :disabled="false" />
</icon-button>
<icon-button
:disabled="modelValue === total - 1"
tooltip="Last row"
tooltipPosition="top-left"
class="last"
@click="$emit('update:modelValue', total - 1)"
>
<edge-arrow-icon :disabled="false" />
</icon-button>
</div>
</template>
<script>
import IconButton from '@/components/Common/IconButton'
import ArrowIcon from '@/components/svg/arrow'
import EdgeArrowIcon from '@/components/svg/edgeArrow'
export default {
components: {
IconButton,
ArrowIcon,
EdgeArrowIcon
},
props: {
modelValue: Number,
total: Number
},
emits: ['update:modelValue']
}
</script>
<style scoped>
.record-navigator {
display: flex;
}
.record-navigator .next,
.record-navigator .last {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div class="record-view">
<div class="table-container">
<table
ref="table"
class="sqliteviz-table"
tabindex="0"
@keydown="onTableKeydown"
>
<thead>
<tr>
<th />
<th>
<div class="cell-data">Row #{{ currentRowIndex + 1 }}</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(col, index) in columns" :key="index">
<th class="column-cell" :title="col">
{{ col }}
</th>
<td
:key="index"
:data-col="index"
:data-row="currentRowIndex"
:data-isNull="isNull(getCellValue(col))"
:data-isBlob="isBlob(getCellValue(col))"
:aria-selected="false"
@click="onCellClick"
>
<div class="cell-data">
{{ getCellText(col) }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="table-footer">
<div class="table-footer-count">
{{ rowCount }} {{ rowCount === 1 ? 'row' : 'rows' }} retrieved
<span v-if="time">in {{ time }}</span>
</div>
<row-navigator v-model="currentRowIndex" :total="rowCount" />
</div>
</div>
</template>
<script>
import RowNavigator from './RowNavigator.vue'
import { nextTick } from 'vue'
export default {
components: { RowNavigator },
props: {
dataSet: Object,
time: String,
rowIndex: { type: Number, default: 0 },
selectedColumnIndex: Number
},
emits: ['updateSelectedCell'],
data() {
return {
selectedCellElement: null,
currentRowIndex: this.rowIndex
}
},
computed: {
columns() {
return this.dataSet.columns
},
rowCount() {
return this.dataSet.values[this.columns[0]].length
}
},
watch: {
async currentRowIndex() {
await nextTick()
if (this.selectedCellElement) {
const previouslySelected = this.selectedCellElement
this.selectCell(null)
this.selectCell(previouslySelected)
}
}
},
mounted() {
const col = this.selectedColumnIndex
const row = this.currentRowIndex
const cell = this.$refs.table.querySelector(
`td[data-col="${col}"][data-row="${row}"]`
)
if (cell) {
this.selectCell(cell)
}
},
methods: {
isBlob(value) {
return value && ArrayBuffer.isView(value)
},
isNull(value) {
return value === null
},
getCellValue(col) {
return this.dataSet.values[col][this.currentRowIndex]
},
getCellText(col) {
const value = this.getCellValue(col)
if (this.isNull(value)) {
return 'NULL'
}
if (this.isBlob(value)) {
return 'BLOB'
}
return value
},
onTableKeydown(e) {
const keyCodeMap = {
38: 'up',
40: 'down'
}
if (
!this.selectedCellElement ||
!Object.keys(keyCodeMap).includes(e.keyCode.toString())
) {
return
}
e.preventDefault()
this.moveFocusInTable(this.selectedCellElement, keyCodeMap[e.keyCode])
},
onCellClick(e) {
this.selectCell(e.target.closest('td'), false)
},
selectCell(cell, scrollTo = true) {
if (!cell) {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
this.selectedCellElement = cell
} else if (!cell.ariaSelected || cell.ariaSelected === 'false') {
if (this.selectedCellElement) {
this.selectedCellElement.ariaSelected = 'false'
}
cell.ariaSelected = 'true'
this.selectedCellElement = cell
} else {
cell.ariaSelected = 'false'
this.selectedCellElement = null
}
if (this.selectedCellElement && scrollTo) {
this.selectedCellElement.scrollIntoView()
this.selectedCellElement
.closest('.table-container')
.scrollTo({ left: 0 })
}
this.$emit('updateSelectedCell', this.selectedCellElement)
},
moveFocusInTable(initialCell, direction) {
const currentColIndex = +initialCell.dataset.col
const newColIndex =
direction === 'up' ? currentColIndex - 1 : currentColIndex + 1
const newCell = this.$refs.table.querySelector(
`td[data-col="${newColIndex}"][data-row="${this.currentRowIndex}"]`
)
if (newCell) {
this.selectCell(newCell)
}
}
}
}
</script>
<style scoped>
table.sqliteviz-table:focus {
outline: none;
}
.sqliteviz-table tbody td:hover {
background-color: var(--color-bg-light-3);
}
.sqliteviz-table tbody td[aria-selected='true'] {
box-shadow: inset 0 0 0 1px var(--color-accent);
}
table.sqliteviz-table {
margin-top: 0;
}
.sqliteviz-table thead tr th {
border-bottom: 1px solid var(--color-border-light);
text-align: left;
}
.sqliteviz-table tbody tr th {
font-size: 14px;
font-weight: 600;
box-sizing: border-box;
background-color: var(--color-bg-dark);
color: var(--color-text-light);
border-bottom: 1px solid var(--color-border-light);
border-right: 1px solid var(--color-border-light);
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.table-footer {
align-items: center;
}
.record-view {
display: flex;
flex-direction: column;
height: 100%;
}
.table-container {
flex-grow: 1;
overflow: auto;
}
.column-cell {
max-width: 150px;
width: 0;
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="value-viewer">
<div class="value-viewer-toolbar">
<button
v-for="format in formats"
:key="format.value"
type="button"
:aria-selected="currentFormat === format.value"
:class="format.value"
@click="currentFormat = format.value"
>
{{ format.text }}
</button>
<button type="button" class="copy" @click="copyToClipboard">Copy</button>
<button
type="button"
class="line-wrap"
:aria-selected="lineWrapping === true"
@click="lineWrapping = !lineWrapping"
>
Line wrap
</button>
</div>
<div class="value-body">
<codemirror
v-if="currentFormat === 'json' && formattedJson"
:value="formattedJson"
:options="cmOptions"
class="json-value original-style"
/>
<pre
v-if="currentFormat === 'text'"
:class="[
'text-value',
{ 'meta-value': isNull || isBlob },
{ 'line-wrap': lineWrapping }
]"
>{{ cellText }}</pre
>
<logs
v-if="messages && messages.length > 0"
:messages="messages"
class="messages"
/>
</div>
</div>
</template>
<script>
import Codemirror from 'codemirror-editor-vue3'
import 'codemirror/lib/codemirror.css'
import 'codemirror/mode/javascript/javascript.js'
import 'codemirror/addon/fold/foldcode.js'
import 'codemirror/addon/fold/foldgutter.js'
import 'codemirror/addon/fold/foldgutter.css'
import 'codemirror/addon/fold/brace-fold.js'
import 'codemirror/theme/neo.css'
import cIo from '@/lib/utils/clipboardIo'
import Logs from '@/components/Common/Logs'
export default {
components: {
Codemirror,
Logs
},
props: {
cellValue: [String, Number, Uint8Array]
},
data() {
return {
formats: [
{ text: 'Text', value: 'text' },
{ text: 'JSON', value: 'json' }
],
currentFormat: 'text',
lineWrapping: false,
formattedJson: '',
messages: []
}
},
computed: {
cmOptions() {
return {
tabSize: 4,
mode: { name: 'javascript', json: true },
theme: 'neo',
lineNumbers: true,
line: true,
lineWrapping: this.lineWrapping,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
readOnly: true
}
},
isBlob() {
return this.cellValue && ArrayBuffer.isView(this.cellValue)
},
isNull() {
return this.cellValue === null
},
cellText() {
const value = this.cellValue
if (this.isNull) {
return 'NULL'
}
if (this.isBlob) {
return 'BLOB'
}
return value
}
},
watch: {
currentFormat() {
this.messages = []
this.formattedJson = ''
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
},
cellValue() {
this.messages = []
if (this.currentFormat === 'json') {
this.formatJson(this.cellValue)
}
}
},
methods: {
formatJson(jsonStr) {
try {
this.formattedJson = JSON.stringify(JSON.parse(jsonStr), null, 4)
} catch (e) {
this.formattedJson = ''
this.messages = [
{
type: 'error',
message: "Can't parse JSON."
}
]
}
},
copyToClipboard() {
cIo.copyText(
this.currentFormat === 'json' ? this.formattedJson : this.cellValue,
'The value is copied to clipboard.'
)
}
}
}
</script>
<style scoped>
.value-viewer {
background-color: var(--color-white);
height: 100%;
display: flex;
flex-direction: column;
}
.value-viewer-toolbar {
display: flex;
justify-content: end;
}
.value-body {
flex-grow: 1;
overflow: auto;
}
.text-value {
padding: 0 8px;
margin: 0;
color: var(--color-text-base);
}
.json-value {
margin-top: -4px;
}
.text-value.meta-value {
font-style: italic;
color: var(--color-text-light-2);
}
.text-value.line-wrap {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.messages {
margin: 0 8px;
}
.value-viewer-toolbar button {
font-size: 10px;
height: 20px;
padding: 0 8px;
border: none;
background: transparent;
color: var(--color-text-base);
border-radius: var(--border-radius-small);
}
.value-viewer-toolbar button:hover {
background-color: var(--color-bg-light);
}
.value-viewer-toolbar button[aria-selected='true'] {
color: var(--color-accent);
}
:deep(.codemirror-container) {
display: block;
height: 100%;
max-height: 100%;
}
:deep(.CodeMirror) {
height: 100%;
max-height: 100%;
}
:deep(.CodeMirror-cursor) {
width: 1px;
background: var(--color-text-base);
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<div ref="runResultPanel" class="run-result-panel">
<component
:is="viewValuePanelVisible ? 'splitpanes' : 'div'"
:before="{ size: 50, max: 100 }"
:after="{ size: 50, max: 100 }"
:default="{ before: 50, after: 50 }"
class="run-result-panel-content"
>
<template #left-pane>
<div
:id="'run-result-left-pane-' + tab.id"
class="result-set-container"
/>
</template>
<div
:id="'run-result-result-set-' + tab.id"
class="result-set-container"
/>
<template v-if="viewValuePanelVisible" #right-pane>
<div class="value-viewer-container">
<value-viewer
v-show="selectedCell"
:cellValue="
selectedCell
? result.values[result.columns[selectedCell.dataset.col]][
selectedCell.dataset.row
]
: ''
"
/>
<div v-show="!selectedCell" class="table-preview">
No cell selected to view
</div>
</div>
</template>
</component>
<side-tool-bar panel="table" @switch-to="$emit('switchTo', $event)">
<icon-button
:disabled="!result"
tooltip="Export result set to CSV file"
tooltipPosition="top-left"
@click="exportToCsv"
>
<export-to-csv-icon />
</icon-button>
<icon-button
ref="copyToClipboardBtn"
:disabled="!result"
tooltip="Copy result set to clipboard"
tooltipPosition="top-left"
@click="prepareCopy"
>
<clipboard-icon />
</icon-button>
<icon-button
ref="rowBtn"
:disabled="!result"
tooltip="View record"
tooltipPosition="top-left"
:active="viewRecord"
@click="toggleViewRecord"
>
<row-icon />
</icon-button>
<icon-button
ref="viewCellValueBtn"
:disabled="!result"
tooltip="View value"
tooltipPosition="top-left"
:active="viewValuePanelVisible"
@click="toggleViewValuePanel"
>
<view-cell-value-icon />
</icon-button>
</side-tool-bar>
<loading-dialog
v-model="showLoadingDialog"
loadingMsg="Building CSV..."
successMsg="CSV is ready"
actionBtnName="Copy"
title="Copy to clipboard"
:loading="preparingCopy"
@action="copyToClipboard"
@cancel="cancelCopy"
/>
<teleport defer :to="resultSetTeleportTarget" :disabled="!enableTeleport">
<div>
<div
v-show="result === null && !isGettingResults && !error"
class="table-preview result-before"
>
Run your query and get results here
</div>
<div v-if="isGettingResults" class="table-preview result-in-progress">
<loading-indicator :size="30" />
Fetching results...
</div>
<div
v-show="result === undefined && !isGettingResults && !error"
class="table-preview result-empty"
>
No rows retrieved according to your query
</div>
<logs v-if="error" :messages="[error]" />
<sql-table
v-if="result && !viewRecord"
:data-set="result"
:time="time"
:pageSize="pageSize"
:page="defaultPage"
:selectedCellCoordinates="defaultSelectedCell"
class="straight"
@update-selected-cell="onUpdateSelectedCell"
/>
<record
v-if="result && viewRecord"
ref="recordView"
:data-set="result"
:time="time"
:selectedColumnIndex="selectedCell ? +selectedCell.dataset.col : 0"
:rowIndex="selectedCell ? +selectedCell.dataset.row : 0"
@update-selected-cell="onUpdateSelectedCell"
/>
</div>
</teleport>
</div>
</template>
<script>
import Logs from '@/components/Common/Logs'
import SqlTable from '@/components/SqlTable'
import LoadingIndicator from '@/components/Common/LoadingIndicator'
import SideToolBar from '@/components/SideToolBar'
import Splitpanes from '@/components/Common/Splitpanes'
import ExportToCsvIcon from '@/components/svg/exportToCsv'
import ClipboardIcon from '@/components/svg/clipboard'
import ViewCellValueIcon from '@/components/svg/viewCellValue'
import RowIcon from '@/components/svg/row'
import IconButton from '@/components/Common/IconButton'
import csv from '@/lib/csv'
import fIo from '@/lib/utils/fileIo'
import cIo from '@/lib/utils/clipboardIo'
import time from '@/lib/utils/time'
import loadingDialog from '@/components/Common/LoadingDialog'
import events from '@/lib/utils/events'
import ValueViewer from './ValueViewer'
import Record from './Record/index.vue'
export default {
name: 'RunResult',
components: {
SqlTable,
LoadingIndicator,
Logs,
SideToolBar,
ExportToCsvIcon,
IconButton,
ClipboardIcon,
ViewCellValueIcon,
RowIcon,
loadingDialog,
ValueViewer,
Record,
Splitpanes
},
props: {
tab: Object,
result: Object,
isGettingResults: Boolean,
error: Object,
time: [String, Number]
},
emits: ['switchTo'],
data() {
return {
resizeObserver: null,
pageSize: 20,
preparingCopy: false,
dataToCopy: null,
viewValuePanelVisible: false,
selectedCell: null,
viewRecord: false,
defaultPage: 1,
defaultSelectedCell: null,
enableTeleport: this.$store.state.isWorkspaceVisible,
showLoadingDialog: false
}
},
computed: {
resultSetTeleportTarget() {
if (!this.enableTeleport) {
return undefined
}
const base = `#${
this.viewValuePanelVisible
? 'run-result-left-pane'
: 'run-result-result-set'
}`
const tabIdPostfix = `-${this.tab.id}`
return base + tabIdPostfix
}
},
watch: {
result() {
this.defaultSelectedCell = null
this.selectedCell = null
}
},
activated() {
this.enableTeleport = true
},
deactivated() {
this.enableTeleport = false
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.runResultPanel)
this.calculatePageSize()
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.runResultPanel)
},
methods: {
handleResize() {
this.calculatePageSize()
},
calculatePageSize() {
const runResultPanel = this.$refs.runResultPanel
// 27 - table footer hight
// 5 - padding-bottom of rounded table container
// 35 - height of table header
const freeSpace = runResultPanel.offsetHeight - 27 - 5 - 35
this.pageSize = Math.max(Math.floor(freeSpace / 35), 20)
},
exportToCsv() {
if (this.result && this.result.values) {
events.send(
'resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'csv' }
)
}
fIo.exportToFile(csv.serialize(this.result), 'result_set.csv', 'text/csv')
},
async prepareCopy() {
if (this.result && this.result.values) {
events.send(
'resultset.export',
this.result.values[this.result.columns[0]].length,
{ to: 'clipboard' }
)
}
if ('ClipboardItem' in window) {
this.preparingCopy = true
this.showLoadingDialog = true
const t0 = performance.now()
await time.sleep(0)
this.dataToCopy = csv.serialize(this.result)
const t1 = performance.now()
if (t1 - t0 < 950) {
this.copyToClipboard()
} else {
this.preparingCopy = false
}
} else {
alert(
"Your browser doesn't support copying into the clipboard. " +
'If you use Firefox you can enable it ' +
'by setting dom.events.asyncClipboard.clipboardItem to true.'
)
}
},
copyToClipboard() {
cIo.copyText(this.dataToCopy, 'CSV copied to clipboard successfully')
this.showLoadingDialog = false
},
cancelCopy() {
this.dataToCopy = null
},
toggleViewValuePanel() {
this.viewValuePanelVisible = !this.viewValuePanelVisible
},
toggleViewRecord() {
if (this.viewRecord) {
this.defaultSelectedCell = {
row: this.$refs.recordView.currentRowIndex,
col: this.selectedCell ? +this.selectedCell.dataset.col : 0
}
this.defaultPage = Math.ceil(
(this.$refs.recordView.currentRowIndex + 1) / this.pageSize
)
}
this.viewRecord = !this.viewRecord
},
onUpdateSelectedCell(e) {
this.selectedCell = e
}
}
}
</script>
<style scoped>
.run-result-panel {
display: flex;
height: 100%;
overflow: hidden;
}
.run-result-panel-content {
flex-grow: 1;
height: 100%;
width: 0;
}
.result-set-container,
.result-set-container > div {
position: relative;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.value-viewer-container {
height: 100%;
width: 100%;
background-color: var(--color-white);
position: relative;
}
.table-preview {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-base);
font-size: 13px;
text-align: center;
}
.result-in-progress {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
will-change: opacity;
/*
We need to show loader in 1 sec after starting query execution. We can't do that with
setTimeout because the main thread can be busy by getting a result set from the web worker.
But we can use CSS animation for opacity. Opacity triggers changes only in the Composite Layer
stage in rendering waterfall. Hence it can be processed only with Compositor Thread while
the Main Thread processes a result set.
https://www.viget.com/articles/animation-performance-101-browser-under-the-hood/
*/
animation: show-loader 1s linear 0s 1;
}
@keyframes show-loader {
0% {
opacity: 0;
}
99% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>