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 @@ - +
+
+ + +
+ +
@@ -364,51 +370,54 @@ diff --git a/core/ui/viewer.template.html b/core/ui/viewer.template.html index c82a8a3..2282910 100644 --- a/core/ui/viewer.template.html +++ b/core/ui/viewer.template.html @@ -100,7 +100,13 @@ - +
+
+ + +
+ +
diff --git a/website/public/sqlite-viewer/viewer.html b/website/public/sqlite-viewer/viewer.html index c5a946c..fc76044 100644 --- a/website/public/sqlite-viewer/viewer.html +++ b/website/public/sqlite-viewer/viewer.html @@ -6,7 +6,7 @@ SQLite Explorer @@ -101,7 +101,13 @@ - +
+
+ + +
+ +
@@ -364,45 +370,48 @@