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
Binary file modified .github/screenshots/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/11.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/12.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ Explorer or a table's right-click menu.
editable before you commit to it, and named in your engine's own dialect
- Headers optional, `NULL` text configurable (`\N`, `NULL`, whatever your exporter writes),
leading lines skippable for files with a preamble, whitespace trimmable
- **Empty fields keep their meaning**: a bare one is `NULL`, a quoted `""` is an empty string -
the same convention Postgres `COPY … FORMAT csv` uses, and what lets a XenSQL CSV export load
straight back in. In a numeric or date column, where `""` is never valid, both mean `NULL`
- UTF-8 BOMs stripped, ragged rows tolerated
- **Bad rows are skipped and reported**, not fatal - or tick **stop at the first error** if you'd
rather nothing partial lands
Expand All @@ -320,6 +323,8 @@ transactions behave. You get a progress bar and a count - not thousands of resul
- Export all or **selected** rows and columns
- Copy to clipboard or **save to file**
- Remembers your last export format
- **CSV and SQL exports import back cleanly** - `NULL` stays apart from an empty string, and cells
that spreadsheets would treat as formulas are guarded on the way out and unguarded on the way in

---

Expand Down
2 changes: 1 addition & 1 deletion build/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ info:
description: "A fast, native SQL client built with Go, Wails and React." # The application description
copyright: "(c) 2026, Bare7a" # Copyright text
comments: "A fast, native SQL client built with Go, Wails and React." # Comments
version: "1.5.0" # The application version
version: "1.5.1" # The application version
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
# # Should match the name of your .icon file without the extension
# # If not set and Assets.car exists, defaults to "appicon"
Expand Down
4 changes: 2 additions & 2 deletions build/darwin/Info.dev.plist
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>CFBundleVersion</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSAppTransportSecurity</key>
Expand Down
4 changes: 2 additions & 2 deletions build/darwin/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>CFBundleVersion</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSHighResolutionCapable</key>
Expand Down
4 changes: 2 additions & 2 deletions build/ios/Info.dev.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.0-dev</string>
<string>1.5.1-dev</string>
<key>CFBundleVersion</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
Expand Down
4 changes: 2 additions & 2 deletions build/ios/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>CFBundleVersion</key>
<string>1.5.0</string>
<string>1.5.1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
Expand Down
2 changes: 1 addition & 1 deletion build/linux/nfpm/nfpm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
name: "XenSQL"
arch: ${GOARCH}
platform: "linux"
version: "1.5.0"
version: "1.5.1"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
Expand Down
4 changes: 2 additions & 2 deletions build/windows/info.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"fixed": {
"file_version": "1.5.0"
"file_version": "1.5.1"
},
"info": {
"0000": {
"ProductVersion": "1.5.0",
"ProductVersion": "1.5.1",
"CompanyName": "Bare7a",
"FileDescription": "A fast, native SQL client built with Go, Wails and React.",
"LegalCopyright": "(c) 2026, Bare7a",
Expand Down
2 changes: 1 addition & 1 deletion build/windows/nsis/wails_tools.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "XenSQL"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "1.5.0"
!define INFO_PRODUCTVERSION "1.5.1"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "(c) 2026, Bare7a"
Expand Down
2 changes: 1 addition & 1 deletion build/windows/wails.exe.manifest
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="eu.bare7a.xensql" version="1.5.0" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="eu.bare7a.xensql" version="1.5.1" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
Expand Down
11 changes: 11 additions & 0 deletions e2e/pages/app-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,21 @@ export class AppPage {
// ── Test isolation ───────────────────────────────────────────────────────
/** Reset shared backend state: the editor session (tabs) and saved connections both persist and restore on load. */
async resetState(): Promise<void> {
await this.dismissModals();
await this.closeAllTabs();
await this.resetConnections();
}

/** A modal left open by a test covers the whole page and would block every click below. */
async dismissModals(): Promise<void> {
for (let i = 0; i < 3; i++) {
const overlay = this.page.locator('.modal-overlay').last();
if (!(await overlay.isVisible().catch(() => false))) return;
await this.page.keyboard.press('Escape');
await overlay.waitFor({ state: 'hidden', timeout: 1_000 }).catch(() => {});
}
}

/**
* Close every editor tab, clearing the persisted session so the next test's page loads clean.
* Runs at teardown, when this test's tabs are already rendered (nothing is restoring), so a
Expand Down
7 changes: 7 additions & 0 deletions e2e/pages/results-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,11 @@ export class ResultsPage {
await this.exportDialog.getByRole('button', { name: 'Cancel' }).click();
await expect(this.exportDialog).toBeHidden();
}

/** Copies the export to the clipboard (which closes the dialog) and returns its text. */
async copyExportToClipboard(): Promise<string> {
await this.exportDialog.getByRole('button', { name: 'Copy to clipboard' }).click();
await expect(this.exportDialog).toBeHidden();
return this.page.evaluate(() => navigator.clipboard.readText());
}
}
13 changes: 1 addition & 12 deletions e2e/screenshots/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Page } from '@playwright/test';
import { expect, test } from '@support/fixtures';
import { stubImportFilePicker } from '@support/importFile';
import type { SchemaPage } from '../pages/schema-page';
import { COLOR, COLOR_INDEX, FORUM, POSTGRES_DEV, POSTGRES_READONLY } from './screenshot-db';

Expand All @@ -13,9 +14,6 @@ const OUT_DIR = path.resolve(screenshotsDir, '../../.github/screenshots');
const IMPORT_CSV = process.env.XENSQL_SCREENSHOT_CSV ?? path.join(screenshotsDir, 'fixtures', 'users.csv');
const IMPORT_CSV_COLUMNS = 13;

/** Wails binding ID of `App.PickImportFile` (frontend/bindings/…/app.ts). */
const PICK_IMPORT_FILE_ID = 452154835;

const GET_ALL_POSTS_SQL = 'SELECT * FROM posts p ORDER BY p.id;';
const PLAN_SQL = `SELECT c.name AS category, u.display_name AS author,
COUNT(*) AS posts, SUM(p.views) AS views
Expand Down Expand Up @@ -218,15 +216,6 @@ async function waitForCellViewerSyntax(page: Page): Promise<void> {
await page.waitForTimeout(250);
}

/** Server mode has no native file dialog, so answer that one binding with a fixture path. */
async function stubImportFilePicker(page: Page, filePath: string): Promise<void> {
await page.route('**/wails/runtime', async (route) => {
const body = route.request().postDataJSON() as { args?: { methodID?: number } } | null;
if (body?.args?.methodID !== PICK_IMPORT_FILE_ID) return route.fallback();
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(filePath) });
});
}

async function createFolder(page: Page, name: string): Promise<void> {
// Assumes the connection switcher menu is already open.
await page.locator('.sidebar-connections-toolbar button[data-tooltip="New folder"]').click();
Expand Down
85 changes: 83 additions & 2 deletions e2e/specs/sidebar/import.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import fs from 'node:fs';
import { POSTGRES } from '@support/databases';
import { expect, test } from '@support/fixtures';
import { stubImportFilePicker, writeCSVFixture } from '@support/importFile';

// The native file picker can't be driven here, so these cover the dialog only; the load itself is
// covered by internal/app/app_import_test.go.
test.use({ permissions: ['clipboard-read', 'clipboard-write'] });

// The native picker is stubbed at the binding; row-level behaviour lives in app_import_test.go.
test.describe('Import dialog', () => {
test('opens from the schema toolbar with CSV selected', async ({ connections, page }) => {
await connections.createAndConnect(POSTGRES);
Expand Down Expand Up @@ -54,4 +57,82 @@ test.describe('Import dialog', () => {
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel('Table')).toHaveValue(table);
});

test('loads a CSV into an existing table, counting rows against the file total', async ({
connections,
page,
schema,
seed,
}) => {
await connections.createAndConnect(POSTGRES);
const table = await seed.table('e2e_import_run');
await schema.refresh();

const rows = ['id,name'];
for (let i = 1; i <= 1200; i++) rows.push(`${i},name${i}`);
const csv = writeCSVFixture('people.csv', rows);
await stubImportFilePicker(page, csv);

await schema.openTableMenu(table);
await page.getByRole('menuitem', { name: 'Import into this table…' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Browse' }).click();
await expect(dialog.locator('#import-path')).toHaveValue(csv);
await expect(dialog.locator('.import-preview-head')).toContainText('1,200 row(s)');
await page.unroute('**/wails/runtime');

await dialog.getByRole('button', { name: 'Import', exact: true }).click();
await expect(dialog.locator('.task-progress')).toContainText(/Importing… [\d,]+ of 1,200 rows read/);
// Reaching a result proves import:done landed; without it the button stays on "Importing…".
await expect(dialog.locator('.import-result')).toContainText('Imported 1,200 row(s), skipped 0', {
timeout: 30_000,
});
await expect(dialog.getByRole('button', { name: 'Import', exact: true })).toBeEnabled();
});

test('re-imports a CSV the export dialog produced, NULLs and formula-shaped text intact', async ({
connections,
editor,
page,
results,
schema,
seed,
}) => {
await connections.createAndConnect(POSTGRES);
const table = await seed.table('e2e_roundtrip', { columns: '(id INT, txt TEXT, note TEXT)' });
await editor.run(
`INSERT INTO ${table} (id, txt, note) VALUES (1, NULL, 'plain'), (2, '', '-not a number'), (3, 'x', '=1+2');`,
);
await schema.refresh();

await editor.run(`SELECT id, txt, note FROM ${table} ORDER BY id;`);
await results.waitForRows();
await results.openExportDialog();
await results.setExportFormat('csv');
const csv = (await results.copyExportToClipboard()).replace(/\r\n/g, '\n');
expect(csv).toBe(`id,txt,note\n1,,plain\n2,"",'-not a number\n3,x,'=1+2`);

const file = writeCSVFixture('roundtrip.csv', csv.split('\n'));
await stubImportFilePicker(page, file);
await schema.openTableMenu(table);
await page.getByRole('menuitem', { name: 'Import into this table…' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Browse' }).click();
await expect(dialog.locator('#import-path')).toHaveValue(file);
await page.unroute('**/wails/runtime');
await dialog.getByRole('button', { name: 'Import', exact: true }).click();
await expect(dialog.locator('.import-result')).toContainText('Imported 3 row(s), skipped 0', { timeout: 30_000 });
await dialog.locator('.modal-footer').getByRole('button', { name: 'Close', exact: true }).click();
fs.rmSync(file, { force: true });

// 6 = the 3 originals plus 3 identical re-imports: NULL, '' and '=1+2' all intact.
await editor.run(
`SELECT COUNT(*) AS intact FROM ${table}
WHERE (id = 1 AND txt IS NULL AND note = 'plain')
OR (id = 2 AND txt = '' AND note = '-not a number')
OR (id = 3 AND txt = 'x' AND note = '=1+2');`,
);
await results.waitForRows();
await expect(results.grid).toContainText('6');
});
});
24 changes: 24 additions & 0 deletions e2e/support/importFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Page } from '@playwright/test';

/** Wails binding ID of `App.PickImportFile` (frontend/bindings/…/app.ts). */
const PICK_IMPORT_FILE_ID = 452154835;

/** Answers the PickImportFile binding with a fixture path; unroute once the dialog has it. */
export async function stubImportFilePicker(page: Page, filePath: string): Promise<void> {
await page.route('**/wails/runtime', async (route) => {
const body = route.request().postDataJSON() as { args?: { methodID?: number } } | null;
if (body?.args?.methodID !== PICK_IMPORT_FILE_ID) return route.fallback();
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(filePath) });
});
}

/** Writes a CSV to a temp dir and returns its path. */
export function writeCSVFixture(name: string, lines: string[]): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xensql-e2e-'));
const file = path.join(dir, name);
fs.writeFileSync(file, `${lines.join('\n')}\n`);
return file;
}
8 changes: 8 additions & 0 deletions frontend/bindings/xensql/internal/app/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ export class ImportPreview {
"totalBytes": number;
"truncated": boolean;

/**
* 0 when the file was too large to count.
*/
"totalRows": number;

/** Creates a new ImportPreview instance. */
constructor($$source: Partial<ImportPreview> = {}) {
if (!("columns" in $$source)) {
Expand All @@ -175,6 +180,9 @@ export class ImportPreview {
if (!("truncated" in $$source)) {
this["truncated"] = false;
}
if (!("totalRows" in $$source)) {
this["totalRows"] = 0;
}

Object.assign(this, $$source);
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/bindings/xensql/internal/service/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class CSVOptions {
"hasHeader": boolean;

/**
* NullLiteral is the text that becomes SQL NULL; blank fields always are.
* NullLiteral is extra text that becomes SQL NULL; a bare empty field already is.
*/
"nullLiteral"?: string;

Expand Down
Loading
Loading