diff --git a/CHANGELOG.md b/CHANGELOG.md index abac603..2595e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Added + +- 📚 **README overhaul + GIF demos**: Updated documentation with a quick start guide, offline/security notes, optional CLI requirements (`sqlite3`/`sqlcipher`), and feature demos +- ↕️ **Per-tab sort persistence**: Remembers client-side column sort (by column name) per table tab and restores it when switching tabs +- 🧾 **Clear empty states**: New “no results on this page” messaging for table page search/filters (virtualized and non-virtual tables) + +### Changed + +- 🧭 **Pagination event handling**: Pagination controls now use delegated listeners so regenerated pagination HTML continues to work without re-binding +- 🔎 **Search UX wording**: Table search placeholder clarifies it searches the current page +- 📄 **Bigger page size options**: Added very large page-size choices for browsing huge tables + +### Fixed + +- 📤 **Export correctness**: CSV export now ignores non-data rows (empty-state, virtual spacer/loading rows) + ## [0.4.0] - 2025-12-13 ### ⚡ Performance, UX, and Reliability Improvements diff --git a/README.md b/README.md index 83916a2..14b3f9b 100644 --- a/README.md +++ b/README.md @@ -1,271 +1,199 @@ -# ⚠️ **BETA WARNING** +# SQLite IntelliView (Beta) -> **SQLite IntelliView is currently in BETA on the VS Code Marketplace. Features and stability are evolving. Please report issues and feedback via GitHub.** - -# SQLite IntelliView - -[![VS Code Marketplace Version](https://img.shields.io/visual-studio-marketplace/v/bowlerr.sqlite-intelliview-vscode.svg?label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=bowlerr.sqlite-intelliview-vscode) +[![VS Code Marketplace Version](https://img.shields.io/visual-studio-marketplace/v/bowlerr.sqlite-intelliview-vscode.svg)](https://marketplace.visualstudio.com/items?itemName=bowlerr.sqlite-intelliview-vscode) [![Installs](https://img.shields.io/visual-studio-marketplace/i/bowlerr.sqlite-intelliview-vscode.svg)](https://marketplace.visualstudio.com/items?itemName=bowlerr.sqlite-intelliview-vscode) [![Rating](https://img.shields.io/visual-studio-marketplace/r/bowlerr.sqlite-intelliview-vscode.svg)](https://marketplace.visualstudio.com/items?itemName=bowlerr.sqlite-intelliview-vscode) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -> Modern SQLite/SQLCipher database viewer and editor for VS Code: Monaco-powered queries, ER diagrams, cell editing, encryption, and more. - - -## Features - -- **Custom Editor for SQLite Files**: Open `.db`, `.sqlite`, and `.sqlite3` files in a rich, Monaco-powered editor. -- **Database Explorer**: Tree view of tables and columns, with icons and tooltips. -- **Monaco Query Editor**: Syntax highlighting, autocompletion, and SQL snippets. -- **Cell Editing**: Edit table data directly with real-time updates. -- **Context Menus**: Right-click for copy, navigation, and export actions. -- **Foreign Key Navigation**: Visual indicators and direct navigation for relationships. -- **Advanced Pagination**: Configurable page size for large tables. -- **SQLCipher Support**: Open encrypted databases with a password. -- **WAL Mode Support**: Automatic detection and checkpoint of Write-Ahead Logging files for up-to-date data. -- **Real-time WAL Monitoring**: Automatically refreshes when WAL files change. -- **Theme Integration**: UI matches your VS Code theme. -- **Keyboard Shortcuts**: Fast access to all major features. - ---- - -## Installation - -Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=bowlerr.sqlite-intelliview-vscode): - -```sh -code --install-extension bowlerr.sqlite-intelliview-vscode -``` - -Or search for **"SQLite IntelliView"** in the Extensions sidebar. - ---- - -## Usage - -### Opening a Database - -- **Right-click** any `.db`, `.sqlite`, or `.sqlite3` file in the Explorer and select **"Open SQLite Database"**. -- Or run the command: - Ctrl/Cmd+Shift+O or search for `Open SQLite Database` in the Command Palette. - -### Connecting to Encrypted Databases - -- Run **"Connect with SQLCipher Key"** from the Command Palette or use Ctrl/Cmd+Shift+K. - -### Database Explorer - -- View all tables and columns in the **Database Explorer** side panel. -- Click tables to view data and schema. - -### Query Editor - -- Write and execute SQL queries in the Monaco-powered editor. -- Use Ctrl/Enter (or Cmd/Enter on Mac) to run queries. - -### Export Data - -- Run **"Export Data"** from the Command Palette or use Ctrl/Shift+E. - -### Context Menus - -- Right-click table cells for copy and navigation options. - ---- +Modern SQLite (and SQLCipher) database viewer/editor for VS Code: table browsing, Monaco-powered queries, ER diagrams, and quality-of-life tooling for JSON/BLOBs and large tables. + +> Beta note: features and stability are evolving. Please report issues/feedback on GitHub. + +**Offline + Secure**: runs locally inside VS Code and the extension does not send your database contents anywhere (no cloud, no telemetry). + +## GIF Demos + +A quick tour of the main workflows (all offline, inside VS Code): + + + + + + + + + + + + + + + + + + + + + + +
+ Browse + edit tables
+ Browse and edit table data +
+ Multi-table tabs
+ Open and reorder multiple table tabs +
+ Schema view
+ Inspect table schema +
+ Query editor (Monaco)
+ Run SQL queries in Monaco editor +
+ Foreign-key navigation
+ Navigate relationships via foreign keys +
+ ER diagram
+ Generate an ER diagram +
+ JSON viewer
+ View formatted JSON from cells +
+ BLOB viewer
+ View and copy/download BLOB data +
+ SQLCipher (encrypted DBs)
+ Connect to encrypted SQLCipher database +
+ External updates
+ Auto-refresh on external database changes +
+ +## What You Get + +- **Free**: no paywalls; MIT-licensed. +- **Offline + secure by design**: database contents stay on your machine (the extension doesn’t make network requests). +- **Custom database editor** for `.db`, `.sqlite`, `.sqlite3` (opens as a rich UI, not plain text). +- **Database Explorer view** (tables + columns) while a database is open. +- **Multi-table tabs** (open multiple tables/results, drag to reorder). +- **Fast table browsing**: pagination, quick search, sorting, column filters, resizable columns/rows, column pinning. +- **Editing**: inline cell edit + row delete (writes changes back to the database file). +- **Context menu tools**: copy cell/row/column, copy row/table as JSON, JSON viewer for JSON cells, BLOB viewer + copy as Base64/Hex. +- **Export from the table UI**: export currently visible rows to CSV. +- **Foreign-key navigation**: jump to referenced rows from FK cells. +- **ER diagram**: interactive relationship diagram (zoom/pan) built with D3. +- **WAL-aware**: checkpoints WAL on open (best-effort) and refreshes when `-wal`/`-shm` change. + +## Quick Start + +1. Open any `.db`, `.sqlite`, or `.sqlite3` file. +2. If VS Code asks, choose **Open With… → SQLite Database IntelliView**. +3. Use the left **Database Explorer** to open tables, then: + - **Data** tab: browse/edit rows + - **Schema** tab: inspect columns/keys + - **Query** tab: run SQL + - **Diagram** tab: generate an ER diagram + +Tip: right-click a database file in Explorer → **Open SQLite Database**. + +## Requirements (Optional, but Recommended) + +SQLite IntelliView runs locally inside VS Code. For best functionality, install these command-line tools and ensure they’re available on your `PATH`: + +- `sqlite3` (recommended): used for WAL checkpointing on unencrypted databases. +- `sqlcipher` (only for encrypted DBs): used to decrypt/re-encrypt SQLCipher databases and checkpoint encrypted WAL databases. + +If you don’t install these: +- Unencrypted databases will still open, but WAL checkpointing may be limited. +- Encrypted (SQLCipher) databases won’t be able to open/decrypt. + +### Install `sqlite3` + +- macOS (Homebrew): `brew install sqlite` +- Ubuntu/Debian: `sudo apt-get update && sudo apt-get install -y sqlite3` +- Windows: + - Winget: `winget install --id SQLite.SQLite -e` + - Chocolatey: `choco install sqlite` + +Verify: `sqlite3 --version` + +### Install `sqlcipher` + +- macOS (Homebrew): `brew install sqlcipher` +- Ubuntu/Debian: `sudo apt-get update && sudo apt-get install -y sqlcipher` +- Windows: + - MSYS2: `pacman -S mingw-w64-x86_64-sqlcipher` + - Or install a SQLCipher build and add `sqlcipher.exe` to your `PATH`. + +Verify: `sqlcipher -version` + +## SQLCipher (Encrypted Databases) + +- Run **SQLite IntelliView: Connect with SQLCipher Key** and enter your key. +- Or use the input box at the top of the Database Explorer view. +- SQLCipher support requires the `sqlcipher` CLI to be available on your `PATH` (used for decrypt/re-encrypt and WAL operations). + +## WAL Mode (Write-Ahead Logging) + +If your database uses WAL mode, IntelliView will try to checkpoint the WAL before loading so you see up-to-date data, and will refresh when WAL/SHM files change. + +If the database is locked by another process (or you only have read-only access), you may see stale data. Use: + +- **SQLite IntelliView: Checkpoint WAL and Refresh** + +For best results, ensure the `sqlite3` CLI is available on your `PATH` (used for WAL checkpointing on unencrypted databases). ## Commands -| Command ID | Title | Description | -| ----------------------------------------- | -------------------------- | ----------------------------------------- | -| sqlite-intelliview-vscode.openDatabase | Open SQLite Database | Open a SQLite/SQLCipher database file | -| sqlite-intelliview-vscode.connectWithKey | Connect with SQLCipher Key | Open encrypted database with a password | -| sqlite-intelliview-vscode.refreshDatabase | Refresh Database | Refresh the database explorer/tree view | -| sqlite-intelliview-vscode.exportData | Export Data | Export table data (CSV/JSON, coming soon) | -| sqlite-intelliview-vscode.checkpointWal | Checkpoint WAL and Refresh | Force checkpoint WAL files and refresh | - ---- - -## Configuration - -| Setting | Type | Default | Description | -| ---------------------------------- | ------- | ------- | -------------------------------------------------------- | -| sqliteIntelliView.defaultPageSize | number | 100 | Default number of rows per page in data tables (10–1000) | -| sqliteIntelliView.enableEncryption | boolean | true | Enable SQLCipher encryption support | -| sqliteIntelliView.themeIntegration | boolean | true | Enable automatic theme integration for the editor UI | - ---- - -## Keybindings - -| Command | Windows/Linux | macOS | When | -| -------------------------- | ------------- | ----------- | ---------------------- | -| Open SQLite Database | Ctrl+Shift+O | Cmd+Shift+O | explorerViewletVisible | -| Connect with SQLCipher Key | Ctrl+Shift+K | Cmd+Shift+K | | -| Refresh Database | Ctrl+Shift+R | Cmd+Shift+R | | -| Export Data | Ctrl+Shift+E | Cmd+Shift+E | | - ---- - -## Example: Command Palette Usage - -- **Open SQLite Database**: - Ctrl/Cmd+Shift+O or search for `Open SQLite Database`. - -- **Connect with SQLCipher Key**: - Ctrl/Cmd+Shift+K or search for `Connect with SQLCipher Key`. +| Command | Purpose | +| ------------------------------------------------ | -------------------------------------------------------------- | +| `SQLite IntelliView: Open SQLite Database` | Open a database in the custom editor | +| `SQLite IntelliView: Connect with SQLCipher Key` | Connect to an encrypted database | +| `SQLite IntelliView: Refresh Database` | Refresh the Database Explorer view | +| `SQLite IntelliView: Checkpoint WAL and Refresh` | Force a WAL checkpoint (best-effort) | +| `SQLite IntelliView: Export Data` | Placeholder command (use the in-table **Export** button today) | -- **Refresh Database**: - Ctrl/Cmd+Shift+R or search for `Refresh Database`. +## Keybindings (VS Code) -- **Export Data**: - Ctrl/Cmd+Shift+E or search for `Export Data`. +| Command | Windows/Linux | macOS | +| -------------------------- | -------------- | ------------- | +| Open SQLite Database | `Ctrl+Shift+O` | `Cmd+Shift+O` | +| Connect with SQLCipher Key | `Ctrl+Shift+K` | `Cmd+Shift+K` | +| Refresh Database | `Ctrl+Shift+R` | `Cmd+Shift+R` | +| Export Data | `Ctrl+Shift+E` | `Cmd+Shift+E` | ---- +## Keyboard Shortcuts (Inside IntelliView) -## Development +These work while focus is inside the database editor webview: -### Prerequisites +| Action | Shortcut | +| ----------------------- | ------------------------------------------- | +| Execute query | `Ctrl+Enter` / `Cmd+Enter` | +| Clear query editor | `Ctrl+K` / `Cmd+K` | +| Focus table search | `Ctrl+F` / `Cmd+F` (or `/` on the Data tab) | +| Refresh view (re-fetch) | `Ctrl+Shift+R` / `Cmd+Shift+R` | +| Hard reload from disk | `Ctrl+Alt+R` / `Cmd+Option+R` | -- Node.js 20.x or higher -- VS Code 1.101.0 or higher +## Notes, Limits, and Safety -### Build & Run - -```sh -git clone https://github.com/Bowlerr/sqlite-intelliview-vscode.git -cd sqlite-intelliview-vscode -npm install -npm run vendor # Vendor external libraries (Monaco Editor, etc.) -npm run compile -# For development mode: -npm run watch -``` - -### Packaging - -```sh -npm run package # Automatically runs vendor + compile + vsce package -``` - -### Debug Controls - -In development, press Ctrl/Cmd+Shift+D to open debug controls for: -- Adjusting log levels (OFF, ERROR, WARN, INFO, DEBUG, TRACE) -- Exporting debug logs for troubleshooting -- Real-time debugging feedback - -### Test - -```sh -npm test -``` - -### Lint - -```sh -npm run lint -``` - ---- - -## WAL Mode Support - -SQLite IntelliView fully supports databases using **Write-Ahead Logging (WAL)** mode. WAL is a high-performance journaling mode that writes changes to a separate `-wal` file before committing them to the main database. - -### Features - -- **Automatic Detection**: Automatically detects when a database is in WAL mode -- **Automatic Checkpoint**: Checkpoints WAL files before opening to ensure you see current data -- **Real-time Monitoring**: Watches WAL and SHM files for changes and auto-refreshes -- **Encrypted Database Support**: Works with SQLCipher encrypted databases in WAL mode - -### How It Works - -When you open a database with WAL mode enabled: -1. The extension detects associated `.db-wal` and `.db-shm` files -2. Automatically checkpoints the WAL to merge uncommitted changes -3. Loads the up-to-date database content -4. Monitors WAL files for external changes and refreshes automatically - -### Configuration - -Control WAL behavior with these settings: - -- `sqliteIntelliView.walAutoCheckpoint`: Automatically checkpoint WAL files before opening (default: `true`) -- `sqliteIntelliView.walMonitoring`: Monitor WAL files for changes and auto-refresh (default: `true`) - -### Manual Checkpoint - -Force a WAL checkpoint and refresh: -- Run command: `SQLite IntelliView: Checkpoint WAL and Refresh` -- Useful when automatic checkpointing is disabled or when you want to force an update - -### Common Scenarios - -**Database shows stale data:** -- The extension automatically checkpoints WAL files, but if the database is locked by another process, you may see a warning -- Try the manual checkpoint command or close the other application - -**Read-only databases:** -- Checkpointing requires write access to perform the operation -- If you have read-only access, the extension will display a warning and show data from the main database file only - -**Large WAL files:** -- Checkpointing may take a few seconds for very large WAL files (>100MB) -- The extension shows a progress indicator during checkpoint operations - ---- +- **Query results are capped** (to keep the UI responsive). For very large exports, use the table UI’s CSV export or a dedicated SQLite client. +- **Query editor writes are not persisted yet**: non-`SELECT` statements may run in-memory but are not currently written back to disk; use inline cell editing / row delete for persisted changes. +- **Edits write to the database file**. Consider working on a copy if the database is important or shared with other processes. +- **SQL restrictions**: some sensitive statements are blocked in the query editor (for example: `PRAGMA key`, `ATTACH DATABASE`, `DETACH DATABASE`). ## Troubleshooting -### Debug Mode -If you encounter issues, enable debug logging: -1. Press Ctrl/Cmd+Shift+D to open debug controls -2. Set debug level to **DEBUG** or **TRACE** -3. Reproduce the issue -4. Click **Export** to save debug logs -5. Share the exported JSON file when reporting issues - -### WAL-Specific Issues - -**"Database is locked" error:** -- Another application has an active connection to the database -- Close the other application or wait for it to release the lock -- The extension will retry automatically up to 3 times with exponential backoff - -**"Cannot checkpoint WAL due to read-only access":** -- You don't have write permissions to the database file -- Data may be stale if there are uncommitted changes in the WAL file -- To see current data, you need write permissions - -**WAL file not detected:** -- Ensure the database is actually in WAL mode (check with: `PRAGMA journal_mode;`) -- WAL files (`.db-wal` and `.db-shm`) must be in the same directory as the database file - ---- +- **“Database is locked” / WAL checkpoint fails**: close other apps holding the DB, or run **Checkpoint WAL and Refresh**. +- **Encrypted DB won’t open**: ensure `sqlcipher` is installed and on `PATH`, then reconnect with the correct key. +- **Stale data in WAL mode**: checkpoint requires write access; read-only workspaces may not be able to merge WAL changes. ## Changelog -See [CHANGELOG.md](CHANGELOG.md) for release notes and version history. - ---- +See `CHANGELOG.md`. ## License -MIT License – see [LICENSE](LICENSE) for details. - ---- - -## Credits / Architecture & Dependencies - -### Core Libraries -- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) - High-performance SQLite binding -- [sql.js](https://github.com/sql-js/sql.js) - SQLite compiled to WebAssembly -- [monaco-editor](https://github.com/microsoft/monaco-editor) - Code editor (bundled locally) -- [sortablejs](https://sortablejs.github.io/Sortable/) - Drag-and-drop functionality (bundled locally) -- [d3](https://d3js.org/) - Data visualization for ER diagrams -- [VS Code API](https://code.visualstudio.com/api) - Extension host integration +MIT License – see `LICENSE`. ---- +## Credits -**Enjoy browsing your SQLite databases with style!** ✨ +Built with (bundled locally): `sql.js` (WASM SQLite), `monaco-editor`, `d3`, `sortablejs`. diff --git a/images/Blob-Viewer.gif b/images/Blob-Viewer.gif new file mode 100644 index 0000000..c209c19 Binary files /dev/null and b/images/Blob-Viewer.gif differ diff --git a/images/Database-Viewer-normal.gif b/images/Database-Viewer-normal.gif new file mode 100644 index 0000000..6220e3e Binary files /dev/null and b/images/Database-Viewer-normal.gif differ diff --git a/images/Diagram.gif b/images/Diagram.gif new file mode 100644 index 0000000..f529a7b Binary files /dev/null and b/images/Diagram.gif differ diff --git a/images/Json-Viewer.gif b/images/Json-Viewer.gif new file mode 100644 index 0000000..db3a157 Binary files /dev/null and b/images/Json-Viewer.gif differ diff --git a/images/Query.gif b/images/Query.gif new file mode 100644 index 0000000..48f66a2 Binary files /dev/null and b/images/Query.gif differ diff --git a/images/Relationation-Query.gif b/images/Relationation-Query.gif new file mode 100644 index 0000000..b71d635 Binary files /dev/null and b/images/Relationation-Query.gif differ diff --git a/images/Schema.gif b/images/Schema.gif new file mode 100644 index 0000000..151ab21 Binary files /dev/null and b/images/Schema.gif differ diff --git a/images/Tab-Organisation.gif b/images/Tab-Organisation.gif new file mode 100644 index 0000000..f99bc7a Binary files /dev/null and b/images/Tab-Organisation.gif differ diff --git a/images/encryption.gif b/images/encryption.gif new file mode 100644 index 0000000..2a87085 Binary files /dev/null and b/images/encryption.gif differ diff --git a/images/external-updates.gif b/images/external-updates.gif new file mode 100644 index 0000000..2e80f94 Binary files /dev/null and b/images/external-updates.gif differ diff --git a/media/css/30-components/tables.css b/media/css/30-components/tables.css index 57e8306..dd3de42 100644 --- a/media/css/30-components/tables.css +++ b/media/css/30-components/tables.css @@ -152,6 +152,31 @@ color: var(--vscode-descriptionForeground); font-style: italic; } +.data-table tbody tr.filter-empty-row { + background: transparent !important; + border-left: none !important; +} +.data-table tbody tr.filter-empty-row:hover { + background: transparent !important; + border-left: none !important; +} +.data-table tbody tr.filter-empty-row td { + padding: 12px 16px !important; + border-right: none !important; + color: var(--vscode-descriptionForeground); +} +.table-empty-message { + display: flex; + flex-direction: column; + gap: 4px; +} +.table-empty-title { + font-weight: 600; + color: var(--vscode-foreground); +} +.table-empty-description { + color: var(--vscode-descriptionForeground); +} .enhanced-table-wrapper { border: 1px solid var(--vscode-panel-border); diff --git a/media/events.js b/media/events.js index 5ec6151..0a555a4 100644 --- a/media/events.js +++ b/media/events.js @@ -1271,33 +1271,6 @@ function handleTableRowCount(message) { totalPages, tableId ); - - // Re-attach pagination listeners (similar to delta updates). - paginationContainer.querySelectorAll(".pagination-btn").forEach((btn) => { - btn.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - const action = btn.getAttribute("data-action"); - const page = btn.getAttribute("data-page"); - if (page && typeof window.handlePagination === "function") { - window.handlePagination(wrapper, "goto", page); - } else if (action && typeof window.handlePagination === "function") { - window.handlePagination(wrapper, action); - } - }); - }); - - const pageInput = paginationContainer.querySelector(".page-input"); - if (pageInput) { - pageInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - const val = parseInt(pageInput.value, 10); - if (!isNaN(val) && typeof window.updateTablePage === "function") { - window.updateTablePage(wrapper, val); - } - } - }); - } } else { paginationContainer.innerHTML = ""; } @@ -1465,6 +1438,95 @@ function applyTabViewStateToWrapper(tableWrapper, tabKey) { const viewState = rawViewState && typeof rawViewState === "object" ? rawViewState : {}; + // Restore persisted sort (client-side; affects current page only). + if (viewState && viewState.sort && typeof viewState.sort === "object") { + const dir = + viewState.sort.dir === "asc" || viewState.sort.dir === "desc" + ? viewState.sort.dir + : null; + const columnName = + typeof viewState.sort.columnName === "string" + ? viewState.sort.columnName + : null; + + const table = tableWrapper.querySelector(".data-table"); + if (table && dir && columnName) { + /** @type {any} */ const vs = /** @type {any} */ (tableWrapper).__virtualTableState; + if (vs && vs.enabled === true) { + const idx = Array.isArray(vs.columns) ? vs.columns.indexOf(columnName) : -1; + if (idx >= 0) { + vs.sort = { columnName, columnIndex: idx, dir }; + table.querySelectorAll("th").forEach((th) => { + th.dataset.sort = "none"; + const indicator = th.querySelector(".sort-indicator"); + if (indicator) { + indicator.textContent = "⇅"; + } + }); + const header = table.querySelector(`th[data-column="${idx}"]`); + if (header) { + header.dataset.sort = dir; + const indicator = header.querySelector(".sort-indicator"); + if (indicator) { + indicator.textContent = dir === "asc" ? "↑" : "↓"; + } + } + if (typeof window.refreshVirtualTable === "function") { + window.refreshVirtualTable(tableWrapper); + } + } + } else { + const header = table.querySelector(`th[data-column-name="${columnName}"]`); + const colIndex = header + ? parseInt(header.getAttribute("data-column") || "-1", 10) + : -1; + if (header && colIndex >= 0) { + table.querySelectorAll("th").forEach((th) => { + th.dataset.sort = "none"; + const indicator = th.querySelector(".sort-indicator"); + if (indicator) { + indicator.textContent = "⇅"; + } + }); + header.dataset.sort = dir; + const indicator = header.querySelector(".sort-indicator"); + if (indicator) { + indicator.textContent = dir === "asc" ? "↑" : "↓"; + } + const tbody = table.querySelector("tbody"); + if (tbody) { + const rows = Array.from(tbody.querySelectorAll("tr")); + const cmp = + typeof window.compareValues === "function" + ? window.compareValues + : (a, b, direction) => + direction === "asc" + ? String(a).localeCompare(String(b)) + : String(b).localeCompare(String(a)); + rows.sort((a, b) => { + const aCell = a.querySelector(`td[data-column="${colIndex}"]`); + const bCell = b.querySelector(`td[data-column="${colIndex}"]`); + const aValue = + typeof window.getCellValue === "function" + ? window.getCellValue(aCell) + : aCell && aCell.textContent + ? aCell.textContent.trim() + : ""; + const bValue = + typeof window.getCellValue === "function" + ? window.getCellValue(bCell) + : bCell && bCell.textContent + ? bCell.textContent.trim() + : ""; + return cmp(aValue ?? "", bValue ?? "", dir); + }); + rows.forEach((row) => tbody.appendChild(row)); + } + } + } + } + } + // Restore pinned columns (before sizing/positioning). if (viewState && Array.isArray(viewState.pinnedColumns)) { const pinnedSet = new Set(viewState.pinnedColumns.filter(Boolean)); @@ -2330,25 +2392,100 @@ function initializeTableEvents(tableWrapper) { }); }); // Pagination controls - const paginationBtns = tableWrapper.querySelectorAll(".pagination-btn"); - paginationBtns.forEach((btn) => { - btn.addEventListener("click", (e) => { + // Pagination (delegated so it continues working after pagination HTML is regenerated) + if (tableWrapper.getAttribute("data-pagination-delegated") !== "true") { + tableWrapper.setAttribute("data-pagination-delegated", "true"); + + tableWrapper.addEventListener("click", (e) => { + const target = e.target instanceof Element ? e.target : null; + if (!target) { + return; + } + const btn = target.closest("button.pagination-btn"); + if (!btn || !(btn instanceof HTMLElement)) { + return; + } + if (!tableWrapper.contains(btn)) { + return; + } + e.preventDefault(); - const tableWrapper = btn.closest(".enhanced-table-wrapper"); - const action = btn.dataset.action; - const page = btn.dataset.page; - - if (typeof handlePagination !== "undefined") { - if (page) { - // Page number button clicked - handlePagination(tableWrapper, "goto", page); - } else if (action) { - // Navigation button clicked (first/prev/next/last) - handlePagination(tableWrapper, action); + + const wrapper = btn.closest(".enhanced-table-wrapper") || tableWrapper; + const page = btn.getAttribute("data-page") || btn.dataset.page || ""; + const action = + btn.getAttribute("data-action") || btn.dataset.action || ""; + + if (page && typeof window.handlePagination === "function") { + window.handlePagination(wrapper, "goto", page); + return; + } + + if (!action) { + return; + } + + if (action === "go") { + const container = btn.closest(".page-input-container"); + const pageInput = + (container && container.querySelector(".page-input")) || + wrapper.querySelector(".page-input"); + const raw = + pageInput && "value" in pageInput ? pageInput.value : ""; + const val = parseInt(String(raw), 10); + if (!isNaN(val) && typeof window.updateTablePage === "function") { + window.updateTablePage(wrapper, val); } + return; + } + + if (typeof window.handlePagination === "function") { + window.handlePagination(wrapper, action); } }); - }); + + tableWrapper.addEventListener("keydown", (e) => { + const ke = /** @type {KeyboardEvent} */ (e); + if (ke.key !== "Enter") { + return; + } + const target = ke.target instanceof Element ? ke.target : null; + if (!target || !target.classList.contains("page-input")) { + return; + } + const wrapper = + target.closest(".enhanced-table-wrapper") || tableWrapper; + const val = parseInt( + String( + target instanceof HTMLInputElement ? target.value : target.value + ), + 10 + ); + if (!isNaN(val) && typeof window.updateTablePage === "function") { + ke.preventDefault(); + window.updateTablePage(wrapper, val); + } + }); + + // Use focusout (bubbles) instead of blur (doesn't bubble) + tableWrapper.addEventListener("focusout", (e) => { + const target = e.target instanceof Element ? e.target : null; + if (!target || !target.classList.contains("page-input")) { + return; + } + const wrapper = + target.closest(".enhanced-table-wrapper") || tableWrapper; + const val = parseInt( + String( + target instanceof HTMLInputElement ? target.value : target.value + ), + 10 + ); + if (!isNaN(val) && typeof window.updateTablePage === "function") { + window.updateTablePage(wrapper, val); + } + }); + } // Page size selector const pageSizeSelect = tableWrapper.querySelector(".page-size-select"); if (pageSizeSelect) { @@ -2928,43 +3065,6 @@ function handleTableDataDelta({ tableId ); - // Re-attach event listeners to the new pagination buttons - const paginationBtns = - paginationContainer.querySelectorAll(".pagination-btn"); - paginationBtns.forEach((btn) => { - btn.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - const action = btn.getAttribute("data-action"); - const page = btn.getAttribute("data-page"); - - if (page && typeof window.handlePagination === "function") { - window.handlePagination(wrapper, "goto", page); - } else if ( - action && - typeof window.handlePagination === "function" - ) { - window.handlePagination(wrapper, action); - } - }); - }); - - // Re-attach page input listeners - const pageInput = paginationContainer.querySelector(".page-input"); - if (pageInput) { - pageInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - const val = parseInt(pageInput.value, 10); - if ( - !isNaN(val) && - typeof window.updateTablePage === "function" - ) { - window.updateTablePage(wrapper, val); - } - } - }); - } - if (window.debug) { window.debug.debug("[events.js] Updated pagination controls", { tableName, diff --git a/media/state.js b/media/state.js index 2b9da38..25749b7 100644 --- a/media/state.js +++ b/media/state.js @@ -50,6 +50,7 @@ function getDefaultTabViewState() { searchTerm: "", scrollTop: 0, scrollLeft: 0, + sort: { columnName: null, dir: "none" }, columnWidths: {}, rowHeights: {}, pinnedColumns: [], diff --git a/media/table.js b/media/table.js index ed12130..c90de1f 100644 --- a/media/table.js +++ b/media/table.js @@ -7,7 +7,7 @@ // Pagination settings const PAGINATION_CONFIG = { defaultPageSize: 100, - pageSizeOptions: [50, 100, 200, 500, 1000], + pageSizeOptions: [50, 100, 200, 500, 1000, 10000, 100000], maxVisiblePages: 5, }; @@ -90,7 +90,10 @@ function normalizeBlobValue(value) { if (value instanceof ArrayBuffer) { return new Uint8Array(value); } - if (Array.isArray(value) && value.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) { + if ( + Array.isArray(value) && + value.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) + ) { return Uint8Array.from(value); } // Node Buffer serialized form (rare, but can happen depending on transport) @@ -300,7 +303,9 @@ function createDataTable(data, columns, tableName = "", options = {}) { : 0 : /** @type {number} */ (totalRows) : 0; - const totalPages = totalRowsKnown ? Math.ceil(effectiveTotalRows / pageSize) : 1; + const totalPages = totalRowsKnown + ? Math.ceil(effectiveTotalRows / pageSize) + : 1; const showSchemaStats = !isSchemaTable; // Determine if editing should be allowed @@ -380,7 +385,7 @@ function createDataTable(data, columns, tableName = "", options = {}) { }" data-total-rows-known="${totalRowsKnown ? "true" : "false"}">
${ @@ -668,7 +673,9 @@ function renderTableCellHtml( isImage ? "🖼️" : "🧩" }`; const label = `${isImage ? "Image" : "BLOB"} ${sizeText}`; - cellContentHtml = `
${thumbHtml}${escapeHtmlFast( label @@ -691,11 +698,12 @@ function renderTableCellHtml( } const cellEditable = isEditable && !isBlob; - const ariaValue = isBlob && blobBytes - ? `BLOB (${formatBytes(blobBytes.length)})` - : cell !== null - ? String(cell).substring(0, 50) - : "null"; + const ariaValue = + isBlob && blobBytes + ? `BLOB (${formatBytes(blobBytes.length)})` + : cell !== null + ? String(cell).substring(0, 50) + : "null"; return ` = 0) { + vs.sort = { columnName: colName, columnIndex: idx, dir }; + } + } + } + // Attach a throttled scroll listener for re-rendering. if (scrollContainer.getAttribute("data-virtual-scroll") !== "true") { scrollContainer.setAttribute("data-virtual-scroll", "true"); @@ -1003,28 +1030,35 @@ function recomputeVirtualMetrics(vs) { }); } - if ( - vs.sort && - vs.sort.dir !== "none" && - typeof vs.sort.columnIndex === "number" - ) { - const colIdx = vs.sort.columnIndex; - const dir = vs.sort.dir; - const cmp = - typeof window.compareValues === "function" - ? window.compareValues - : (a, b, direction) => - direction === "asc" - ? String(a).localeCompare(String(b)) - : String(b).localeCompare(String(a)); - - order.sort((aIdx, bIdx) => { - const aRow = vs.pageData[aIdx]; - const bRow = vs.pageData[bIdx]; - const aVal = Array.isArray(aRow) ? aRow[colIdx] : ""; - const bVal = Array.isArray(bRow) ? bRow[colIdx] : ""; - return cmp(aVal ?? "", bVal ?? "", dir); - }); + if (vs.sort && vs.sort.dir !== "none") { + // Best-effort: derive index from columnName if needed + if (typeof vs.sort.columnIndex !== "number") { + if (vs.sort.columnName && Array.isArray(vs.columns)) { + const nextIdx = vs.columns.indexOf(vs.sort.columnName); + if (nextIdx >= 0) { + vs.sort.columnIndex = nextIdx; + } + } + } + if (typeof vs.sort.columnIndex === "number") { + const colIdx = vs.sort.columnIndex; + const dir = vs.sort.dir; + const cmp = + typeof window.compareValues === "function" + ? window.compareValues + : (a, b, direction) => + direction === "asc" + ? String(a).localeCompare(String(b)) + : String(b).localeCompare(String(a)); + + order.sort((aIdx, bIdx) => { + const aRow = vs.pageData[aIdx]; + const bRow = vs.pageData[bIdx]; + const aVal = Array.isArray(aRow) ? aRow[colIdx] : ""; + const bVal = Array.isArray(bRow) ? bRow[colIdx] : ""; + return cmp(aVal ?? "", bVal ?? "", dir); + }); + } } vs.order = order; @@ -1093,13 +1127,40 @@ function virtualRender(vs, { force }) { const tbody = vs.tbody; const colCount = Array.isArray(vs.columns) ? vs.columns.length : 0; const totalRows = Array.isArray(vs.order) ? vs.order.length : 0; + const pageRows = Array.isArray(vs.pageData) ? vs.pageData.length : 0; + const hasSearch = !!(vs.searchTerm && String(vs.searchTerm).trim().length); + const hasColFilter = !!(vs.columnFilter && vs.columnFilter.value); if (!tbody || colCount <= 0) { return; } if (totalRows === 0) { - tbody.innerHTML = `No matching rows.`; + const title = + hasSearch || hasColFilter + ? "No results on this page" + : pageRows === 0 + ? "No rows on this page" + : "No rows to show"; + const descriptionLine1 = + hasSearch || hasColFilter + ? "Try a different search/filter, clear it, or change page." + : "Try changing page or refreshing the table."; + const descriptionLine2 = + hasSearch || hasColFilter + ? "Or use the Query tab to search the whole database with SQL." + : ""; + tbody.innerHTML = `
${escapeHtmlFast( + title + )}
${escapeHtmlFast( + descriptionLine1 + )}
${ + descriptionLine2 + ? `
${escapeHtmlFast( + descriptionLine2 + )}
` + : "" + }
`; vs.lastStart = -1; vs.lastEnd = -1; return; @@ -1237,7 +1298,9 @@ function syncColumnWidthsForVirtual(vs) { if (!width || !Number.isFinite(width)) { return; } - const colEl = vs.table.querySelector(`colgroup col[data-column="${colIdx}"]`); + const colEl = vs.table.querySelector( + `colgroup col[data-column="${colIdx}"]` + ); if (colEl && colEl instanceof HTMLElement) { colEl.style.width = `${width}px`; } @@ -1283,12 +1346,23 @@ function filterTable(tableWrapper, searchTerm) { } const table = wrapper.querySelector(".data-table"); - const rows = table.querySelectorAll("tbody tr"); + const tbody = table ? table.querySelector("tbody") : null; + if (!table || !tbody) { + return; + } + + // Remove any prior empty-state placeholder row. + tbody.querySelectorAll("tr.filter-empty-row").forEach((row) => row.remove()); + + const rows = tbody.querySelectorAll("tr"); let visibleCount = 0; const term = searchTerm.toLowerCase(); rows.forEach((row) => { + if (row.classList.contains("filter-empty-row")) { + return; + } const cells = row.querySelectorAll("td"); let rowMatches = false; @@ -1307,10 +1381,30 @@ function filterTable(tableWrapper, searchTerm) { } }); + if (visibleCount === 0 && searchTerm) { + const colCount = table.querySelectorAll("thead th").length || 1; + const emptyRow = document.createElement("tr"); + emptyRow.className = "filter-empty-row"; + emptyRow.setAttribute("role", "row"); + const td = document.createElement("td"); + td.colSpan = colCount; + td.innerHTML = ` +
+
No results on this page
+
Try a different search term, clear the search, or change page.
+
Or use the Query tab to search the whole database with SQL.
+
+ `; + emptyRow.appendChild(td); + tbody.prepend(emptyRow); + } + // Update row count - const visibleRowsSpan = tableWrapper.querySelector(".visible-rows"); + const visibleRowsSpan = wrapper.querySelector(".visible-rows"); if (visibleRowsSpan) { - const totalRows = rows.length; + const totalRows = Array.from(rows).filter( + (r) => !r.classList.contains("filter-empty-row") + ).length; visibleRowsSpan.textContent = `Showing ${visibleCount} of ${totalRows} ${ pluralize ? pluralize(totalRows, "row") : "rows" }`; @@ -1358,10 +1452,35 @@ function sortTableByColumn(table, columnIndex) { indicator.textContent = newSort === "asc" ? "↑" : "↓"; } - vs.sort = { columnIndex, dir: /** @type {'asc'|'desc'} */ (newSort) }; + const columnName = header.getAttribute("data-column-name") || null; + vs.sort = { + columnName, + columnIndex, + dir: /** @type {'asc'|'desc'} */ (newSort), + }; recomputeVirtualMetrics(vs); virtualRender(vs, { force: true }); + // Persist sort for this tab (by column name) + try { + const tabKey = + wrapper && + (wrapper.getAttribute("data-table") || wrapper.dataset.table); + if ( + tabKey && + typeof window.setTabViewState === "function" && + columnName + ) { + window.setTabViewState( + tabKey, + { sort: { columnName, dir: newSort } }, + { renderTabs: false, renderSidebar: false, persistState: "debounced" } + ); + } + } catch (_) { + // ignore + } + if (typeof showSuccess !== "undefined") { showSuccess( `Table sorted by column ${columnIndex + 1} (${newSort}ending)` @@ -1419,6 +1538,23 @@ function sortTableByColumn(table, columnIndex) { // Re-append sorted rows rows.forEach((row) => tbody.appendChild(row)); + // Persist sort for this tab (by column name) + try { + const wrapper = table.closest(".enhanced-table-wrapper"); + const tabKey = + wrapper && (wrapper.getAttribute("data-table") || wrapper.dataset.table); + const columnName = header.getAttribute("data-column-name") || null; + if (tabKey && columnName && typeof window.setTabViewState === "function") { + window.setTabViewState( + tabKey, + { sort: { columnName, dir: newSort } }, + { renderTabs: false, renderSidebar: false, persistState: "debounced" } + ); + } + } catch (_) { + // ignore + } + if (typeof showSuccess !== "undefined") { showSuccess(`Table sorted by column ${columnIndex + 1} (${newSort}ending)`); } @@ -1583,12 +1719,28 @@ function filterTableByColumn(table, columnIndex, filterValue) { return; } - const rows = table.querySelectorAll("tbody tr"); + const tbody = table.querySelector("tbody"); + if (!tbody) { + return; + } + + // Remove any prior empty-state placeholder row. + tbody.querySelectorAll("tr.filter-empty-row").forEach((row) => row.remove()); + + const rows = tbody.querySelectorAll("tr"); let visibleCount = 0; rows.forEach((row) => { + if (row.classList.contains("filter-empty-row")) { + return; + } const cell = row.querySelector(`td[data-column="${columnIndex}"]`); - const cellValue = getCellValue ? getCellValue(cell) : cell.textContent; + const rawCellValue = getCellValue + ? getCellValue(cell) + : cell + ? cell.textContent + : ""; + const cellValue = String(rawCellValue ?? ""); const shouldShow = filterValue === "" || cellValue.toLowerCase().includes(filterValue.toLowerCase()); @@ -1601,11 +1753,33 @@ function filterTableByColumn(table, columnIndex, filterValue) { } }); + if (visibleCount === 0 && filterValue) { + const colCount = table.querySelectorAll("thead th").length || 1; + const emptyRow = document.createElement("tr"); + emptyRow.className = "filter-empty-row"; + emptyRow.setAttribute("role", "row"); + const td = document.createElement("td"); + td.colSpan = colCount; + td.innerHTML = ` +
+
No results on this page
+
Try a different filter, clear it, or change page.
+
Or use the Query tab to search the whole database with SQL.
+
+ `; + emptyRow.appendChild(td); + tbody.prepend(emptyRow); + } + // Update row count const tableWrapper = table.closest(".enhanced-table-wrapper"); - const visibleRowsSpan = tableWrapper.querySelector(".visible-rows"); + const visibleRowsSpan = tableWrapper + ? tableWrapper.querySelector(".visible-rows") + : null; if (visibleRowsSpan) { - const totalRows = rows.length; + const totalRows = Array.from(rows).filter( + (r) => !r.classList.contains("filter-empty-row") + ).length; visibleRowsSpan.textContent = `Showing ${visibleCount} of ${totalRows} ${ pluralize ? pluralize(totalRows, "row") : "rows" }`; @@ -1656,7 +1830,7 @@ function exportTableData(tableWrapper) { const table = wrapper.querySelector(".data-table"); const visibleRows = table.querySelectorAll( - 'tbody tr:not([style*="display: none"])' + 'tbody tr:not([style*="display: none"]):not(.filter-empty-row):not(.virtual-spacer):not(.virtual-loading):not(.virtual-empty)' ); // Get headers