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
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

152 changes: 78 additions & 74 deletions src/components/database-browser/DataTableView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -470,12 +470,24 @@
: new Set([...hiddenColumns.value, col])
}

function formatTimestamp(): string {
const d = new Date()
const pad = (n: number) => n.toString().padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`
}

async function exportCSV() {
if (!data.value)
return

const csv = rowsToCsv(data.value.rows, visibleColumns.value)
const defaultName = `${props.tableName}.csv`
const conn = connectionStore.getConnectionById(props.connectionId)
const connName = conn?.name || props.connectionId
const parts = [connName, props.database]
if (props.schema)
parts.push(props.schema)
parts.push(props.tableName, formatTimestamp())
const defaultName = `${parts.join('-')}.csv`

try {
const selectedPath = await showSaveDialog({
Expand Down Expand Up @@ -841,16 +853,15 @@

<!-- Data sub-page: Toolbar bar (search + filter + actions) -->
<template v-if="activeSubPage === 'data'">
<div class="px-3 py-1.5 border-b bg-muted/20 flex shrink-0 gap-1.5 items-center">
<span class="i-carbon-search text-muted-foreground flex-shrink-0 h-3.5 w-3.5" />

<div class="px-3 py-1.5 border-b bg-muted/20 flex shrink-0 gap-1 items-center">
<!-- Search input — searches across all columns -->
<div class="flex-1 min-w-0 relative">
<span class="i-carbon-search text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 pointer-events-none" />

Check warning on line 859 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 859 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 859 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24.x)

UnoCSS utilities are not ordered
<Input
ref="searchInputRef"
v-model="searchTerm"
:placeholder="t('components.dataTableView.searchPlaceholder')"
class="text-xs pr-7 flex-1 h-7"
class="text-xs pl-7 pr-7 h-7"
@keydown="onSearchKeydown"
@focus="onSearchFocus"
/>
Expand All @@ -861,44 +872,36 @@
>
<span class="i-carbon-loading h-3.5 w-3.5 animate-spin" />
</div>
<!-- Clear search button (inside input, on the right) -->
<button
v-if="searchTerm"
class="text-muted-foreground right-1 top-1/2 absolute -translate-y-1/2 flex items-center justify-center h-5 w-5 rounded hover:bg-accent"

Check warning on line 878 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 878 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 878 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24.x)

UnoCSS utilities are not ordered
:title="t('components.dataTableView.clearSearch')"
@click.stop="clearSearch"
>
<span class="i-carbon-close h-3 w-3" />
</button>
</div>

<!-- Clear search button -->
<Button
v-if="searchTerm"
variant="ghost"
size="icon"
class="flex-shrink-0 h-7 w-7"
:title="t('components.dataTableView.clearSearch')"
@click.stop="clearSearch"
>
<span class="i-carbon-close h-3.5 w-3.5" />
</Button>

<!-- Visual separator between search and filter sections -->
<div class="mx-0.5 bg-border flex-shrink-0 h-5 w-px" />

<span class="i-carbon-filter text-muted-foreground flex-shrink-0 h-3.5 w-3.5" />

<!-- Filter input -->
<Input
v-model="filterInput"
:placeholder="t('components.dataTableView.filterPlaceholder')"
class="text-xs font-mono flex-1 h-7"
@keydown.enter="applyFilter"
/>

<!-- Clear filter button -->
<Button
v-if="filterInput || appliedFilter"
variant="ghost"
size="icon"
class="flex-shrink-0 h-7 w-7"
:title="t('components.dataTableView.clearFilter')"
@click.stop="clearFilter"
>
<span class="i-carbon-close h-3.5 w-3.5" />
</Button>
<div class="flex-1 min-w-0 relative">
<span class="i-carbon-filter text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 pointer-events-none" />

Check warning on line 888 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 888 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 888 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24.x)

UnoCSS utilities are not ordered
<Input
v-model="filterInput"
:placeholder="t('components.dataTableView.filterPlaceholder')"
class="text-xs font-mono pl-7 h-7"
@keydown.enter="applyFilter"
/>
<!-- Clear filter button (inside input, on the right) -->
<button
v-if="filterInput || appliedFilter"
class="text-muted-foreground right-1 top-1/2 absolute -translate-y-1/2 flex items-center justify-center h-5 w-5 rounded hover:bg-accent"

Check warning on line 898 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 898 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (macos-latest, 24.x)

UnoCSS utilities are not ordered

Check warning on line 898 in src/components/database-browser/DataTableView.vue

View workflow job for this annotation

GitHub Actions / build (windows-latest, 24.x)

UnoCSS utilities are not ordered
:title="t('components.dataTableView.clearFilter')"
@click.stop="clearFilter"
>
<span class="i-carbon-close h-3 w-3" />
</button>
</div>

<!-- Refresh button -->
<Button
Expand All @@ -909,42 +912,43 @@
:title="t('components.dataTableView.refresh')"
@click.stop="refresh"
>
<span class="i-carbon-refresh h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
<!-- Column visibility dropdown -->
<div class="flex-shrink-0 relative">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="t('components.dataTableView.columns')"
@click.stop="showColumnMenu = !showColumnMenu"
>
<span class="i-carbon-table-split h-3.5 w-3.5" />
</Button>
<span class="i-carbon-renew h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
</Button>

<div
v-if="showColumnMenu && data && data.columns.length > 0"
class="text-popover-foreground mt-1 border rounded-md bg-popover max-h-64 min-w-36 shadow-md right-0 top-full absolute z-50 overflow-auto"
@click.stop
>
<div class="text-xs text-muted-foreground font-semibold p-1 px-2 py-1.5 border-b">
{{ t('components.dataTableView.columns') }}
</div>
<div class="p-1">
<button
v-for="col in data.columns"
:key="col"
class="text-sm px-2 py-1 text-left rounded flex gap-2 w-full cursor-pointer items-center hover:bg-accent"
@click="toggleColumn(col)"
>
<span v-if="!hiddenColumns.has(col)" class="i-carbon-checkmark text-primary flex-shrink-0 h-3 w-3" />
<span v-else class="flex-shrink-0 w-3 inline-block" />
<span class="truncate">{{ col }}</span>
</button>
</div>
<!-- Column visibility dropdown -->
<div class="flex-shrink-0 relative">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="t('components.dataTableView.columns')"
@click.stop="showColumnMenu = !showColumnMenu"
>
<span class="i-carbon-table-split h-3.5 w-3.5" />
</Button>

<div
v-if="showColumnMenu && data && data.columns.length > 0"
class="text-popover-foreground mt-1 border rounded-md bg-popover max-h-64 min-w-36 shadow-md right-0 top-full absolute z-50 overflow-auto"
@click.stop
>
<div class="text-xs text-muted-foreground font-semibold p-1 px-2 py-1.5 border-b">
{{ t('components.dataTableView.columns') }}
</div>
<div class="p-1">
<button
v-for="col in data.columns"
:key="col"
class="text-sm px-2 py-1 text-left rounded flex gap-2 w-full cursor-pointer items-center hover:bg-accent"
@click="toggleColumn(col)"
>
<span v-if="!hiddenColumns.has(col)" class="i-carbon-checkmark text-primary flex-shrink-0 h-3 w-3" />
<span v-else class="flex-shrink-0 w-3 inline-block" />
<span class="truncate">{{ col }}</span>
</button>
</div>
</div>
</Button>
</div>

<!-- Delete Selected button -->
<Button
Expand Down Expand Up @@ -994,7 +998,7 @@
{{ connectionError }}
</p>
<Button size="sm" @click="handleRetry">
<span class="i-carbon-refresh mr-1.5 h-3.5 w-3.5" />
<span class="i-carbon-renew mr-1.5 h-3.5 w-3.5" />
{{ t('components.dataTableView.reconnect') }}
</Button>
</div>
Expand Down
144 changes: 143 additions & 1 deletion src/composables/useSqlStatements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,150 @@ function skipLeadingComments(text: string): number {
return skipped
}

// Keywords that can signal a new statement start after 2+ blank lines
// (when a `;` was forgotten). Covers DML, DDL, TCL, and utility commands.
const SOFT_STATEMENT_KEYWORDS = new Set([
'SELECT',
'CREATE',
'ALTER',
'DROP',
'INSERT',
'UPDATE',
'DELETE',
'TRUNCATE',
'GRANT',
'REVOKE',
'EXPLAIN',
'SHOW',
'DESCRIBE',
'USE',
'SET',
'CALL',
'EXEC',
'EXECUTE',
'BEGIN',
'COMMIT',
'ROLLBACK',
'DECLARE',
'ANALYZE',
'VACUUM',
'PRAGMA',
'REFRESH',
'COPY',
'WITH',
'MERGE',
'REPLACE',
])

/**
* After splitting by `;`, scan each range for 2+ consecutive blank lines
* followed by a SQL keyword. When found, split the range there — this
* recovers from missing `;` between statements separated by blank lines.
*/
function splitRangesAtBlankLines(ranges: StatementRange[], content: string): StatementRange[] {
const result: StatementRange[] = []

for (const range of ranges) {
const text = content.slice(range.startOffset, range.endOffset)
const lines = text.split('\n')

// Pre-compute each line's byte offset within `text`
const lineOffsets: number[] = []
let off = 0
for (const line of lines) {
lineOffsets.push(off)
off += line.length + 1 // +1 for \n
}

let blankRun = 0
let seenContent = false
let inBlockComment = false
// Track the line index of the last actual SQL content (for split boundaries)
let lastContentLine = -1
// When the segment starts with `WITH`, suppress soft-split for the next DML
// keyword — it's the main query of a CTE, not a new statement.
let segmentFirstKeyword: string | null = null
const FIRST_DML_KEYWORDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'MERGE', 'REPLACE'])
// Each split: { sqlLine: the SQL keyword line, prevContentLine: last content before blank run }
const splits: { sqlLine: number, prevContentLine: number }[] = []

for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()

// Lines inside a multi-line block comment — skip without resetting blankRun
if (inBlockComment) {
if (trimmed.endsWith('*/'))
inBlockComment = false
continue
}

if (trimmed.length === 0) {
if (seenContent)
blankRun++
continue
}

// Comment-only lines should not reset the blank-run counter,
// so that `SELECT ...\n\n\n-- comment\nSELECT ...` still splits.
if (trimmed.startsWith('--') || trimmed.startsWith('#')) {
continue
}
if (trimmed.startsWith('/*')) {
if (!trimmed.endsWith('*/'))
inBlockComment = true
continue
}

// Actual SQL content
const firstWord = trimmed.split(/\s+/)[0]?.toUpperCase()
if (!seenContent) {
seenContent = true
segmentFirstKeyword = firstWord ?? null
}
else if (blankRun >= 2 && firstWord && SOFT_STATEMENT_KEYWORDS.has(firstWord)) {
// When the segment starts with WITH, the next DML keyword is the main
// query of the CTE (e.g. `WITH cte AS (...) \n\n\n SELECT ...`).
// Splitting there would orphan the CTE definition.
const isCteMainQuery = segmentFirstKeyword === 'WITH' && firstWord && FIRST_DML_KEYWORDS.has(firstWord)
if (!isCteMainQuery)
splits.push({ sqlLine: i, prevContentLine: lastContentLine })
}
blankRun = 0
lastContentLine = i
}

if (splits.length === 0) {
result.push(range)
continue
}

// Build sub-ranges. Each preceding segment ends at its last content line
// (excluding trailing blank/comment lines). Each new segment starts at the
// SQL keyword line in `splits[].sqlLine`.
let segStart = range.startOffset + lineOffsets[0]

for (const { sqlLine, prevContentLine } of splits) {
// End the preceding segment right after `prevContentLine`
const segEnd = range.startOffset + lineOffsets[prevContentLine] + lines[prevContentLine].length
result.push(buildRange(content, segStart, segEnd))
segStart = range.startOffset + lineOffsets[sqlLine]
}

// Final segment (trim leading whitespace)
const rawFinal = content.slice(segStart, range.endOffset)
const finalTrimmed = rawFinal.trimStart()
if (finalTrimmed.length > 0) {
const skipped = rawFinal.length - finalTrimmed.length
result.push(buildRange(content, segStart + skipped, range.endOffset))
}
}

return result.filter(r => r.text.trim().length > 0)
}

export function parseSqlStatements(content: string): SqlStatement[] {
const ranges = scanStatements(content)
const rawRanges = scanStatements(content)
const ranges = splitRangesAtBlankLines(rawRanges, content)
const lines = content.split('\n')

return ranges
Expand Down
Loading
Loading