diff --git a/core/ui/modules/grid-actions.js b/core/ui/modules/grid-actions.js
index fb9c59f..883c25f 100644
--- a/core/ui/modules/grid-actions.js
+++ b/core/ui/modules/grid-actions.js
@@ -6,20 +6,56 @@ import { updateToolbarButtons } from './ui.js';
import { updateBatchSidebar } from './sidebar.js';
import { getRowId, getCellValue } from './data-utils.js';
import { openCellPreview, startCellEdit, openCellInVsCode } from './edit.js';
-
-export function onFilterChange() {
- clearTimeout(state.filterTimer);
- state.filterTimer = setTimeout(() => {
- state.filterQuery = document.getElementById('filterInput').value;
+import { navigateMatches, resetMatchNav } from './match-nav.js';
+
+/**
+ * Apply the global filter and jump to a match. The filter is only run when the
+ * user submits (Enter / Search button) — there is no filter-as-you-type. If the
+ * term is unchanged the grid already reflects it, so we skip the refetch and
+ * just advance to the next match.
+ */
+export async function applyGlobalFilter(direction = 1) {
+ // The toolbar filter input bypasses the #gridContainer guards, so block here
+ // while a reload is in flight to avoid a concurrent refetch / acting on the
+ // stale grid (the column filter is already covered by handleKeydown/handleClick).
+ if (state.isGridReloading) return;
+ const input = document.getElementById('filterInput');
+ if (!input) return;
+ const value = input.value;
+ if (value !== state.filterQuery) {
+ const previous = state.filterQuery;
+ state.filterQuery = value;
state.currentPageIndex = 0;
- loadTableData();
+ resetMatchNav();
+ const ok = await loadTableData();
+ if (ok !== true) {
+ // Only a fully-applied load (true) should persist/navigate. false = a
+ // genuine failure: revert so the same query can be retried. undefined =
+ // superseded by a newer load (pagination/page-size/table switch); leave
+ // the term (that load is using it) and don't navigate against the stale
+ // grid while it's still in flight.
+ if (ok === false) state.filterQuery = previous;
+ return;
+ }
persistState();
- }, 300);
+ }
+ navigateMatches('global', direction);
+}
+
+export function onFilterEnter(event) {
+ // Enter jumps to the next match, Shift+Enter to the previous one. Ignore the
+ // Enter that confirms an IME composition candidate (isComposing) so we don't
+ // submit the filter / preventDefault before the composed text is committed.
+ if (event.key === 'Enter' && !event.isComposing) {
+ event.preventDefault();
+ applyGlobalFilter(event.shiftKey ? -1 : 1);
+ }
}
export function onPageSizeChange() {
state.rowsPerPage = parseInt(document.getElementById('pageSizeSelect').value, 10);
state.currentPageIndex = 0;
+ resetMatchNav();
loadTableData();
persistState();
}
@@ -28,6 +64,9 @@ export function onDateFormatChange() {
const select = document.getElementById('dateFormatSelect');
if (select) {
state.dateFormat = select.value;
+ // Cached matches were computed against the previous formatted text, so they
+ // (and the highlighted active cell) are stale once the format changes.
+ resetMatchNav();
renderDataGrid();
persistState();
}
@@ -37,6 +76,7 @@ export function goToPage(pageIndex) {
if (pageIndex >= 0 && pageIndex < state.totalPageCount) {
state.currentPageIndex = pageIndex;
state.scrollPosition = { top: 0, left: 0 };
+ resetMatchNav();
loadTableData(true, false);
}
}
@@ -54,22 +94,56 @@ export function onColumnSort(columnName) {
state.sortedColumn = null;
state.sortAscending = true;
}
+ resetMatchNav();
loadTableData();
persistState();
}
-export function applyColumnFilter(columnName) {
+/**
+ * Apply a column filter and jump to a match. Like the global filter, this only
+ * runs on submit (Enter / Search button). When the term changed we refetch and
+ * restore focus to the (rebuilt) input so the user can keep pressing Enter to
+ * cycle through matches; when unchanged we just advance to the next match.
+ */
+export async function applyColumnFilter(columnName, direction = 1) {
+ if (state.isGridReloading) return; // don't stack a refetch/navigate on an in-flight reload
const input = document.querySelector(`.column-filter[data-column="${columnName}"]`);
- if (input) {
+ if (!input) return;
+
+ const changed = input.value !== (state.columnFilters[columnName] || '');
+ if (changed) {
+ const previous = state.columnFilters[columnName];
state.columnFilters[columnName] = input.value;
state.currentPageIndex = 0;
- loadTableData();
+ resetMatchNav();
+ const ok = await loadTableData();
+ if (ok !== true) {
+ // Only a fully-applied load (true) proceeds. false = genuine failure:
+ // restore the prior value so the query can be retried. undefined =
+ // superseded by a newer load; leave the value and don't navigate.
+ if (ok === false) {
+ if (previous === undefined) delete state.columnFilters[columnName];
+ else state.columnFilters[columnName] = previous;
+ }
+ return;
+ }
+ // loadTableData() rebuilds the header, so the input we focused is gone.
+ // Re-focus the freshly rendered one and place the caret at the end.
+ const newInput = document.querySelector(`.column-filter[data-column="${columnName}"]`);
+ if (newInput) {
+ newInput.focus();
+ newInput.setSelectionRange(newInput.value.length, newInput.value.length);
+ }
}
+ navigateMatches(columnName, direction);
}
export function onColumnFilterKeydown(event, columnName) {
- if (event.key === 'Enter') {
- applyColumnFilter(columnName);
+ // Enter jumps to the next match, Shift+Enter to the previous one. Ignore the
+ // IME composition-confirm Enter (isComposing) so CJK input isn't broken.
+ if (event.key === 'Enter' && !event.isComposing) {
+ event.preventDefault();
+ applyColumnFilter(columnName, event.shiftKey ? -1 : 1);
}
}
diff --git a/core/ui/modules/grid-data.js b/core/ui/modules/grid-data.js
index bfe1c46..f77ccac 100644
--- a/core/ui/modules/grid-data.js
+++ b/core/ui/modules/grid-data.js
@@ -180,6 +180,7 @@ export async function loadTableData(showSpinner = true, saveScrollPosition = tru
updatePagination();
updateStatus(`${state.totalRecordCount} records`);
+ return true; // signals callers (e.g. filter submit) that the load applied
} catch (err) {
console.error('Error loading data:', err);
@@ -187,7 +188,9 @@ export async function loadTableData(showSpinner = true, saveScrollPosition = tru
if (!isSuperseded()) {
updateStatus(`Error: ${err.message}`);
showErrorState(err.message);
+ return false; // the current load genuinely failed (lets callers retry)
}
+ // Superseded failure: a newer load owns the outcome — return undefined.
} finally {
// isLoadingData keeps its original lifecycle. It is also set by BLOB uploads
// (dnd.js), so a superseded or no-spinner load must NOT clear it — only the
diff --git a/core/ui/modules/grid-events.js b/core/ui/modules/grid-events.js
index d7c0b1a..088835e 100644
--- a/core/ui/modules/grid-events.js
+++ b/core/ui/modules/grid-events.js
@@ -1,7 +1,8 @@
import { state, persistState } from './state.js';
import {
goToPage,
- onFilterChange,
+ applyGlobalFilter,
+ onFilterEnter,
onPageSizeChange,
onDateFormatChange,
startColumnResize,
@@ -19,7 +20,10 @@ import {
import { openCellPreview } from './edit.js';
export function initGridControls() {
- document.getElementById('filterInput')?.addEventListener('keyup', onFilterChange);
+ document.getElementById('filterInput')?.addEventListener('keydown', onFilterEnter);
+ // Wrap so the click MouseEvent isn't passed as `direction` (which would make
+ // navigateMatches compute NaN); the Search button always advances forward.
+ document.getElementById('btnApplyFilter')?.addEventListener('click', () => applyGlobalFilter(1));
document.getElementById('pageSizeSelect')?.addEventListener('change', onPageSizeChange);
document.getElementById('dateFormatSelect')?.addEventListener('change', onDateFormatChange);
diff --git a/core/ui/modules/grid-render.js b/core/ui/modules/grid-render.js
index 60f0b11..dad47fa 100644
--- a/core/ui/modules/grid-render.js
+++ b/core/ui/modules/grid-render.js
@@ -62,6 +62,9 @@ function createTableHeader(rowNumWidth, orderedColumns, pinnedColumnOffsets) {
const keyIcon = col.isPrimaryKey ? '' : '';
const pinClass = isPinned ? 'pinned' : '';
const pinTitle = isPinned ? 'Unpin column' : 'Pin column';
+ const matchCounterText = state.matchNav.scope === col.name && state.matchNav.matches.length > 0
+ ? `${state.matchNav.currentIndex + 1}/${state.matchNav.matches.length}`
+ : '';
th.innerHTML = `
@@ -71,8 +74,11 @@ function createTableHeader(rowNumWidth, orderedColumns, pinnedColumnOffsets) {
-
-
+
+
+ ${matchCounterText}
+
+
@@ -86,6 +92,10 @@ function createTableHeader(rowNumWidth, orderedColumns, pinnedColumnOffsets) {
function createTableBody(orderedColumns, columnIndexMap, pinnedColumnOffsets, rowNumWidth, headerHeight, rowHeight, selectedCellKeys, hasActiveFilters) {
const tbody = document.createElement('tbody');
+ const activeMatch = state.matchNav.currentIndex >= 0
+ ? state.matchNav.matches[state.matchNav.currentIndex]
+ : null;
+
// Pinned rows logic
const pinnedRowsList = [];
for (let rowIdx = 0; rowIdx < state.gridData.length; rowIdx++) {
@@ -159,10 +169,11 @@ function createTableBody(orderedColumns, columnIndexMap, pinnedColumnOffsets, ro
const isColPinned = state.pinnedColumns.has(col.name);
const hasContent = !isNull && !(value instanceof Uint8Array);
const colWidth = state.columnWidths[col.name] || 120;
+ const isActiveMatch = !!activeMatch && activeMatch.rowIdx === rowIdx && activeMatch.colIdx === originalColIdx;
const td = document.createElement('td');
td.id = `cell-${rowIdx}-${originalColIdx}`;
- td.className = `data-cell ${isNull ? 'null-value' : ''} ${isCellSelected ? 'cell-selected' : ''} ${isColPinned ? 'pinned' : ''}`;
+ td.className = `data-cell ${isNull ? 'null-value' : ''} ${isCellSelected ? 'cell-selected' : ''} ${isColPinned ? 'pinned' : ''} ${isActiveMatch ? 'active-match-cell' : ''}`;
td.dataset.rowidx = rowIdx;
td.dataset.colidx = originalColIdx;
@@ -208,7 +219,7 @@ function createTableBody(orderedColumns, columnIndexMap, pinnedColumnOffsets, ro
padding: '20px',
color: 'var(--text-secondary)'
});
- td.textContent = 'No rows match the current filter. Modify or clear filters above.';
+ td.textContent = 'No rows match the current filter.';
tr.appendChild(td);
fragment.appendChild(tr);
}
diff --git a/core/ui/modules/match-nav.js b/core/ui/modules/match-nav.js
new file mode 100644
index 0000000..0bb6fb5
--- /dev/null
+++ b/core/ui/modules/match-nav.js
@@ -0,0 +1,119 @@
+/**
+ * Filter Match Navigation
+ *
+ * Lets the user press Enter in the global filter or a column filter to jump
+ * between cells whose displayed text contains the active filter term,
+ * cycling through them with a visible border + a "current / total" counter.
+ */
+import { state } from './state.js';
+import { getCellValue } from './data-utils.js';
+import { formatCellValueAsText } from './utils.js';
+
+function activeTerm(scope) {
+ return (scope === 'global' ? state.filterQuery : state.columnFilters[scope] || '').trim().toLowerCase();
+}
+
+function computeMatches(scope, term) {
+ const matches = [];
+ if (!term) return matches;
+
+ // Resolve the columns to scan and their data indices once, outside the row loop.
+ const columnsToScan = [];
+ state.tableColumns.forEach((col, idx) => {
+ if (scope === 'global' || col.name === scope) {
+ columnsToScan.push({ col, colIdx: idx });
+ }
+ });
+
+ for (let rowIdx = 0; rowIdx < state.gridData.length; rowIdx++) {
+ const row = state.gridData[rowIdx];
+ for (const { col, colIdx } of columnsToScan) {
+ const value = getCellValue(row, colIdx);
+ // String() guards against formatters that may return a non-string
+ // (number/null/undefined), which would otherwise throw on .toLowerCase().
+ const text = String(formatCellValueAsText(value, col.type, state.dateFormat, col.name));
+ if (text.toLowerCase().includes(term)) {
+ matches.push({ rowIdx, colIdx });
+ }
+ }
+ }
+ return matches;
+}
+
+function focusActiveMatch() {
+ document.querySelectorAll('.active-match-cell').forEach(el => el.classList.remove('active-match-cell'));
+
+ const { matches, currentIndex } = state.matchNav;
+ if (currentIndex < 0 || currentIndex >= matches.length) return;
+
+ const { rowIdx, colIdx } = matches[currentIndex];
+ const cellEl = document.getElementById(`cell-${rowIdx}-${colIdx}`);
+ if (cellEl) {
+ cellEl.classList.add('active-match-cell');
+ cellEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ }
+}
+
+function updateMatchCounterUI() {
+ const { scope, matches, currentIndex } = state.matchNav;
+ const counterText = matches.length > 0 ? `${currentIndex + 1}/${matches.length}` : '';
+
+ const globalCounter = document.getElementById('filterMatchCounter');
+ if (globalCounter) {
+ globalCounter.textContent = scope === 'global' ? counterText : '';
+ }
+
+ document.querySelectorAll('.column-filter-counter').forEach(el => {
+ el.textContent = el.dataset.column === scope ? counterText : '';
+ });
+}
+
+/**
+ * Move to the next (direction = 1) or previous (direction = -1) match for the
+ * given scope ('global' or a column name), wrapping around at either end.
+ * Matches are cached on `state.matchNav` and only recomputed when the scope or
+ * term changes (a fresh term is detected after `resetMatchNav()` cleared the
+ * cache), so pressing Enter/Shift+Enter repeatedly is O(matches), not a full
+ * rescan of every row.
+ */
+export function navigateMatches(scope, direction = 1) {
+ // Normalize to ±1 so a stray non-numeric arg (e.g. a DOM event) can never
+ // produce a NaN index in the modulo arithmetic below.
+ direction = direction < 0 ? -1 : 1;
+ const term = activeTerm(scope);
+ const cacheValid = state.matchNav.scope === scope
+ && state.matchNav.term === term
+ && state.matchNav.matches.length > 0;
+
+ if (cacheValid) {
+ const len = state.matchNav.matches.length;
+ state.matchNav.currentIndex = (state.matchNav.currentIndex + direction + len) % len;
+ } else {
+ const matches = computeMatches(scope, term);
+ state.matchNav.scope = scope;
+ state.matchNav.term = term;
+ state.matchNav.matches = matches;
+ // Fresh search: forward starts at the first match, backward at the last.
+ state.matchNav.currentIndex = matches.length === 0
+ ? -1
+ : (direction === 1 ? 0 : matches.length - 1);
+ }
+
+ focusActiveMatch();
+ updateMatchCounterUI();
+}
+
+/**
+ * Clear match navigation state, e.g. when the filter term or grid data
+ * changes outside of an explicit Enter-to-navigate action (sorting, paging,
+ * applying a new term) so a stale border/counter isn't left pointing at the
+ * wrong cell and the cache is invalidated.
+ */
+export function resetMatchNav() {
+ state.matchNav.scope = null;
+ state.matchNav.term = null;
+ state.matchNav.matches = [];
+ state.matchNav.currentIndex = -1;
+ document.querySelectorAll('.active-match-cell').forEach(el => el.classList.remove('active-match-cell'));
+ updateMatchCounterUI();
+}
diff --git a/core/ui/modules/state.js b/core/ui/modules/state.js
index 0aee203..b51fded 100644
--- a/core/ui/modules/state.js
+++ b/core/ui/modules/state.js
@@ -19,7 +19,6 @@ export const state = {
sortedColumn: null,
sortAscending: true,
filterQuery: '',
- filterTimer: null,
selectedRowIds: new Set(),
gridData: [],
@@ -75,7 +74,15 @@ export const state = {
// Settings
dateFormat: 'raw', // 'raw', 'local', 'iso', 'relative'
- cellEditBehavior: 'inline' // 'inline', 'modal', 'vscode'
+ cellEditBehavior: 'inline', // 'inline', 'modal', 'vscode'
+
+ // Filter match navigation (Enter-to-jump on global/column filters)
+ matchNav: {
+ scope: null, // 'global' or a column name
+ term: null, // the lowercased term the cached matches were computed for
+ matches: [], // [{ rowIdx, colIdx }] in row/column order
+ currentIndex: -1
+ }
};
/**
diff --git a/core/ui/viewer.css b/core/ui/viewer.css
index f7898e3..1a485fc 100644
--- a/core/ui/viewer.css
+++ b/core/ui/viewer.css
@@ -287,12 +287,24 @@ body {
cursor: not-allowed;
}
+/* Groups the filter input + Search button so they sit flush as one control */
+.filter-group {
+ display: flex;
+ align-items: stretch;
+}
+
+.filter-input-wrap {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
.filter-input {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
- border-radius: 3px;
+ border-radius: 3px 0 0 3px;
font-size: 12px;
width: 200px;
}
@@ -302,6 +314,29 @@ body {
border-color: var(--accent-color);
}
+/* Current/total match counter overlaid at the right edge inside the filter input */
+.filter-match-counter {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 10px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ pointer-events: none;
+}
+
+/* Reserve room for the counter only when it has content */
+.filter-input-wrap:has(.filter-match-counter:not(:empty)) .filter-input {
+ padding-right: 38px;
+}
+
+/* Search button flush against the global filter (inherits joined borders/radii
+ from .filter-apply-btn, matching the column filter); just match input padding */
+.filter-apply-btn-toolbar {
+ padding: 4px 8px;
+}
+
/* ================================================================
DATA GRID STYLES
================================================================ */
@@ -460,6 +495,31 @@ body {
opacity: 0.7;
}
+/* Wrapper so the counter can overlay the right edge inside the column filter input */
+.column-filter-wrap {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ display: flex;
+}
+
+/* Current/total match counter overlaid inside the column filter input, when non-empty */
+.column-filter-counter {
+ position: absolute;
+ right: 6px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 10px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ pointer-events: none;
+}
+
+/* Reserve room for the counter only when it has content */
+.column-filter-wrap:has(.column-filter-counter:not(:empty)) .column-filter {
+ padding-right: 30px;
+}
+
/* Filter apply button - triggers filter on click */
.filter-apply-btn {
padding: 2px 6px;
@@ -636,6 +696,21 @@ body {
background: var(--active-bg);
}
+/* Cell the user just jumped to via Enter-to-navigate on a filter */
+.data-cell.active-match-cell {
+ outline: 2px solid var(--accent-color);
+ outline-offset: -2px;
+ z-index: 3;
+}
+
+/* A pinned cell must keep its sticky stacking (z-index 5) even when it is the
+ active match — this rule is later + more specific than .data-cell.pinned, so
+ without it the active-match z-index (3) would drop the pinned cell below its
+ neighbors. */
+.data-cell.pinned.active-match-cell {
+ z-index: 5;
+}
+
/* Header cell styling when entire column is selected */
.header-cell.column-selected {
/* Layer active background over secondary background to ensure opacity for sticky header */
diff --git a/core/ui/viewer.html b/core/ui/viewer.html
index a7c5a42..fe1d6dd 100644
--- a/core/ui/viewer.html
+++ b/core/ui/viewer.html
@@ -6,7 +6,7 @@
SQLite Explorer
@@ -101,7 +101,13 @@
-
+